diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index 6290ab9f7..f46881c5b 100644 --- a/.github/workflows/desktop-connect-discovery-guard.yml +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -79,6 +79,10 @@ jobs: if: matrix.platform == 'win32' run: npm run test:windows-fixture-acl -w @propr/desktop + - name: Verify Windows packaged launcher authority + if: matrix.platform == 'win32' + run: node --test apps/desktop/scripts/windows-packaged-connect-staging.test.mjs + - name: Package the target-native desktop app run: npm run desktop:package @@ -99,29 +103,6 @@ jobs: - name: Run packaged Windows main-to-renderer discovery as an ordinary user if: matrix.platform == 'win32' shell: powershell - run: | - $ErrorActionPreference = 'Stop' - $userName = 'propr-connect-ci' - if ($userName.Length -gt 20) { throw 'packaged discovery user name exceeds the local-account limit' } - $plainPassword = [Guid]::NewGuid().ToString('N') + 'aA1!' - $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force - $credential = [PSCredential]::new("$env:COMPUTERNAME\$userName", $securePassword) - $stdout = Join-Path $env:RUNNER_TEMP 'packaged-connect.stdout' - $stderr = Join-Path $env:RUNNER_TEMP 'packaged-connect.stderr' - try { - New-LocalUser -Name $userName -Password $securePassword -PasswordNeverExpires | Out-Null - $administrators = Get-LocalGroupMember -Group 'Administrators' | ForEach-Object { $_.Name } - if ($administrators -contains "$env:COMPUTERNAME\$userName") { throw 'packaged discovery user is an administrator' } - $node = (Get-Command node.exe).Source - $desktopDirectory = Join-Path $env:GITHUB_WORKSPACE 'apps/desktop' - $process = Start-Process -FilePath $node -ArgumentList @('scripts/smoke-packaged-connect.mjs') -WorkingDirectory $desktopDirectory -Credential $credential -LoadUserProfile -Wait -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr - Get-Content -LiteralPath $stdout - if ($process.ExitCode -ne 0) { - Get-Content -LiteralPath $stderr - throw "packaged Connect discovery exited $($process.ExitCode)" - } - if ((Get-Content -Raw -LiteralPath $stderr).Length -ne 0) { throw 'packaged Connect discovery wrote stderr' } - } finally { - Remove-LocalUser -Name $userName -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $stdout,$stderr -Force -ErrorAction SilentlyContinue - } + run: >- + & apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 + -Architecture '${{ matrix.arch }}' diff --git a/apps/desktop/scripts/packaged-layout.d.mts b/apps/desktop/scripts/packaged-layout.d.mts new file mode 100644 index 000000000..4970d65ef --- /dev/null +++ b/apps/desktop/scripts/packaged-layout.d.mts @@ -0,0 +1,3 @@ +export const parseEventRecord: (smokeOutput: string, expectedEvent: string) => Record | undefined; +export const parseEventLayout: (smokeOutput: string, expectedEvent: string) => unknown; +export const assertPackagedLayout: (layout: unknown, platform?: NodeJS.Platform) => void; diff --git a/apps/desktop/scripts/packaged-layout.mjs b/apps/desktop/scripts/packaged-layout.mjs index 32114d489..2d4658b38 100644 --- a/apps/desktop/scripts/packaged-layout.mjs +++ b/apps/desktop/scripts/packaged-layout.mjs @@ -1,6 +1,23 @@ const EXPECTED_WINDOW_SIZE = { width: 1280, height: 820 }; const MINIMUM_WINDOW_SIZE = { width: 880, height: 620 }; +export const parseEventRecord = (smokeOutput, expectedEvent) => { + for (const line of smokeOutput.split(/\r?\n/)) { + if (!line.includes(expectedEvent)) continue; + try { + const record = JSON.parse(line.slice(line.indexOf('{'))); + if (record.event === expectedEvent) return record; + } catch { + // Ignore non-JSON Chromium output that happens to mention the event name. + } + } + return undefined; +}; + +export const parseEventLayout = (smokeOutput, expectedEvent) => ( + parseEventRecord(smokeOutput, expectedEvent)?.layout +); + const fail = message => { throw new Error(message); }; diff --git a/apps/desktop/scripts/packaged-layout.test.mjs b/apps/desktop/scripts/packaged-layout.test.mjs index ac14d6cbf..d7a2b3aec 100644 --- a/apps/desktop/scripts/packaged-layout.test.mjs +++ b/apps/desktop/scripts/packaged-layout.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { assertPackagedLayout } from './packaged-layout.mjs'; +import { assertPackagedLayout, parseEventRecord } from './packaged-layout.mjs'; const bounds = (left, top, width, height) => ({ bottom: top + height, @@ -30,6 +30,36 @@ const layout = ({ connectDescription: bounds((viewportWidth - 300) / 2, 270, 300, 18), }); +describe('packaged desktop event parsing', () => { + it('returns the first full record for the exact matching event', () => { + const firstProof = { + event: 'desktop.renderer.mvp_flows.ready', + localProfile: true, + remoteActiveProfile: true, + lifecycleBoundary: true, + connectUiPopulated: true, + }; + const output = [ + 'not JSON: desktop.renderer.mvp_flows.ready', + JSON.stringify({ event: 'desktop.renderer.mvp_flows.ready.extra', localProfile: false }), + JSON.stringify({ event: 'desktop.renderer.other', note: 'desktop.renderer.mvp_flows.ready' }), + JSON.stringify(firstProof), + JSON.stringify({ event: 'desktop.renderer.mvp_flows.ready', localProfile: false }), + ].join('\n'); + + assert.deepEqual(parseEventRecord(output, firstProof.event), firstProof); + }); + + it('returns undefined when the event is absent', () => { + const output = [ + '{malformed', + JSON.stringify({ event: 'desktop.renderer.other' }), + ].join('\n'); + + assert.equal(parseEventRecord(output, 'desktop.renderer.mvp_flows.ready'), undefined); + }); +}); + describe('packaged desktop layout assertions', () => { it('retains the exact 1280x820 Linux Xvfb proof', () => { assert.doesNotThrow(() => assertPackagedLayout(layout(), 'linux')); diff --git a/apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 b/apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 new file mode 100644 index 000000000..b4d5d1da2 --- /dev/null +++ b/apps/desktop/scripts/run-packaged-windows-connect-smoke.ps1 @@ -0,0 +1,2584 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet('x64','arm64')] + [string]$Architecture, + [ValidateSet('none','terminate-tree','cleanup-timeout','diagnostic-subphase','host-node-producer','launcher-authority','capture-parser','capture-redirection')] + [string]$LifecycleTestMode = 'none', + [ValidateRange(0,2147483647)] + [int]$LifecycleTestProcessId = 0, + [ValidateSet( + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', + 'host-node-path-binding', + 'host-node-launcher-return-authority', + 'host-launcher-native-initialization', + 'host-launcher-selected-path-input', + 'host-launcher-selected-path-extra-colon', + 'host-launcher-selected-path-get-full-path', + 'host-launcher-selected-path-absolute-shape', + 'host-launcher-selected-path-canonical-equality', + 'host-launcher-source-open', + 'host-launcher-source-type', + 'host-launcher-source-identity', + 'host-launcher-source-final-path', + 'host-launcher-final-open', + 'host-launcher-final-type', + 'host-launcher-final-identity', + 'host-launcher-final-path', + 'host-launcher-final-match', + 'host-launcher-source-reopen', + 'host-launcher-source-reopen-type', + 'host-launcher-source-reopen-identity', + 'host-launcher-source-reopen-final-path', + 'host-launcher-source-reopen-match', + 'host-capture-contract', + 'host-staging-handoff' + )] + [string]$DiagnosticTestSubphase = 'host-node-command-cardinality', + [ValidateSet( + 'positive','zero','duplicate','multiple','mixed-types','case-collision', + 'non-application','missing-source','non-scalar-source' + )] + [string]$HostNodeProducerTestCase = 'positive', + [ValidateSet('normal','alias','retarget-alias','identity-mismatch')] + [string]$LauncherAuthorityTestCase = 'normal', + [string]$LauncherAuthorityTestPath = '', + [string]$LauncherAuthorityTestRetargetPath = '', + [string]$CaptureParserTestPath = '', + [ValidateSet( + 'administrators-owner','current-owner','foreign-owner','ordinary-owner', + 'ordinary-write','broad-write','unprotected-dacl','foreign-parent-owner', + 'identity-change','existing' + )] + [string]$CaptureParserAuthorityTestCase = 'existing', + [ValidateSet('success','nonzero','empty','hostile')] + [string]$CaptureRedirectionProducerTestCase = 'success' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$failureCategories = @( + 'artifact-missing', + 'artifact-inaccessible', + 'artifact-type', + 'architecture-mismatch', + 'spawn-failed' +) +$failurePhases = @( + 'source-layout', + 'runner-authority', + 'account-setup', + 'staging-copy', + 'staging-acl', + 'staged-contract', + 'staged-tree', + 'staged-architecture', + 'ordinary-user-preflight', + 'fixture-setup', + 'package-authority', + 'application-spawn', + 'application-runtime', + 'capture-parse', + 'result-verify', + 'cleanup' +) +$hostFailureSubphases = @( + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', + 'host-node-path-binding', + 'host-node-launcher-return-authority', + 'host-launcher-native-initialization', + 'host-launcher-selected-path-input', + 'host-launcher-selected-path-extra-colon', + 'host-launcher-selected-path-get-full-path', + 'host-launcher-selected-path-absolute-shape', + 'host-launcher-selected-path-canonical-equality', + 'host-launcher-source-open', + 'host-launcher-source-type', + 'host-launcher-source-identity', + 'host-launcher-source-final-path', + 'host-launcher-final-open', + 'host-launcher-final-type', + 'host-launcher-final-identity', + 'host-launcher-final-path', + 'host-launcher-final-match', + 'host-launcher-source-reopen', + 'host-launcher-source-reopen-type', + 'host-launcher-source-reopen-identity', + 'host-launcher-source-reopen-final-path', + 'host-launcher-source-reopen-match', + 'host-capture-contract', + 'host-staging-handoff', + 'host-state-contract' +) +$childFailureSubphases = @( + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract' +) +$childStagedContractSubphases = @( + 'runner-temp-input-shape', + 'staging-parent-input-shape', + 'parent-to-runner-binding', + 'fixed-parent-leaf', + 'generated-stage-leaf', + 'derived-root-to-parent-binding' +) +$captureParseSubphases = @( + 'capture-authority', + 'capture-size', + 'capture-read', + 'capture-utf8', + 'capture-json', + 'capture-line-cardinality', + 'capture-event-cardinality', + 'capture-schema-cardinality', + 'capture-lifecycle-category', + 'capture-lifecycle-phase', + 'capture-lifecycle-subphase', + 'capture-redaction' +) +$captureAuthorityPredicates = @( + 'parent-owner', + 'capture-owner', + 'dacl-canonicality', + 'unauthorized-writer', + 'link-path-type', + 'identity-replacement', + 'pre-create', + 'redirect-open', + 'redirect-timeout', + 'redirect-child-exit', + 'post-redirection-identity', + 'capture-content', + 'cleanup' +) +$captureProducerExitBuckets = @('zero','forced-23','other') +$captureProducerOutputStates = @('exact-expected','empty','other-bounded') +$captureProducerResultPredicates = @('redirect-child-exit','capture-content') +$lifecycleFailureSubphases = @( + 'fixture-setup', + 'package-validation', + 'lifecycle-internal', + 'spawn-error', + 'output-rejected', + 'ready-validation', + 'timeout-before-ready', + 'child-exit-before-ready', + 'child-exit-after-ready', + 'tree-termination', + 'ready-clean-exit', + 'ready-forced-exit', + 'ready-duplicate', + 'child-remained-alive' +) +$failureSubphases = @( + $hostFailureSubphases + + $childFailureSubphases + + $childStagedContractSubphases + + $captureParseSubphases + + $lifecycleFailureSubphases +) +$applicationTimeoutMilliseconds = 5 * 60 * 1000 +$terminationTimeoutMilliseconds = 30 * 1000 +$cleanupTimeoutMilliseconds = 60 * 1000 +$streamCloseTimeoutMilliseconds = 30 * 1000 +$taskkillExecutable = 'C:\Windows\System32\taskkill.exe' +$primaryFailure = $null +$primaryPhase = $null +$primarySubphase = $null +$failurePhase = 'source-layout' +$failureSubphase = $null +$cleanupSecondary = 'none' +$testUser = $null +$testUserSid = $null +$stageParent = $null +$stageRoot = $null +$stageLeaf = $null +$stdout = $null +$stderr = $null +$stdoutAuthority = $null +$stderrAuthority = $null +$privilegedSid = $null +$launcherAuthority = $null +$plainPassword = $null +$handoffArgument = $null +$captureAuthorityPredicate = $null +$captureProducerResultAttributed = $false +$captureProducerExitBucket = $null +$captureProducerStdoutState = $null +$captureProducerStderrState = $null + +function Stop-PackagedConnect { + param([Parameter(Mandatory=$true)][ValidateSet( + 'artifact-missing','artifact-inaccessible','artifact-type','architecture-mismatch','spawn-failed' + )][string]$Category) + throw [InvalidOperationException]::new("PROPR_PACKAGED_CONNECT_FAILURE:$Category") +} + +function Get-FixedFailureCategory { + param([Parameter(Mandatory=$true)][Exception]$Exception) + if ($Exception.Message -cmatch '^PROPR_PACKAGED_CONNECT_FAILURE:(artifact-missing|artifact-inaccessible|artifact-type|architecture-mismatch|spawn-failed)$') { + return $Matches[1] + } + if ($failurePhase -in @('application-spawn','application-runtime','result-verify')) { + return 'spawn-failed' + } + return 'artifact-inaccessible' +} + +function Set-FailurePhase { + param([Parameter(Mandatory=$true)][string]$Phase) + if ($failurePhases -cnotcontains $Phase) { + throw [InvalidOperationException]::new('invalid-fixed-failure-phase') + } + $script:failurePhase = $Phase + if ($Phase -cnotin @('staged-contract','ordinary-user-preflight','capture-parse','application-runtime')) { + $script:failureSubphase = $null + } +} + +function Set-CaptureParseSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($captureParseSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-capture-subphase') + } + $script:failurePhase = 'capture-parse' + $script:failureSubphase = $Subphase +} + +function Set-CaptureAuthorityPredicate { + param([Parameter(Mandatory=$true)][string]$Predicate) + if ($captureAuthorityPredicates -cnotcontains $Predicate) { + throw [InvalidOperationException]::new('invalid-fixed-capture-authority-predicate') + } + $script:captureAuthorityPredicate = $Predicate +} + +function Get-TestOnlyCaptureProducerOutputState { + param( + [Parameter(Mandatory=$true)]$Authority, + [Parameter(Mandatory=$true)] + [Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [Parameter(Mandatory=$true)][string]$Expected + ) + if ($LifecycleTestMode -cne 'capture-redirection') { + throw [InvalidOperationException]::new('capture-producer-state-outside-test-mode') + } + $captureReadHandle = $null + try { + $maximumAttributedBytes = 256 + if ($null -eq $Authority -or !($Authority.Path -is [string]) -or + !($Authority.Identity -is [string]) -or + !($Authority.SecurityDescriptor -is [string]) -or + !($Authority.Handle -is [Microsoft.Win32.SafeHandles.SafeFileHandle]) -or + $Authority.Handle.IsInvalid -or $Authority.Handle.IsClosed -or + ![String]::Equals( + [ProprHostLauncherNative]::GetIdentity($Authority.Handle), + $Authority.Identity, + [StringComparison]::Ordinal + )) { + return 'other-bounded' + } + + $captureReadHandle = [ProprHostLauncherNative]::OpenCapture($Authority.Path, $true) + $null = Assert-PrivilegedCaptureFile ` + $Authority.Path $captureReadHandle $CapturePrivilegedSid $Authority.Identity ` + -TestOnlyIdentityPredicate 'capture-content' + Set-CaptureAuthorityPredicate 'capture-content' + if ((Get-CaptureAuthorityDescriptor $Authority.Path) -cne + $Authority.SecurityDescriptor) { + return 'other-bounded' + } + + $state = 'other-bounded' + $length = [ProprHostLauncherNative]::GetLength($captureReadHandle) + if ($length -eq 0) { + $state = 'empty' + } elseif ($length -le $maximumAttributedBytes) { + $bytes = [ProprHostLauncherNative]::ReadBounded( + $captureReadHandle, $maximumAttributedBytes + ) + if ($bytes.Length -eq $length -and + [Text.Encoding]::UTF8.GetString($bytes) -ceq $Expected) { + $state = 'exact-expected' + } + } + + $null = Assert-PrivilegedCaptureFile ` + $Authority.Path $captureReadHandle $CapturePrivilegedSid $Authority.Identity ` + -TestOnlyIdentityPredicate 'capture-content' + Set-CaptureAuthorityPredicate 'capture-content' + if (![String]::Equals( + [ProprHostLauncherNative]::GetIdentity($Authority.Handle), + $Authority.Identity, + [StringComparison]::Ordinal + ) -or (Get-CaptureAuthorityDescriptor $Authority.Path) -cne + $Authority.SecurityDescriptor) { + return 'other-bounded' + } + return $state + } catch {} + finally { + if ($null -ne $captureReadHandle) { + try { $captureReadHandle.Dispose() } catch {} + } + } + return 'other-bounded' +} + +function Set-LifecycleFailureSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($lifecycleFailureSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-lifecycle-subphase') + } + $script:failurePhase = 'application-runtime' + $script:failureSubphase = $Subphase +} + +function Set-StagedContractSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($childStagedContractSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-failure-subphase') + } + $script:failureSubphase = $Subphase + $script:failurePhase = 'staged-contract' +} + +function Set-OrdinaryUserPreflightSubphase { + param([Parameter(Mandatory=$true)][string]$Subphase) + if ($failureSubphases -cnotcontains $Subphase) { + throw [InvalidOperationException]::new('invalid-fixed-failure-subphase') + } + $script:failureSubphase = $Subphase + $script:failurePhase = 'ordinary-user-preflight' +} + +function Set-PrimaryFailureFromException { + param([Parameter(Mandatory=$true)][Exception]$Exception) + $script:primaryFailure = Get-FixedFailureCategory $Exception + $script:primaryPhase = $failurePhase + $script:primarySubphase = $null + if ($script:primaryPhase -ceq 'ordinary-user-preflight') { + $script:primarySubphase = if ($failureSubphases -ccontains $failureSubphase) { + $failureSubphase + } else { + 'host-state-contract' + } + } elseif ($script:primaryPhase -ceq 'staged-contract' -and + $childStagedContractSubphases -ccontains $failureSubphase) { + $script:primarySubphase = $failureSubphase + } elseif ($script:primaryPhase -ceq 'capture-parse' -and + $captureParseSubphases -ccontains $failureSubphase) { + $script:primarySubphase = $failureSubphase + } elseif ($script:primaryPhase -ceq 'application-runtime' -and + $lifecycleFailureSubphases -ccontains $failureSubphase) { + $script:primarySubphase = $failureSubphase + } +} + +function Get-ValidatedHostNodePath { + param( + [switch]$UseTestOnlyCommandResults, + [AllowNull()][AllowEmptyCollection()][object[]]$TestOnlyCommandResults, + [scriptblock]$TestOnlySourceProducer + ) + Set-OrdinaryUserPreflightSubphase 'host-node-command-cardinality' + if ($UseTestOnlyCommandResults) { + $commandResults = @($TestOnlyCommandResults) + } else { + $commandResults = @( + Get-Command node.exe ` + -CommandType Application ` + -TotalCount 1 ` + -ErrorAction Stop + ) + } + if ($commandResults.Count -ne 1) { + Stop-PackagedConnect 'artifact-type' + } + + Set-OrdinaryUserPreflightSubphase 'host-node-command-type' + $candidate = $commandResults[0] + if (!($candidate -is [System.Management.Automation.ApplicationInfo])) { + Stop-PackagedConnect 'artifact-type' + } + + Set-OrdinaryUserPreflightSubphase 'host-node-source' + if ($null -eq $TestOnlySourceProducer) { + $sourceResults = @($candidate.Source) + } else { + $sourceResults = @(& $TestOnlySourceProducer $candidate) + } + if ($sourceResults.Count -ne 1 -or + !($sourceResults[0] -is [string]) -or + [String]::IsNullOrEmpty($sourceResults[0])) { + Stop-PackagedConnect 'artifact-type' + } + return $sourceResults[0] +} + +function Stop-SpawnedProcess { + param([Parameter(Mandatory=$true)][Diagnostics.Process]$Process) + try { + if ($Process.HasExited) { return } + $processId = $Process.Id + $processIdText = $processId.ToString([Globalization.CultureInfo]::InvariantCulture) + $validatedProcessId = 0 + if ($processIdText -cnotmatch '^[1-9][0-9]{0,9}$' -or + ![Int32]::TryParse( + $processIdText, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$validatedProcessId + ) -or $validatedProcessId -ne $processId) { + Stop-PackagedConnect 'spawn-failed' + } + + $taskkillStart = [Diagnostics.ProcessStartInfo]::new() + $taskkillStart.FileName = $taskkillExecutable + $taskkillStart.Arguments = [String]::Join(' ', [string[]]@('/PID', $processIdText, '/T', '/F')) + $taskkillStart.UseShellExecute = $false + $taskkillStart.CreateNoWindow = $true + $taskkillStart.RedirectStandardOutput = $true + $taskkillStart.RedirectStandardError = $true + $taskkillProcess = [Diagnostics.Process]::new() + $taskkillProcess.StartInfo = $taskkillStart + try { + if (!$taskkillProcess.Start()) { Stop-PackagedConnect 'spawn-failed' } + $taskkillOutputClose = $taskkillProcess.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null) + $taskkillErrorClose = $taskkillProcess.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null) + if (!$taskkillProcess.WaitForExit($terminationTimeoutMilliseconds)) { + try { $taskkillProcess.Kill() } catch {} + try { $null = $taskkillProcess.WaitForExit($terminationTimeoutMilliseconds) } catch {} + try { + $null = [Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($taskkillOutputClose, $taskkillErrorClose), + $streamCloseTimeoutMilliseconds + ) + } catch {} + Stop-PackagedConnect 'spawn-failed' + } + if (![Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($taskkillOutputClose, $taskkillErrorClose), + $streamCloseTimeoutMilliseconds + ) -or $taskkillOutputClose.IsFaulted -or $taskkillErrorClose.IsFaulted -or + $taskkillProcess.ExitCode -ne 0 -or !$Process.WaitForExit($terminationTimeoutMilliseconds) -or + !$Process.HasExited) { + Stop-PackagedConnect 'spawn-failed' + } + } finally { + $taskkillProcess.Dispose() + } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'spawn-failed' + } +} + +function Get-CanonicalItem { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][ValidateSet('directory','file')][string]$Kind + ) + try { + if (![IO.Path]::IsPathRooted($Path) -or [IO.Path]::GetFullPath($Path) -cne $Path) { + Stop-PackagedConnect 'artifact-type' + } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch [Management.Automation.ItemNotFoundException] { + Stop-PackagedConnect 'artifact-missing' + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } + if (($Kind -eq 'directory') -ne $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ![String]::Equals($item.FullName, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + return $item +} + +function Test-ExactJsonProperties { + param( + [AllowNull()][object]$Object, + [Parameter(Mandatory=$true)][string[]]$Expected + ) + if ($null -eq $Object -or $Object -is [Array] -or $Object -is [string] -or + $Object -is [ValueType]) { + return $false + } + $actual = @($Object.PSObject.Properties | ForEach-Object { $_.Name }) + if ($actual.Count -ne $Expected.Count) { return $false } + foreach ($name in $Expected) { + if ($actual -cnotcontains $name) { return $false } + } + return $true +} + +function Test-UniqueJsonPropertyNames { + param([Parameter(Mandatory=$true)][string]$Text) + $objectKeys = [Collections.ArrayList]::new() + $index = 0 + while ($index -lt $Text.Length) { + $character = $Text[$index] + if ($character -ceq '{') { + $keys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $null = $objectKeys.Add($keys) + $index++ + continue + } + if ($character -ceq '}') { + if ($objectKeys.Count -eq 0) { return $true } + $objectKeys.RemoveAt($objectKeys.Count - 1) + $index++ + continue + } + if ($character -cne '"') { + $index++ + continue + } + $start = $index + 1 + $escaped = $false + $containsEscape = $false + $index++ + while ($index -lt $Text.Length) { + $stringCharacter = $Text[$index] + if ($escaped) { + $escaped = $false + } elseif ($stringCharacter -ceq '\') { + $escaped = $true + $containsEscape = $true + } elseif ($stringCharacter -ceq '"') { + break + } + $index++ + } + if ($index -ge $Text.Length) { return $true } + $end = $index + $lookahead = $index + 1 + while ($lookahead -lt $Text.Length -and [Char]::IsWhiteSpace($Text[$lookahead])) { + $lookahead++ + } + if ($lookahead -lt $Text.Length -and $Text[$lookahead] -ceq ':') { + if ($objectKeys.Count -eq 0 -or $containsEscape) { return $false } + $propertyName = $Text.Substring($start, $end - $start) + $keys = $objectKeys[$objectKeys.Count - 1] + if (!$keys.Add($propertyName)) { return $false } + } + $index++ + } + return $true +} + +function Assert-CaptureAuthorityAcl { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)] + [Security.Principal.SecurityIdentifier]$CapturePrivilegedSid + ) + Set-CaptureAuthorityPredicate 'capture-owner' + try { + $sections = [Security.AccessControl.AccessControlSections]::Access -bor + [Security.AccessControl.AccessControlSections]::Owner + $acl = [IO.File]::GetAccessControl($Path, $sections) + $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]) + $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + $ownerValues = @($CapturePrivilegedSid.Value, $administratorsSid.Value) + if ($null -eq $owner -or + $ownerValues -cnotcontains $owner.Value -or + ($null -ne $testUserSid -and $owner.Value -ceq $testUserSid.Value)) { + Stop-PackagedConnect 'artifact-type' + } + Set-CaptureAuthorityPredicate 'dacl-canonicality' + if (!$acl.AreAccessRulesProtected -or !$acl.AreAccessRulesCanonical) { + Stop-PackagedConnect 'artifact-type' + } + + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $authorizedWriters = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($identity in @($CapturePrivilegedSid, $administratorsSid, $systemSid)) { + if ($null -ne $identity) { $null = $authorizedWriters.Add($identity.Value) } + } + $mutationRights = [Security.AccessControl.FileSystemRights]::Write -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + Set-CaptureAuthorityPredicate 'unauthorized-writer' + foreach ($rule in $rules) { + if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and + ($rule.FileSystemRights -band $mutationRights) -ne 0 -and + !$authorizedWriters.Contains($rule.IdentityReference.Value)) { + Stop-PackagedConnect 'artifact-type' + } + } +} + +function Assert-PrivilegedCaptureFile { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][Microsoft.Win32.SafeHandles.SafeFileHandle]$AuthorityHandle, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [string]$ExpectedIdentity = '', + [string]$TestOnlyIdentityPredicate = 'identity-replacement', + [switch]$SkipAcl + ) + Set-CaptureAuthorityPredicate 'link-path-type' + $attributes = [ProprHostLauncherNative]::GetAttributes($AuthorityHandle) + $finalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($AuthorityHandle)) + ) + if ([ProprHostLauncherNative]::GetHandleType($AuthorityHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($attributes -band ( + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT + )) -ne 0 -or + [ProprHostLauncherNative]::GetLinkCount($AuthorityHandle) -ne 1 -or + ![String]::Equals($finalPath, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + $identity = [ProprHostLauncherNative]::GetIdentity($AuthorityHandle) + Set-CaptureAuthorityPredicate $TestOnlyIdentityPredicate + if (![String]::IsNullOrEmpty($ExpectedIdentity) -and + ![String]::Equals($identity, $ExpectedIdentity, [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + if (!$SkipAcl) { Assert-CaptureAuthorityAcl $Path $CapturePrivilegedSid } + return $identity +} + +function Get-CaptureAuthorityDescriptor { + param([Parameter(Mandatory=$true)][string]$Path) + $sections = [Security.AccessControl.AccessControlSections]::Access -bor + [Security.AccessControl.AccessControlSections]::Owner + return [IO.File]::GetAccessControl($Path, $sections).GetSecurityDescriptorSddlForm($sections) +} + +function Initialize-PrivilegedCaptureFile { + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [switch]$NormalizeExisting + ) + $authorityHandle = $null + try { + Set-CaptureAuthorityPredicate 'link-path-type' + if ([String]::IsNullOrEmpty($authenticatedRunnerTemp) -or + ![IO.Path]::IsPathRooted($authenticatedRunnerTemp) -or + [IO.Path]::GetFullPath($authenticatedRunnerTemp).TrimEnd('\') -cne $authenticatedRunnerTemp -or + [IO.Path]::GetDirectoryName($Path) -cne $authenticatedRunnerTemp -or + [IO.Path]::GetFileName($Path) -cnotmatch '^propr-connect-[a-f0-9]{32}\.(stdout|stderr)$' -or + [IO.Path]::GetFullPath($Path) -cne $Path) { + Stop-PackagedConnect 'artifact-type' + } + + Initialize-HostLauncherNative + if ($NormalizeExisting) { + $authorityHandle = [ProprHostLauncherNative]::OpenRedirectCaptureAuthority($Path) + $null = Assert-PrivilegedCaptureFile ` + $Path $authorityHandle $CapturePrivilegedSid -SkipAcl + } elseif (Test-Path -LiteralPath $Path) { + Stop-PackagedConnect 'artifact-type' + } + + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + if ($LifecycleTestMode -ceq 'capture-redirection') { + Set-CaptureAuthorityPredicate 'pre-create' + } + $captureAcl = [Security.AccessControl.FileSecurity]::new() + $captureAcl.SetAccessRuleProtection($true, $false) + $captureAcl.SetOwner($CapturePrivilegedSid) + foreach ($identity in @($CapturePrivilegedSid, $administratorsSid, $systemSid)) { + $null = $captureAcl.AddAccessRule( + [Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + ) + } + + if ($NormalizeExisting) { + [IO.File]::SetAccessControl($Path, $captureAcl) + $authorityHandle.Dispose() + $authorityHandle = $null + } else { + $captureStream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [Security.AccessControl.FileSystemRights]::FullControl, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::None, + $captureAcl + ) + $captureStream.Dispose() + } + + $authorityHandle = [ProprHostLauncherNative]::OpenRedirectCaptureAuthority($Path) + $identity = Assert-PrivilegedCaptureFile $Path $authorityHandle $CapturePrivilegedSid + $result = [PSCustomObject]@{ + Path = $Path + Identity = $identity + SecurityDescriptor = (Get-CaptureAuthorityDescriptor $Path) + Handle = $authorityHandle + } + $authorityHandle = $null + return $result + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + if ($null -ne $authorityHandle) { $authorityHandle.Dispose() } + } +} + +function Assert-PrivilegedCaptureIdentity { + param( + [Parameter(Mandatory=$true)]$Authority, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$CapturePrivilegedSid, + [string]$TestOnlyIdentityPredicate = 'identity-replacement' + ) + $reopenHandle = $null + try { + Set-CaptureAuthorityPredicate $TestOnlyIdentityPredicate + if ($null -eq $Authority -or !($Authority.Path -is [string]) -or + !($Authority.Identity -is [string]) -or + !($Authority.SecurityDescriptor -is [string]) -or + !($Authority.Handle -is [Microsoft.Win32.SafeHandles.SafeFileHandle]) -or + $Authority.Handle.IsInvalid -or $Authority.Handle.IsClosed -or + ![String]::Equals( + [ProprHostLauncherNative]::GetIdentity($Authority.Handle), + $Authority.Identity, + [StringComparison]::Ordinal + )) { + Stop-PackagedConnect 'artifact-type' + } + $reopenHandle = [ProprHostLauncherNative]::OpenRedirectCaptureAuthority($Authority.Path) + $null = Assert-PrivilegedCaptureFile ` + $Authority.Path $reopenHandle $CapturePrivilegedSid $Authority.Identity ` + -TestOnlyIdentityPredicate $TestOnlyIdentityPredicate + Set-CaptureAuthorityPredicate 'dacl-canonicality' + if ((Get-CaptureAuthorityDescriptor $Authority.Path) -cne $Authority.SecurityDescriptor) { + Stop-PackagedConnect 'artifact-type' + } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + if ($null -ne $reopenHandle) { $reopenHandle.Dispose() } + } +} + +function Read-AuthorizedCaptureBytes { + param( + [Parameter(Mandatory=$true)][string]$Path, + [scriptblock]$TestOnlyBeforeReopen, + [switch]$TestOnlyAllowReplacement, + [Security.Principal.SecurityIdentifier]$TestOnlyCapturePrivilegedSid, + [Security.Principal.SecurityIdentifier]$TestOnlyExpectedParentOwnerSid, + [string]$ExpectedCaptureIdentity = '' + ) + $parentHandle = $null + $parentReopenHandle = $null + $captureHandle = $null + $captureReopenHandle = $null + $captureFinalHandle = $null + try { + Set-CaptureParseSubphase 'capture-authority' + Set-CaptureAuthorityPredicate 'link-path-type' + $capturePrivilegedSid = if ($null -eq $TestOnlyCapturePrivilegedSid) { + $privilegedSid + } else { + $TestOnlyCapturePrivilegedSid + } + if ([String]::IsNullOrEmpty($authenticatedRunnerTemp) -or + $null -eq $capturePrivilegedSid -or + ![IO.Path]::IsPathRooted($authenticatedRunnerTemp) -or + [IO.Path]::GetFullPath($authenticatedRunnerTemp).TrimEnd('\') -cne $authenticatedRunnerTemp -or + [IO.Path]::GetDirectoryName($Path) -cne $authenticatedRunnerTemp -or + [IO.Path]::GetFileName($Path) -cnotmatch '^propr-connect-[a-f0-9]{32}\.stderr$' -or + [IO.Path]::GetFullPath($Path) -cne $Path) { + Stop-PackagedConnect 'artifact-type' + } + + Initialize-HostLauncherNative + $parentHandle = [ProprHostLauncherNative]::Open($authenticatedRunnerTemp, $true) + $parentAttributes = [ProprHostLauncherNative]::GetAttributes($parentHandle) + $parentFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($parentHandle)) + ) + if ([ProprHostLauncherNative]::GetHandleType($parentHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($parentAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY) -eq 0 -or + ($parentAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE) -ne 0 -or + ($parentAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT) -ne 0 -or + ![String]::Equals( + $parentFinalPath, $authenticatedRunnerTemp, [StringComparison]::OrdinalIgnoreCase + )) { + Stop-PackagedConnect 'artifact-type' + } + $parentIdentity = [ProprHostLauncherNative]::GetIdentity($parentHandle) + try { + $parentAcl = [IO.Directory]::GetAccessControl( + $authenticatedRunnerTemp, + [Security.AccessControl.AccessControlSections]::Owner + ) + $parentOwner = $parentAcl.GetOwner([Security.Principal.SecurityIdentifier]) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + Set-CaptureAuthorityPredicate 'parent-owner' + if ($null -eq $parentOwner -or @( + $privilegedSid.Value, $administratorsSid.Value, 'S-1-5-18' + ) -cnotcontains $parentOwner.Value) { + Stop-PackagedConnect 'artifact-type' + } + if ($null -ne $TestOnlyExpectedParentOwnerSid -and + ($LifecycleTestMode -cne 'capture-parser' -or + $CaptureParserAuthorityTestCase -cne 'foreign-parent-owner')) { + Stop-PackagedConnect 'artifact-type' + } + if ($null -ne $TestOnlyExpectedParentOwnerSid -and + $parentOwner.Value -cne $TestOnlyExpectedParentOwnerSid.Value) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureAuthorityPredicate 'link-path-type' + $captureHandle = [ProprHostLauncherNative]::OpenCapture( + $Path, !$TestOnlyAllowReplacement.IsPresent + ) + $captureAttributes = [ProprHostLauncherNative]::GetAttributes($captureHandle) + $captureFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($captureHandle)) + ) + if ([ProprHostLauncherNative]::GetHandleType($captureHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($captureAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY) -ne 0 -or + ($captureAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE) -ne 0 -or + ($captureAttributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT) -ne 0 -or + [ProprHostLauncherNative]::GetLinkCount($captureHandle) -ne 1 -or + ![String]::Equals($captureFinalPath, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + $captureIdentity = [ProprHostLauncherNative]::GetIdentity($captureHandle) + Set-CaptureAuthorityPredicate 'identity-replacement' + if (![String]::IsNullOrEmpty($ExpectedCaptureIdentity) -and + ![String]::Equals($captureIdentity, $ExpectedCaptureIdentity, [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + Assert-CaptureAuthorityAcl $Path $capturePrivilegedSid + + if ($null -ne $TestOnlyBeforeReopen) { & $TestOnlyBeforeReopen } + $captureReopenHandle = [ProprHostLauncherNative]::OpenCapture($Path, $true) + $captureReopenAttributes = [ProprHostLauncherNative]::GetAttributes($captureReopenHandle) + $captureReopenIdentity = [ProprHostLauncherNative]::GetIdentity($captureReopenHandle) + $captureReopenFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($captureReopenHandle)) + ) + Set-CaptureAuthorityPredicate 'link-path-type' + if ([ProprHostLauncherNative]::GetHandleType($captureReopenHandle) -ne + [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($captureReopenAttributes -band ( + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE -bor + [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT + )) -ne 0 -or + [ProprHostLauncherNative]::GetLinkCount($captureReopenHandle) -ne 1 -or + ![String]::Equals($captureFinalPath, $captureReopenFinalPath, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + Set-CaptureAuthorityPredicate 'identity-replacement' + if (![String]::Equals($captureIdentity, $captureReopenIdentity, [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + Assert-CaptureAuthorityAcl $Path $capturePrivilegedSid + + Set-CaptureParseSubphase 'capture-size' + $captureLength = [ProprHostLauncherNative]::GetLength($captureReopenHandle) + if ($captureLength -lt 1 -or $captureLength -gt 65536) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-read' + $captureBytes = [ProprHostLauncherNative]::ReadBounded($captureReopenHandle, 65536) + if ($captureBytes.Length -ne $captureLength) { Stop-PackagedConnect 'artifact-type' } + + Set-CaptureParseSubphase 'capture-authority' + Set-CaptureAuthorityPredicate 'identity-replacement' + if (![String]::Equals( + $captureReopenIdentity, + [ProprHostLauncherNative]::GetIdentity($captureReopenHandle), + [StringComparison]::Ordinal + )) { + Stop-PackagedConnect 'artifact-type' + } + $captureFinalHandle = [ProprHostLauncherNative]::OpenCapture($Path, $true) + if (![String]::Equals( + $captureReopenIdentity, + [ProprHostLauncherNative]::GetIdentity($captureFinalHandle), + [StringComparison]::Ordinal + ) -or [ProprHostLauncherNative]::GetLinkCount($captureFinalHandle) -ne 1) { + Stop-PackagedConnect 'artifact-type' + } + Assert-CaptureAuthorityAcl $Path $capturePrivilegedSid + $parentReopenHandle = [ProprHostLauncherNative]::Open($authenticatedRunnerTemp, $true) + if (![String]::Equals( + $parentIdentity, + [ProprHostLauncherNative]::GetIdentity($parentReopenHandle), + [StringComparison]::Ordinal + )) { + Stop-PackagedConnect 'artifact-type' + } + return ,$captureBytes + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + foreach ($handle in @( + $captureFinalHandle, $captureReopenHandle, $captureHandle, + $parentReopenHandle, $parentHandle + )) { + if ($null -ne $handle) { $handle.Dispose() } + } + } +} + +function Read-PackagedConnectSmokeFailure { + param( + [Parameter(Mandatory=$true)][string]$Path, + [scriptblock]$TestOnlyBeforeReopen, + [switch]$TestOnlyAllowReplacement, + [Security.Principal.SecurityIdentifier]$TestOnlyCapturePrivilegedSid, + [Security.Principal.SecurityIdentifier]$TestOnlyExpectedParentOwnerSid, + [string]$ExpectedCaptureIdentity = '' + ) + + $captureBytes = Read-AuthorizedCaptureBytes ` + -Path $Path ` + -TestOnlyBeforeReopen $TestOnlyBeforeReopen ` + -TestOnlyAllowReplacement:$TestOnlyAllowReplacement ` + -TestOnlyCapturePrivilegedSid $TestOnlyCapturePrivilegedSid ` + -TestOnlyExpectedParentOwnerSid $TestOnlyExpectedParentOwnerSid ` + -ExpectedCaptureIdentity $ExpectedCaptureIdentity + + Set-CaptureParseSubphase 'capture-utf8' + try { + $captureText = [Text.UTF8Encoding]::new($false, $true).GetString($captureBytes) + } catch { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-redaction' + $sensitiveValues = @( + $stageRoot, $stageParent, $stageLeaf, $stdout, $stderr, $testUser, + $plainPassword, $handoffArgument, 'S-1-5-', 'SENTINEL' + ) + foreach ($sensitiveValue in $sensitiveValues) { + if ($sensitiveValue -is [string] -and $sensitiveValue.Length -gt 0 -and + $captureText.IndexOf($sensitiveValue, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + Stop-PackagedConnect 'artifact-type' + } + } + + Set-CaptureParseSubphase 'capture-line-cardinality' + if (!$captureText.EndsWith("`n", [StringComparison]::Ordinal) -or + $captureText.IndexOf("`r", [StringComparison]::Ordinal) -ge 0) { + Stop-PackagedConnect 'artifact-type' + } + $jsonLine = $captureText.Substring(0, $captureText.Length - 1) + if ($jsonLine.Length -eq 0 -or $jsonLine.IndexOf("`n", [StringComparison]::Ordinal) -ge 0) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-schema-cardinality' + if (!(Test-UniqueJsonPropertyNames $jsonLine)) { + Stop-PackagedConnect 'artifact-type' + } + Set-CaptureParseSubphase 'capture-json' + try { + $failureRecord = ConvertFrom-Json -InputObject $jsonLine -ErrorAction Stop + } catch { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-event-cardinality' + if ($null -eq $failureRecord -or $failureRecord -is [Array] -or + $failureRecord -is [string] -or $failureRecord -is [ValueType] -or + !($failureRecord.event -is [string]) -or + $failureRecord.event -cnotin @( + 'packaged_connect.artifact_failed','packaged_connect.smoke_failed' + )) { + Stop-PackagedConnect 'artifact-type' + } + + if ($failureRecord.event -ceq 'packaged_connect.artifact_failed') { + $artifactPhases = @( + 'staged-contract','staged-tree','staged-architecture','ordinary-user-preflight' + ) + Set-CaptureParseSubphase 'capture-lifecycle-phase' + if (!($failureRecord.phase -is [string]) -or + $artifactPhases -cnotcontains $failureRecord.phase) { + Stop-PackagedConnect 'artifact-type' + } + + $artifactRequiresSubphase = $failureRecord.phase -cin @( + 'staged-contract','ordinary-user-preflight' + ) + $artifactProperties = @('event','category','phase') + if ($artifactRequiresSubphase) { $artifactProperties += 'subphase' } + Set-CaptureParseSubphase 'capture-schema-cardinality' + if (!(Test-ExactJsonProperties $failureRecord $artifactProperties) -or + !($failureRecord.category -is [string])) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-category' + $artifactCategories = if ($failureRecord.phase -ceq 'staged-contract') { + @('artifact-type') + } elseif ($failureRecord.phase -ceq 'staged-tree') { + @('artifact-missing','artifact-inaccessible','artifact-type') + } elseif ($failureRecord.phase -ceq 'staged-architecture') { + @('artifact-missing','artifact-inaccessible','artifact-type','architecture-mismatch') + } else { + @('artifact-inaccessible','artifact-type') + } + if ($artifactCategories -cnotcontains $failureRecord.category) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-subphase' + if ($failureRecord.phase -ceq 'staged-contract') { + if (!($failureRecord.subphase -is [string]) -or + $childStagedContractSubphases -cnotcontains $failureRecord.subphase) { + Stop-PackagedConnect 'artifact-type' + } + } elseif ($failureRecord.phase -ceq 'ordinary-user-preflight') { + if (!($failureRecord.subphase -is [string]) -or + $childFailureSubphases -cnotcontains $failureRecord.subphase) { + Stop-PackagedConnect 'artifact-type' + } + } + + $script:failurePhase = $failureRecord.phase + $script:failureSubphase = if ($artifactRequiresSubphase) { + $failureRecord.subphase + } else { + $null + } + return $failureRecord.category + } + + Set-CaptureParseSubphase 'capture-schema-cardinality' + $hasSecondary = $null -ne $failureRecord -and + $null -ne $failureRecord.PSObject.Properties['secondary'] + $topLevelProperties = @('event','category','capture','records') + if ($hasSecondary) { $topLevelProperties += 'secondary' } + if (!(Test-ExactJsonProperties $failureRecord $topLevelProperties) -or + !($failureRecord.category -is [string]) -or + !($failureRecord.capture -is [string]) -or + !($failureRecord.records -is [Array])) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-category' + if ($lifecycleFailureSubphases -cnotcontains $failureRecord.category) { + Stop-PackagedConnect 'artifact-type' + } + if ($failureRecord.capture -cnotin @('complete','truncated')) { + Stop-PackagedConnect 'artifact-type' + } + $diagnosticRecords = @($failureRecord.records) + if ($diagnosticRecords.Count -gt 20) { + Stop-PackagedConnect 'artifact-type' + } + + $diagnosticEvents = @( + 'desktop.app.ready', + 'desktop.app.start_failed', + 'desktop.log.write_failed', + 'desktop.main_process.uncaught_exception', + 'desktop.renderer.connect_discovery.ready', + 'desktop.renderer.connect_discovery.phase', + 'desktop.renderer.connect_discovery.proof', + 'desktop.renderer.connect_discovery.status', + 'desktop.renderer.gone', + 'desktop.renderer.ready' + ) + $diagnosticCodes = @( + 'CONNECT_STATUS_INCOMPATIBLE','CONNECT_STATUS_INTERNAL_FAILURE', + 'CONNECT_STATUS_INVALID_CONFIG','CONNECT_STATUS_NOT_READY','CONNECT_STATUS_READY', + 'CONNECT_STATUS_TIMEOUT','DETAIL_REDACTED','LOG_WRITE_FAILED','OPERATION_FAILED', + 'UNCAUGHT_EXCEPTION' + ) + $diagnosticPhases = @( + 'config-read','addon-integrity-type','addon-load','descriptor-operation', + 'authority-inspection','status-resolution' + ) + $diagnosticSubsteps = @('directory-open','addon-open','fstat-type') + $diagnosticCategories = @( + 'access-denied','invalid-argument','io-failure','missing-entry','not-directory', + 'symlink-refused','type-mismatch','unexpected' + ) + foreach ($diagnosticRecord in $diagnosticRecords) { + Set-CaptureParseSubphase 'capture-schema-cardinality' + if ($null -eq $diagnosticRecord -or $diagnosticRecord -is [Array] -or + $diagnosticRecord -is [string] -or $diagnosticRecord -is [ValueType]) { + Stop-PackagedConnect 'artifact-type' + } + $hasCode = $null -ne $diagnosticRecord.PSObject.Properties['code'] + $hasPhase = $null -ne $diagnosticRecord.PSObject.Properties['phase'] + $hasSubstep = $null -ne $diagnosticRecord.PSObject.Properties['substep'] + $hasCategory = $null -ne $diagnosticRecord.PSObject.Properties['category'] + $expectedProperties = @('event') + if ($hasCode) { $expectedProperties += 'code' } + if ($hasPhase) { $expectedProperties += 'phase' } + if ($hasSubstep) { $expectedProperties += 'substep' } + if ($hasCategory) { $expectedProperties += 'category' } + if (!(Test-ExactJsonProperties $diagnosticRecord $expectedProperties)) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-event-cardinality' + if (!($diagnosticRecord.event -is [string]) -or + $diagnosticEvents -cnotcontains $diagnosticRecord.event) { + Stop-PackagedConnect 'artifact-type' + } + + Set-CaptureParseSubphase 'capture-lifecycle-phase' + if ($hasPhase) { + if (!$hasCode -or !($diagnosticRecord.phase -is [string]) -or + $diagnosticPhases -cnotcontains $diagnosticRecord.phase -or + !($diagnosticRecord.code -is [string]) -or + $diagnosticRecord.code -cnotin @('STARTED','PASSED','FAILED')) { + Stop-PackagedConnect 'artifact-type' + } + } elseif ($hasCode) { + if (!($diagnosticRecord.code -is [string]) -or + $diagnosticCodes -cnotcontains $diagnosticRecord.code) { + Stop-PackagedConnect 'artifact-type' + } + } + + Set-CaptureParseSubphase 'capture-lifecycle-subphase' + if (($hasSubstep -or $hasCategory) -and + (!$hasPhase -or $diagnosticRecord.code -cne 'FAILED')) { + Stop-PackagedConnect 'artifact-type' + } + if ($hasSubstep -and (!($diagnosticRecord.substep -is [string]) -or + $diagnosticSubsteps -cnotcontains $diagnosticRecord.substep)) { + Stop-PackagedConnect 'artifact-type' + } + if ($hasCategory -and (!($diagnosticRecord.category -is [string]) -or + $diagnosticCategories -cnotcontains $diagnosticRecord.category)) { + Stop-PackagedConnect 'artifact-type' + } + } + + Set-CaptureParseSubphase 'capture-lifecycle-subphase' + if ($hasSecondary) { + if (!($failureRecord.secondary -is [Array])) { + Stop-PackagedConnect 'artifact-type' + } + $secondaryValues = @($failureRecord.secondary) + if ($secondaryValues.Count -lt 1 -or $secondaryValues.Count -gt 5) { + Stop-PackagedConnect 'artifact-type' + } + $allowedSecondary = @( + 'tree-termination-failed','child-close-unconfirmed','stream-drain-failed', + 'fixture-cleanup-failed','fixture-cleanup-authorization-failed' + ) + $uniqueSecondary = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($secondaryValue in $secondaryValues) { + if (!($secondaryValue -is [string]) -or + $allowedSecondary -cnotcontains $secondaryValue -or + !$uniqueSecondary.Add($secondaryValue)) { + Stop-PackagedConnect 'artifact-type' + } + } + } + Set-LifecycleFailureSubphase $failureRecord.category + return 'spawn-failed' +} + +$hostLauncherNativeSource = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class ProprHostLauncherNative { + public const uint GENERIC_READ = 0x80000000; + public const uint READ_CONTROL = 0x00020000; + public const uint FILE_READ_ATTRIBUTES = 0x00000080; + public const uint FILE_SHARE_READ = 0x00000001; + public const uint FILE_SHARE_WRITE = 0x00000002; + public const uint FILE_SHARE_DELETE = 0x00000004; + public const uint OPEN_EXISTING = 3; + public const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + public const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010; + public const uint FILE_ATTRIBUTE_DEVICE = 0x00000040; + public const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + public const uint FILE_TYPE_DISK = 0x0001; + + [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; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ID_128 { + public ulong Low; + public ulong High; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ID_INFO { + public ulong VolumeSerialNumber; + public FILE_ID_128 FileId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, + out BY_HANDLE_FILE_INFORMATION information + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle file, + int fileInformationClass, + out FILE_ID_INFO information, + uint bufferSize + ); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetFileType(SafeFileHandle file); + + [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] + private static extern bool ReadFile( + SafeFileHandle file, + byte[] buffer, + uint bytesToRead, + out uint bytesRead, + IntPtr overlapped + ); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle file, + StringBuilder path, + uint pathLength, + uint flags + ); + + public static SafeFileHandle Open(string path, bool finalPathAuthority) { + uint share = finalPathAuthority + ? FILE_SHARE_READ + : FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + uint flags = FILE_FLAG_BACKUP_SEMANTICS; + if (finalPathAuthority) flags |= FILE_FLAG_OPEN_REPARSE_POINT; + SafeFileHandle handle = CreateFileW( + path, + FILE_READ_ATTRIBUTES, + share, + IntPtr.Zero, + OPEN_EXISTING, + flags, + IntPtr.Zero + ); + if (handle.IsInvalid) { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + return handle; + } + + public static SafeFileHandle OpenCapture(string path, bool lockAuthority) { + uint share = lockAuthority + ? FILE_SHARE_READ + : FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + SafeFileHandle handle = CreateFileW( + path, + GENERIC_READ | READ_CONTROL, + share, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + IntPtr.Zero + ); + if (handle.IsInvalid) { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + return handle; + } + + public static SafeFileHandle OpenRedirectCaptureAuthority(string path) { + SafeFileHandle handle = CreateFileW( + path, + FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + IntPtr.Zero + ); + if (handle.IsInvalid) { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + return handle; + } + + public static string GetIdentity(SafeFileHandle handle) { + const int FileIdInfo = 18; + FILE_ID_INFO information; + if (!GetFileInformationByHandleEx( + handle, + FileIdInfo, + out information, + (uint)Marshal.SizeOf(typeof(FILE_ID_INFO)) + )) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return String.Format( + System.Globalization.CultureInfo.InvariantCulture, + "{0:X16}:{1:X16}:{2:X16}", + information.VolumeSerialNumber, + information.FileId.High, + information.FileId.Low + ); + } + + public static uint GetAttributes(SafeFileHandle handle) { + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return information.FileAttributes; + } + + public static uint GetLinkCount(SafeFileHandle handle) { + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return information.NumberOfLinks; + } + + public static long GetLength(SafeFileHandle handle) { + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + return ((long)information.FileSizeHigh << 32) | information.FileSizeLow; + } + + public static byte[] ReadBounded(SafeFileHandle handle, int maximumLength) { + if (maximumLength < 1) throw new ArgumentOutOfRangeException("maximumLength"); + using (System.IO.MemoryStream output = new System.IO.MemoryStream()) { + byte[] buffer = new byte[Math.Min(4096, maximumLength + 1)]; + while (output.Length <= maximumLength) { + int remaining = maximumLength + 1 - (int)output.Length; + uint requested = (uint)Math.Min(buffer.Length, remaining); + uint read; + if (!ReadFile(handle, buffer, requested, out read, IntPtr.Zero)) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + if (read == 0) break; + output.Write(buffer, 0, (int)read); + } + return output.ToArray(); + } + } + + public static uint GetHandleType(SafeFileHandle handle) { + uint type = GetFileType(handle); + if (type == 0) { + int error = Marshal.GetLastWin32Error(); + if (error != 0) throw new Win32Exception(error); + } + return type; + } + + public static string GetFinalPath(SafeFileHandle handle) { + StringBuilder path = new StringBuilder(32768); + uint length = GetFinalPathNameByHandleW(handle, path, (uint)path.Capacity, 0); + if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (length >= path.Capacity) throw new Win32Exception(206); + return path.ToString(); + } +} +'@ + +function Initialize-HostLauncherNative { + if ($null -eq ('ProprHostLauncherNative' -as [type])) { + Add-Type -TypeDefinition $hostLauncherNativeSource -Language CSharp -ErrorAction Stop + } +} + +function Get-BoundedAbsoluteWindowsPath { + param( + [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Path, + [switch]$SelectedPathPredicates + ) + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-input' + } + if ([String]::IsNullOrEmpty($Path) -or $Path.Length -gt 259 -or $Path -cmatch '[\x00-\x1f\x7f]' -or + $Path.StartsWith('\\?\', [StringComparison]::Ordinal) -or + $Path.StartsWith('\\.\', [StringComparison]::Ordinal) -or + $Path.StartsWith('\??\', [StringComparison]::Ordinal)) { + Stop-PackagedConnect 'artifact-type' + } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-extra-colon' + } + if ($Path.Length -gt 2 -and $Path.Substring(2).Contains(':')) { + Stop-PackagedConnect 'artifact-type' + } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-get-full-path' + } + try { + $fullPath = [IO.Path]::GetFullPath($Path) + } catch { + Stop-PackagedConnect 'artifact-type' + } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-absolute-shape' + } + $driveAbsolute = $fullPath -cmatch '^[A-Za-z]:\\' + $uncAbsolute = $fullPath -cmatch '^\\\\[^\\:]+\\[^\\:]+\\' + if (!$driveAbsolute -and !$uncAbsolute) { Stop-PackagedConnect 'artifact-type' } + if ($SelectedPathPredicates) { + Set-OrdinaryUserPreflightSubphase 'host-launcher-selected-path-canonical-equality' + } + if (![String]::Equals($fullPath, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + return $fullPath +} + +function ConvertFrom-NativeFinalPath { + param([Parameter(Mandatory=$true)][string]$Path) + if ($Path.StartsWith('\\?\UNC\', [StringComparison]::OrdinalIgnoreCase)) { + return '\\' + $Path.Substring(8) + } + if ($Path.StartsWith('\\?\', [StringComparison]::OrdinalIgnoreCase)) { + return $Path.Substring(4) + } + Stop-PackagedConnect 'artifact-type' +} + +function Assert-OrdinaryHostLauncherHandle { + param([Parameter(Mandatory=$true)]$Handle) + $attributes = [ProprHostLauncherNative]::GetAttributes($Handle) + if ([ProprHostLauncherNative]::GetHandleType($Handle) -ne [ProprHostLauncherNative]::FILE_TYPE_DISK -or + ($attributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DIRECTORY) -ne 0 -or + ($attributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_DEVICE) -ne 0 -or + ($attributes -band [ProprHostLauncherNative]::FILE_ATTRIBUTE_REPARSE_POINT) -ne 0) { + Stop-PackagedConnect 'artifact-type' + } +} + +function Get-TrustedHostLauncher { + param( + [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Path, + [scriptblock]$TestOnlyBeforeFinalReopen, + [scriptblock]$TestOnlyBeforeSourceReopen + ) + $sourceHandle = $null + $authorityHandle = $null + $sourceReopenHandle = $null + $authorityTransferred = $false + try { + Set-OrdinaryUserPreflightSubphase 'host-launcher-native-initialization' + Initialize-HostLauncherNative + $selectedPath = Get-BoundedAbsoluteWindowsPath -Path $Path -SelectedPathPredicates + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-open' + $sourceHandle = [ProprHostLauncherNative]::Open($selectedPath, $false) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-type' + Assert-OrdinaryHostLauncherHandle $sourceHandle + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-identity' + $sourceIdentity = [ProprHostLauncherNative]::GetIdentity($sourceHandle) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-final-path' + $finalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($sourceHandle)) + ) + + if ($null -ne $TestOnlyBeforeFinalReopen) { & $TestOnlyBeforeFinalReopen } + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-open' + $authorityHandle = [ProprHostLauncherNative]::Open($finalPath, $true) + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-type' + Assert-OrdinaryHostLauncherHandle $authorityHandle + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-identity' + $authorityIdentity = [ProprHostLauncherNative]::GetIdentity($authorityHandle) + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-path' + $authorityFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($authorityHandle)) + ) + Set-OrdinaryUserPreflightSubphase 'host-launcher-final-match' + if (![String]::Equals($sourceIdentity, $authorityIdentity, [StringComparison]::Ordinal) -or + ![String]::Equals($finalPath, $authorityFinalPath, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + + if ($null -ne $TestOnlyBeforeSourceReopen) { & $TestOnlyBeforeSourceReopen } + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen' + $sourceReopenHandle = [ProprHostLauncherNative]::Open($selectedPath, $false) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-type' + Assert-OrdinaryHostLauncherHandle $sourceReopenHandle + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-identity' + $sourceReopenIdentity = [ProprHostLauncherNative]::GetIdentity($sourceReopenHandle) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-final-path' + $sourceReopenFinalPath = Get-BoundedAbsoluteWindowsPath ( + ConvertFrom-NativeFinalPath ([ProprHostLauncherNative]::GetFinalPath($sourceReopenHandle)) + ) + Set-OrdinaryUserPreflightSubphase 'host-launcher-source-reopen-match' + if (![String]::Equals($authorityIdentity, $sourceReopenIdentity, [StringComparison]::Ordinal) -or + ![String]::Equals($finalPath, $sourceReopenFinalPath, [StringComparison]::OrdinalIgnoreCase)) { + Stop-PackagedConnect 'artifact-type' + } + + $authorityTransferred = $true + return [PSCustomObject]@{ Path = $finalPath; Handle = $authorityHandle } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + $nativeException = $_.Exception + while ($null -ne $nativeException.InnerException) { $nativeException = $nativeException.InnerException } + if ($nativeException -is [ComponentModel.Win32Exception] -and $nativeException.NativeErrorCode -in @(2,3)) { + Stop-PackagedConnect 'artifact-missing' + } + Stop-PackagedConnect 'artifact-inaccessible' + } finally { + if ($null -ne $sourceHandle) { $sourceHandle.Dispose() } + if ($null -ne $sourceReopenHandle) { $sourceReopenHandle.Dispose() } + if (!$authorityTransferred -and $null -ne $authorityHandle) { $authorityHandle.Dispose() } + } +} + +function Assert-PeArchitecture { + param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$ExpectedArchitecture + ) + try { + $stream = [IO.FileStream]::new($Executable, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $header = New-Object byte[] 4096 + $length = $stream.Read($header, 0, $header.Length) + } finally { + $stream.Dispose() + } + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + if ($length -lt 64 -or [Text.Encoding]::ASCII.GetString($header, 0, 2) -cne 'MZ') { + Stop-PackagedConnect 'artifact-type' + } + $pe = [BitConverter]::ToUInt32($header, 0x3c) + if ($pe -lt 0x40 -or $pe + 6 -gt $length -or + [Text.Encoding]::ASCII.GetString($header, [int]$pe, 4) -cne "PE`0`0") { + Stop-PackagedConnect 'artifact-type' + } + $expectedMachine = if ($ExpectedArchitecture -eq 'arm64') { 0xaa64 } else { 0x8664 } + if ([BitConverter]::ToUInt16($header, [int]$pe + 4) -ne $expectedMachine) { + Stop-PackagedConnect 'architecture-mismatch' + } +} + +function Assert-PackageTreeTypes { + param([Parameter(Mandatory=$true)][string]$Root) + try { + $entries = @(Get-ChildItem -LiteralPath $Root -Force -Recurse -ErrorAction Stop) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + if ($entries.Count -lt 1 -or $entries.Count -gt 20000) { Stop-PackagedConnect 'artifact-type' } + foreach ($entry in $entries) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + (!$entry.PSIsContainer -and !($entry -is [IO.FileInfo]))) { + Stop-PackagedConnect 'artifact-type' + } + } + return $entries +} + +function Assert-CopiedPackageTree { + param( + [Parameter(Mandatory=$true)][string]$SourceRoot, + [Parameter(Mandatory=$true)][object[]]$SourceEntries, + [Parameter(Mandatory=$true)][string]$DestinationRoot, + [Parameter(Mandatory=$true)][object[]]$DestinationEntries + ) + if ($SourceEntries.Count -ne $DestinationEntries.Count) { Stop-PackagedConnect 'artifact-type' } + $destinationByRelativePath = @{} + foreach ($entry in $DestinationEntries) { + $relative = $entry.FullName.Substring($DestinationRoot.Length).TrimStart('\') + if ([String]::IsNullOrEmpty($relative) -or $destinationByRelativePath.ContainsKey($relative)) { + Stop-PackagedConnect 'artifact-type' + } + $destinationByRelativePath.Add($relative, $entry) + } + foreach ($source in $SourceEntries) { + $relative = $source.FullName.Substring($SourceRoot.Length).TrimStart('\') + if (!$destinationByRelativePath.ContainsKey($relative)) { Stop-PackagedConnect 'artifact-missing' } + $destination = $destinationByRelativePath[$relative] + if ($source.PSIsContainer -ne $destination.PSIsContainer -or + (!$source.PSIsContainer -and $source.Length -ne $destination.Length)) { + Stop-PackagedConnect 'artifact-type' + } + } +} + +function Set-StagedEntryAcl { + param( + [Parameter(Mandatory=$true)][IO.FileSystemInfo]$Item, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$OrdinaryUser, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$Administrators + ) + $system = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $directory = $Item.PSIsContainer + try { + $acl = if ($directory) { + [Security.AccessControl.DirectorySecurity]::new() + } else { + [Security.AccessControl.FileSecurity]::new() + } + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($Administrators) + foreach ($identity in @($OrdinaryUser, $system, $Administrators)) { + $rights = if ($identity.Value -eq $OrdinaryUser.Value) { + [Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [Security.AccessControl.FileSystemRights]::Synchronize + } else { + [Security.AccessControl.FileSystemRights]::FullControl + } + $rule = if ($directory) { + [Security.AccessControl.FileSystemAccessRule]::new( + $identity, + $rights, + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow + ) + } else { + [Security.AccessControl.FileSystemAccessRule]::new( + $identity, $rights, [Security.AccessControl.AccessControlType]::Allow + ) + } + $null = $acl.AddAccessRule($rule) + } + if ($directory) { + [IO.Directory]::SetAccessControl($Item.FullName, [Security.AccessControl.DirectorySecurity]$acl) + } else { + [IO.File]::SetAccessControl($Item.FullName, [Security.AccessControl.FileSecurity]$acl) + } + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } +} + +function Assert-StagedEntryAcl { + param( + [Parameter(Mandatory=$true)][IO.FileSystemInfo]$Item, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$OrdinaryUser, + [Parameter(Mandatory=$true)][Security.Principal.SecurityIdentifier]$Administrators + ) + $system = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + try { + $sections = [Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner + $acl = if ($Item.PSIsContainer) { + [IO.Directory]::GetAccessControl($Item.FullName, $sections) + } else { + [IO.File]::GetAccessControl($Item.FullName, $sections) + } + $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]) + $rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + } catch { + Stop-PackagedConnect 'artifact-inaccessible' + } + if ($owner.Value -ne $Administrators.Value -or !$acl.AreAccessRulesProtected -or + !$acl.AreAccessRulesCanonical -or $rules.Count -ne 3) { + Stop-PackagedConnect 'artifact-type' + } + foreach ($identity in @($OrdinaryUser, $system, $Administrators)) { + $matches = @($rules | Where-Object { $_.IdentityReference.Value -eq $identity.Value }) + $expected = if ($identity.Value -eq $OrdinaryUser.Value) { + [Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [Security.AccessControl.FileSystemRights]::Synchronize + } else { + [Security.AccessControl.FileSystemRights]::FullControl + } + $expectedInheritance = if ($Item.PSIsContainer) { + [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit + } else { + [Security.AccessControl.InheritanceFlags]::None + } + if ($matches.Count -ne 1 -or + $matches[0].AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + $matches[0].FileSystemRights -ne $expected -or + $matches[0].InheritanceFlags -ne $expectedInheritance -or + $matches[0].PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None -or + $matches[0].IsInherited) { + Stop-PackagedConnect 'artifact-type' + } + } +} + +$boundedCleanupSource = @' +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +try { + $runnerTemp=$env:PROPR_CLEANUP_RUNNER_TEMP + $parent=$env:PROPR_CLEANUP_STAGE_PARENT + $leaf=$env:PROPR_CLEANUP_STAGE_LEAF + $privileged=[Security.Principal.SecurityIdentifier]::new($env:PROPR_CLEANUP_PRIVILEGED_SID) + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + if([String]::IsNullOrEmpty($runnerTemp) -or ![IO.Path]::IsPathRooted($runnerTemp) -or + ![String]::Equals([IO.Path]::GetFullPath($runnerTemp),$runnerTemp,[StringComparison]::OrdinalIgnoreCase)){exit 91} + if(![String]::IsNullOrEmpty($parent) -or ![String]::IsNullOrEmpty($leaf)){ + if([IO.Path]::GetDirectoryName($parent) -cne $runnerTemp -or + [IO.Path]::GetFileName($parent) -cne 'propr-connect-packaged-stage' -or + $leaf -cnotmatch '^propr-connect-package-[a-f0-9]{32}$'){exit 91} + $root=[IO.Path]::Combine($parent,$leaf) + if([IO.Path]::GetDirectoryName($root) -cne $parent -or [IO.Path]::GetFileName($root) -cne $leaf){exit 91} + if(Test-Path -LiteralPath $root){ + $items=@((Get-Item -LiteralPath $root -Force -ErrorAction Stop)) + $items+=@(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop) + if($items.Count -gt 20001){exit 91} + foreach($item in $items){ + $isRoot=[String]::Equals($item.FullName,$root,[StringComparison]::OrdinalIgnoreCase) + if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ![String]::Equals([IO.Path]::GetFullPath($item.FullName),$item.FullName,[StringComparison]::OrdinalIgnoreCase) -or + (!$isRoot -and !$item.FullName.StartsWith($root+'\',[StringComparison]::OrdinalIgnoreCase)) -or + ($isRoot -and !$item.PSIsContainer)){exit 91} + $sections=[Security.AccessControl.AccessControlSections]::Owner + $acl=if($item.PSIsContainer){[IO.Directory]::GetAccessControl($item.FullName,$sections)}else{[IO.File]::GetAccessControl($item.FullName,$sections)} + $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]) + if(@($privileged.Value,$admins.Value) -cnotcontains $owner.Value){exit 91} + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop + if(Test-Path -LiteralPath $root){exit 92} + } + if(Test-Path -LiteralPath $parent){ + $parentItem=Get-Item -LiteralPath $parent -Force -ErrorAction Stop + if(!$parentItem.PSIsContainer -or ($parentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @(Get-ChildItem -LiteralPath $parent -Force -ErrorAction Stop).Count -ne 0){exit 91} + $parentAcl=[IO.Directory]::GetAccessControl($parent,[Security.AccessControl.AccessControlSections]::Owner) + $parentOwner=$parentAcl.GetOwner([Security.Principal.SecurityIdentifier]) + if(@($privileged.Value,$admins.Value) -cnotcontains $parentOwner.Value){exit 91} + Remove-Item -LiteralPath $parent -Force -ErrorAction Stop + if(Test-Path -LiteralPath $parent){exit 92} + } + } + foreach($capture in @($env:PROPR_CLEANUP_STDOUT,$env:PROPR_CLEANUP_STDERR)){ + if(![String]::IsNullOrEmpty($capture)){ + if([IO.Path]::GetDirectoryName($capture) -cne $runnerTemp -or + [IO.Path]::GetFileName($capture) -cnotmatch '^propr-connect-[a-f0-9]{32}\.(stdout|stderr)$'){exit 91} + if(Test-Path -LiteralPath $capture){ + $captureItem=Get-Item -LiteralPath $capture -Force -ErrorAction Stop + if($captureItem.PSIsContainer -or ($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){exit 91} + $captureAcl=[IO.File]::GetAccessControl($capture,[Security.AccessControl.AccessControlSections]::Owner) + $captureOwner=$captureAcl.GetOwner([Security.Principal.SecurityIdentifier]) + if(@($privileged.Value,$admins.Value) -cnotcontains $captureOwner.Value){exit 91} + Remove-Item -LiteralPath $capture -Force -ErrorAction Stop + if(Test-Path -LiteralPath $capture){exit 92} + } + } + } + $user=$env:PROPR_CLEANUP_USER + $userSid=$env:PROPR_CLEANUP_USER_SID + if(![String]::IsNullOrEmpty($user) -or ![String]::IsNullOrEmpty($userSid)){ + if($user -cnotmatch '^prpc[a-f0-9]{12}$' -or [String]::IsNullOrEmpty($userSid)){exit 91} + $account=Get-LocalUser -Name $user -ErrorAction Stop + if($account.SID.Value -cne $userSid){exit 91} + Remove-LocalUser -Name $user -ErrorAction Stop + if($null -ne (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)){exit 92} + } + exit 0 +} catch { exit 93 } +'@ + +function Invoke-BoundedCleanup { + param( + [string]$CleanupSource = $boundedCleanupSource, + [ref]$ObservedProcessId + ) + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($CleanupSource)) + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=Join-Path $PSHOME 'powershell.exe' + $start.Arguments="-NoLogo -NoProfile -NonInteractive -EncodedCommand $encoded" + $start.UseShellExecute=$false + $start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true + $start.RedirectStandardError=$true + $start.EnvironmentVariables['PROPR_CLEANUP_RUNNER_TEMP']=[string]$authenticatedRunnerTemp + $cleanupStageParent=if($null -eq $stageLeaf){''}else{[string]$stageParent} + $cleanupStageLeaf=if($null -eq $stageLeaf){''}else{[string]$stageLeaf} + $start.EnvironmentVariables['PROPR_CLEANUP_STAGE_PARENT']=$cleanupStageParent + $start.EnvironmentVariables['PROPR_CLEANUP_STAGE_LEAF']=$cleanupStageLeaf + $start.EnvironmentVariables['PROPR_CLEANUP_PRIVILEGED_SID']=if($null -eq $privilegedSid){''}else{$privilegedSid.Value} + $start.EnvironmentVariables['PROPR_CLEANUP_STDOUT']=[string]$stdout + $start.EnvironmentVariables['PROPR_CLEANUP_STDERR']=[string]$stderr + $start.EnvironmentVariables['PROPR_CLEANUP_USER']=[string]$testUser + $start.EnvironmentVariables['PROPR_CLEANUP_USER_SID']=if($null -eq $testUserSid){''}else{$testUserSid.Value} + $cleanupProcess=[Diagnostics.Process]::new() + $cleanupProcess.StartInfo=$start + $cleanupOutputBuffer=[IO.MemoryStream]::new() + $cleanupErrorBuffer=[IO.MemoryStream]::new() + try { + if(!$cleanupProcess.Start()){return 'failed'} + if($null -ne $ObservedProcessId){$ObservedProcessId.Value=$cleanupProcess.Id} + $cleanupOutputClose=$cleanupProcess.StandardOutput.BaseStream.CopyToAsync($cleanupOutputBuffer) + $cleanupErrorClose=$cleanupProcess.StandardError.BaseStream.CopyToAsync($cleanupErrorBuffer) + if(!$cleanupProcess.WaitForExit($cleanupTimeoutMilliseconds)){ + try{$cleanupProcess.Kill()}catch{return 'failed'} + try{if(!$cleanupProcess.WaitForExit($terminationTimeoutMilliseconds)){return 'failed'}}catch{return 'failed'} + try { + if(![Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($cleanupOutputClose,$cleanupErrorClose), + $streamCloseTimeoutMilliseconds + ) -or $cleanupOutputClose.IsFaulted -or $cleanupErrorClose.IsFaulted){return 'failed'} + } catch { return 'failed' } + return 'timeout' + } + if(![Threading.Tasks.Task]::WaitAll( + [Threading.Tasks.Task[]]@($cleanupOutputClose,$cleanupErrorClose), + $streamCloseTimeoutMilliseconds + ) -or $cleanupOutputClose.IsFaulted -or $cleanupErrorClose.IsFaulted -or + $cleanupProcess.ExitCode -ne 0 -or $cleanupOutputBuffer.Length -ne 0 -or + $cleanupErrorBuffer.Length -ne 0){return 'failed'} + return 'none' + } catch { + try{ + if(!$cleanupProcess.HasExited){ + $cleanupProcess.Kill() + $null=$cleanupProcess.WaitForExit($terminationTimeoutMilliseconds) + } + }catch{} + return 'failed' + } finally { + $cleanupProcess.Dispose() + $cleanupOutputBuffer.Dispose() + $cleanupErrorBuffer.Dispose() + } +} + +$authenticatedRunnerTemp = $null +$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + +if ($LifecycleTestMode -eq 'capture-redirection') { + $redirectionProcess = $null + $redirectionAccepted = $false + $redirectionFailurePredicate = $null + try { + Set-CaptureParseSubphase 'capture-authority' + Set-CaptureAuthorityPredicate 'pre-create' + if ([String]::IsNullOrEmpty($env:RUNNER_TEMP) -or ![IO.Path]::IsPathRooted($env:RUNNER_TEMP)) { + Stop-PackagedConnect 'artifact-type' + } + $authenticatedRunnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') + if ($authenticatedRunnerTemp -cne $env:RUNNER_TEMP.TrimEnd('\')) { + Stop-PackagedConnect 'artifact-type' + } + $privilegedSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $stdout = Join-Path $authenticatedRunnerTemp ( + 'propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stdout' + ) + $stderr = Join-Path $authenticatedRunnerTemp ( + 'propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stderr' + ) + $stdoutAuthority = Initialize-PrivilegedCaptureFile $stdout $privilegedSid + $stderrAuthority = Initialize-PrivilegedCaptureFile $stderr $privilegedSid + Set-CaptureAuthorityPredicate 'redirect-open' + $captureProducerExitCode = if ( + $CaptureRedirectionProducerTestCase -ceq 'nonzero' + ) { 23 } elseif ($CaptureRedirectionProducerTestCase -in @('empty','hostile')) { + 71 + } else { 0 } + $captureProducerSource = if ($CaptureRedirectionProducerTestCase -ceq 'empty') { + "exit $captureProducerExitCode" + } elseif ($CaptureRedirectionProducerTestCase -ceq 'hostile') { + "[Console]::Out.Write('C:\hostile\capture stdout environment-secret');" + + "[Console]::Error.Write('S-1-5-21 stderr native-text');" + + "exit $captureProducerExitCode" + } else { + "[Console]::Out.Write('capture-stdout');" + + "[Console]::Error.Write('capture-stderr');" + + "exit $captureProducerExitCode" + } + $captureProducerArgument = [Convert]::ToBase64String( + [Text.Encoding]::Unicode.GetBytes($captureProducerSource) + ) + $captureProducerArguments = ( + '-NoLogo -NoProfile -NonInteractive -EncodedCommand "' + + $captureProducerArgument + '"' + ) + $redirectionProcess = Start-Process ` + -FilePath (Join-Path $PSHOME 'powershell.exe') ` + -ArgumentList $captureProducerArguments ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr ` + -ErrorAction Stop + if ($null -eq $redirectionProcess -or + !($redirectionProcess -is [System.Diagnostics.Process])) { + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureAuthorityPredicate 'redirect-open' + # PS5.1 must acquire the redirected process handle before waiting or ExitCode can remain unset. + $redirectionProcessHandle = $redirectionProcess.Handle + if ($redirectionProcessHandle -eq [IntPtr]::Zero) { + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureAuthorityPredicate 'redirect-timeout' + if (!$redirectionProcess.WaitForExit($terminationTimeoutMilliseconds)) { + Stop-PackagedConnect 'spawn-failed' + } + Assert-PrivilegedCaptureIdentity ` + $stdoutAuthority $privilegedSid -TestOnlyIdentityPredicate 'post-redirection-identity' + Assert-PrivilegedCaptureIdentity ` + $stderrAuthority $privilegedSid -TestOnlyIdentityPredicate 'post-redirection-identity' + Set-CaptureAuthorityPredicate 'redirect-child-exit' + $captureProducerActualExit = try { $redirectionProcess.ExitCode } catch { $null } + $captureProducerExitBucket = if ($captureProducerActualExit -eq 0) { + 'zero' + } elseif ($CaptureRedirectionProducerTestCase -ceq 'nonzero' -and + $captureProducerActualExit -eq 23) { + 'forced-23' + } else { + 'other' + } + Set-CaptureAuthorityPredicate 'capture-content' + $captureProducerStdoutState = Get-TestOnlyCaptureProducerOutputState ` + $stdoutAuthority $privilegedSid 'capture-stdout' + $captureProducerStderrState = Get-TestOnlyCaptureProducerOutputState ` + $stderrAuthority $privilegedSid 'capture-stderr' + $captureProducerResultAttributed = $true + Set-CaptureAuthorityPredicate 'redirect-child-exit' + if ($CaptureRedirectionProducerTestCase -cne 'success' -or + $captureProducerExitBucket -cne 'zero') { + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureAuthorityPredicate 'capture-content' + if ($captureProducerStdoutState -cne 'exact-expected' -or + $captureProducerStderrState -cne 'exact-expected') { + Stop-PackagedConnect 'artifact-type' + } + $redirectionAccepted = $true + } catch { + $redirectionFailurePredicate = if ( + $captureAuthorityPredicates -ccontains $captureAuthorityPredicate + ) { $captureAuthorityPredicate } else { 'pre-create' } + } finally { + $redirectionCleanupFailed = $false + Set-CaptureAuthorityPredicate 'cleanup' + if ($null -ne $redirectionProcess) { + try { + if (!$redirectionProcess.HasExited) { Stop-SpawnedProcess $redirectionProcess } + } catch { $redirectionCleanupFailed = $true } + try { $redirectionProcess.Dispose() } catch { $redirectionCleanupFailed = $true } + } + foreach ($authority in @($stdoutAuthority, $stderrAuthority)) { + if ($null -ne $authority -and $null -ne $authority.Handle) { + try { $authority.Handle.Dispose() } catch { $redirectionCleanupFailed = $true } + } + } + foreach ($capture in @($stdout, $stderr)) { + if (![String]::IsNullOrEmpty($capture)) { + try { + if (Test-Path -LiteralPath $capture) { + Remove-Item -LiteralPath $capture -Force -ErrorAction Stop + } + if (Test-Path -LiteralPath $capture) { $redirectionCleanupFailed = $true } + } catch { $redirectionCleanupFailed = $true } + } + } + if ($redirectionCleanupFailed -and $null -eq $redirectionFailurePredicate) { + $redirectionFailurePredicate = 'cleanup' + } + } + if ($redirectionAccepted -and $null -eq $redirectionFailurePredicate) { + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST:accepted') + exit 0 + } + $primaryFailure = 'artifact-type' + $primaryPhase = 'capture-parse' + $primarySubphase = 'capture-authority' + if ($captureAuthorityPredicates -cnotcontains $redirectionFailurePredicate) { + $redirectionFailurePredicate = 'pre-create' + } + Set-CaptureAuthorityPredicate $redirectionFailurePredicate +} + +if ($LifecycleTestMode -eq 'capture-parser') { + try { + if ([String]::IsNullOrEmpty($env:RUNNER_TEMP) -or ![IO.Path]::IsPathRooted($env:RUNNER_TEMP)) { + Set-CaptureParseSubphase 'capture-authority' + Stop-PackagedConnect 'artifact-type' + } + $authenticatedRunnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') + if ($authenticatedRunnerTemp -cne $env:RUNNER_TEMP.TrimEnd('\')) { + Set-CaptureParseSubphase 'capture-authority' + Stop-PackagedConnect 'artifact-type' + } + $privilegedSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $testUserSid = [Security.Principal.SecurityIdentifier]::new( + 'S-1-5-21-42424242-42424242-42424242-1001' + ) + $stderr = $CaptureParserTestPath + Set-CaptureParseSubphase 'capture-authority' + $fixtureAuthority = Initialize-PrivilegedCaptureFile ` + -Path $stderr ` + -CapturePrivilegedSid $privilegedSid ` + -NormalizeExisting + $fixtureAuthority.Handle.Dispose() + $beforeCaptureReopen = $null + $allowCaptureReplacement = $false + $captureExpectedPrivilegedSid = $null + $captureExpectedParentOwnerSid = $null + if ($CaptureParserAuthorityTestCase -in @( + 'administrators-owner','current-owner','foreign-owner','ordinary-owner' + )) { + $captureOwner = if ($CaptureParserAuthorityTestCase -eq 'administrators-owner') { + $administratorsSid + } else { + $privilegedSid + } + $captureAcl = [IO.File]::GetAccessControl($stderr) + $captureAcl.SetOwner($captureOwner) + [IO.File]::SetAccessControl($stderr, $captureAcl) + } + if ($CaptureParserAuthorityTestCase -eq 'foreign-owner') { + $captureExpectedPrivilegedSid = [Security.Principal.SecurityIdentifier]::new( + 'S-1-5-21-51515151-51515151-51515151-1001' + ) + } elseif ($CaptureParserAuthorityTestCase -eq 'ordinary-owner') { + $testUserSid = $privilegedSid + } elseif ($CaptureParserAuthorityTestCase -in @('ordinary-write','broad-write')) { + $writeSid = if ($CaptureParserAuthorityTestCase -eq 'ordinary-write') { + $testUserSid + } else { + [Security.Principal.SecurityIdentifier]::new('S-1-1-0') + } + $captureAcl = [IO.File]::GetAccessControl($stderr) + $null = $captureAcl.AddAccessRule( + [Security.AccessControl.FileSystemAccessRule]::new( + $writeSid, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + ) + [IO.File]::SetAccessControl($stderr, $captureAcl) + } elseif ($CaptureParserAuthorityTestCase -eq 'unprotected-dacl') { + $captureAcl = [IO.File]::GetAccessControl($stderr) + $captureAcl.SetAccessRuleProtection($false, $true) + [IO.File]::SetAccessControl($stderr, $captureAcl) + } elseif ($CaptureParserAuthorityTestCase -eq 'foreign-parent-owner') { + $captureExpectedParentOwnerSid = [Security.Principal.SecurityIdentifier]::new( + 'S-1-5-21-61616161-61616161-61616161-1001' + ) + } elseif ($CaptureParserAuthorityTestCase -eq 'identity-change') { + $allowCaptureReplacement = $true + $beforeCaptureReopen = { + $captureBackup = $stderr + '.propr-replaced' + $captureContent = [IO.File]::ReadAllBytes($stderr) + Move-Item -LiteralPath $stderr -Destination $captureBackup -ErrorAction Stop + [IO.File]::WriteAllBytes($stderr, $captureContent) + } + } + $childFailureCategory = Read-PackagedConnectSmokeFailure ` + -Path $stderr ` + -TestOnlyBeforeReopen $beforeCaptureReopen ` + -TestOnlyAllowReplacement:$allowCaptureReplacement ` + -TestOnlyCapturePrivilegedSid $captureExpectedPrivilegedSid ` + -TestOnlyExpectedParentOwnerSid $captureExpectedParentOwnerSid + Stop-PackagedConnect $childFailureCategory + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} + +if ($LifecycleTestMode -eq 'diagnostic-subphase') { + Set-OrdinaryUserPreflightSubphase $DiagnosticTestSubphase + try { + throw [InvalidOperationException]::new( + 'C:\hostile\package S-1-5-21-123 account-name stdout stderr exception environment-secret' + ) + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} + +if ($LifecycleTestMode -eq 'terminate-tree') { + $lifecycleTarget = $null + try { + if ($LifecycleTestProcessId -lt 1) { Stop-PackagedConnect 'spawn-failed' } + $lifecycleTarget = [Diagnostics.Process]::GetProcessById($LifecycleTestProcessId) + if ($lifecycleTarget.HasExited) { Stop-PackagedConnect 'spawn-failed' } + if ($lifecycleTarget.WaitForExit(250)) { Stop-PackagedConnect 'spawn-failed' } + Stop-SpawnedProcess $lifecycleTarget + if (!$lifecycleTarget.HasExited) { Stop-PackagedConnect 'spawn-failed' } + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_LIFECYCLE_TEST:tree-terminated') + exit 0 + } catch { + [Console]::Error.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_LIFECYCLE_TEST:failed:category=spawn-failed') + exit 1 + } finally { + if ($null -ne $lifecycleTarget) { $lifecycleTarget.Dispose() } + } +} + +if ($LifecycleTestMode -eq 'host-node-producer') { + try { + if ($HostNodeProducerTestCase -eq 'positive') { + $node = Get-ValidatedHostNodePath + } elseif ($HostNodeProducerTestCase -eq 'zero') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@()) + } else { + $knownApplications = @(Get-Command ` + -Name ([Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) ` + -CommandType Application ` + -TotalCount 1 ` + -ErrorAction Stop) + if ($knownApplications.Count -ne 1) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-cardinality' + Stop-PackagedConnect 'artifact-type' + } + $knownApplication = $knownApplications[0] + if (!($knownApplication -is [System.Management.Automation.ApplicationInfo])) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-type' + Stop-PackagedConnect 'artifact-type' + } + if ($HostNodeProducerTestCase -eq 'non-application') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@( + [PSCustomObject]@{ Source = 'C:\hostile\node.exe' } + )) + } elseif ($HostNodeProducerTestCase -eq 'duplicate') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication, $knownApplication)) + } elseif ($HostNodeProducerTestCase -eq 'mixed-types') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@( + $knownApplication, + [PSCustomObject]@{ Source = 'C:\hostile\node.exe' } + )) + } elseif ($HostNodeProducerTestCase -in @('multiple','case-collision')) { + $otherApplications = @(Get-Command ` + -Name $taskkillExecutable ` + -CommandType Application ` + -TotalCount 1 ` + -ErrorAction Stop) + if ($otherApplications.Count -ne 1) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-cardinality' + Stop-PackagedConnect 'artifact-type' + } + $otherApplication = $otherApplications[0] + if (!($otherApplication -is [System.Management.Automation.ApplicationInfo])) { + Set-OrdinaryUserPreflightSubphase 'host-node-command-type' + Stop-PackagedConnect 'artifact-type' + } + if ($HostNodeProducerTestCase -eq 'multiple') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication, $otherApplication)) + } else { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication, $otherApplication)) ` + -TestOnlySourceProducer { + if ([String]::Equals( + $args[0].Source, + $knownApplication.Source, + [StringComparison]::Ordinal + )) { + 'C:\hostile\node.exe' + } else { + 'c:\hostile\node.exe' + } + } + } + } elseif ($HostNodeProducerTestCase -eq 'missing-source') { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication)) ` + -TestOnlySourceProducer { $null } + } else { + $node = Get-ValidatedHostNodePath ` + -UseTestOnlyCommandResults ` + -TestOnlyCommandResults ([object[]]@($knownApplication)) ` + -TestOnlySourceProducer { [object[]]@('C:\hostile\one.exe', 'C:\hostile\two.exe') } + } + } + if (!($node -is [string]) -or [String]::IsNullOrEmpty($node)) { + Set-OrdinaryUserPreflightSubphase 'host-node-source' + Stop-PackagedConnect 'artifact-type' + } + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST:accepted') + exit 0 + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} + +if ($LifecycleTestMode -eq 'launcher-authority') { + Set-OrdinaryUserPreflightSubphase 'host-node-path-binding' + try { + $beforeFinalReopen = $null + $beforeSourceReopen = $null + if ($LauncherAuthorityTestCase -eq 'identity-mismatch') { + $beforeFinalReopen = { + $replacementBackup = $LauncherAuthorityTestPath + '.propr-identity-' + [Guid]::NewGuid().ToString('N') + Move-Item -LiteralPath $LauncherAuthorityTestPath -Destination $replacementBackup -ErrorAction Stop + [IO.File]::WriteAllBytes($LauncherAuthorityTestPath, [byte[]]@(0x4d,0x5a)) + } + } elseif ($LauncherAuthorityTestCase -eq 'retarget-alias') { + $beforeSourceReopen = { + $null = Get-BoundedAbsoluteWindowsPath $LauncherAuthorityTestRetargetPath + Remove-Item -LiteralPath $LauncherAuthorityTestPath -Force -ErrorAction Stop + $null = New-Item ` + -ItemType SymbolicLink ` + -Path $LauncherAuthorityTestPath ` + -Target $LauncherAuthorityTestRetargetPath ` + -ErrorAction Stop + } + } + $launcherAuthority = Get-TrustedHostLauncher ` + -Path $LauncherAuthorityTestPath ` + -TestOnlyBeforeFinalReopen $beforeFinalReopen ` + -TestOnlyBeforeSourceReopen $beforeSourceReopen + $launcherAuthority.Handle.Dispose() + $launcherAuthority = $null + [Console]::Out.WriteLine('PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:accepted') + exit 0 + } catch { + Set-PrimaryFailureFromException $_.Exception + } finally { + if ($null -ne $launcherAuthority) { + $launcherAuthority.Handle.Dispose() + $launcherAuthority = $null + } + } +} + +if ($LifecycleTestMode -in @( + 'diagnostic-subphase','host-node-producer','launcher-authority','capture-parser','capture-redirection' + )) { + # The shared final diagnostic below emits the injected fixed state. +} elseif ($LifecycleTestMode -eq 'cleanup-timeout') { + $cleanupTimeoutMilliseconds = 750 + $terminationTimeoutMilliseconds = 3000 + $streamCloseTimeoutMilliseconds = 3000 + $primaryFailure = 'artifact-type' + $primaryPhase = 'staged-tree' + $neverSettlingCleanupSource = 'while($true){Start-Sleep -Seconds 1}' + $observedCleanupProcessId = 0 + $cleanupResult = Invoke-BoundedCleanup ` + -CleanupSource $neverSettlingCleanupSource ` + -ObservedProcessId ([ref]$observedCleanupProcessId) + $cleanupProcessStillRunning = $false + if ($observedCleanupProcessId -gt 0) { + try { + $observedCleanupProcess = [Diagnostics.Process]::GetProcessById($observedCleanupProcessId) + try { $cleanupProcessStillRunning = !$observedCleanupProcess.HasExited } finally { $observedCleanupProcess.Dispose() } + } catch {} + } + if ($cleanupResult -eq 'timeout' -and !$cleanupProcessStillRunning) { + $cleanupSecondary = 'cleanup-timeout' + } else { + $cleanupSecondary = 'cleanup-failed' + } +} else { +try { + try { + Set-FailurePhase 'source-layout' + $desktopDirectory = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + $sourceRoot = [IO.Path]::GetFullPath((Join-Path $desktopDirectory "out\propr-desktop-win32-$Architecture")) + if ([IO.Path]::GetDirectoryName($sourceRoot) -cne (Join-Path $desktopDirectory 'out') -or + [IO.Path]::GetFileName($sourceRoot) -cne "propr-desktop-win32-$Architecture") { + Stop-PackagedConnect 'artifact-type' + } + $null = Get-CanonicalItem $sourceRoot 'directory' + $sourceExecutable = Join-Path $sourceRoot 'propr-desktop.exe' + $sourceResources = Join-Path $sourceRoot 'resources' + $sourceArchive = Join-Path $sourceResources 'app.asar' + $sourceLocales = Join-Path $sourceRoot 'locales' + $null = Get-CanonicalItem $sourceExecutable 'file' + $null = Get-CanonicalItem $sourceResources 'directory' + $null = Get-CanonicalItem $sourceArchive 'file' + $null = Get-CanonicalItem $sourceLocales 'directory' + foreach ($requiredFile in @('chrome_100_percent.pak','chrome_200_percent.pak','icudtl.dat','resources.pak','v8_context_snapshot.bin')) { + $null = Get-CanonicalItem (Join-Path $sourceRoot $requiredFile) 'file' + } + $sourceEntries = @(Assert-PackageTreeTypes $sourceRoot) + Assert-PeArchitecture $sourceExecutable $Architecture + + Set-FailurePhase 'runner-authority' + if ([String]::IsNullOrEmpty($env:RUNNER_TEMP) -or ![IO.Path]::IsPathRooted($env:RUNNER_TEMP)) { + Stop-PackagedConnect 'artifact-type' + } + $authenticatedRunnerTemp = [IO.Path]::GetFullPath($env:RUNNER_TEMP) + if ($authenticatedRunnerTemp -cne $env:RUNNER_TEMP.TrimEnd('\')) { + Stop-PackagedConnect 'artifact-type' + } + $runnerTempItem = Get-CanonicalItem $authenticatedRunnerTemp 'directory' + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $privilegedSid = $currentSid + $runnerTempAcl = [IO.Directory]::GetAccessControl( + $authenticatedRunnerTemp, + [Security.AccessControl.AccessControlSections]::Owner + ) + $runnerTempOwner = $runnerTempAcl.GetOwner([Security.Principal.SecurityIdentifier]) + if ($null -eq $currentSid -or @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544') -cnotcontains $runnerTempOwner.Value) { + Stop-PackagedConnect 'artifact-type' + } + $privilegedPrincipal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if (!$privilegedPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Stop-PackagedConnect 'artifact-inaccessible' + } + + $stageParent = Join-Path $authenticatedRunnerTemp 'propr-connect-packaged-stage' + if (Test-Path -LiteralPath $stageParent) { Stop-PackagedConnect 'artifact-type' } + + Set-FailurePhase 'account-setup' + $testUser = 'prpc' + [Guid]::NewGuid().ToString('N').Substring(0, 12) + $plainPassword = [Guid]::NewGuid().ToString('N') + 'aA1!' + $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force + $credential = [Management.Automation.PSCredential]::new("$env:COMPUTERNAME\$testUser", $securePassword) + $createdUser = New-LocalUser -Name $testUser -Password $securePassword -PasswordNeverExpires -ErrorAction Stop + $testUserSid = $createdUser.SID + if ($null -eq $testUserSid -or $testUser.Length -gt 20) { Stop-PackagedConnect 'artifact-type' } + $createdAccount = Get-LocalUser -Name $testUser -ErrorAction Stop + if ($createdAccount.SID.Value -cne $testUserSid.Value) { Stop-PackagedConnect 'artifact-type' } + $administratorsAccount = $administratorsSid.Translate([Security.Principal.NTAccount]).Value + $administratorsName = $administratorsAccount.Substring($administratorsAccount.IndexOf('\') + 1) + if ([String]::IsNullOrEmpty($administratorsName)) { Stop-PackagedConnect 'artifact-type' } + $administratorsGroup = [ADSI]("WinNT://$env:COMPUTERNAME/$administratorsName,group") + $ordinaryUserEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$testUser,user") + if ([bool]$administratorsGroup.psbase.Invoke('IsMember', $ordinaryUserEntry.Path)) { + Stop-PackagedConnect 'artifact-type' + } + + Set-FailurePhase 'staging-copy' + $stageLeaf = 'propr-connect-package-' + [Guid]::NewGuid().ToString('N') + $stageRoot = Join-Path $stageParent $stageLeaf + $null = New-Item -ItemType Directory -Path $stageParent -ErrorAction Stop + $null = New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop + foreach ($entry in Get-ChildItem -LiteralPath $sourceRoot -Force -ErrorAction Stop) { + Copy-Item -LiteralPath $entry.FullName -Destination $stageRoot -Recurse -Force -ErrorAction Stop + } + $stagedEntries = @(Assert-PackageTreeTypes $stageRoot) + Assert-CopiedPackageTree $sourceRoot $sourceEntries $stageRoot $stagedEntries + $null = Get-CanonicalItem $stageRoot 'directory' + $stagedExecutable = Join-Path $stageRoot 'propr-desktop.exe' + $null = Get-CanonicalItem $stagedExecutable 'file' + $null = Get-CanonicalItem (Join-Path $stageRoot 'resources') 'directory' + $null = Get-CanonicalItem (Join-Path $stageRoot 'resources\app.asar') 'file' + Assert-PeArchitecture $stagedExecutable $Architecture + + Set-FailurePhase 'staging-acl' + $aclEntries = @((Get-Item -LiteralPath $stageParent -Force), (Get-Item -LiteralPath $stageRoot -Force)) + $aclEntries += @(Get-ChildItem -LiteralPath $stageRoot -Force -Recurse -ErrorAction Stop) + foreach ($item in $aclEntries) { Set-StagedEntryAcl $item $testUserSid $administratorsSid } + foreach ($item in $aclEntries) { Assert-StagedEntryAcl $item $testUserSid $administratorsSid } + + $node = Get-ValidatedHostNodePath + Set-OrdinaryUserPreflightSubphase 'host-node-path-binding' + $launcherAuthority = Get-TrustedHostLauncher -Path $node + Set-OrdinaryUserPreflightSubphase 'host-node-launcher-return-authority' + $launcherAuthorityResults = @($launcherAuthority) + if ($launcherAuthorityResults.Count -ne 1) { Stop-PackagedConnect 'artifact-type' } + $launcherAuthority = $launcherAuthorityResults[0] + $launcherPathProperty = $launcherAuthority.PSObject.Properties['Path'] + $launcherHandleProperty = $launcherAuthority.PSObject.Properties['Handle'] + if ($null -eq $launcherPathProperty -or $null -eq $launcherHandleProperty -or + !($launcherPathProperty.Value -is [string]) -or + [String]::IsNullOrEmpty($launcherPathProperty.Value) -or + !($launcherHandleProperty.Value -is [Microsoft.Win32.SafeHandles.SafeFileHandle]) -or + $launcherHandleProperty.Value.IsInvalid -or $launcherHandleProperty.Value.IsClosed) { + Stop-PackagedConnect 'artifact-type' + } + $node = $launcherPathProperty.Value + Set-OrdinaryUserPreflightSubphase 'host-capture-contract' + $stdout = Join-Path $authenticatedRunnerTemp ('propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stdout') + $stderr = Join-Path $authenticatedRunnerTemp ('propr-connect-' + [Guid]::NewGuid().ToString('N') + '.stderr') + if ((Test-Path -LiteralPath $stdout) -or (Test-Path -LiteralPath $stderr)) { + Stop-PackagedConnect 'artifact-type' + } + $stdoutAuthority = Initialize-PrivilegedCaptureFile $stdout $privilegedSid + $stderrAuthority = Initialize-PrivilegedCaptureFile $stderr $privilegedSid + Set-OrdinaryUserPreflightSubphase 'host-staging-handoff' + $handoffText = [String]::Join("`n", [string[]]@($authenticatedRunnerTemp, $stageParent, $stageLeaf)) + $handoffBytes = [Text.Encoding]::UTF8.GetBytes($handoffText) + $handoffArgument = '--propr-windows-staged-contract=' + [Convert]::ToBase64String($handoffBytes) + if ($handoffArgument.Length -gt 16384 -or $handoffArgument -cnotmatch '^--propr-windows-staged-contract=[A-Za-z0-9+/]+={0,2}$') { + Stop-PackagedConnect 'artifact-type' + } + try { + Set-FailurePhase 'application-spawn' + try { + $process = Start-Process ` + -FilePath $node ` + -ArgumentList @('scripts/smoke-packaged-connect.mjs', $handoffArgument) ` + -WorkingDirectory $desktopDirectory ` + -Credential $credential ` + -LoadUserProfile ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr ` + -ErrorAction Stop + Set-CaptureParseSubphase 'capture-authority' + Assert-PrivilegedCaptureIdentity $stdoutAuthority $privilegedSid + Assert-PrivilegedCaptureIdentity $stderrAuthority $privilegedSid + } finally { + $launcherAuthority.Handle.Dispose() + $launcherAuthority = $null + } + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'spawn-failed' + } + Set-FailurePhase 'application-runtime' + try { + if (!$process.WaitForExit($applicationTimeoutMilliseconds)) { + Stop-SpawnedProcess $process + Stop-PackagedConnect 'spawn-failed' + } + } catch { + try { Stop-SpawnedProcess $process } catch {} + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'spawn-failed' + } + Set-CaptureParseSubphase 'capture-authority' + Assert-PrivilegedCaptureIdentity $stdoutAuthority $privilegedSid + Assert-PrivilegedCaptureIdentity $stderrAuthority $privilegedSid + if ($process.ExitCode -ne 0) { + try { + $childFailureCategory = Read-PackagedConnectSmokeFailure ` + -Path $stderr ` + -ExpectedCaptureIdentity $stderrAuthority.Identity + Stop-PackagedConnect $childFailureCategory + } catch { + if ($_.Exception.Message -clike 'PROPR_PACKAGED_CONNECT_FAILURE:*') { throw } + Stop-PackagedConnect 'artifact-type' + } + } + Set-FailurePhase 'result-verify' + foreach ($capture in @($stdout, $stderr)) { + $captureItem = Get-CanonicalItem $capture 'file' + if ($captureItem.Length -gt 65536) { Stop-PackagedConnect 'spawn-failed' } + } + $capturedStdout = [IO.File]::ReadAllText($stdout) + $capturedStderr = [IO.File]::ReadAllText($stderr) + $expectedSuccess = "Packaged Connect discovery passed for win32-$Architecture`: inherited-standard-handle." + if ($capturedStderr.Length -ne 0 -or $capturedStdout.TrimEnd("`r", "`n") -cne $expectedSuccess) { + Stop-PackagedConnect 'spawn-failed' + } + } catch { + Set-PrimaryFailureFromException $_.Exception + } +} finally { + if ($null -ne $launcherAuthority) { + try { $launcherAuthority.Handle.Dispose() } catch {} + $launcherAuthority = $null + } + foreach ($authority in @($stdoutAuthority, $stderrAuthority)) { + if ($null -ne $authority -and $null -ne $authority.Handle) { + try { $authority.Handle.Dispose() } catch {} + } + } + if ($null -ne $authenticatedRunnerTemp -and $null -ne $privilegedSid) { + $cleanupResult = Invoke-BoundedCleanup + if ($cleanupResult -eq 'timeout') { + $cleanupSecondary = 'cleanup-timeout' + } elseif ($cleanupResult -ne 'none') { + $cleanupSecondary = 'cleanup-failed' + } + } +} +} + +if ($null -eq $primaryFailure -and $cleanupSecondary -ne 'none') { + $primaryFailure = 'artifact-inaccessible' + $primaryPhase = 'cleanup' +} +if ($null -ne $primaryFailure) { + if ($failureCategories -cnotcontains $primaryFailure) { $primaryFailure = 'spawn-failed' } + if ($failurePhases -cnotcontains $primaryPhase) { $primaryPhase = 'application-runtime' } + $subphaseEvidence = '' + if ($primaryPhase -ceq 'ordinary-user-preflight') { + if ($failureSubphases -cnotcontains $primarySubphase) { + $primarySubphase = 'host-state-contract' + } + $subphaseEvidence = ":subphase=$primarySubphase" + } elseif ($primaryPhase -ceq 'staged-contract' -and + $childStagedContractSubphases -ccontains $primarySubphase) { + $subphaseEvidence = ":subphase=$primarySubphase" + } elseif ($primaryPhase -ceq 'capture-parse' -and + $captureParseSubphases -ccontains $primarySubphase) { + $subphaseEvidence = ":subphase=$primarySubphase" + if ($LifecycleTestMode -in @('capture-parser','capture-redirection') -and + $primarySubphase -ceq 'capture-authority' -and + $captureAuthorityPredicates -ccontains $captureAuthorityPredicate) { + $subphaseEvidence += ":predicate=$captureAuthorityPredicate" + if ($LifecycleTestMode -ceq 'capture-redirection' -and + $captureProducerResultPredicates -ccontains $captureAuthorityPredicate -and + $captureProducerResultAttributed -and + $captureProducerExitBuckets -ccontains $captureProducerExitBucket -and + $captureProducerOutputStates -ccontains $captureProducerStdoutState -and + $captureProducerOutputStates -ccontains $captureProducerStderrState) { + $subphaseEvidence += ":exit=$captureProducerExitBucket" + + ":out=$captureProducerStdoutState`:err=$captureProducerStderrState" + } + } + } elseif ($primaryPhase -ceq 'application-runtime' -and + $lifecycleFailureSubphases -ccontains $primarySubphase) { + $subphaseEvidence = ":subphase=$primarySubphase" + } + [Console]::Error.WriteLine("PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=$primaryFailure`:phase=$primaryPhase$subphaseEvidence`:cleanup=$cleanupSecondary") + exit 1 +} +[Console]::Out.WriteLine("PROPR_WINDOWS_PACKAGED_CONNECT:passed:$Architecture") diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index bc5dcd743..a8b8493be 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, writeFile, @@ -15,6 +15,12 @@ import { encodedWindowsFixtureAcl, windowsPowerShell51Path, } from './windows-fixture-acl.mjs'; +import { + describeWindowsArtifactFailure, + packagedConnectArtifactSensitiveNeedles, + parseWindowsStagedPackageHandoff, + validateWindowsStagedPackage, +} from './windows-packaged-connect-staging.mjs'; if (!['darwin', 'linux', 'win32'].includes(process.platform)) { throw new Error('Packaged Connect discovery smoke requires Darwin, Linux, or Windows'); @@ -23,14 +29,14 @@ if (process.arch !== 'x64' && process.arch !== 'arm64') { throw new Error('Packaged Connect discovery smoke requires x64 or arm64'); } -const artifactRoot = resolve('out', `propr-desktop-${process.platform}-${process.arch}`); -const binaryPath = process.platform === 'darwin' +let artifactRoot = resolve('out', `propr-desktop-${process.platform}-${process.arch}`); +let binaryPath = process.platform === 'darwin' ? join(artifactRoot, 'propr-desktop.app', 'Contents', 'MacOS', 'propr-desktop') : join(artifactRoot, process.platform === 'linux' ? 'propr-desktop' : 'propr-desktop.exe'); -const resourcesPath = process.platform === 'darwin' +let resourcesPath = process.platform === 'darwin' ? join(artifactRoot, 'propr-desktop.app', 'Contents', 'Resources') : join(artifactRoot, 'resources'); -const unpackedNative = join(resourcesPath, 'app.asar.unpacked', '.vite', 'native', 'prebuilds'); +let unpackedNative = join(resourcesPath, 'app.asar.unpacked', '.vite', 'native', 'prebuilds'); const endpoint = 'https://t-packaged123.propr.dev'; const identity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const secrets = [ @@ -57,6 +63,37 @@ const nativeHashes = { }, }, }; +let packagedConnectPhase = 'fixture-setup'; +let windowsStagedContract; +let windowsStagedHandoff; + +if (process.platform === 'win32') { + try { + packagedConnectPhase = 'staged-contract'; + [windowsStagedHandoff] = process.argv.slice(2); + windowsStagedContract = parseWindowsStagedPackageHandoff(process.argv.slice(2)); + const staged = await validateWindowsStagedPackage({ + environment: { + RUNNER_TEMP: windowsStagedContract.runnerTemp, + PROPR_DESKTOP_CONNECT_STAGING_PARENT: windowsStagedContract.parent, + PROPR_DESKTOP_CONNECT_STAGING_LEAF: windowsStagedContract.leaf, + }, + expectedArchitecture: process.arch, + }); + artifactRoot = staged.root; + binaryPath = staged.executable; + resourcesPath = staged.resources; + unpackedNative = join(resourcesPath, 'app.asar.unpacked', '.vite', 'native', 'prebuilds'); + } catch (error) { + const failure = describeWindowsArtifactFailure(error, packagedConnectPhase); + process.stderr.write(`${JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...failure, + })}\n`); + process.exit(1); + } +} + const authorityMechanism = () => { if (process.platform === 'darwin') return 'packaged-broker'; if (process.platform === 'linux') return 'in-process-native-addon'; @@ -231,8 +268,33 @@ try { const treeKillerPath = await windowsTreeKiller(); const sensitiveNeedles = [ ...secrets, fixture, configRoot, stackRoot, identity, + ...packagedConnectArtifactSensitiveNeedles({ + platform: process.platform, + artifactRoot, + binaryPath, + stagedContract: windowsStagedContract, + stagedHandoff: windowsStagedHandoff, + }), 'S-1-5-', 'volumeSerialNumber', 'fileId', 'authorityDiagnostic', ]; + const childEnvironment = { + ...process.env, + PROPR_DESKTOP_CONNECT_SMOKE_TEST: '1', + PROPR_DESKTOP_CONNECT_SMOKE_CONFIG_ROOT: configRoot, + PROPR_CONNECTOR_TOKEN: secrets[1], + PROPR_RELAY_TOKEN: secrets[2], + GITHUB_TOKEN: secrets[3], + }; + delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_PARENT; + delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_LEAF; + const spawnLifecycleProcess = (executable, args, options) => { + if (executable !== binaryPath) return spawn(executable, args, options); + const child = spawn(binaryPath, ['--disable-gpu', `--user-data-dir=${userDataPath}`], { + ...options, + env: childEnvironment, + }); + return child; + }; failurePhase = 'lifecycle-internal'; outcome = await runPackagedConnectLifecycle({ binaryPath, @@ -242,14 +304,8 @@ try { authorityMechanism: authorityMechanism(), sensitiveNeedles, treeKillerPath, - env: { - ...process.env, - PROPR_DESKTOP_CONNECT_SMOKE_TEST: '1', - PROPR_DESKTOP_CONNECT_SMOKE_CONFIG_ROOT: configRoot, - PROPR_CONNECTOR_TOKEN: secrets[1], - PROPR_RELAY_TOKEN: secrets[2], - GITHUB_TOKEN: secrets[3], - }, + env: childEnvironment, + spawn: spawnLifecycleProcess, }); } catch { outcome = { ok: false, category: failurePhase, capture: 'complete', records: [] }; diff --git a/apps/desktop/scripts/smoke-packaged.mjs b/apps/desktop/scripts/smoke-packaged.mjs index 951e8f03a..850b202d3 100644 --- a/apps/desktop/scripts/smoke-packaged.mjs +++ b/apps/desktop/scripts/smoke-packaged.mjs @@ -16,7 +16,7 @@ import { FuseVersion, getCurrentFuseWire, } from '@electron/fuses'; -import { assertPackagedLayout } from './packaged-layout.mjs'; +import { assertPackagedLayout, parseEventLayout, parseEventRecord } from './packaged-layout.mjs'; import { createPackagedSmokeLaunch, LAYOUT_READY_EVENT, @@ -58,20 +58,6 @@ if (process.platform === 'win32') { } } -const parseEventRecord = (smokeOutput, expectedEvent) => { - for (const line of smokeOutput.split(/\r?\n/)) { - if (!line.includes(expectedEvent)) continue; - try { - const record = JSON.parse(line.slice(line.indexOf('{'))); - if (record.event === expectedEvent) return record; - } catch { - // Ignore non-JSON Chromium output that happens to mention the event name. - } - } - return undefined; -}; -const parseEventLayout = (smokeOutput, expectedEvent) => parseEventRecord(smokeOutput, expectedEvent)?.layout; - await access(binaryPath); const expectedFuses = new Map([ diff --git a/apps/desktop/scripts/windows-packaged-connect-staging.mjs b/apps/desktop/scripts/windows-packaged-connect-staging.mjs new file mode 100644 index 000000000..87430d408 --- /dev/null +++ b/apps/desktop/scripts/windows-packaged-connect-staging.mjs @@ -0,0 +1,387 @@ +import { spawnSync } from 'node:child_process'; +import { open } from 'node:fs/promises'; +import { win32 } from 'node:path'; +import { + canonicalizeWindowsFixtureEntry, + windowsPowerShell51Path, +} from './windows-fixture-acl.mjs'; + +export const WINDOWS_ARTIFACT_FAILURE_CATEGORIES = Object.freeze([ + 'artifact-missing', + 'artifact-inaccessible', + 'artifact-type', + 'architecture-mismatch', + 'spawn-failed', +]); + +export const WINDOWS_ARTIFACT_FAILURE_PHASES = Object.freeze([ + 'staged-contract', + 'staged-tree', + 'staged-architecture', + 'ordinary-user-preflight', + 'fixture-setup', + 'package-authority', + 'application-spawn', + 'application-runtime', + 'result-verify', +]); + +export const WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES = Object.freeze([ + 'runner-temp-input-shape', + 'staging-parent-input-shape', + 'parent-to-runner-binding', + 'fixed-parent-leaf', + 'generated-stage-leaf', + 'derived-root-to-parent-binding', +]); + +export const WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES = Object.freeze([ + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract', +]); + +export const WINDOWS_ARTIFACT_FAILURE_SUBPHASES = Object.freeze([ + ...WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, + ...WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, +]); + +const STAGING_PARENT_LEAF = 'propr-connect-packaged-stage'; +const STAGING_LEAF_PATTERN = /^propr-connect-package-[a-f0-9]{32}$/u; +const EXPECTED_MACHINES = Object.freeze({ x64: 0x8664, arm64: 0xaa64 }); +const MAX_CONTRACT_PATH_LENGTH = 4096; +const MAX_HANDOFF_LENGTH = 16_384; +const STAGED_CONTRACT_HANDOFF_PREFIX = '--propr-windows-staged-contract='; +const PE_HEADER_BYTES = 4096; + +const isAllowedSubphase = (phase, subphase) => ( + (phase === 'staged-contract' + && WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES.includes(subphase)) + || (phase === 'ordinary-user-preflight' + && WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES.includes(subphase)) +); + +export const packagedConnectArtifactSensitiveNeedles = ({ + platform, + artifactRoot, + binaryPath, + stagedContract, + stagedHandoff, +}) => platform === 'win32' ? [ + artifactRoot, + binaryPath, + stagedContract.runnerTemp, + stagedContract.parent, + stagedContract.leaf, + stagedHandoff, +] : []; + +export class WindowsArtifactFailure extends Error { + constructor(category, phase = 'application-runtime', subphase) { + const fixedCategory = WINDOWS_ARTIFACT_FAILURE_CATEGORIES.includes(category) + ? category : 'artifact-inaccessible'; + const fixedPhase = WINDOWS_ARTIFACT_FAILURE_PHASES.includes(phase) + ? phase : 'application-runtime'; + const fixedSubphase = isAllowedSubphase(fixedPhase, subphase) + ? subphase : undefined; + super(`Packaged Connect Windows artifact failed [category=${fixedCategory} phase=${fixedPhase}` + + `${fixedSubphase ? ` subphase=${fixedSubphase}` : ''}]`); + this.name = 'WindowsArtifactFailure'; + this.category = fixedCategory; + this.phase = fixedPhase; + this.subphase = fixedSubphase; + this.stack = this.message; + } +} + +const fail = (category, phase, subphase) => { + throw new WindowsArtifactFailure(category, phase, subphase); +}; + +const isCanonicalAbsoluteWindowsPath = value => ( + typeof value === 'string' + && value.length > 3 + && value.length <= MAX_CONTRACT_PATH_LENGTH + && !value.includes('\0') + && !value.includes('\r') + && !value.includes('\n') + && !value.includes('/') + && /^[A-Za-z]:\\/u.test(value) + && win32.isAbsolute(value) + && win32.normalize(value) === value + && !value.endsWith('\\') +); + +export const parseWindowsStagedPackageContract = environment => { + const runnerTemp = environment?.RUNNER_TEMP; + const parent = environment?.PROPR_DESKTOP_CONNECT_STAGING_PARENT; + const leaf = environment?.PROPR_DESKTOP_CONNECT_STAGING_LEAF; + if (!isCanonicalAbsoluteWindowsPath(runnerTemp)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + if (!isCanonicalAbsoluteWindowsPath(parent)) { + fail('artifact-type', 'staged-contract', 'staging-parent-input-shape'); + } + if (win32.dirname(parent) !== runnerTemp) { + fail('artifact-type', 'staged-contract', 'parent-to-runner-binding'); + } + if (win32.basename(parent) !== STAGING_PARENT_LEAF) { + fail('artifact-type', 'staged-contract', 'fixed-parent-leaf'); + } + if (!STAGING_LEAF_PATTERN.test(leaf ?? '')) { + fail('artifact-type', 'staged-contract', 'generated-stage-leaf'); + } + const root = win32.join(parent, leaf); + if (win32.dirname(root) !== parent || win32.basename(root) !== leaf) { + fail('artifact-type', 'staged-contract', 'derived-root-to-parent-binding'); + } + return Object.freeze({ + runnerTemp, + parent, + leaf, + root, + executable: win32.join(root, 'propr-desktop.exe'), + resources: win32.join(root, 'resources'), + applicationArchive: win32.join(root, 'resources', 'app.asar'), + }); +}; + +export const parseWindowsStagedPackageHandoff = arguments_ => { + if (!Array.isArray(arguments_) || arguments_.length !== 1 + || typeof arguments_[0] !== 'string' + || !arguments_[0].startsWith(STAGED_CONTRACT_HANDOFF_PREFIX)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const encoded = arguments_[0].slice(STAGED_CONTRACT_HANDOFF_PREFIX.length); + if (encoded.length < 4 || encoded.length > MAX_HANDOFF_LENGTH + || encoded.length % 4 !== 0 + || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.toString('base64') !== encoded) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const decoded = bytes.toString('utf8'); + if (!Buffer.from(decoded, 'utf8').equals(bytes)) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + const fields = decoded.split('\n'); + if (fields.length !== 3) { + fail('artifact-type', 'staged-contract', 'runner-temp-input-shape'); + } + return parseWindowsStagedPackageContract({ + RUNNER_TEMP: fields[0], + PROPR_DESKTOP_CONNECT_STAGING_PARENT: fields[1], + PROPR_DESKTOP_CONNECT_STAGING_LEAF: fields[2], + }); +}; + +export const assertPackagedWindowsPeArchitecture = (bytes, expectedArchitecture) => { + if (!Buffer.isBuffer(bytes) || !Object.hasOwn(EXPECTED_MACHINES, expectedArchitecture)) { + fail('architecture-mismatch', 'staged-architecture'); + } + if (bytes.length < 0x40 || bytes.toString('ascii', 0, 2) !== 'MZ') { + fail('artifact-type', 'staged-architecture'); + } + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset < 0x40 + || peOffset + 6 > bytes.length + || bytes.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') { + fail('artifact-type', 'staged-architecture'); + } + if (bytes.readUInt16LE(peOffset + 4) !== EXPECTED_MACHINES[expectedArchitecture]) { + fail('architecture-mismatch', 'staged-architecture'); + } +}; + +const readPeHeader = async path => { + let handle; + try { + handle = await open(path, 'r'); + const bytes = Buffer.alloc(PE_HEADER_BYTES); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); + return bytes.subarray(0, bytesRead); + } catch (error) { + if (error?.code === 'ENOENT') fail('artifact-missing', 'staged-architecture'); + fail('artifact-inaccessible', 'staged-architecture'); + } finally { + await handle?.close().catch(() => {}); + } +}; + +const windowsStagedPackagePreflightSource = String.raw` +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +try { + $parent=$env:PROPR_DESKTOP_CONNECT_STAGING_PARENT + $leaf=$env:PROPR_DESKTOP_CONNECT_STAGING_LEAF + if([String]::IsNullOrEmpty($parent) -or [String]::IsNullOrEmpty($leaf)){exit 80} + $root=[IO.Path]::Combine($parent,$leaf) + $executable=[IO.Path]::Combine($root,'propr-desktop.exe') + $resources=[IO.Path]::Combine($root,'resources') + $archive=[IO.Path]::Combine($resources,'app.asar') + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + if($null -eq $current -or $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){exit 81} + $system=[Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $admins=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +} catch { exit 80 } +try { + $entries=@( + @{Path=$parent;Directory=$true}, + @{Path=$root;Directory=$true}, + @{Path=$resources;Directory=$true}, + @{Path=$archive;Directory=$false}, + @{Path=$executable;Directory=$false} + ) + $descendants=@(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop) + if($descendants.Count -lt 1 -or $descendants.Count -gt 20000){exit 82} + foreach($item in $descendants){$entries+=@{Path=$item.FullName;Directory=$item.PSIsContainer}} +} catch { exit 83 } +try { + foreach($entry in $entries){ + $item=Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + if($item.PSIsContainer -ne $entry.Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [String]::Equals($item.FullName,$entry.Path,[StringComparison]::OrdinalIgnoreCase)){exit 82} + $sections=[Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner + $acl=if($entry.Directory){[IO.Directory]::GetAccessControl($entry.Path,$sections)}else{[IO.File]::GetAccessControl($entry.Path,$sections)} + $owner=$acl.GetOwner([Security.Principal.SecurityIdentifier]) + $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) + if($owner.Value -ne $admins.Value -or -not $acl.AreAccessRulesProtected -or + -not $acl.AreAccessRulesCanonical -or $rules.Count -ne 3){exit 84} + foreach($identity in @($current,$system,$admins)){ + $matches=@($rules | Where-Object {$_.IdentityReference.Value -eq $identity.Value}) + if($matches.Count -ne 1 -or $matches[0].AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow){exit 84} + $expected=if($identity.Value -eq $current.Value){[Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [Security.AccessControl.FileSystemRights]::Synchronize}else{[Security.AccessControl.FileSystemRights]::FullControl} + $expectedInheritance=if($entry.Directory){[Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit}else{[Security.AccessControl.InheritanceFlags]::None} + if($matches[0].FileSystemRights -ne $expected -or $matches[0].InheritanceFlags -ne $expectedInheritance -or + $matches[0].PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None -or $matches[0].IsInherited){exit 84} + } + } +} catch { exit 84 } +try { + $stream=[IO.FileStream]::new($executable,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { if($stream.ReadByte() -lt 0){exit 85} } finally { $stream.Dispose() } +} catch { exit 85 } +`; + +const encodedWindowsStagedPackagePreflight = Buffer.from( + windowsStagedPackagePreflightSource, + 'utf16le', +).toString('base64'); + +export const assertWindowsStagedPackagePreflightResult = result => { + if (result?.error || result?.signal || !Buffer.isBuffer(result?.stdout) + || result.stdout.length !== 0 || !Buffer.isBuffer(result?.stderr) + || result.stderr.length !== 0) { + fail('artifact-inaccessible', 'ordinary-user-preflight', 'preflight-invocation'); + } + if (result.status === 0) return; + if (result.status === 83) { + fail('artifact-inaccessible', 'ordinary-user-preflight', 'descendant-enumeration'); + } + if (result.status === 85) { + fail('artifact-inaccessible', 'ordinary-user-preflight', 'executable-read'); + } + if ([80, 81, 82, 84].includes(result.status)) { + fail('artifact-type', 'ordinary-user-preflight', 'authority-contract'); + } + fail('artifact-inaccessible', 'ordinary-user-preflight', 'unexpected-exit'); +}; + +const runWindowsStagedPackagePreflight = paths => { + const powershell = windowsPowerShell51Path(); + const result = spawnSync(powershell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedWindowsStagedPackagePreflight, + ], { + shell: false, + windowsHide: true, + timeout: 60_000, + maxBuffer: 1024, + env: { + SystemRoot: process.env.SystemRoot, + PROPR_DESKTOP_CONNECT_STAGING_PARENT: paths.parent, + PROPR_DESKTOP_CONNECT_STAGING_LEAF: paths.leaf, + }, + }); + assertWindowsStagedPackagePreflightResult(result); +}; + +const canonicalizeEntry = async (kind, path) => canonicalizeWindowsFixtureEntry({ + entryKind: kind, + entryPath: path, + powershellPath: windowsPowerShell51Path(), +}); + +export const validateWindowsStagedPackage = async ({ + environment = process.env, + expectedArchitecture = process.arch, + inspectPath, + canonicalize = canonicalizeEntry, + readHeader = readPeHeader, + preflight = runWindowsStagedPackagePreflight, +} = {}) => { + const paths = parseWindowsStagedPackageContract(environment); + const inspect = inspectPath ?? (await import('node:fs/promises')).lstat; + const entries = [ + ['directory', paths.runnerTemp], + ['directory', paths.parent], + ['directory', paths.root], + ['directory', paths.resources], + ['file', paths.applicationArchive], + ['file', paths.executable], + ]; + for (const [kind, path] of entries) { + let stats; + try { stats = await inspect(path); } catch (error) { + if (error?.code === 'ENOENT') fail('artifact-missing', 'staged-tree'); + fail('artifact-inaccessible', 'staged-tree'); + } + if (stats.isSymbolicLink() + || (kind === 'directory' ? !stats.isDirectory() : !stats.isFile())) { + fail('artifact-type', 'staged-tree'); + } + let canonical; + try { canonical = await canonicalize(kind, path); } catch { fail('artifact-type', 'staged-tree'); } + if (!canonical || typeof canonical.path !== 'string' + || canonical.path.toUpperCase() !== path.toUpperCase()) fail('artifact-type', 'staged-tree'); + } + assertPackagedWindowsPeArchitecture(await readHeader(paths.executable), expectedArchitecture); + try { await preflight(paths); } catch (error) { + if (error instanceof WindowsArtifactFailure) throw error; + fail('artifact-inaccessible', 'ordinary-user-preflight', 'preflight-invocation'); + } + return paths; +}; + +export const classifyWindowsArtifactFailure = error => { + if (error instanceof WindowsArtifactFailure + && WINDOWS_ARTIFACT_FAILURE_CATEGORIES.includes(error.category)) return error.category; + if (error?.code === 'ENOENT') return 'artifact-missing'; + if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'artifact-inaccessible'; + return 'spawn-failed'; +}; + +export const describeWindowsArtifactFailure = (error, fallbackPhase = 'application-runtime') => { + const phase = error instanceof WindowsArtifactFailure + && WINDOWS_ARTIFACT_FAILURE_PHASES.includes(error.phase) + ? error.phase + : (WINDOWS_ARTIFACT_FAILURE_PHASES.includes(fallbackPhase) + ? fallbackPhase : 'application-runtime'); + const preSpawn = !['application-spawn', 'application-runtime', 'result-verify'].includes(phase); + const category = error instanceof WindowsArtifactFailure + ? classifyWindowsArtifactFailure(error) + : (preSpawn ? (error?.code === 'ENOENT' ? 'artifact-missing' : 'artifact-inaccessible') + : classifyWindowsArtifactFailure(error)); + const fixedErrorSubphase = error instanceof WindowsArtifactFailure + && isAllowedSubphase(phase, error.subphase) + ? error.subphase : undefined; + const subphase = phase === 'ordinary-user-preflight' + ? (fixedErrorSubphase ?? 'preflight-invocation') + : fixedErrorSubphase; + return Object.freeze({ category, phase, ...(subphase ? { subphase } : {}) }); +}; diff --git a/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs new file mode 100644 index 000000000..862c7fa79 --- /dev/null +++ b/apps/desktop/scripts/windows-packaged-connect-staging.test.mjs @@ -0,0 +1,1883 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { link, lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, win32 } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + assertPackagedWindowsPeArchitecture, + assertWindowsStagedPackagePreflightResult, + classifyWindowsArtifactFailure, + describeWindowsArtifactFailure, + packagedConnectArtifactSensitiveNeedles, + parseWindowsStagedPackageContract, + parseWindowsStagedPackageHandoff, + validateWindowsStagedPackage, + WINDOWS_ARTIFACT_FAILURE_CATEGORIES, + WINDOWS_ARTIFACT_FAILURE_PHASES, + WINDOWS_ARTIFACT_FAILURE_SUBPHASES, + WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, + WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, + WindowsArtifactFailure, +} from './windows-packaged-connect-staging.mjs'; +import { windowsPowerShell51Path } from './windows-fixture-acl.mjs'; + +const windowsTest = process.platform === 'win32' ? test : test.skip; +const orchestratorPath = fileURLToPath(new URL('./run-packaged-windows-connect-smoke.ps1', import.meta.url)); +const taskkillPath = String.raw`C:\Windows\System32\taskkill.exe`; +const hostPreflightSubphases = Object.freeze([ + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', + 'host-node-path-binding', + 'host-node-launcher-return-authority', + 'host-capture-contract', + 'host-staging-handoff', +]); +const launcherAuthoritySubphases = Object.freeze([ + 'host-launcher-native-initialization', + 'host-launcher-selected-path-input', + 'host-launcher-selected-path-extra-colon', + 'host-launcher-selected-path-get-full-path', + 'host-launcher-selected-path-absolute-shape', + 'host-launcher-selected-path-canonical-equality', + 'host-launcher-source-open', + 'host-launcher-source-type', + 'host-launcher-source-identity', + 'host-launcher-source-final-path', + 'host-launcher-final-open', + 'host-launcher-final-type', + 'host-launcher-final-identity', + 'host-launcher-final-path', + 'host-launcher-final-match', + 'host-launcher-source-reopen', + 'host-launcher-source-reopen-type', + 'host-launcher-source-reopen-identity', + 'host-launcher-source-reopen-final-path', + 'host-launcher-source-reopen-match', +]); +const fixedHostDiagnosticSubphases = Object.freeze([ + ...hostPreflightSubphases, + ...launcherAuthoritySubphases, +]); +const launcherInvocationSubphases = Object.freeze([ + 'host-node-path-binding', + ...launcherAuthoritySubphases, +]); +const positiveHostNodeProducerSubphases = Object.freeze([ + 'host-node-command-cardinality', + 'host-node-command-type', + 'host-node-source', +]); +const captureRedirectionFailurePredicates = Object.freeze([ + 'pre-create', + 'redirect-open', + 'redirect-timeout', + 'redirect-child-exit', + 'post-redirection-identity', + 'capture-owner', + 'dacl-canonicality', + 'unauthorized-writer', + 'link-path-type', + 'identity-replacement', + 'capture-content', + 'cleanup', +]); +const captureRedirectionReportedPredicates = Object.freeze([ + ...captureRedirectionFailurePredicates, + 'diagnostic-contract', +]); +const captureProducerExitBuckets = Object.freeze(['zero', 'forced-23', 'other']); +const captureProducerOutputStates = Object.freeze(['exact-expected', 'empty', 'other-bounded']); +const captureRedirectionResultPredicates = Object.freeze([ + 'redirect-child-exit', + 'capture-content', +]); +const captureRedirectionDiagnosticPattern = new RegExp( + '^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=([a-z-]+):cleanup=none\\r?\\n$', + 'u', +); +const captureRedirectionResultDiagnosticPattern = new RegExp( + '^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=([a-z-]+):exit=([a-z0-9-]+)' + + ':out=([a-z-]+):err=([a-z-]+):cleanup=none\\r?\\n$', + 'u', +); +const captureRedirectionAcceptedPattern = + /^PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST:accepted\r?\n$/u; +const hostileDiagnosticPattern = /[A-Z]:\\|\\\\|S-1-5-|account-name|username|stdout|stderr|exception|native-text|command-line|sddl|exit-code|environment-secret/iu; +const uppercasePathDiagnosticPattern = /\bPATH\b/u; +const hasHostileDiagnosticEvidence = value => hostileDiagnosticPattern.test(value) + || uppercasePathDiagnosticPattern.test(value); +const assertNoHostileDiagnosticEvidence = value => { + assert.doesNotMatch(value, hostileDiagnosticPattern); + assert.doesNotMatch(value, uppercasePathDiagnosticPattern); +}; + +const parent = String.raw`C:\runner-temp\propr-connect-packaged-stage`; +const leaf = 'propr-connect-package-0123456789abcdef0123456789abcdef'; +const environment = { + RUNNER_TEMP: String.raw`C:\runner-temp`, + PROPR_DESKTOP_CONNECT_STAGING_PARENT: parent, + PROPR_DESKTOP_CONNECT_STAGING_LEAF: leaf, +}; +const handoffFor = ({ + RUNNER_TEMP = environment.RUNNER_TEMP, + PROPR_DESKTOP_CONNECT_STAGING_PARENT = environment.PROPR_DESKTOP_CONNECT_STAGING_PARENT, + PROPR_DESKTOP_CONNECT_STAGING_LEAF = environment.PROPR_DESKTOP_CONNECT_STAGING_LEAF, +} = {}) => '--propr-windows-staged-contract=' + Buffer.from([ + RUNNER_TEMP, + PROPR_DESKTOP_CONNECT_STAGING_PARENT, + PROPR_DESKTOP_CONNECT_STAGING_LEAF, +].join('\n'), 'utf8').toString('base64'); +const regularFile = { + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, +}; +const regularDirectory = { + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, +}; + +const peFixture = architecture => { + const bytes = Buffer.alloc(256); + bytes.write('MZ', 0, 'ascii'); + bytes.writeUInt32LE(0x80, 0x3c); + bytes.write('PE\0\0', 0x80, 'ascii'); + bytes.writeUInt16LE(architecture === 'arm64' ? 0xaa64 : 0x8664, 0x84); + return bytes; +}; + +const processExists = processId => { + try { + process.kill(processId, 0); + return true; + } catch (error) { + if (error?.code === 'ESRCH') return false; + throw error; + } +}; + +const waitForProcessExit = async (processId, timeoutMilliseconds = 5_000) => { + const deadline = Date.now() + timeoutMilliseconds; + while (processExists(processId) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 25)); + } + return !processExists(processId); +}; + +const startNativeNodeTree = async () => { + const rootSource = String.raw` +const { spawn } = require('node:child_process'); +const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + shell: false, + windowsHide: true, + stdio: 'ignore', +}); +process.stdout.write(String(descendant.pid) + '\n'); +setInterval(() => {}, 1000); +`; + const root = spawn(process.execPath, ['-e', rootSource], { + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const descendantProcessId = await new Promise((resolve, reject) => { + let output = ''; + const timeout = setTimeout(() => reject(new Error('native process tree did not start')), 5_000); + root.once('error', error => { + clearTimeout(timeout); + reject(error); + }); + root.stdout.on('data', chunk => { + output += chunk.toString('ascii'); + const newline = output.indexOf('\n'); + if (newline < 0) return; + clearTimeout(timeout); + const value = output.slice(0, newline).trim(); + if (!/^[1-9][0-9]{0,9}$/u.test(value)) reject(new Error('native descendant pid was invalid')); + else resolve(Number(value)); + }); + }); + return { root, descendantProcessId }; +}; + +const terminateTreeAfterTest = processId => { + if (!Number.isSafeInteger(processId) || processId < 1 || !processExists(processId)) return; + spawnSync(taskkillPath, ['/PID', String(processId), '/T', '/F'], { + shell: false, + windowsHide: true, + stdio: 'ignore', + timeout: 5_000, + }); +}; + +const runLauncherAuthorityTest = (path, testCase = 'normal', retargetPath) => { + const arguments_ = [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'launcher-authority', + '-LauncherAuthorityTestCase', + testCase, + '-LauncherAuthorityTestPath', + path, + ]; + if (retargetPath !== undefined) { + arguments_.push('-LauncherAuthorityTestRetargetPath', retargetPath); + } + return spawnSync(windowsPowerShell51Path(), arguments_, { + shell: false, + windowsHide: true, + timeout: 15_000, + }); +}; + +const runHostNodeProducerTest = testCase => spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'host-node-producer', + '-HostNodeProducerTestCase', + testCase, +], { + shell: false, + windowsHide: true, + timeout: 10_000, +}); + +const runCaptureParserTest = ( + path, + authorityCase = 'existing', + environmentOverrides = {}, +) => spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', orchestratorPath, + '-Architecture', process.arch, + '-LifecycleTestMode', 'capture-parser', + '-CaptureParserTestPath', path, + '-CaptureParserAuthorityTestCase', authorityCase, +], { + shell: false, + windowsHide: true, + timeout: 10_000, + env: { ...process.env, ...environmentOverrides }, +}); + +const runCaptureRedirectionTest = (producerTestCase = 'success') => spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', orchestratorPath, + '-Architecture', process.arch, + '-LifecycleTestMode', 'capture-redirection', + '-CaptureRedirectionProducerTestCase', producerTestCase, +], { + shell: false, + windowsHide: true, + timeout: 45_000, +}); + +const failCaptureRedirectionTest = result => { + let evidence = 'predicate=diagnostic-contract'; + if (!result.error && result.signal === null && result.status === 1 + && Buffer.isBuffer(result.stdout) && result.stdout.length === 0 + && Buffer.isBuffer(result.stderr) && result.stderr.length <= 256) { + const diagnostic = result.stderr.toString('utf8'); + const resultMatch = captureRedirectionResultDiagnosticPattern.exec(diagnostic); + const predicateMatch = captureRedirectionDiagnosticPattern.exec(diagnostic); + if (resultMatch + && captureRedirectionResultPredicates.includes(resultMatch[1]) + && captureProducerExitBuckets.includes(resultMatch[2]) + && captureProducerOutputStates.includes(resultMatch[3]) + && captureProducerOutputStates.includes(resultMatch[4]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `predicate=${resultMatch[1]}:exit=${resultMatch[2]}` + + `:out=${resultMatch[3]}:err=${resultMatch[4]}`; + } else if (predicateMatch + && captureRedirectionFailurePredicates.includes(predicateMatch[1]) + && !captureRedirectionResultPredicates.includes(predicateMatch[1]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `predicate=${predicateMatch[1]}`; + } + } + assert.ok(captureRedirectionReportedPredicates.includes(evidence.slice('predicate='.length).split(':')[0])); + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST:failed:${evidence}`, + ); + error.stack = error.message; + throw error; +}; + +test('capture redirection mismatch reporting is total and redacted for each launch predicate', () => { + const resultFor = stderr => ({ + error: undefined, + signal: null, + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), + }); + const assertDiagnosticContract = (result, label) => assert.throws( + () => failCaptureRedirectionTest(result), + error => error.message === 'PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST' + + ':failed:predicate=diagnostic-contract' + && error.stack === error.message + && !hasHostileDiagnosticEvidence(error.message), + label, + ); + for (const predicate of ['redirect-open', 'redirect-timeout']) { + const diagnostic = 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + `:predicate=${predicate}:cleanup=none\r\n`; + assert.throws( + () => failCaptureRedirectionTest(resultFor(diagnostic)), + error => error.message === 'PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST' + + `:failed:predicate=${predicate}` + && error.stack === error.message + && !hasHostileDiagnosticEvidence(error.message), + predicate, + ); + + assertDiagnosticContract(resultFor( + diagnostic + String.raw`C:\hostile\capture S-1-5-21 account-name username stdout stderr exception native-text command-line sddl exit-code environment-secret`, + ), `${predicate}-hostile-output`); + + assertDiagnosticContract({ + error: new Error(String.raw`C:\hostile\exception`), + signal: 'hostile-signal', + status: null, + stdout: Buffer.from('environment-secret'), + stderr: Buffer.from(diagnostic), + }, `${predicate}-totality`); + } + + for (const [predicate, exit, out, err] of [ + ['redirect-child-exit', 'zero', 'exact-expected', 'exact-expected'], + ['redirect-child-exit', 'forced-23', 'empty', 'other-bounded'], + ['capture-content', 'other', 'other-bounded', 'empty'], + ]) { + const diagnostic = 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + `:predicate=${predicate}:exit=${exit}:out=${out}:err=${err}:cleanup=none\r\n`; + assert.throws( + () => failCaptureRedirectionTest(resultFor(diagnostic)), + error => error.message === 'PROPR_WINDOWS_PACKAGED_CONNECT_CAPTURE_REDIRECTION_TEST' + + `:failed:predicate=${predicate}:exit=${exit}:out=${out}:err=${err}` + && error.stack === error.message + && !hasHostileDiagnosticEvidence(error.message), + `${predicate}-${exit}-${out}-${err}`, + ); + assertDiagnosticContract(resultFor( + diagnostic + String.raw`C:\hostile\capture S-1-5-21 environment-secret`, + ), `${predicate}-hostile-output`); + } + + for (const diagnostic of [ + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:cleanup=none\r\n', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:exit=23:out=exact-expected:err=exact-expected' + + ':cleanup=none\r\n', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:exit=other:out=raw-value:err=empty' + + ':cleanup=none\r\n', + ]) assertDiagnosticContract(resultFor(diagnostic), 'result-attribution-totality'); +}); + +const assertLauncherAuthorityRejected = (result, category, subphase) => { + const expected = `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=${category}` + + `:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`; + const diagnostic = Buffer.isBuffer(result.stderr) + && result.stderr.length <= 512 ? result.stderr.toString('utf8').trim() : ''; + if (result.error || result.signal !== null || result.status !== 1 + || !Buffer.isBuffer(result.stdout) || result.stdout.length !== 0 + || diagnostic !== expected || hasHostileDiagnosticEvidence(diagnostic)) { + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:rejection-diagnostic-failed` + + `:category=${category}:phase=ordinary-user-preflight:subphase=${subphase}`, + ); + error.stack = error.message; + throw error; + } +}; + +const failAcceptedLauncherCase = (caseName, result) => { + const fallback = 'category=artifact-inaccessible:phase=ordinary-user-preflight:subphase=host-state-contract'; + let evidence = fallback; + if (!result.error && result.signal === null && result.status === 1 + && Buffer.isBuffer(result.stdout) && result.stdout.length === 0 + && Buffer.isBuffer(result.stderr) && result.stderr.length <= 512) { + const diagnostic = result.stderr.toString('utf8').trim(); + const match = /^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=(artifact-missing|artifact-inaccessible|artifact-type|architecture-mismatch|spawn-failed):phase=(ordinary-user-preflight):subphase=([a-z-]+):cleanup=none$/u.exec(diagnostic); + if (match && launcherInvocationSubphases.includes(match[3]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `category=${match[1]}:phase=${match[2]}:subphase=${match[3]}`; + } + } + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:accepted-case-failed:case=${caseName}:${evidence}`, + ); + error.stack = error.message; + throw error; +}; + +const assertLauncherAuthorityAccepted = (result, caseName) => { + if (result.error || result.signal !== null || result.status !== 0 + || !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr) + || result.stdout.toString('utf8').trim() + !== 'PROPR_WINDOWS_PACKAGED_CONNECT_LAUNCHER_AUTHORITY_TEST:accepted' + || result.stderr.length !== 0) { + failAcceptedLauncherCase(caseName, result); + } +}; + +const failPositiveHostNodeProducer = result => { + const fallback = 'category=artifact-inaccessible:phase=ordinary-user-preflight' + + ':subphase=host-node-command-cardinality'; + let evidence = fallback; + if (!result.error && result.signal === null && result.status === 1 + && Buffer.isBuffer(result.stdout) && result.stdout.length === 0 + && Buffer.isBuffer(result.stderr) && result.stderr.length <= 512) { + const diagnostic = result.stderr.toString('utf8').trim(); + const match = /^PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=(artifact-inaccessible|artifact-type):phase=(ordinary-user-preflight):subphase=([a-z-]+):cleanup=none$/u.exec(diagnostic); + if (match && positiveHostNodeProducerSubphases.includes(match[3]) + && !hasHostileDiagnosticEvidence(diagnostic)) { + evidence = `category=${match[1]}:phase=${match[2]}:subphase=${match[3]}`; + } + } + const error = new Error( + `PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST:positive-case-failed:${evidence}`, + ); + error.stack = error.message; + throw error; +}; + +const assertPositiveHostNodeProducer = result => { + if (result.error || result.signal !== null || result.status !== 0 + || !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr) + || result.stdout.toString('utf8').trim() + !== 'PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST:accepted' + || result.stderr.length !== 0) { + failPositiveHostNodeProducer(result); + } +}; + +test('positive host Node producer failures expose only fixed allowlisted evidence', () => { + for (const category of ['artifact-inaccessible', 'artifact-type']) { + for (const subphase of positiveHostNodeProducerSubphases) { + const result = { + error: undefined, + signal: null, + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from( + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=${category}` + + `:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`, + ), + }; + assert.throws( + () => failPositiveHostNodeProducer(result), + { + message: 'PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST' + + `:positive-case-failed:category=${category}` + + `:phase=ordinary-user-preflight:subphase=${subphase}`, + }, + ); + } + } + + const fallback = 'PROPR_WINDOWS_PACKAGED_CONNECT_HOST_NODE_PRODUCER_TEST' + + ':positive-case-failed:category=artifact-inaccessible' + + ':phase=ordinary-user-preflight:subphase=host-node-command-cardinality'; + for (const stderr of [ + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=spawn-failed' + + ':phase=ordinary-user-preflight:subphase=host-node-source:cleanup=none', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=application-spawn:subphase=host-node-source:cleanup=none', + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=ordinary-user-preflight:subphase=host-node-path-binding:cleanup=none', + String.raw`C:\hostile\node.exe \\hostile PATH account-name S-1-5-21 stdout stderr exception native-text environment-secret`, + ]) { + assert.throws( + () => failPositiveHostNodeProducer({ + error: undefined, + signal: null, + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), + }), + { message: fallback }, + ); + } + assertNoHostileDiagnosticEvidence(fallback); +}); + +test('hostile diagnostics reject uppercase PATH without matching fixed path subphases', () => { + assert.equal( + hasHostileDiagnosticEvidence( + 'category=artifact-type:phase=ordinary-user-preflight:subphase=host-node-path-binding', + ), + false, + ); + assert.equal(hasHostileDiagnosticEvidence('PATH'), true); +}); + +const validationOptions = overrides => ({ + environment, + expectedArchitecture: 'arm64', + inspectPath: async path => path.endsWith('.exe') || path.endsWith('.asar') + ? regularFile + : regularDirectory, + canonicalize: async (kind, path) => ({ path }), + readHeader: async () => peFixture('arm64'), + preflight: async () => {}, + ...overrides, +}); + +describe('packaged Windows Connect staging contract', () => { + test('accepts only the exact generated leaf below the fixed canonical staging parent', () => { + const contract = parseWindowsStagedPackageContract(environment); + assert.equal(contract.parent, parent); + assert.equal(contract.root, win32.join(parent, leaf)); + assert.equal(contract.executable, win32.join(parent, leaf, 'propr-desktop.exe')); + + for (const [invalid, subphase] of [ + [{}, 'runner-temp-input-shape'], + [{ PROPR_DESKTOP_CONNECT_STAGED_ROOT: contract.root }, 'runner-temp-input-shape'], + [{ ...environment, RUNNER_TEMP: 'runner-temp' }, 'runner-temp-input-shape'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: `${parent}\\` }, 'staging-parent-input-shape'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: String.raw`\\server\share\propr-connect-packaged-stage` }, 'staging-parent-input-shape'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: String.raw`C:\runner-temp\x\propr-connect-packaged-stage` }, 'parent-to-runner-binding'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_PARENT: String.raw`C:\runner-temp\other` }, 'fixed-parent-leaf'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_LEAF: '../package' }, 'generated-stage-leaf'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_LEAF: 'propr-connect-package-ABCDEF0123456789abcdef0123456789' }, 'generated-stage-leaf'], + [{ ...environment, PROPR_DESKTOP_CONNECT_STAGING_LEAF: 'propr-connect-package-0123' }, 'generated-stage-leaf'], + ]) { + assert.throws( + () => parseWindowsStagedPackageContract(invalid), + error => error instanceof WindowsArtifactFailure + && error.category === 'artifact-type' + && error.phase === 'staged-contract' + && error.subphase === subphase, + ); + } + }); + + test('accepts one bounded parent-owned handoff and rejects every other input shape', () => { + const contract = parseWindowsStagedPackageHandoff([handoffFor()]); + assert.equal(contract.runnerTemp, environment.RUNNER_TEMP); + assert.equal(contract.parent, parent); + assert.equal(contract.leaf, leaf); + for (const arguments_ of [ + [], + [handoffFor(), handoffFor()], + ['--propr-windows-staged-contract=not-base64'], + ['--different-contract=AAAA'], + [`--propr-windows-staged-contract=${'A'.repeat(16_388)}`], + ['--propr-windows-staged-contract=' + Buffer.from('one\ntwo', 'utf8').toString('base64')], + ]) { + assert.throws( + () => parseWindowsStagedPackageHandoff(arguments_), + error => error instanceof WindowsArtifactFailure + && error.category === 'artifact-type' + && error.phase === 'staged-contract' + && error.subphase === 'runner-temp-input-shape', + ); + } + }); + + test('emits only fixed staged-contract predicate evidence', () => { + const diagnostics = WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES.map(subphase => { + const failure = new WindowsArtifactFailure('artifact-type', 'staged-contract', subphase); + return JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...describeWindowsArtifactFailure(failure, 'application-spawn'), + }); + }); + assert.deepEqual(diagnostics, WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES.map(subphase => ( + `{"event":"packaged_connect.artifact_failed","category":"artifact-type",` + + `"phase":"staged-contract","subphase":"${subphase}"}` + ))); + assertNoHostileDiagnosticEvidence(diagnostics.join('\n')); + + const hostileSubphase = new WindowsArtifactFailure( + 'artifact-type', + 'staged-contract', + String.raw`C:\secret\account-name-S-1-5-21-123`, + ); + assert.equal(hostileSubphase.subphase, undefined); + assert.deepEqual(describeWindowsArtifactFailure(hostileSubphase, 'staged-contract'), { + category: 'artifact-type', + phase: 'staged-contract', + }); + assertNoHostileDiagnosticEvidence(hostileSubphase.message); + }); + + test('rejects missing, inaccessible, reparse, wrong-type, and noncanonical entries before preflight', async () => { + let preflightCalls = 0; + const assertCategory = async (inspectPath, canonicalize, category) => { + await assert.rejects( + validateWindowsStagedPackage(validationOptions({ + inspectPath, + canonicalize: canonicalize ?? (async (kind, path) => ({ path })), + preflight: async () => { preflightCalls += 1; }, + })), + error => error instanceof WindowsArtifactFailure && error.category === category, + ); + }; + await assertCategory(async () => { const error = new Error('sensitive path'); error.code = 'ENOENT'; throw error; }, null, 'artifact-missing'); + await assertCategory(async () => { const error = new Error('sensitive path'); error.code = 'EACCES'; throw error; }, null, 'artifact-inaccessible'); + await assertCategory(async () => ({ ...regularDirectory, isSymbolicLink: () => true }), null, 'artifact-type'); + await assertCategory(async () => regularFile, null, 'artifact-type'); + await assertCategory( + async path => path.endsWith('.exe') || path.endsWith('.asar') ? regularFile : regularDirectory, + async (kind, path) => ({ path: `${path}-alias` }), + 'artifact-type', + ); + assert.equal(preflightCalls, 0, 'a rejected package must fail before the access preflight'); + }); + + test('proves target PE architecture and ordinary-user access before returning the executable', async () => { + let preflightCalls = 0; + const result = await validateWindowsStagedPackage(validationOptions({ + preflight: async paths => { + preflightCalls += 1; + assert.equal(paths.executable, win32.join(parent, leaf, 'propr-desktop.exe')); + }, + })); + assert.equal(result.root, win32.join(parent, leaf)); + assert.equal(preflightCalls, 1); + + await assert.rejects( + validateWindowsStagedPackage(validationOptions({ readHeader: async () => peFixture('x64') })), + error => error instanceof WindowsArtifactFailure && error.category === 'architecture-mismatch', + ); + }); + + test('maps a hostile preflight callback throw totally and redacts all supplied evidence', async () => { + const hostile = new Error( + String.raw`hostile exception C:\secret\package S-1-5-21-123 account-name raw stdout raw stderr environment-secret`, + ); + hostile.stdout = 'raw stdout'; + hostile.stderr = 'raw stderr'; + hostile.environment = { SECRET: 'environment-secret' }; + await assert.rejects( + validateWindowsStagedPackage(validationOptions({ + preflight: async () => { throw hostile; }, + })), + error => { + assert.ok(error instanceof WindowsArtifactFailure); + assert.equal(error.category, 'artifact-inaccessible'); + assert.equal(error.phase, 'ordinary-user-preflight'); + assert.equal(error.subphase, 'preflight-invocation'); + const diagnostic = JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...describeWindowsArtifactFailure(error, 'ordinary-user-preflight'), + }); + assert.equal( + diagnostic, + '{"event":"packaged_connect.artifact_failed","category":"artifact-inaccessible","phase":"ordinary-user-preflight","subphase":"preflight-invocation"}', + ); + assertNoHostileDiagnosticEvidence(`${error.message}\n${diagnostic}`); + return true; + }, + ); + }); + + test('keeps PE type and architecture failures distinct', () => { + assert.doesNotThrow(() => assertPackagedWindowsPeArchitecture(peFixture('arm64'), 'arm64')); + assert.throws( + () => assertPackagedWindowsPeArchitecture(Buffer.from('not a PE'), 'arm64'), + error => error.category === 'artifact-type', + ); + assert.throws( + () => assertPackagedWindowsPeArchitecture(peFixture('x64'), 'arm64'), + error => error.category === 'architecture-mismatch', + ); + }); + + test('maps hostile exceptions to a fixed path-free allowlist', () => { + assert.deepEqual(WINDOWS_ARTIFACT_FAILURE_CATEGORIES, [ + 'artifact-missing', + 'artifact-inaccessible', + 'artifact-type', + 'architecture-mismatch', + 'spawn-failed', + ]); + const hostile = new Error(String.raw`spawn C:\secret\propr-desktop.exe ENOENT --token=secret`); + hostile.code = 'ENOENT'; + assert.equal(classifyWindowsArtifactFailure(hostile), 'artifact-missing'); + assert.equal(classifyWindowsArtifactFailure(new Error('username SID environment stack')), 'spawn-failed'); + for (const category of WINDOWS_ARTIFACT_FAILURE_CATEGORIES) { + const failure = new WindowsArtifactFailure(category, 'staged-tree'); + assert.equal(classifyWindowsArtifactFailure(failure), category); + assert.doesNotMatch(failure.message, /[A-Z]:\\|S-1-5-|--|username|environment|stack/iu); + } + const invalidSubphase = new WindowsArtifactFailure( + 'artifact-inaccessible', + 'ordinary-user-preflight', + String.raw`C:\secret\account-name-S-1-5-21-123`, + ); + assert.equal(invalidSubphase.subphase, undefined); + assert.doesNotMatch(invalidSubphase.message, /[A-Z]:\\|S-1-5-|account-name/iu); + }); + + test('classifies fixed phases without collapsing pre-spawn failures into spawn', () => { + assert.deepEqual(WINDOWS_ARTIFACT_FAILURE_PHASES, [ + 'staged-contract', + 'staged-tree', + 'staged-architecture', + 'ordinary-user-preflight', + 'fixture-setup', + 'package-authority', + 'application-spawn', + 'application-runtime', + 'result-verify', + ]); + assert.deepEqual(WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, [ + 'runner-temp-input-shape', + 'staging-parent-input-shape', + 'parent-to-runner-binding', + 'fixed-parent-leaf', + 'generated-stage-leaf', + 'derived-root-to-parent-binding', + ]); + assert.deepEqual( + describeWindowsArtifactFailure(new Error(String.raw`C:\secret\account`), 'fixture-setup'), + { category: 'artifact-inaccessible', phase: 'fixture-setup' }, + ); + assert.deepEqual( + describeWindowsArtifactFailure( + new WindowsArtifactFailure( + 'artifact-type', + 'ordinary-user-preflight', + 'authority-contract', + ), + 'application-spawn', + ), + { + category: 'artifact-type', + phase: 'ordinary-user-preflight', + subphase: 'authority-contract', + }, + ); + assert.deepEqual( + describeWindowsArtifactFailure(new Error('--token secret'), 'application-spawn'), + { category: 'spawn-failed', phase: 'application-spawn' }, + ); + }); + + test('maps every preflight transport and exit result to fixed subphase evidence', () => { + assert.deepEqual(WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, [ + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract', + ]); + assert.deepEqual(WINDOWS_ARTIFACT_FAILURE_SUBPHASES, [ + ...WINDOWS_STAGED_CONTRACT_FAILURE_SUBPHASES, + ...WINDOWS_ORDINARY_USER_PREFLIGHT_FAILURE_SUBPHASES, + ]); + const clean = status => ({ + status, + error: undefined, + signal: null, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); + assert.doesNotThrow(() => assertWindowsStagedPackagePreflightResult(clean(0))); + + for (const [status, category, subphase] of [ + [80, 'artifact-type', 'authority-contract'], + [81, 'artifact-type', 'authority-contract'], + [82, 'artifact-type', 'authority-contract'], + [83, 'artifact-inaccessible', 'descendant-enumeration'], + [84, 'artifact-type', 'authority-contract'], + [85, 'artifact-inaccessible', 'executable-read'], + [1, 'artifact-inaccessible', 'unexpected-exit'], + [86, 'artifact-inaccessible', 'unexpected-exit'], + [null, 'artifact-inaccessible', 'unexpected-exit'], + ]) { + assert.throws( + () => assertWindowsStagedPackagePreflightResult(clean(status)), + error => error instanceof WindowsArtifactFailure + && error.category === category + && error.phase === 'ordinary-user-preflight' + && error.subphase === subphase, + ); + } + + const invocationFailures = [ + { ...clean(null), error: new Error(String.raw`C:\secret\invoke.exe`) }, + { ...clean(null), signal: 'SIGTERM' }, + { ...clean(0), stdout: Buffer.from('raw stdout account-name') }, + { ...clean(0), stderr: Buffer.from('raw stderr S-1-5-21-123') }, + { ...clean(0), stdout: 'not-a-buffer' }, + { ...clean(0), stderr: 'not-a-buffer' }, + ]; + for (const result of invocationFailures) { + assert.throws( + () => assertWindowsStagedPackagePreflightResult(result), + error => error instanceof WindowsArtifactFailure + && error.category === 'artifact-inaccessible' + && error.phase === 'ordinary-user-preflight' + && error.subphase === 'preflight-invocation', + ); + } + }); + + test('preflight diagnostics exclude path, SID, account name, stdout, and stderr evidence', () => { + const clean = status => ({ + status, + error: undefined, + signal: null, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); + const hostileResult = { + status: 85, + error: new Error(String.raw`C:\runner-temp\secret\propr-desktop.exe account-name S-1-5-21-123`), + signal: null, + stdout: Buffer.from('raw stdout account-name'), + stderr: Buffer.from(String.raw`raw stderr C:\secret S-1-5-21-123`), + }; + const diagnosticFor = result => { + try { + assertWindowsStagedPackagePreflightResult(result); + assert.fail('the preflight result must fail'); + } catch (error) { + return JSON.stringify({ + event: 'packaged_connect.artifact_failed', + ...describeWindowsArtifactFailure(error, 'ordinary-user-preflight'), + }); + } + }; + const diagnostics = [ + diagnosticFor(hostileResult), + diagnosticFor(clean(83)), + diagnosticFor(clean(85)), + diagnosticFor(clean(86)), + diagnosticFor(clean(84)), + ]; + assert.deepEqual( + diagnostics.map(diagnostic => JSON.parse(diagnostic).subphase), + [ + 'preflight-invocation', + 'descendant-enumeration', + 'executable-read', + 'unexpected-exit', + 'authority-contract', + ], + ); + assertNoHostileDiagnosticEvidence(diagnostics.join('\n')); + }); + + test('scopes staged-root and executable leak needles to Windows', () => { + const options = { + artifactRoot: String.raw`C:\runner-temp\stage\leaf`, + binaryPath: String.raw`C:\runner-temp\stage\leaf\propr-desktop.exe`, + stagedContract: { + runnerTemp: String.raw`C:\runner-temp`, + parent: String.raw`C:\runner-temp\stage`, + leaf: 'leaf', + }, + stagedHandoff: handoffFor(), + }; + assert.deepEqual(packagedConnectArtifactSensitiveNeedles({ platform: 'darwin', ...options }), []); + assert.deepEqual(packagedConnectArtifactSensitiveNeedles({ platform: 'linux', ...options }), []); + assert.deepEqual(packagedConnectArtifactSensitiveNeedles({ platform: 'win32', ...options }), [ + options.artifactRoot, + options.binaryPath, + options.stagedContract.runnerTemp, + options.stagedContract.parent, + options.stagedContract.leaf, + options.stagedHandoff, + ]); + }); +}); + +test('the workflow stages before alternate credentials and the harness preflights before application spawn', async () => { + const workflow = await readFile(new URL('../../../.github/workflows/desktop-connect-discovery-guard.yml', import.meta.url), 'utf8'); + const orchestrator = await readFile(new URL('./run-packaged-windows-connect-smoke.ps1', import.meta.url), 'utf8'); + const harness = await readFile(new URL('./smoke-packaged-connect.mjs', import.meta.url), 'utf8'); + const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.match(workflow, /run-packaged-windows-connect-smoke\.ps1\s+-Architecture '\$\{\{ matrix\.arch \}\}'/u); + assert.doesNotMatch(workflow, /Start-Process|Get-Content|New-LocalUser/u); + + const copy = orchestrator.indexOf('Copy-Item -LiteralPath $entry.FullName'); + const acl = orchestrator.indexOf('Set-StagedEntryAcl $item'); + const alternateLaunch = orchestrator.indexOf('$process = Start-Process'); + const nativeAuthorityTests = workflow.indexOf( + 'node --test apps/desktop/scripts/windows-packaged-connect-staging.test.mjs', + ); + const packageStep = workflow.indexOf('npm run desktop:package'); + const packagedLaunch = workflow.indexOf('run-packaged-windows-connect-smoke.ps1'); + assert.ok(copy >= 0 && copy < acl && acl < alternateLaunch); + assert.ok(nativeAuthorityTests >= 0 + && nativeAuthorityTests < packageStep + && packageStep < packagedLaunch); + assert.doesNotMatch(orchestrator.slice(alternateLaunch, alternateLaunch + 700), /\s-Wait(?:\s|`)/u); + assert.match(orchestrator, /Assert-PeArchitecture \$sourceExecutable \$Architecture/u); + assert.match(orchestrator, /Assert-PeArchitecture \$stagedExecutable \$Architecture/u); + assert.match(orchestrator, /FileSystemRights\]::ReadAndExecute/u); + assert.match(orchestrator, /FileSystemRights\]::FullControl/u); + assert.match(orchestrator, /SetAccessRuleProtection\(\$true, \$false\)/u); + assert.match(orchestrator, /SetOwner\(\$Administrators\)/u); + assert.match(orchestrator, /\[Diagnostics\.Process\]::new\(\)/u); + assert.match(orchestrator, /\$taskkillExecutable = 'C:\\Windows\\System32\\taskkill\.exe'/u); + assert.match( + orchestrator, + /\$taskkillStart\.Arguments = \[String\]::Join\(' ', \[string\[\]\]@\('\/PID', \$processIdText, '\/T', '\/F'\)\)/u, + ); + assert.match(orchestrator, /\$taskkillStart\.UseShellExecute = \$false/u); + assert.match(orchestrator, /\$processIdText -cnotmatch '\^\[1-9\]\[0-9\]\{0,9\}\$'/u); + assert.match(orchestrator, /\$taskkillProcess\.WaitForExit\(\$terminationTimeoutMilliseconds\)/u); + assert.match(orchestrator, /Task\]::WaitAll\([\s\S]*?\$streamCloseTimeoutMilliseconds/u); + assert.doesNotMatch(orchestrator, /(?:cmd(?:\.exe)?|powershell(?:\.exe)?)['"]?\s+\/c[\s\S]*?taskkill/iu); + assert.match(orchestrator, /WaitForExit\(\$cleanupTimeoutMilliseconds\)/u); + assert.match(orchestrator, /if\(!\$cleanupProcess\.WaitForExit[\s\S]*?\$cleanupProcess\.Kill\(\)[\s\S]*?WaitForExit\(\$terminationTimeoutMilliseconds\)/u); + assert.match(orchestrator, /Remove-Item -LiteralPath \$root -Recurse/u); + assert.doesNotMatch(orchestrator, /Remove-Item -LiteralPath \$parent -Recurse/u); + assert.match(orchestrator, /\$createdAccount\.SID\.Value -cne \$testUserSid\.Value/u); + assert.match(orchestrator, /\$administratorsSid\.Translate\(\[Security\.Principal\.NTAccount\]\)/u); + assert.match(orchestrator, /\.psbase\.Invoke\('IsMember', \$ordinaryUserEntry\.Path\)/u); + assert.doesNotMatch(orchestrator, /Get-LocalGroupMember/u); + assert.doesNotMatch(orchestrator, /Get-Content|Write-(?:Host|Error|Verbose|Debug|Information)|GITHUB_WORKSPACE/u); + const hostNodeProducer = orchestrator.slice( + orchestrator.indexOf('function Get-ValidatedHostNodePath'), + orchestrator.indexOf('function Stop-SpawnedProcess'), + ); + const producerTransitions = [ + ['host-node-command-cardinality', 'Get-Command node.exe'], + ['host-node-command-type', '$candidate -is [System.Management.Automation.ApplicationInfo]'], + ['host-node-source', '@($candidate.Source)'], + ]; + for (let index = 0; index < producerTransitions.length; index += 1) { + const [subphase, operation] = producerTransitions[index]; + const transition = hostNodeProducer.indexOf(`Set-OrdinaryUserPreflightSubphase '${subphase}'`); + const operationIndex = hostNodeProducer.indexOf(operation); + const nextTransition = index + 1 < producerTransitions.length + ? hostNodeProducer.indexOf( + `Set-OrdinaryUserPreflightSubphase '${producerTransitions[index + 1][0]}'`, + ) + : hostNodeProducer.length; + assert.ok(transition >= 0 && transition < operationIndex && operationIndex < nextTransition, + `${subphase} must cover exactly its producer operation boundary`); + } + assert.match(hostNodeProducer, /Get-Command node\.exe[\s\S]*?-CommandType Application[\s\S]*?-TotalCount 1[\s\S]*?-ErrorAction Stop/u); + assert.match(hostNodeProducer, /\$commandResults\.Count -ne 1[\s\S]*?host-node-command-type[\s\S]*?\$candidate = \$commandResults\[0\][\s\S]*?System\.Management\.Automation\.ApplicationInfo/u); + assert.match(hostNodeProducer, /\$sourceResults\.Count -ne 1[\s\S]*?\$sourceResults\[0\] -is \[string\]/u); + assert.match(hostNodeProducer, /return \$sourceResults\[0\]/u); + assert.doesNotMatch(hostNodeProducer, /validatedSources|StringComparison|foreach \(\$candidate in \$commandResults\)/u); + assert.doesNotMatch(hostNodeProducer, /PSObject\.Properties\['Source'\]/u); + assert.doesNotMatch(hostNodeProducer, /\$env:PATH|Select-Object\s+-First|where(?:\.exe)?/iu); + assert.doesNotMatch(orchestrator, /\$node\s*=\s*['"]node(?:\.exe)?['"]/iu); + const hostBoundary = orchestrator.slice( + orchestrator.indexOf('$node = Get-ValidatedHostNodePath', orchestrator.indexOf("Set-FailurePhase 'staging-acl'")), + orchestrator.indexOf("Set-FailurePhase 'application-spawn'"), + ); + const hostTransitions = [ + ['host-node-path-binding', '$launcherAuthority = Get-TrustedHostLauncher -Path $node'], + ['host-node-launcher-return-authority', '$launcherAuthorityResults = @($launcherAuthority)'], + ['host-capture-contract', '$stdout = Join-Path $authenticatedRunnerTemp'], + ['host-staging-handoff', '$handoffText = [String]::Join'], + ]; + for (let index = 0; index < hostTransitions.length; index += 1) { + const [subphase, operation] = hostTransitions[index]; + const transition = hostBoundary.indexOf(`Set-OrdinaryUserPreflightSubphase '${subphase}'`); + const operationIndex = hostBoundary.indexOf(operation); + const nextTransition = index + 1 < hostTransitions.length + ? hostBoundary.indexOf(`Set-OrdinaryUserPreflightSubphase '${hostTransitions[index + 1][0]}'`) + : hostBoundary.length; + assert.ok(transition >= 0 && transition < operationIndex && operationIndex < nextTransition, + `${subphase} must cover exactly its host operation boundary`); + } + assert.match(orchestrator, /function Set-PrimaryFailureFromException[\s\S]*?\$script:primaryPhase = \$failurePhase[\s\S]*?\$script:primarySubphase = if \(\$failureSubphases -ccontains \$failureSubphase\)/u); + assert.match(orchestrator, /function Get-TrustedHostLauncher[\s\S]*?GetFinalPath\(\$sourceHandle\)[\s\S]*?Open\(\$finalPath, \$true\)[\s\S]*?GetIdentity\(\$authorityHandle\)[\s\S]*?Open\(\$selectedPath, \$false\)/u); + assert.doesNotMatch(hostBoundary, /Get-TrustedHostLauncher \$node/u); + assert.match(orchestrator, /\$node = \$launcherPathProperty\.Value[\s\S]*?-FilePath \$node/u); + assert.match(hostBoundary, /SafeFileHandle[\s\S]*?\.IsInvalid[\s\S]*?\.IsClosed/u); + assert.match(orchestrator, /Start-Process[\s\S]*?finally \{\s*\$launcherAuthority\.Handle\.Dispose\(\)/u); + assert.match(orchestrator, /\$handoffArgument = '--propr-windows-staged-contract=' \+ \[Convert\]::ToBase64String\(\$handoffBytes\)/u); + assert.match(orchestrator, /-ArgumentList @\('scripts\/smoke-packaged-connect\.mjs', \$handoffArgument\)[\s\S]*?-Credential \$credential[\s\S]*?-LoadUserProfile/u); + assert.doesNotMatch(orchestrator, /SetEnvironmentVariable\('PROPR_DESKTOP_CONNECT_STAGING_/u); + assert.match(orchestrator, /FILE_FLAG_OPEN_REPARSE_POINT/u); + assert.match( + orchestrator, + /\[DllImport\("kernel32\.dll", CharSet = CharSet\.Unicode, ExactSpelling = true, SetLastError = true\)\]\s*private static extern SafeFileHandle CreateFileW/u, + ); + assert.match( + orchestrator, + /\[DllImport\("kernel32\.dll", CharSet = CharSet\.Unicode, ExactSpelling = true, SetLastError = true\)\]\s*private static extern uint GetFinalPathNameByHandleW/u, + ); + assert.match(orchestrator, /FILE_ID_INFO[\s\S]*?GetFileInformationByHandleEx[\s\S]*?FileIdInfo = 18/u); + assert.match(orchestrator, /FILE_SHARE_READ\s*\n\s*: FILE_SHARE_READ \| FILE_SHARE_WRITE \| FILE_SHARE_DELETE/u); + assert.match(orchestrator, /\$Path\.Length -gt 259[\s\S]*?\[\\x00-\\x1f\\x7f\]/u); + const selectedPathValidation = orchestrator.slice( + orchestrator.indexOf('function Get-BoundedAbsoluteWindowsPath'), + orchestrator.indexOf('function ConvertFrom-NativeFinalPath'), + ); + const selectedPathPredicateTransitions = [ + ['host-launcher-selected-path-input', '[String]::IsNullOrEmpty($Path)'], + ['host-launcher-selected-path-extra-colon', "$Path.Substring(2).Contains(':')"], + ['host-launcher-selected-path-get-full-path', '$fullPath = [IO.Path]::GetFullPath($Path)'], + ['host-launcher-selected-path-absolute-shape', "$driveAbsolute = $fullPath -cmatch '^[A-Za-z]:\\\\'"], + ['host-launcher-selected-path-canonical-equality', '[String]::Equals($fullPath, $Path'], + ]; + let previousSelectedPathPredicate = -1; + for (const [subphase, predicate] of selectedPathPredicateTransitions) { + const transition = selectedPathValidation.indexOf( + `Set-OrdinaryUserPreflightSubphase '${subphase}'`, + ); + const predicateIndex = selectedPathValidation.indexOf(predicate); + assert.ok(previousSelectedPathPredicate < transition && transition < predicateIndex, + `${subphase} must identify only its selected-path predicate`); + previousSelectedPathPredicate = predicateIndex; + } + assert.match(orchestrator, /function Get-CanonicalItem[\s\S]*?FileAttributes\]::ReparsePoint/u); + assert.match(orchestrator, /function Assert-PackageTreeTypes[\s\S]*?FileAttributes\]::ReparsePoint/u); + const captureAuthority = orchestrator.slice( + orchestrator.indexOf('function Assert-CaptureAuthorityAcl'), + orchestrator.indexOf('function Read-PackagedConnectSmokeFailure'), + ); + const captureParser = orchestrator.slice( + orchestrator.indexOf('function Read-PackagedConnectSmokeFailure'), + orchestrator.indexOf('$hostLauncherNativeSource'), + ); + assert.match(captureParser, /packaged_connect\.artifact_failed/u); + assert.match(captureParser, /packaged_connect\.smoke_failed/u); + assert.doesNotMatch(captureParser, /packaged_connect\.child_failed/u); + const nestedDiagnosticEvents = captureParser.slice( + captureParser.indexOf('$diagnosticEvents = @('), + captureParser.indexOf('$diagnosticCodes = @('), + ); + assert.match(nestedDiagnosticEvents, /'desktop\.renderer\.connect_discovery\.proof'/u); + assert.equal( + (orchestrator.match(/desktop\.renderer\.connect_discovery\.proof/gu) ?? []).length, + 1, + ); + assert.match(captureParser, /Test-UniqueJsonPropertyNames \$jsonLine/u); + assert.match(captureParser, /\[Text\.UTF8Encoding\]::new\(\$false, \$true\)/u); + assert.match(captureAuthority, /\$captureLength -lt 1 -or \$captureLength -gt 65536/u); + assert.match(captureParser, /\$diagnosticRecords\.Count -gt 20/u); + assert.match(captureAuthority, /\$ownerValues -cnotcontains \$owner\.Value/u); + assert.match(captureAuthority, /\$acl\.AreAccessRulesProtected/u); + assert.match(captureAuthority, /\$acl\.AreAccessRulesCanonical/u); + assert.match(captureAuthority, /\$authorizedWriters\.Contains\(\$rule\.IdentityReference\.Value\)/u); + assert.match(captureAuthority, /function Initialize-PrivilegedCaptureFile/u); + assert.match(captureAuthority, /GetSecurityDescriptorSddlForm\(\$sections\)/u); + assert.match( + captureAuthority, + /SecurityDescriptor = \(Get-CaptureAuthorityDescriptor \$Path\)[\s\S]*?Get-CaptureAuthorityDescriptor \$Authority\.Path\) -cne \$Authority\.SecurityDescriptor/u, + ); + assert.match(captureAuthority, /SetAccessRuleProtection\(\$true, \$false\)/u); + assert.match(captureAuthority, /SetOwner\(\$CapturePrivilegedSid\)/u); + assert.match( + captureAuthority, + /foreach \(\$identity in @\(\$CapturePrivilegedSid, \$administratorsSid, \$systemSid\)\)/u, + ); + assert.match( + captureAuthority, + /\[IO\.FileStream\]::new\([\s\S]*?FileMode\]::CreateNew[\s\S]*?\$captureAcl/u, + ); + assert.doesNotMatch(captureAuthority, /S-1-1-0|S-1-5-11|S-1-5-32-545/u); + assert.match(captureAuthority, /GetLinkCount\(\$captureHandle\) -ne 1/u); + assert.match(captureAuthority, /GetIdentity\(\$captureHandle\)[\s\S]*?GetIdentity\(\$captureReopenHandle\)/u); + assert.match(captureAuthority, /ReadBounded\(\$captureReopenHandle, 65536\)/u); + assert.doesNotMatch(captureAuthority, /ReadAllBytes\(\$Path\)/u); + assert.match( + captureAuthority, + /\$privilegedSid\.Value, \$administratorsSid\.Value, 'S-1-5-18'[\s\S]*?-cnotcontains \$parentOwner\.Value[\s\S]*?\$TestOnlyExpectedParentOwnerSid[\s\S]*?\$parentOwner\.Value -cne \$TestOnlyExpectedParentOwnerSid\.Value/u, + ); + const topLevelParameters = orchestrator.slice(0, orchestrator.indexOf('$ErrorActionPreference')); + assert.doesNotMatch(topLevelParameters, /TestOnlyExpectedParentOwnerSid/u); + const captureParserTestMode = orchestrator.slice( + orchestrator.indexOf("if ($LifecycleTestMode -eq 'capture-parser')"), + orchestrator.indexOf("if ($LifecycleTestMode -eq 'diagnostic-subphase')"), + ); + assert.match( + captureParserTestMode, + /foreign-parent-owner'[\s\S]*?\$captureExpectedParentOwnerSid = \[Security\.Principal\.SecurityIdentifier\]::new\([\s\S]*?-TestOnlyExpectedParentOwnerSid \$captureExpectedParentOwnerSid/u, + ); + assert.doesNotMatch( + captureParserTestMode, + /\[IO\.Directory\]::SetAccessControl\(\$authenticatedRunnerTemp|\$parentAcl\.SetOwner/u, + ); + const captureReadOpen = orchestrator.slice( + orchestrator.indexOf('public static SafeFileHandle OpenCapture'), + orchestrator.indexOf('public static SafeFileHandle OpenRedirectCaptureAuthority'), + ); + assert.match( + captureReadOpen, + /lockAuthority\s*\? FILE_SHARE_READ\s*:\s*FILE_SHARE_READ \| FILE_SHARE_WRITE \| FILE_SHARE_DELETE/u, + ); + assert.match(captureReadOpen, /GENERIC_READ \| READ_CONTROL/u); + const redirectCaptureAuthorityOpen = orchestrator.slice( + orchestrator.indexOf('public static SafeFileHandle OpenRedirectCaptureAuthority'), + orchestrator.indexOf('public static string GetIdentity'), + ); + assert.match( + redirectCaptureAuthorityOpen, + /FILE_READ_ATTRIBUTES \| READ_CONTROL,[\s\S]*?FILE_SHARE_READ \| FILE_SHARE_WRITE,[\s\S]*?OPEN_EXISTING/u, + ); + assert.doesNotMatch(redirectCaptureAuthorityOpen, /GENERIC_READ/u); + assert.match(orchestrator, /public static uint GetLinkCount/u); + assert.match( + orchestrator, + /Initialize-PrivilegedCaptureFile \$stdout \$privilegedSid[\s\S]*?Initialize-PrivilegedCaptureFile \$stderr \$privilegedSid[\s\S]*?Start-Process/u, + ); + assert.match( + orchestrator, + /Start-Process[\s\S]*?Assert-PrivilegedCaptureIdentity \$stdoutAuthority \$privilegedSid[\s\S]*?Assert-PrivilegedCaptureIdentity \$stderrAuthority \$privilegedSid/u, + ); + const captureRedirectionTestMode = orchestrator.slice( + orchestrator.indexOf("if ($LifecycleTestMode -eq 'capture-redirection')"), + orchestrator.indexOf("if ($LifecycleTestMode -eq 'capture-parser')"), + ); + const captureProducerOutputClassifier = orchestrator.slice( + orchestrator.indexOf('function Get-TestOnlyCaptureProducerOutputState'), + orchestrator.indexOf('function Set-LifecycleFailureSubphase'), + ); + assert.match( + captureProducerOutputClassifier, + /\$captureReadHandle = \[ProprHostLauncherNative\]::OpenCapture\(\$Authority\.Path, \$true\)/u, + ); + assert.doesNotMatch( + captureProducerOutputClassifier, + /(?:GetLength|ReadBounded)\(\s*\$Authority\.Handle/u, + ); + assert.match( + captureProducerOutputClassifier, + /\$maximumAttributedBytes = 256[\s\S]*?GetLength\(\$captureReadHandle\)[\s\S]*?\$length -le \$maximumAttributedBytes[\s\S]*?ReadBounded\(\s*\$captureReadHandle, \$maximumAttributedBytes\s*\)/u, + ); + assert.equal( + (captureProducerOutputClassifier.match(/GetIdentity\(\$Authority\.Handle\)/gu) ?? []).length, + 2, + 'the retained non-readable authority identity must be unchanged across classification', + ); + assert.equal( + (captureProducerOutputClassifier.match(/Assert-PrivilegedCaptureFile/gu) ?? []).length, + 2, + 'the temporary read handle must be exact-bound before and after classification', + ); + assert.match( + captureProducerOutputClassifier, + /Assert-PrivilegedCaptureFile[\s\S]*?\$Authority\.Identity[\s\S]*?Get-CaptureAuthorityDescriptor \$Authority\.Path\) -cne[\s\S]*?\$Authority\.SecurityDescriptor[\s\S]*?ReadBounded[\s\S]*?Assert-PrivilegedCaptureFile[\s\S]*?GetIdentity\(\$Authority\.Handle\)[\s\S]*?\$Authority\.SecurityDescriptor/u, + ); + assert.match( + captureProducerOutputClassifier, + /finally \{\s*if \(\$null -ne \$captureReadHandle\) \{\s*try \{ \$captureReadHandle\.Dispose\(\) \} catch \{\}\s*\}\s*\}/u, + ); + for (const predicate of [ + 'pre-create', + 'redirect-open', + 'redirect-timeout', + 'redirect-child-exit', + 'capture-content', + 'cleanup', + ]) { + assert.match( + captureRedirectionTestMode, + new RegExp(`Set-CaptureAuthorityPredicate '${predicate}'`, 'u'), + ); + } + assert.match( + captureRedirectionTestMode, + /Set-CaptureAuthorityPredicate 'redirect-open'[\s\S]*?Start-Process[\s\S]*?!\(\$redirectionProcess -is \[System\.Diagnostics\.Process\]\)/u, + ); + assert.match( + captureRedirectionTestMode, + /CaptureRedirectionProducerTestCase -ceq 'nonzero'[\s\S]*?\{ 23 \}[\s\S]*?\$captureProducerSource = if[\s\S]*?capture-stdout[\s\S]*?capture-stderr[\s\S]*?exit \$captureProducerExitCode[\s\S]*?\[Text\.Encoding\]::Unicode\.GetBytes\(\$captureProducerSource\)/u, + ); + assert.match( + captureRedirectionTestMode, + /\$captureProducerArguments = \(\s*'-NoLogo -NoProfile -NonInteractive -EncodedCommand "' \+\s*\$captureProducerArgument \+ '"'\s*\)\s*\$redirectionProcess = Start-Process[\s\S]*?-ArgumentList \$captureProducerArguments/u, + ); + assert.doesNotMatch(captureRedirectionTestMode, /-ArgumentList @\(|StartInfo\.Arguments/u); + assert.match( + captureRedirectionTestMode, + /\$redirectionProcessHandle = \$redirectionProcess\.Handle[\s\S]*?Set-CaptureAuthorityPredicate 'redirect-timeout'[\s\S]*?WaitForExit\(\$terminationTimeoutMilliseconds\)[\s\S]*?Assert-PrivilegedCaptureIdentity[\s\S]*?Assert-PrivilegedCaptureIdentity[\s\S]*?Get-TestOnlyCaptureProducerOutputState/u, + ); + assert.match( + captureRedirectionTestMode, + /Get-TestOnlyCaptureProducerOutputState\s*`\s*\$stdoutAuthority \$privilegedSid 'capture-stdout'[\s\S]*?Get-TestOnlyCaptureProducerOutputState\s*`\s*\$stderrAuthority \$privilegedSid 'capture-stderr'/u, + ); + assert.match( + captureRedirectionTestMode, + /CaptureRedirectionProducerTestCase -cne 'success' -or\s*\$captureProducerExitBucket -cne 'zero'[\s\S]*?\$captureProducerStdoutState -cne 'exact-expected' -or\s*\$captureProducerStderrState -cne 'exact-expected'[\s\S]*?\$redirectionAccepted = \$true/u, + ); + assert.doesNotMatch( + captureRedirectionTestMode.slice( + captureRedirectionTestMode.indexOf('WaitForExit($terminationTimeoutMilliseconds)'), + captureRedirectionTestMode.indexOf('Assert-PrivilegedCaptureIdentity'), + ), + /ReadAllText|ReadAllBytes|ReadBounded/u, + ); + assert.doesNotMatch(captureRedirectionTestMode, /start-process-launch/u); + assert.match( + captureRedirectionTestMode, + /-TestOnlyIdentityPredicate 'post-redirection-identity'/u, + ); + assert.match( + captureRedirectionTestMode, + /\$primaryFailure = 'artifact-type'[\s\S]*?\$primaryPhase = 'capture-parse'[\s\S]*?\$primarySubphase = 'capture-authority'[\s\S]*?Set-CaptureAuthorityPredicate \$redirectionFailurePredicate/u, + ); + assert.match(captureParser, /Set-LifecycleFailureSubphase \$failureRecord\.category[\s\S]*?return 'spawn-failed'/u); + assert.doesNotMatch(captureParser, /lastMilestone/u); + assert.match(captureParser, /\$script:failurePhase = \$failureRecord\.phase/u); + assert.match( + orchestrator, + /Read-PackagedConnectSmokeFailure[\s\S]*?-Path \$stderr[\s\S]*?-ExpectedCaptureIdentity \$stderrAuthority\.Identity[\s\S]*?Stop-PackagedConnect \$childFailureCategory/u, + ); + assert.match(orchestrator, /catch \{\s*Set-PrimaryFailureFromException \$_\.Exception\s*\}/u); + assert.match(orchestrator, /\$primaryPhase -ceq 'ordinary-user-preflight'[\s\S]*?\$primarySubphase = 'host-state-contract'/u); + assert.match(orchestrator, /\$subphaseEvidence = ":subphase=\$primarySubphase"/u); + assert.match(orchestrator, /PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=\$primaryFailure`:phase=\$primaryPhase\$subphaseEvidence`:cleanup=\$cleanupSecondary/u); + + const cleanupFinally = orchestrator.slice(orchestrator.lastIndexOf('} finally {')); + assert.match(cleanupFinally, /\$cleanupResult = Invoke-BoundedCleanup/u); + assert.doesNotMatch(cleanupFinally, /Get-ChildItem|GetAccessControl|Remove-Item|Test-Path|Remove-LocalUser/u); + assert.match(cleanupFinally, /if \(\$null -eq \$primaryFailure -and \$cleanupSecondary -ne 'none'\)/u); + assert.doesNotMatch( + cleanupFinally.slice(0, cleanupFinally.indexOf("if ($null -eq $primaryFailure")), + /\$primaryFailure\s*=/u, + 'a cleanup timeout must not replace an existing primary failure', + ); + + const preflight = harness.indexOf('const staged = await validateWindowsStagedPackage'); + const spawn = harness.indexOf("const child = spawn(binaryPath, ['--disable-gpu'"); + assert.ok(preflight >= 0 && preflight < spawn, 'ordinary-user package preflight must complete before spawn'); + assert.equal((harness.match(/await validateWindowsStagedPackage\(/gu) ?? []).length, 1); + assert.equal((harness.match(/await runPackagedConnectLifecycle\(/gu) ?? []).length, 1); + assert.match(harness, /shell: false/u); + assert.match(harness, /parseWindowsStagedPackageHandoff\(process\.argv\.slice\(2\)\)/u); + assert.match(harness, /delete childEnvironment\.PROPR_DESKTOP_CONNECT_STAGING_PARENT/u); + assert.match(harness, /delete childEnvironment\.PROPR_DESKTOP_CONNECT_STAGING_LEAF/u); + assert.match(harness, /describeWindowsArtifactFailure\(error, packagedConnectPhase\)/u); + assert.match(harness, /packagedConnectArtifactSensitiveNeedles\(\{\s*platform: process\.platform,\s*artifactRoot,\s*binaryPath,/u); + assert.doesNotMatch(harness, /identity, artifactRoot, binaryPath,/u); + assert.doesNotMatch(harness, /child\.once\('error', error/u); + const readyProducer = main.slice( + main.indexOf('const runPackagedConnectDiscoverySmoke'), + main.indexOf('const runPackagedTransportSmoke'), + ); + assert.match(readyProducer, /await window\.webContents\.executeJavaScript/u); + assert.match(readyProducer, /process\.stdout\.write\(`\$\{JSON\.stringify\(\{/u); + assert.match(readyProducer, /const readyFields = \{[\s\S]*?selectedPlatform: process\.platform[\s\S]*?selectedArch: process\.arch[\s\S]*?authorityMechanism:[\s\S]*?rendererSchemaValid: true/u); + assert.match(readyProducer, /timestamp: new Date\(\)\.toISOString\(\)[\s\S]*?level: 'info'[\s\S]*?event: 'desktop\.renderer\.connect_discovery\.ready'[\s\S]*?\.\.\.readyFields/u); + assert.ok( + readyProducer.indexOf("throw new Error('Packaged Connect renderer discovery proof was invalid')") + < readyProducer.indexOf('process.stdout.write'), + 'READY must be emitted only after the renderer discovery proof succeeds', + ); +}); + +windowsTest('the PS5.1 child-failure parser accepts only the two exact bounded producer schemas', async context => { + const runnerTemp = process.env.RUNNER_TEMP; + assert.equal(typeof runnerTemp, 'string'); + const smokeRecord = { + event: 'packaged_connect.smoke_failed', + category: 'timeout-before-ready', + capture: 'complete', + records: [{ + event: 'desktop.renderer.connect_discovery.phase', + phase: 'config-read', + code: 'FAILED', + substep: 'directory-open', + category: 'access-denied', + }], + secondary: ['tree-termination-failed'], + }; + const stagedContractRecord = { + event: 'packaged_connect.artifact_failed', + category: 'artifact-type', + phase: 'staged-contract', + subphase: 'parent-to-runner-binding', + }; + const stagedTreeRecord = { + event: 'packaged_connect.artifact_failed', + category: 'artifact-inaccessible', + phase: 'staged-tree', + }; + const stagedArchitectureRecord = { + event: 'packaged_connect.artifact_failed', + category: 'architecture-mismatch', + phase: 'staged-architecture', + }; + const ordinaryPreflightRecord = { + event: 'packaged_connect.artifact_failed', + category: 'artifact-inaccessible', + phase: 'ordinary-user-preflight', + subphase: 'executable-read', + }; + const smokeLine = `${JSON.stringify(smokeRecord)}\n`; + const artifactLine = `${JSON.stringify(stagedContractRecord)}\n`; + const cases = [ + ['valid-smoke', smokeLine, + 'category=spawn-failed:phase=application-runtime:subphase=timeout-before-ready'], + ['valid-record-contained-ready-milestone', `${JSON.stringify({ + ...smokeRecord, + records: [{ event: 'desktop.renderer.connect_discovery.ready' }], + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=timeout-before-ready'], + ['valid-record-contained-proof-milestone', `${JSON.stringify({ + ...smokeRecord, + records: [{ event: 'desktop.renderer.connect_discovery.proof' }], + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=timeout-before-ready'], + ['valid-ready-duplicate', `${JSON.stringify({ + ...smokeRecord, category: 'ready-duplicate', + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=ready-duplicate'], + ['valid-child-remained-alive', `${JSON.stringify({ + ...smokeRecord, category: 'child-remained-alive', + })}\n`, 'category=spawn-failed:phase=application-runtime:subphase=child-remained-alive'], + ['valid-staged-contract', artifactLine, + 'category=artifact-type:phase=staged-contract:subphase=parent-to-runner-binding'], + ['valid-staged-tree', `${JSON.stringify(stagedTreeRecord)}\n`, + 'category=artifact-inaccessible:phase=staged-tree'], + ['valid-staged-architecture', `${JSON.stringify(stagedArchitectureRecord)}\n`, + 'category=architecture-mismatch:phase=staged-architecture'], + ['valid-ordinary-user-preflight', `${JSON.stringify(ordinaryPreflightRecord)}\n`, + 'category=artifact-inaccessible:phase=ordinary-user-preflight:subphase=executable-read'], + ['malformed', '{"event":\n', + 'category=artifact-type:phase=capture-parse:subphase=capture-json'], + ['smoke-duplicate-field', smokeLine.replace( + '{"event":"packaged_connect.smoke_failed",', + '{"event":"packaged_connect.smoke_failed","event":"packaged_connect.smoke_failed",', + ), 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-duplicate-field', artifactLine.replace( + '"phase":"staged-contract",', + '"phase":"staged-contract","phase":"staged-contract",', + ), 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-extra-field', `${JSON.stringify({ ...smokeRecord, detail: 'fixed' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-top-level-last-milestone', `${JSON.stringify({ + ...smokeRecord, lastMilestone: 'desktop.app.ready', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-extra-field', `${JSON.stringify({ ...stagedContractRecord, detail: 'fixed' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-missing-field', `${JSON.stringify({ + event: smokeRecord.event, category: smokeRecord.category, records: smokeRecord.records, + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-missing-field', `${JSON.stringify({ + event: stagedContractRecord.event, + category: stagedContractRecord.category, + phase: stagedContractRecord.phase, + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-cross-schema-phase', `${JSON.stringify({ + ...smokeRecord, phase: 'staged-tree', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-cross-schema-subphase', `${JSON.stringify({ + ...smokeRecord, subphase: 'fixed-parent-leaf', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-cross-schema-capture', `${JSON.stringify({ + ...stagedContractRecord, capture: 'complete', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-cross-schema-records', `${JSON.stringify({ + ...stagedContractRecord, records: [], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['artifact-cross-schema-secondary', `${JSON.stringify({ + ...stagedContractRecord, secondary: ['tree-termination-failed'], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-multiline', `${smokeLine}${smokeLine}`, + 'category=artifact-type:phase=capture-parse:subphase=capture-line-cardinality'], + ['artifact-multiline', `${artifactLine}${artifactLine}`, + 'category=artifact-type:phase=capture-parse:subphase=capture-line-cardinality'], + ['oversized', Buffer.alloc(65_537, 0x61), + 'category=artifact-type:phase=capture-parse:subphase=capture-size'], + ['wrong-event', `${JSON.stringify({ ...smokeRecord, event: 'packaged_connect.child_failed' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-event-cardinality'], + ['wrong-nested-event', `${JSON.stringify({ + ...smokeRecord, records: [{ event: 'desktop.renderer.connect_discovery.arbitrary' }], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-event-cardinality'], + ['proof-extra-field', `${JSON.stringify({ + ...smokeRecord, + records: [{ event: 'desktop.renderer.connect_discovery.proof', milestone: 'connect-proof' }], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-wrong-category', `${JSON.stringify({ + ...smokeRecord, category: 'arbitrary-runtime-error', + })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-category'], + ['artifact-wrong-category', `${JSON.stringify({ + ...stagedContractRecord, category: 'artifact-inaccessible', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-category'], + ['artifact-wrong-phase', `${JSON.stringify({ + ...stagedContractRecord, phase: 'application-runtime', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-phase'], + ['artifact-wrong-required-subphase', `${JSON.stringify({ + ...stagedContractRecord, subphase: 'executable-read', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-subphase'], + ['artifact-forbidden-subphase', `${JSON.stringify({ + ...stagedTreeRecord, subphase: 'executable-read', + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-schema-cardinality'], + ['smoke-wrong-record-category', `${JSON.stringify({ + ...smokeRecord, + records: [{ ...smokeRecord.records[0], category: 'arbitrary-category' }], + })}\n`, 'category=artifact-type:phase=capture-parse:subphase=capture-lifecycle-subphase'], + ['smoke-sensitive', `${JSON.stringify({ ...smokeRecord, category: 'environment-secret-SENTINEL' })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-redaction'], + ['artifact-sensitive', `${JSON.stringify({ + ...stagedContractRecord, subphase: 'environment-secret-SENTINEL', + })}\n`, + 'category=artifact-type:phase=capture-parse:subphase=capture-redaction'], + ['invalid-utf8', Buffer.from([0xc3, 0x28, 0x0a]), + 'category=artifact-type:phase=capture-parse:subphase=capture-utf8'], + ]; + + for (let index = 0; index < cases.length; index += 1) { + const [name, content, evidence] = cases[index]; + const capturePath = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await writeFile(capturePath, content, { flag: 'wx' }); + context.after(() => rm(capturePath, { force: true })); + const result = runCaptureParserTest(capturePath); + assert.ifError(result.error, name); + assert.equal(result.signal, null, name); + assert.equal(result.status, 1, name); + assert.equal(result.stdout.length, 0, name); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal( + diagnostic, + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:${evidence}:cleanup=none`, + name, + ); + assert.ok(diagnostic.length <= 256, name); + assertNoHostileDiagnosticEvidence(diagnostic); + assert.doesNotMatch(diagnostic, /SENTINEL|arbitrary|fixed/iu, name); + } +}); + +windowsTest('the PS5.1 capture parser enforces native owner ACL path and identity authority', async context => { + const runnerTemp = process.env.RUNNER_TEMP; + assert.equal(typeof runnerTemp, 'string'); + const content = `${JSON.stringify({ + event: 'packaged_connect.artifact_failed', + category: 'artifact-type', + phase: 'staged-contract', + subphase: 'parent-to-runner-binding', + })}\n`; + const expectedAccepted = 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=staged-contract:subphase=parent-to-runner-binding:cleanup=none'; + const expectedRejected = predicate => 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + `:phase=capture-parse:subphase=capture-authority:predicate=${predicate}:cleanup=none`; + const trackedPaths = []; + context.after(async () => { + await Promise.all(trackedPaths.map(path => rm(path, { force: true, recursive: true }))); + }); + const newCapturePath = async (parent = runnerTemp, leaf) => { + const path = join( + parent, + leaf ?? `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await writeFile(path, content, { flag: 'wx' }); + trackedPaths.push(path); + return path; + }; + const assertResult = (name, result, expected) => { + assert.ifError(result.error, name); + assert.equal(result.signal, null, name); + assert.equal(result.status, 1, name); + assert.equal(result.stdout.length, 0, name); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal(diagnostic, expected, name); + assertNoHostileDiagnosticEvidence(diagnostic); + }; + + for (const authorityCase of ['current-owner', 'administrators-owner']) { + const path = await newCapturePath(); + assertResult(authorityCase, runCaptureParserTest(path, authorityCase), expectedAccepted); + } + + for (const [authorityCase, predicate] of [ + ['foreign-owner', 'capture-owner'], + ['ordinary-owner', 'capture-owner'], + ['ordinary-write', 'unauthorized-writer'], + ['broad-write', 'unauthorized-writer'], + ['unprotected-dacl', 'dacl-canonicality'], + ]) { + const path = await newCapturePath(); + assertResult(authorityCase, runCaptureParserTest(path, authorityCase), expectedRejected(predicate)); + } + + const isolatedParent = await mkdtemp(join(runnerTemp, 'propr-capture-parent-owner-')); + trackedPaths.push(isolatedParent); + const isolatedParentCapture = await newCapturePath(isolatedParent); + assertResult( + 'foreign-parent-owner', + runCaptureParserTest( + isolatedParentCapture, + 'foreign-parent-owner', + { RUNNER_TEMP: isolatedParent }, + ), + expectedRejected('parent-owner'), + ); + + const wrongLeaf = await newCapturePath( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.txt`, + ); + assertResult('wrong-leaf', runCaptureParserTest(wrongLeaf), expectedRejected('link-path-type')); + + const escapeParent = await mkdtemp(join(runnerTemp, 'propr-capture-escape-')); + trackedPaths.push(escapeParent); + const escapedCapture = await newCapturePath(escapeParent); + assertResult( + 'parent-escape', + runCaptureParserTest(escapedCapture), + expectedRejected('link-path-type'), + ); + + const hardlinkCapture = await newCapturePath(); + const hardlinkAlias = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await link(hardlinkCapture, hardlinkAlias); + trackedPaths.push(hardlinkAlias); + assertResult( + 'hardlink', + runCaptureParserTest(hardlinkAlias, 'existing'), + expectedRejected('link-path-type'), + ); + + const directoryCapture = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await mkdir(directoryCapture); + trackedPaths.push(directoryCapture); + assertResult( + 'non-regular-file', + runCaptureParserTest(directoryCapture), + expectedRejected('link-path-type'), + ); + + const reparseTarget = await newCapturePath( + runnerTemp, + `propr-capture-target-${randomBytes(8).toString('hex')}.txt`, + ); + const reparseCapture = join( + runnerTemp, + `propr-connect-${randomBytes(16).toString('hex')}.stderr`, + ); + await symlink(reparseTarget, reparseCapture, 'file'); + trackedPaths.push(reparseCapture); + assertResult( + 'reparse-file', + runCaptureParserTest(reparseCapture, 'existing'), + expectedRejected('link-path-type'), + ); + + const reparseParentTarget = await mkdtemp(join(runnerTemp, 'propr-capture-parent-target-')); + trackedPaths.push(reparseParentTarget); + const reparseParent = join(runnerTemp, `propr-capture-parent-${randomBytes(8).toString('hex')}`); + await symlink(reparseParentTarget, reparseParent, 'junction'); + trackedPaths.push(reparseParent); + const reparseParentCapture = await newCapturePath(reparseParentTarget); + const captureThroughReparseParent = join(reparseParent, reparseParentCapture.slice( + reparseParentTarget.length + 1, + )); + assertResult( + 'reparse-parent', + runCaptureParserTest(captureThroughReparseParent, 'existing', { RUNNER_TEMP: reparseParent }), + expectedRejected('link-path-type'), + ); + + const identityChangeCapture = await newCapturePath(); + trackedPaths.push(`${identityChangeCapture}.propr-replaced`); + assertResult( + 'identity-change', + runCaptureParserTest(identityChangeCapture, 'identity-change'), + expectedRejected('identity-replacement'), + ); +}); + +windowsTest('nominal reaches zero with exact protected stdout and stderr capture', () => { + const result = runCaptureRedirectionTest(); + const accepted = !result.error && result.signal === null && result.status === 0 + && Buffer.isBuffer(result.stdout) && result.stdout.length <= 128 + && captureRedirectionAcceptedPattern.test(result.stdout.toString('utf8')) + && Buffer.isBuffer(result.stderr) && result.stderr.length === 0; + if (!accepted) failCaptureRedirectionTest(result); +}); + +windowsTest('a forced nonzero capture producer maps only to redirect-child-exit', () => { + const result = runCaptureRedirectionTest('nonzero'); + assert.equal(result.error, undefined); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + const diagnostic = result.stderr.toString('utf8'); + assert.equal( + diagnostic, + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + ':predicate=redirect-child-exit:exit=forced-23' + + ':out=exact-expected:err=exact-expected:cleanup=none\r\n', + ); + assertNoHostileDiagnosticEvidence(diagnostic); +}); + +windowsTest('empty and hostile producer results map only to fixed bounded buckets', () => { + for (const [producerTestCase, expectedResult] of [ + ['empty', 'exit=other:out=empty:err=empty'], + ['hostile', 'exit=other:out=other-bounded:err=other-bounded'], + ]) { + const result = runCaptureRedirectionTest(producerTestCase); + assert.equal(result.error, undefined, producerTestCase); + assert.equal(result.signal, null, producerTestCase); + assert.equal(result.status, 1, producerTestCase); + assert.equal(result.stdout.length, 0, producerTestCase); + const diagnostic = result.stderr.toString('utf8'); + assert.equal( + diagnostic, + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type' + + ':phase=capture-parse:subphase=capture-authority' + + `:predicate=redirect-child-exit:${expectedResult}:cleanup=none\r\n`, + producerTestCase, + ); + assertNoHostileDiagnosticEvidence(diagnostic); + } +}); + +windowsTest('each host preflight failure transition emits one fixed redacted subphase', () => { + for (const subphase of fixedHostDiagnosticSubphases) { + const result = spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'diagnostic-subphase', + '-DiagnosticTestSubphase', + subphase, + ], { + shell: false, + windowsHide: true, + timeout: 10_000, + }); + + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal( + diagnostic, + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-inaccessible:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`, + ); + assert.equal((diagnostic.match(/:subphase=/gu) ?? []).length, 1); + assertNoHostileDiagnosticEvidence(diagnostic); + } +}); + +for (const [testCase, subphase] of [ + ['zero', 'host-node-command-cardinality'], + ['duplicate', 'host-node-command-cardinality'], + ['multiple', 'host-node-command-cardinality'], + ['mixed-types', 'host-node-command-cardinality'], + ['case-collision', 'host-node-command-cardinality'], + ['non-application', 'host-node-command-type'], + ['missing-source', 'host-node-source'], + ['non-scalar-source', 'host-node-source'], +]) { + windowsTest(`the PS5.1 host Node producer rejects ${testCase} command evidence`, () => { + const result = runHostNodeProducerTest(testCase); + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + const diagnostic = result.stderr.toString('utf8').trim(); + assert.equal( + diagnostic, + `PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type` + + `:phase=ordinary-user-preflight:subphase=${subphase}:cleanup=none`, + ); + assert.equal((diagnostic.match(/:subphase=/gu) ?? []).length, 1); + assertNoHostileDiagnosticEvidence(diagnostic); + }); +} + +windowsTest('the PS5.1 host Node producer returns one validated scalar Source', () => { + const result = runHostNodeProducerTest('positive'); + assertPositiveHostNodeProducer(result); +}); + +windowsTest('the host launcher accepts only a stable final ordinary-file identity', async context => { + const producedRoot = await mkdtemp(join(tmpdir(), 'propr-launcher-authority-')); + context.after(() => rm(producedRoot, { force: true, recursive: true })); + // PowerShell 5.1 expands an existing 8.3 path in GetFullPath, so join fixtures only below this final spelling. + const root = await realpath(producedRoot); + const rootEntry = await lstat(root); + assert.equal(rootEntry.isDirectory(), true); + assert.equal(rootEntry.isSymbolicLink(), false); + assert.equal(await realpath(root), root, 'the native fixture producer must return its canonical root'); + const target = join(root, 'node-target.exe'); + const otherTarget = join(root, 'node-other.exe'); + const alias = join(root, 'node-alias.exe'); + const brokenAlias = join(root, 'node-broken.exe'); + const retargetedAlias = join(root, 'node-retargeted.exe'); + const identityTarget = join(root, 'node-identity.exe'); + const directory = join(root, 'node-directory.exe'); + await Promise.all([ + writeFile(target, Buffer.from('ordinary launcher target')), + writeFile(otherTarget, Buffer.from('other ordinary launcher target')), + writeFile(identityTarget, Buffer.from('identity launcher target')), + ]); + await symlink(target, alias, 'file'); + await symlink(join(root, 'missing-target.exe'), brokenAlias, 'file'); + await symlink(target, retargetedAlias, 'file'); + await mkdir(directory); + + if (producedRoot.toUpperCase() !== root.toUpperCase()) { + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(join(producedRoot, 'node-target.exe')), + 'artifact-type', + 'host-launcher-selected-path-canonical-equality', + ); + } + + for (const [caseName, acceptedPath] of [['normal', target], ['alias', alias]]) { + const result = runLauncherAuthorityTest(acceptedPath, caseName); + assertLauncherAuthorityAccepted(result, caseName); + } + + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(brokenAlias), + 'artifact-missing', + 'host-launcher-source-open', + ); + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(retargetedAlias, 'retarget-alias', otherTarget), + 'artifact-type', + 'host-launcher-source-reopen-match', + ); + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(identityTarget, 'identity-mismatch'), + 'artifact-type', + 'host-launcher-final-match', + ); + assertLauncherAuthorityRejected( + runLauncherAuthorityTest(directory), + 'artifact-type', + 'host-launcher-source-type', + ); + const selectedPathRejections = [ + ['', 'host-launcher-selected-path-input'], + [String.raw`\\.\NUL`, 'host-launcher-selected-path-input'], + [String.raw`\\?\C:\ordinary.exe`, 'host-launcher-selected-path-input'], + [String.raw`\??\C:\ordinary.exe`, 'host-launcher-selected-path-input'], + [`${root}\\${'x'.repeat(260)}`, 'host-launcher-selected-path-input'], + [`${root}\\control-${String.fromCharCode(1)}.exe`, 'host-launcher-selected-path-input'], + [String.raw`C:\invalid|path.exe`, 'host-launcher-selected-path-get-full-path'], + [String.raw`\\server\share`, 'host-launcher-selected-path-absolute-shape'], + [String.raw`C:\ordinary.exe:alternate-stream`, 'host-launcher-selected-path-extra-colon'], + ['node.exe', 'host-launcher-selected-path-canonical-equality'], + [String.raw`C:\ordinary\..\ordinary.exe`, 'host-launcher-selected-path-canonical-equality'], + ]; + for (const [rejectedPath, subphase] of selectedPathRejections) { + assertLauncherAuthorityRejected(runLauncherAuthorityTest(rejectedPath), 'artifact-type', subphase); + } +}); + +test('the bounded cleanup source requires proven child exit and bounded stream closure', async () => { + const orchestrator = await readFile(new URL('./run-packaged-windows-connect-smoke.ps1', import.meta.url), 'utf8'); + const boundedCleanup = orchestrator.slice( + orchestrator.indexOf('function Invoke-BoundedCleanup'), + orchestrator.indexOf('$authenticatedRunnerTemp = $null'), + ); + assert.match(boundedCleanup, /\$cleanupProcess=\[Diagnostics\.Process\]::new\(\)/u); + assert.match( + boundedCleanup, + /if\(!\$cleanupProcess\.WaitForExit\(\$cleanupTimeoutMilliseconds\)\)\{[\s\S]*?\$cleanupProcess\.Kill\(\)[\s\S]*?if\(!\$cleanupProcess\.WaitForExit\(\$terminationTimeoutMilliseconds\)\)\{return 'failed'\}[\s\S]*?Task\]::WaitAll[\s\S]*?return 'timeout'/u, + ); + assert.match(boundedCleanup, /\$cleanupOutputClose=\$cleanupProcess\.StandardOutput\.BaseStream\.CopyToAsync/u); + assert.match(boundedCleanup, /\$cleanupErrorClose=\$cleanupProcess\.StandardError\.BaseStream\.CopyToAsync/u); +}); + +windowsTest('the native timeout path terminates an actual child and descendant tree', async context => { + const { root, descendantProcessId } = await startNativeNodeTree(); + context.after(() => terminateTreeAfterTest(root.pid)); + context.after(() => terminateTreeAfterTest(descendantProcessId)); + assert.equal(processExists(root.pid), true); + assert.equal(processExists(descendantProcessId), true); + + const result = spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'terminate-tree', + '-LifecycleTestProcessId', + String(root.pid), + ], { + shell: false, + windowsHide: true, + timeout: 15_000, + }); + + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 0); + assert.equal(result.stdout.toString('utf8').trim(), + 'PROPR_WINDOWS_PACKAGED_CONNECT_LIFECYCLE_TEST:tree-terminated'); + assert.equal(result.stderr.length, 0); + assert.equal(await waitForProcessExit(root.pid), true, 'the native harness root must terminate'); + assert.equal(await waitForProcessExit(descendantProcessId), true, + 'the native harness descendant must terminate'); +}); + +windowsTest('a real never-settling cleanup is bounded, terminated, and remains secondary', () => { + const startedAt = Date.now(); + const result = spawnSync(windowsPowerShell51Path(), [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + orchestratorPath, + '-Architecture', + process.arch, + '-LifecycleTestMode', + 'cleanup-timeout', + ], { + shell: false, + windowsHide: true, + timeout: 10_000, + }); + const elapsedMilliseconds = Date.now() - startedAt; + + assert.ifError(result.error); + assert.equal(result.signal, null); + assert.equal(result.status, 1); + assert.equal(result.stdout.length, 0); + assert.equal( + result.stderr.toString('utf8').trim(), + 'PROPR_WINDOWS_PACKAGED_CONNECT:failed:category=artifact-type:phase=staged-tree:cleanup=cleanup-timeout', + ); + assert.ok(elapsedMilliseconds >= 750, 'the injected cleanup must reach its deadline'); + assert.ok(elapsedMilliseconds < 8_000, 'the cleanup deadline and termination must remain bounded'); +}); diff --git a/apps/desktop/src/logger.test.ts b/apps/desktop/src/logger.test.ts index e653d7b8d..12d2a1908 100644 --- a/apps/desktop/src/logger.test.ts +++ b/apps/desktop/src/logger.test.ts @@ -1,37 +1,143 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { sanitizeDesktopLogFields } from './logger'; +import { assertPackagedLayout, parseEventLayout } from '../scripts/packaged-layout.mjs'; +import { formatDesktopLogRecord, sanitizeDesktopLogFields } from './logger'; + +const bounds = (left: number, top: number, width: number, height: number) => ({ + bottom: top + height, + height, + left, + right: left + width, + top, + width, +}); + +const completePackagedLayout = () => ({ + screen: { height: 1080, width: 1920 }, + viewport: { height: 780, width: 1280 }, + entry: bounds(0, 0, 1280, 780), + card: bounds(350, 40, 580, 640), + logo: bounds(624, 72, 32, 32), + heading: bounds(430, 132, 420, 58), + connectButton: bounds(380, 230, 520, 76), + connectDescription: bounds(490, 270, 300, 18), + windowBounds: { x: 0, y: 0, width: 1280, height: 820 }, + contentBounds: { x: 0, y: 0, width: 1280, height: 780 }, + minimumSize: { width: 880, height: 620 }, + workArea: { x: 0, y: 0, width: 1920, height: 1040 }, +}); describe('desktop logger field schemas', () => { - it('preserves only bounded numeric and boolean packaged layout measurements', () => { - assert.deepEqual(sanitizeDesktopLogFields('desktop.renderer.layout.ready', { - layout: { - windowBounds: { x: 12, y: 24, width: 1280, height: 820, visible: true }, - viewport: { width: 1240, height: 760 }, - card: { top: 10.5, right: 900, bottom: 700, left: 100, width: 800, height: 690 }, - }, - }), { - layout: { - windowBounds: { x: 12, y: 24, width: 1280, height: 820, visible: true }, - viewport: { width: 1240, height: 760 }, - card: { top: 10.5, right: 900, bottom: 700, left: 100, width: 800, height: 690 }, - }, + it('logs the complete successful packaged layout for the smoke parser and assertion', () => { + const inspectedLayout = completePackagedLayout(); + const record = formatDesktopLogRecord( + 'info', + 'desktop.renderer.layout.ready', + { layout: inspectedLayout }, + '2026-09-02T00:00:00.000Z', + ); + assert.equal(record, JSON.stringify({ + timestamp: '2026-09-02T00:00:00.000Z', + level: 'info', + event: 'desktop.renderer.layout.ready', + layout: inspectedLayout, + })); + + const parsedLayout = parseEventLayout(`Chromium prefix\n${record}\n`, 'desktop.renderer.layout.ready'); + assert.deepEqual(parsedLayout, inspectedLayout); + assert.doesNotThrow(() => assertPackagedLayout(parsedLayout, 'linux')); + assert.deepEqual( + sanitizeDesktopLogFields('desktop.renderer.layout.ready', { + layout: { ...inspectedLayout, missing: [] }, + }), + { layout: inspectedLayout }, + ); + }); + + it('preserves the exact reduced native window geometry schema', () => { + const layout = { + displayWorkArea: { x: -1600, y: 0, width: 1600, height: 900 }, + workArea: { x: -1200, y: 170, width: 800, height: 560 }, + windowBounds: { x: -1200, y: 170, width: 800, height: 560, visible: true }, + minimumSize: { width: 800, height: 560 }, + }; + assert.deepEqual(sanitizeDesktopLogFields('desktop.native.reduced_window.ready', { layout }), { layout }); + }); + + it('requires own layout and geometry keys despite inherited keys and a shadowed hasOwnProperty', () => { + const valid = completePackagedLayout(); + const { workArea, ...layoutWithoutOwnWorkArea } = valid; + const inheritedLayoutKey = Object.assign(Object.create({ workArea }), layoutWithoutOwnWorkArea); + const inheritedGeometryKey = Object.assign( + Object.create({ width: valid.windowBounds.width }) as Record, + { x: 0, y: 0, height: valid.windowBounds.height, visible: true }, + ); + Object.defineProperty(inheritedGeometryKey, 'hasOwnProperty', { + value: () => true, }); + + for (const layout of [ + inheritedLayoutKey, + { ...valid, windowBounds: inheritedGeometryKey }, + ]) { + assert.deepEqual(sanitizeDesktopLogFields('desktop.renderer.layout.ready', { layout }), { + layout: { code: 'DETAIL_REDACTED' }, + }); + } + }); + + it('redacts malformed, secret, path-bearing, array, error, and over-broad layouts', () => { + const valid = completePackagedLayout(); + const rejectedLayouts: unknown[] = [ + { ...valid, unknown: { width: 1, height: 1 } }, + { ...valid, windowBounds: { ...valid.windowBounds, width: '1280' } }, + { ...valid, windowBounds: [0, 0, 1280, 820] }, + { ...valid, windowBounds: { ...valid.windowBounds, width: Number.POSITIVE_INFINITY } }, + { ...valid, windowBounds: { ...valid.windowBounds, token: 'secret-SENTINEL' } }, + { ...valid, windowBounds: { ...valid.windowBounds, path: '/private/path-SENTINEL' } }, + { ...valid, windowBounds: new Error('/private/path-SENTINEL') }, + { ...valid, windowBounds: { width: 1280, height: 820 } }, + { ...valid, missing: ['connectDescription'] }, + Object.fromEntries(Array.from({ length: 64 }, (_, index) => [ + `geometry${index}`, + { width: index + 1, height: index + 1 }, + ])), + ]; + + for (const layout of rejectedLayouts) { + const sanitized = sanitizeDesktopLogFields('desktop.renderer.layout.ready', { layout }); + assert.deepEqual(sanitized, { layout: { code: 'DETAIL_REDACTED' } }); + const serialized = JSON.stringify(sanitized); + assert.doesNotMatch(serialized, /secret-SENTINEL|private\/path-SENTINEL|connectDescription/u); + } + }); + + it('redacts a non-empty missing-selector result and leaves layout assertion failed closed', () => { + const record = formatDesktopLogRecord( + 'info', + 'desktop.renderer.layout.ready', + { layout: { missing: ['connectButton', 'connectDescription'] } }, + '2026-09-02T00:00:00.000Z', + ); + assert.doesNotMatch(record, /connectButton|connectDescription/u); + assert.match(record, /DETAIL_REDACTED/u); + + const parsedLayout = parseEventLayout(record, 'desktop.renderer.layout.ready'); + assert.deepEqual(parsedLayout, { code: 'DETAIL_REDACTED' }); + assert.throws( + () => assertPackagedLayout(parsedLayout, 'linux'), + /does not have positive bounds/, + ); }); - it('does not weaken object, secret, path, error, or malformed-layout redaction', () => { + it('does not weaken general object or error redaction', () => { const secret = { token: 'secret-SENTINEL', path: '/private/path-SENTINEL' }; - assert.deepEqual(sanitizeDesktopLogFields('desktop.other', { detail: secret }), { - detail: { code: 'DETAIL_REDACTED' }, - }); - assert.deepEqual(sanitizeDesktopLogFields('desktop.renderer.layout.ready', { - layout: { windowBounds: { width: 1280, token: 'secret-SENTINEL' } }, + assert.deepEqual(sanitizeDesktopLogFields('desktop.other', { + detail: secret, error: new Error('/private/path-SENTINEL'), - evidence: secret, }), { - layout: { code: 'DETAIL_REDACTED' }, + detail: { code: 'DETAIL_REDACTED' }, error: { code: 'OPERATION_FAILED' }, - evidence: { code: 'DETAIL_REDACTED' }, }); }); }); diff --git a/apps/desktop/src/logger.ts b/apps/desktop/src/logger.ts index f0cc636f0..f63c89f90 100644 --- a/apps/desktop/src/logger.ts +++ b/apps/desktop/src/logger.ts @@ -16,36 +16,77 @@ const safeField = (value: unknown): unknown => { const LAYOUT_EVENT = 'desktop.renderer.layout.ready'; const REDUCED_NATIVE_WINDOW_EVENT = 'desktop.native.reduced_window.ready'; -const LAYOUT_KEYS = new Set([ - 'windowBounds', 'contentBounds', 'minimumSize', 'workArea', 'displayWorkArea', - 'screen', 'viewport', 'entry', 'card', 'logo', 'heading', 'connectButton', - 'connectDescription', +const RENDERER_LAYOUT_KEYS = new Set([ + 'windowBounds', 'contentBounds', 'minimumSize', 'workArea', 'screen', 'viewport', + 'entry', 'card', 'logo', 'heading', 'connectButton', 'connectDescription', ]); -const LAYOUT_NUMBER_KEYS = new Set([ - 'x', 'y', 'width', 'height', 'top', 'right', 'bottom', 'left', +const REDUCED_NATIVE_WINDOW_LAYOUT_KEYS = new Set([ + 'windowBounds', 'minimumSize', 'workArea', 'displayWorkArea', ]); -const LAYOUT_BOOLEAN_KEYS = new Set(['visible', 'maximized', 'fullScreen']); +const RECTANGLE_NUMBER_KEYS = new Set(['x', 'y', 'width', 'height']); +const DIMENSION_NUMBER_KEYS = new Set(['width', 'height']); +const ELEMENT_NUMBER_KEYS = new Set(['top', 'right', 'bottom', 'left', 'width', 'height']); +const LAYOUT_NUMBER_KEYS = new Map>([ + ['windowBounds', RECTANGLE_NUMBER_KEYS], + ['contentBounds', RECTANGLE_NUMBER_KEYS], + ['minimumSize', DIMENSION_NUMBER_KEYS], + ['workArea', RECTANGLE_NUMBER_KEYS], + ['displayWorkArea', RECTANGLE_NUMBER_KEYS], + ['screen', DIMENSION_NUMBER_KEYS], + ['viewport', DIMENSION_NUMBER_KEYS], + ['entry', ELEMENT_NUMBER_KEYS], + ['card', ELEMENT_NUMBER_KEYS], + ['logo', ELEMENT_NUMBER_KEYS], + ['heading', ELEMENT_NUMBER_KEYS], + ['connectButton', ELEMENT_NUMBER_KEYS], + ['connectDescription', ELEMENT_NUMBER_KEYS], +]); +const WINDOW_BOOLEAN_KEYS = new Set(['visible', 'maximized', 'fullScreen']); -const boundedLayout = (value: unknown): Record> | null => { +const boundedLayout = ( + event: string, + value: unknown, +): Record> | null => { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const entries = Object.entries(value); - if (entries.length === 0 || entries.length > LAYOUT_KEYS.size) return null; + const expectedLayoutKeys = event === LAYOUT_EVENT + ? RENDERER_LAYOUT_KEYS + : REDUCED_NATIVE_WINDOW_LAYOUT_KEYS; + const normalizedEntries: Array<[string, unknown]> = []; + for (const entry of entries) { + if (entry[0] !== 'missing') { + normalizedEntries.push(entry); + continue; + } + if (event !== LAYOUT_EVENT || !Array.isArray(entry[1]) || entry[1].length !== 0) return null; + } + if (normalizedEntries.length !== expectedLayoutKeys.size) return null; const result: Record> = {}; - for (const [name, rawGeometry] of entries) { - if (!LAYOUT_KEYS.has(name) || !rawGeometry || typeof rawGeometry !== 'object' || Array.isArray(rawGeometry)) { + for (const [name, rawGeometry] of normalizedEntries) { + if (!expectedLayoutKeys.has(name) + || !rawGeometry + || typeof rawGeometry !== 'object' + || Array.isArray(rawGeometry)) { return null; } const geometry = Object.entries(rawGeometry); - if (geometry.length === 0 || geometry.length > LAYOUT_NUMBER_KEYS.size + LAYOUT_BOOLEAN_KEYS.size) return null; + const expectedNumberKeys = LAYOUT_NUMBER_KEYS.get(name); + if (!expectedNumberKeys) return null; + const allowedBooleanKeys = name === 'windowBounds' ? WINDOW_BOOLEAN_KEYS : undefined; + if (geometry.length < expectedNumberKeys.size + || geometry.length > expectedNumberKeys.size + (allowedBooleanKeys?.size ?? 0)) return null; const safeGeometry: Record = {}; for (const [key, measurement] of geometry) { - const validNumber = LAYOUT_NUMBER_KEYS.has(key) + const validNumber = expectedNumberKeys.has(key) && typeof measurement === 'number' && Number.isFinite(measurement); - const validBoolean = LAYOUT_BOOLEAN_KEYS.has(key) && typeof measurement === 'boolean'; + const validBoolean = allowedBooleanKeys?.has(key) === true && typeof measurement === 'boolean'; if (!validNumber && !validBoolean) return null; safeGeometry[key] = measurement; } + if ([...expectedNumberKeys].some( + key => !Object.prototype.hasOwnProperty.call(safeGeometry, key), + )) return null; result[name] = safeGeometry; } return result; @@ -56,23 +97,30 @@ export const sanitizeDesktopLogFields = ( fields: Record, ): Record => Object.fromEntries(Object.entries(fields).map(([key, value]) => { if ((event === LAYOUT_EVENT || event === REDUCED_NATIVE_WINDOW_EVENT) && key === 'layout') { - return [key, boundedLayout(value) ?? { code: 'DETAIL_REDACTED' }]; + return [key, boundedLayout(event, value) ?? { code: 'DETAIL_REDACTED' }]; } return [key, safeField(value)]; })); +export const formatDesktopLogRecord = ( + level: LogLevel, + event: string, + fields: Record = {}, + timestamp = new Date().toISOString(), +): string => JSON.stringify({ + timestamp, + level, + event, + ...sanitizeDesktopLogFields(event, fields), +}); + export const createDesktopLogger = ( logPath: string, onWriteFailure?: () => void, ): DesktopLogger => { let pending = Promise.resolve(); const log = (level: LogLevel, event: string, fields: Record = {}) => { - const record = JSON.stringify({ - timestamp: new Date().toISOString(), - level, - event, - ...sanitizeDesktopLogFields(event, fields), - }); + const record = formatDesktopLogRecord(level, event, fields); const consoleMethod = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; consoleMethod(record); pending = pending diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7065b87b9..0d105b1e9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -313,6 +313,44 @@ const inspectPackagedLayout = async (window: BrowserWindow): Promise => { + const chooserReady = await window.webContents.executeJavaScript(`(async () => { + const editor = document.querySelector('.desktop-welcome-card form.desktop-profile-form'); + const backButton = editor?.querySelector('button.desktop-back-button'); + if (!(backButton instanceof HTMLButtonElement)) return false; + backButton.click(); + + const deadline = performance.now() + 5000; + do { + const card = document.querySelector('.desktop-welcome-card'); + const connectButton = card?.querySelector('.desktop-choice-button'); + const elements = { + entry: document.querySelector('.desktop-entry'), + card, + logo: card?.querySelector('.desktop-brand img'), + heading: card?.querySelector('.desktop-welcome-copy h1'), + connectButton, + connectDescription: connectButton?.querySelector('small'), + }; + const visiblyReady = Object.values(elements).every(element => { + if (!(element instanceof HTMLElement)) return false; + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return bounds.width > 0 && bounds.height > 0 + && bounds.right > 0 && bounds.bottom > 0 + && bounds.left < window.innerWidth && bounds.top < window.innerHeight + && style.display !== 'none' && style.visibility === 'visible' && style.opacity !== '0'; + }); + if (visiblyReady) return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + if (chooserReady !== true) { + throw new Error('Packaged desktop welcome chooser was not restored after the profile flow'); + } +}; + const createReducedSmokeWorkArea = (displayWorkArea: Rectangle): Rectangle => { const width = Math.min(displayWorkArea.width, MINIMUM_BROWSER_WINDOW_SIZE.width - 80); const height = Math.min(displayWorkArea.height, MINIMUM_BROWSER_WINDOW_SIZE.height - 60); @@ -364,7 +402,7 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< || candidate.apiBaseUrl !== 'https://t-packaged123.propr.dev') { throw new Error('Packaged Connect renderer discovery proof was invalid'); } - log('info', 'desktop.renderer.connect_discovery.ready', { + const readyFields = { selectedPlatform: process.platform, selectedArch: process.arch, authorityMechanism: process.platform === 'darwin' @@ -373,6 +411,18 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< ? 'in-process-native-addon' : 'inherited-standard-handle', rendererSchemaValid: true, + } as const; + log('info', 'desktop.renderer.connect_discovery.ready', readyFields); + await new Promise((resolveReady, rejectReady) => { + process.stdout.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'info', + event: 'desktop.renderer.connect_discovery.ready', + ...readyFields, + })}\n`, error => { + if (error) rejectReady(new Error('Packaged Connect READY publication failed')); + else resolveReady(); + }); }); }; @@ -666,6 +716,7 @@ const createMainWindow = async ( lifecycleBoundary: profileFlow.lifecycleBoundary, connectUiPopulated: profileFlow.connectDeepLink, }; + await closePackagedProfileEditorAndWaitForWelcomeChooser(window); } else if (packagedSmokeTest) { const boundary = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; diff --git a/apps/desktop/src/smoke-test-authorization.test.ts b/apps/desktop/src/smoke-test-authorization.test.ts index 833017b4c..2c355f014 100644 --- a/apps/desktop/src/smoke-test-authorization.test.ts +++ b/apps/desktop/src/smoke-test-authorization.test.ts @@ -123,6 +123,7 @@ describe('packaged smoke profile authorization', () => { const beforeQuit = main.indexOf("app.on('before-quit', event => shutdown.beforeQuit(event));"); const createWindow = main.indexOf('mainWindow = await createMainWindow()'); const mvpReady = main.indexOf("log('info', 'desktop.renderer.mvp_flows.ready'"); + const chooserRestore = main.lastIndexOf('await closePackagedProfileEditorAndWaitForWelcomeChooser(window);'); const layoutReady = main.indexOf("log('info', PACKAGED_LAYOUT_READY_EVENT"); const reducedWindowReady = main.indexOf("log('info', PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT"); const rendererReady = main.indexOf("log('info', 'desktop.renderer.ready'"); @@ -134,7 +135,9 @@ describe('packaged smoke profile authorization', () => { assert.ok(authorized < appReady && appReady < shutdownCoordinator); assert.ok(shutdownCoordinator < beforeQuit && beforeQuit < createWindow); assert.equal(main.match(/app\.on\('before-quit', event => shutdown\.beforeQuit\(event\)\);/g)?.length, 1); - assert.ok(mvpReady < layoutReady && layoutReady < reducedWindowReady && reducedWindowReady < rendererReady); + assert.notEqual(chooserRestore, -1); + assert.ok(chooserRestore < mvpReady && mvpReady < layoutReady + && layoutReady < reducedWindowReady && reducedWindowReady < rendererReady); assert.ok(beforeQuit < willQuit && willQuit < sinkClose); assert.deepEqual(Array.from(requiredEvents?.matchAll(/'([^']+)'/g) ?? [], match => match[1]), [ 'desktop.smoke.authorized', diff --git a/package-lock.json b/package-lock.json index 6c50b90c4..fad20a9ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7428,9 +7428,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", @@ -12020,9 +12020,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", diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index 8b0e997b4..ea7a818d9 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -135,6 +135,31 @@ describe('DesktopExperience', () => { expect(adapters.connection.activate).toHaveBeenCalledOnce(); }); + it('returns from the prefilled profile editor to every packaged-layout chooser element', async () => { + const adapters = adaptersFor(); + const deepLinks = new DesktopDeepLinkInbox(); + render(
Shared route tree
); + + expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); + act(() => deepLinks.receive('propr://connect?api=https%3A%2F%2Fconnect.propr.dev')); + expect(await screen.findByLabelText('Instance URL')).toHaveValue('https://connect.propr.dev'); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + await screen.findByRole('heading', { name: 'Let’s set up this computer' }); + + for (const selector of [ + '.desktop-entry', + '.desktop-welcome-card', + '.desktop-welcome-card .desktop-brand img', + '.desktop-welcome-card .desktop-welcome-copy h1', + '.desktop-welcome-card .desktop-choice-button', + '.desktop-welcome-card .desktop-choice-button small', + ]) { + expect(document.querySelector(selector), selector).toBeVisible(); + } + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + }); + it('keeps Open deep-link navigation separate and bound to the active profile', async () => { const adapters = adaptersFor([localProfile], localProfile.id); const deepLinks = new DesktopDeepLinkInbox();