diff --git a/README.md b/README.md index b55c4c5..6ebc3e7 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,13 @@ winget import apps.json --accept-package-agreements --accept-source-agreements ``` JSON inspection does not resolve package availability or predict installation results. + +Bootstrap supports `PackageIdentifier` and an optional exact `Version` for each package. `SourceDetails.Name` selects an already registered source; supplied `Identifier`, `Argument`, and `Type` must match that registration. Source and version remain attached to the request during inventory checks, installation, and user-scope retry. Identical IDs from different sources remain separate requests. Omitting `SourceDetails` keeps WinGet's default source selection. Other installation fields, including `Scope`, `Channel`, and installer override arguments, are rejected before installation. + +WinGet must be available through Microsoft App Installer for the current user. An unavailable client produces one prerequisite failure. Bootstrap refreshes its process PATH from the registered machine and user paths so tools installed during setup can be discovered later in the same run. + +Package outcomes in `state.json` distinguish `verified`, `unverified`, and `failed`, with the requested source/version, exit code, and diagnostic category. An unverified success is checked again on the next ordinary run; a package that is then discoverable is not reinstalled. Summaries preserve hash-mismatch failures without copying arbitrary installer output. Full native output stays in `install.log`. + [`winget import`](https://learn.microsoft.com/en-us/windows/package-manager/winget/import) installs applications. `--ignore-versions` installs the latest available versions; it is not a dry-run option. @@ -157,6 +164,9 @@ If you want to test OS tweaks before automation: ```powershell # 1. Download Sophia Script for Windows 11 Invoke-WebRequest -Uri "https://github.com/farag2/Sophia-Script-for-Windows/releases/download/7.3.0/Sophia.Script.for.Windows.11.v7.3.0.zip" -OutFile "SophiaScript.zip" +if ((Get-FileHash -LiteralPath .\SophiaScript.zip -Algorithm SHA256).Hash -ne 'd342149e13053ea87c6119706a1f9d7d56d08c6e55ced113b1c32a30e7873bf2') { + throw 'Sophia release SHA-256 mismatch' +} # 2. Extract the archive Expand-Archive -Path "SophiaScript.zip" -DestinationPath ".\SophiaScript" -Force @@ -167,6 +177,8 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\modules\Run-SophiaPres -PresetPath ".\Sophia-Preset.ps1" -CompletionPath ".\sophia-test.completed" ``` +Bootstrap verifies this [7.3.0 release digest](https://github.com/farag2/Sophia-Script-for-Windows/releases/tag/7.3.0) before extraction. It retains `.release.zip` and checks cached framework files against that archive before reuse. If an older cache lacks the archive or its files have changed, move that Sophia cache directory aside and rerun setup to download a verified copy. + **Important:** - Always test in a VM first before running on your main PC - Review `Sophia-Preset.ps1` and customize it for your needs diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 0b118a0..68b0398 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -56,6 +56,8 @@ $ProgressFile = Join-Path $SetupPath "progress.json" $CanonicalRepoPath = Join-Path ([Environment]::GetFolderPath("MyDocuments")) "declarative-windows" $CanonicalBootstrap = Join-Path $CanonicalRepoPath "bootstrap.ps1" $SophiaVersion = '7.3.0' +# GitHub's release asset digest, verified from releases/tag/7.3.0. +$SophiaArchiveSha256 = 'd342149e13053ea87c6119706a1f9d7d56d08c6e55ced113b1c32a30e7873bf2' $SophiaDir = Join-Path $SetupPath "Sophia-Script-$SophiaVersion" $SophiaScript = Join-Path $SophiaDir "Sophia.ps1" $SophiaZipName = "Sophia.Script.for.Windows.11.v$SophiaVersion.zip" @@ -414,6 +416,34 @@ function Test-SophiaFramework { return $manifest.ModuleVersion -eq $SophiaVersion } +function Assert-SophiaReleaseIntegrity { + param([string]$ArchivePath, [string]$FrameworkPath) + + $actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256 -ErrorAction Stop).Hash + if ($actualHash -ne $SophiaArchiveSha256) { throw 'Sophia archive SHA-256 mismatch. Refusing to execute downloaded code.' } + if ($FrameworkPath) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($ArchivePath) + try { + $prefix = "Sophia_Script_for_Windows_11_v$SophiaVersion/" + foreach ($entry in $archive.Entries) { + if (-not $entry.Name -or -not $entry.FullName.StartsWith($prefix, [StringComparison]::Ordinal)) { continue } + $relativePath = $entry.FullName.Substring($prefix.Length) + $filePath = Join-Path $FrameworkPath $relativePath + $stream = $entry.Open() + $sha256 = [Security.Cryptography.SHA256]::Create() + try { $expected = [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '') } + finally { $sha256.Dispose(); $stream.Dispose() } + if ((Get-FileHash -LiteralPath $filePath -Algorithm SHA256 -ErrorAction Stop).Hash -ne $expected) { + throw "Sophia release file differs from the verified archive: $relativePath. Move the cached framework aside before retrying." + } + } + } + finally { $archive.Dispose() } + } + Write-Log "Verified Sophia $SophiaVersion artifact SHA256 $actualHash" -Level INFO +} + function Get-SophiaScript { $stagingPath = $null try { @@ -421,6 +451,7 @@ function Get-SophiaScript { if (-not (Test-SophiaFramework -Path $SophiaDir)) { throw "Existing Sophia directory is incomplete or has the wrong version: $SophiaDir. Move it aside before retrying." } + Assert-SophiaReleaseIntegrity -ArchivePath (Join-Path $SophiaDir '.release.zip') -FrameworkPath $SophiaDir Write-Log "Sophia Script already extracted at $SophiaDir" -Level INFO return $SophiaScript } @@ -431,12 +462,14 @@ function Get-SophiaScript { $zipPath = Join-Path $stagingPath $SophiaZipName Write-Log "Downloading Sophia Script v$SophiaVersion..." -Level INFO Invoke-WebRequest -Uri $SophiaDownloadUrl -OutFile $zipPath -UseBasicParsing -ErrorAction Stop + Assert-SophiaReleaseIntegrity -ArchivePath $zipPath Expand-Archive -LiteralPath $zipPath -DestinationPath $stagingPath -ErrorAction Stop $extractedDir = Join-Path $stagingPath "Sophia_Script_for_Windows_11_v$SophiaVersion" if (-not (Test-SophiaFramework -Path $extractedDir)) { throw 'The pinned Sophia release is missing required framework files or has the wrong version.' } + Move-Item -LiteralPath $zipPath -Destination (Join-Path $extractedDir '.release.zip') -ErrorAction Stop # Publish only the validated release, without searching or removing neighboring setup files. Move-Item -LiteralPath $extractedDir -Destination $SophiaDir -ErrorAction Stop Write-Log "Sophia Script extracted to $SophiaDir" -Level SUCCESS @@ -1253,6 +1286,7 @@ function Ensure-CanonicalRepo { return $false } + Update-SetupToolPath $gitCommand = Get-Command git -ErrorAction SilentlyContinue if (-not $gitCommand) { Write-Log "Git is not available yet; skipping canonical repo clone" -Level WARNING @@ -1344,6 +1378,16 @@ foreach ($moduleName in @("BootstrapRun.ps1", "BackupManifest.ps1", "WinGetInsta } } +$stateLock = $null +if (-not $DryRun -and (Test-Path -LiteralPath $SetupPath -PathType Container)) { + try { $stateLock = Enter-BootstrapStateLock -StatePath $StateFile } + catch { + # A competing run must not overwrite the owner's logs, state or reports. + Write-Error $_.Exception.Message + exit 1 + } +} + try { Write-Log "========================================" -Level INFO Write-Log "Windows Setup Bootstrap - Starting" -Level INFO @@ -1715,5 +1759,8 @@ catch { $null = Complete-BootstrapRun -DesktopPath ([Environment]::GetFolderPath("Desktop")) -FailureMessage $_.Exception.Message exit 1 } +finally { + if ($stateLock) { $stateLock.Dispose() } +} exit $runResult.ExitCode diff --git a/build-iso.ps1 b/build-iso.ps1 index b7b8830..827266f 100644 --- a/build-iso.ps1 +++ b/build-iso.ps1 @@ -19,6 +19,9 @@ .PARAMETER KeepTemp If specified, keeps the temporary working directory for debugging. +.PARAMETER OscdimgSha256 + Expected SHA-256 of the optional oscdimg download, obtained from a trusted source independently of the download. + .EXAMPLE .\build-iso.ps1 -SourceISO "Win11_English_x64.iso" -OutputISO "Win11_Custom.iso" @@ -48,6 +51,10 @@ param( [Parameter(Mandatory = $false)] [string]$OscdimgDownloadUrl, + [Parameter(Mandatory = $false)] + [ValidatePattern('^[a-fA-F0-9]{64}$')] + [string]$OscdimgSha256, + [Parameter(Mandatory = $false)] [switch]$KeepTemp ) @@ -111,7 +118,7 @@ if (Test-Path $StagedSetupPayloadModule) { # Function to find oscdimg.exe from Windows ADK function Find-OscdImg { - param([string]$DownloadUrl) + param([string]$DownloadUrl, [string]$ExpectedHash) Write-Step "Locating oscdimg.exe from Windows ADK" @@ -143,9 +150,12 @@ function Find-OscdImg { } if ($DownloadUrl) { + if ($ExpectedHash -notmatch '^[a-fA-F0-9]{64}$') { + throw 'An oscdimg download requires -OscdimgSha256 from a trusted, independent source.' + } Write-Step "Downloading oscdimg.exe" - $cacheDir = Join-Path $env:TEMP "declarative-windows-tools" + $cacheDir = Join-Path $TempDir 'tools' if (-not (Test-Path $cacheDir)) { New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null } @@ -155,18 +165,24 @@ function Find-OscdImg { if ($extension -eq ".zip") { $downloadPath = Join-Path $cacheDir "oscdimg.zip" - Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath + Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath -ErrorAction Stop + $actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256 -ErrorAction Stop).Hash + if ($actualHash -ne $ExpectedHash) { throw 'oscdimg download SHA-256 mismatch.' } + Write-Info "Verified oscdimg archive SHA256 $actualHash" Expand-Archive -Path $downloadPath -DestinationPath $cacheDir -Force - $oscdimgFile = Get-ChildItem -Path $cacheDir -Filter "oscdimg.exe" -Recurse | Select-Object -First 1 - if ($oscdimgFile) { - Write-Success "Downloaded oscdimg.exe to: $($oscdimgFile.FullName)" - return $oscdimgFile.FullName + $oscdimgFiles = @(Get-ChildItem -Path $cacheDir -Filter "oscdimg.exe" -Recurse -File) + if ($oscdimgFiles.Count -eq 1) { + Write-Success "Downloaded oscdimg.exe to: $($oscdimgFiles[0].FullName)" + return $oscdimgFiles[0].FullName } } else { $downloadPath = Join-Path $cacheDir "oscdimg.exe" - Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath + Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath -ErrorAction Stop + $actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256 -ErrorAction Stop).Hash + if ($actualHash -ne $ExpectedHash) { throw 'oscdimg download SHA-256 mismatch.' } + Write-Info "Verified oscdimg executable SHA256 $actualHash" if (Test-Path $downloadPath) { Write-Success "Downloaded oscdimg.exe to: $downloadPath" return $downloadPath @@ -225,7 +241,7 @@ try { Write-Success "autounattend.xml validation passed" # Find oscdimg.exe - $oscdimgPath = Find-OscdImg -DownloadUrl $OscdimgDownloadUrl + $oscdimgPath = Find-OscdImg -DownloadUrl $OscdimgDownloadUrl -ExpectedHash $OscdimgSha256 # Mount source ISO Write-Step "Mounting source ISO" diff --git a/docs/ISO-GENERATION.md b/docs/ISO-GENERATION.md index 5b16193..8d2ccd4 100644 --- a/docs/ISO-GENERATION.md +++ b/docs/ISO-GENERATION.md @@ -43,6 +43,7 @@ This must complete successfully. The validator's `-SchemaDllPath` option support - `-SourceIsoHash` (optional): Expected SHA256 hash for the source ISO. - `-IsoLabel` (optional): ISO label passed to `oscdimg`. - `-OscdimgDownloadUrl` (optional): Direct URL to `oscdimg.exe` or a ZIP containing it, used only if an installed copy is not found. It does not supply Windows SIM or bypass schema validation; ADK Deployment Tools remain required. +- `-OscdimgSha256` (required when downloading oscdimg): Expected SHA-256 of the downloaded EXE or ZIP, obtained from a trusted source independently of the download. The builder rejects a mismatch before extraction or execution and logs the verified digest. ZIP downloads must contain exactly one `oscdimg.exe`. - `-KeepTemp` (optional): Retain temporary extraction files for debugging. ## ISO Contents @@ -108,6 +109,10 @@ After first login, bootstrap attempts to clone the original repo remote into `%U - `C:\Setup\state.json`: step resume state - `C:\Users\\Desktop\Setup Summary.txt`: summary report +Only one bootstrap run may own `C:\Setup\state.json` at a time. A competing launch exits without rewriting the owner's reports. State publication is atomic and retains the previous committed file as `state.json.previous`. Corrupt state stops setup and remains untouched. Review the saved state and its previous copy before recovering it; deleting state can repeat completed actions. + +User-scope retries publish a complete JSON result before the parent reads it. On timeout or cancellation, bootstrap stops the scheduled task and checks its state before deleting runner files. If termination cannot be confirmed, it retains the task and files and reports their identity in `install.log`; settle that task before retrying setup. + ## Manual Re-run Use the desktop shortcut or run: diff --git a/modules/BootstrapRun.ps1 b/modules/BootstrapRun.ps1 index 07304f1..d48bc12 100644 --- a/modules/BootstrapRun.ps1 +++ b/modules/BootstrapRun.ps1 @@ -30,10 +30,24 @@ function Initialize-State { $state = $null if (Test-Path $StatePath) { try { - $state = Get-Content -Path $StatePath -Raw | ConvertFrom-Json + $state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($state -isnot [pscustomobject] -or $state.version -ne '1' -or + $state.steps -isnot [pscustomobject]) { + throw 'Expected version 1 state with a steps object.' + } + $state.steps = Convert-StepsToHashtable -Steps $state.steps + foreach ($id in $state.steps.Keys) { + $step = $state.steps[$id] + if ($step -isnot [pscustomobject] -or + $step.status -notin @('pending', 'done', 'failed', 'skipped', 'unverified') -or + $null -eq $step.PSObject.Properties['message'] -or + $null -eq $step.PSObject.Properties['lastRun']) { + throw "Invalid record for step '$id'." + } + } } catch { - $state = $null + throw "Cannot safely resume state at '${StatePath}': $($_.Exception.Message) The original file is preserved. Recover it from a known-good copy before retrying; empty state could repeat completed actions." } } @@ -60,6 +74,18 @@ function Initialize-State { return $state } +function Enter-BootstrapStateLock { + param([Parameter(Mandatory)][string]$StatePath) + + try { + # Keep the handle open for the whole run. The OS releases it after a crash. + return [IO.File]::Open("$StatePath.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } + catch { + throw "Cannot own bootstrap state at '${StatePath}'. Another setup run may be active: $($_.Exception.Message)" + } +} + function Save-State { param( [Parameter(Mandatory)] @@ -72,7 +98,26 @@ function Save-State { if ($DryRun) { return } $State.lastUpdated = (Get-Date).ToString("o") - $State | ConvertTo-Json -Depth 6 | Set-Content -Path $StatePath -Force + $destination = [IO.Path]::GetFullPath($StatePath) + $temporaryPath = "$destination.$([guid]::NewGuid().ToString('N')).tmp" + try { + $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($State | ConvertTo-Json -Depth 6)) + $stream = [IO.File]::Open($temporaryPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } + finally { $stream.Dispose() } + if ([IO.File]::Exists($destination)) { + [IO.File]::Replace($temporaryPath, $destination, "$destination.previous") + } + else { + [IO.File]::Move($temporaryPath, $destination) + } + } + finally { + if ([IO.File]::Exists($temporaryPath)) { [IO.File]::Delete($temporaryPath) } + } } function Set-StepState { diff --git a/modules/WinGetInstall.ps1 b/modules/WinGetInstall.ps1 index d33d365..eb659c2 100644 --- a/modules/WinGetInstall.ps1 +++ b/modules/WinGetInstall.ps1 @@ -1,4 +1,4 @@ -function Get-WingetPackageIdsFromJson { +function Get-WingetPackagesFromJson { param([string]$Path) try { @@ -14,7 +14,12 @@ function Get-WingetPackageIdsFromJson { throw "Invalid WinGet manifest '${Path}': expected an object with a Sources array." } - $packageIds = @() + foreach ($property in $data.PSObject.Properties.Name) { + if ($property -notin @('$schema', 'CreationDate', 'WinGetVersion', 'Sources')) { + throw "Invalid WinGet manifest '${Path}': unsupported manifest field '$property'." + } + } + $packages = @() for ($sourceIndex = 0; $sourceIndex -lt $data.Sources.Count; $sourceIndex++) { $source = $data.Sources[$sourceIndex] @@ -23,28 +28,141 @@ function Get-WingetPackageIdsFromJson { throw "Invalid WinGet manifest '${Path}': Sources[$sourceIndex] must contain a Packages array." } + foreach ($property in $source.PSObject.Properties.Name) { + if ($property -notin @('Packages', 'SourceDetails')) { + throw "Invalid WinGet manifest '${Path}': unsupported source field '$property'." + } + } + $sourceName = '' + if ($null -ne $source.PSObject.Properties['SourceDetails']) { + if ($source.SourceDetails -isnot [pscustomobject] -or + $source.SourceDetails.Name -isnot [string] -or [string]::IsNullOrWhiteSpace($source.SourceDetails.Name)) { + throw "Invalid WinGet manifest '${Path}': SourceDetails requires a non-empty Name." + } + foreach ($property in $source.SourceDetails.PSObject.Properties) { + if ($property.Name -notin @('Name', 'Identifier', 'Argument', 'Type') -or + $property.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($property.Value)) { + throw "Invalid WinGet manifest '${Path}': unsupported or invalid SourceDetails field '$($property.Name)'." + } + } + $sourceName = $source.SourceDetails.Name + } + for ($packageIndex = 0; $packageIndex -lt $source.Packages.Count; $packageIndex++) { $package = $source.Packages[$packageIndex] if ($package -isnot [pscustomobject] -or $null -eq $package.PSObject.Properties['PackageIdentifier'] -or $package.PackageIdentifier -isnot [string] -or [string]::IsNullOrWhiteSpace($package.PackageIdentifier)) { throw "Invalid WinGet manifest '${Path}': Sources[$sourceIndex].Packages[$packageIndex].PackageIdentifier must be a non-empty string." } - $packageIds += $package.PackageIdentifier + foreach ($property in $package.PSObject.Properties.Name) { + if ($property -notin @('PackageIdentifier', 'Version')) { + throw "Invalid WinGet manifest '${Path}': unsupported package field '$property'. Supported fields are PackageIdentifier and Version." + } + } + $version = '' + if ($null -ne $package.PSObject.Properties['Version']) { + if ($package.Version -isnot [string] -or [string]::IsNullOrWhiteSpace($package.Version)) { + throw "Invalid WinGet manifest '${Path}': Version must be a non-empty string." + } + $version = $package.Version + } + $packages += [pscustomobject]@{ + PackageId = $package.PackageIdentifier + Source = $sourceName + Version = $version + SourceDetails = $source.SourceDetails + } } } - return ,@($packageIds | Sort-Object -Unique) + return ,@($packages | Sort-Object PackageId, Source, Version, @{ Expression = { $_.SourceDetails | ConvertTo-Json -Compress } } -Unique) +} + +function Get-WingetPackageIdsFromJson { + param([string]$Path) + return ,@(Get-WingetPackagesFromJson -Path $Path | ForEach-Object { $_.PackageId } | Sort-Object -Unique) +} + +function Update-SetupToolPath { + $paths = @($env:Path -split ';') + foreach ($target in @('Machine', 'User')) { + $registeredPath = [Environment]::GetEnvironmentVariable('Path', $target) + foreach ($entry in ($registeredPath -split ';')) { + if ($entry) { + $expanded = [Environment]::ExpandEnvironmentVariables($entry) + if ($expanded -notin $paths) { $paths += $expanded } + } + } + } + $env:Path = $paths -join ';' +} + +function Assert-WingetReady { + Update-SetupToolPath + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + throw 'Prerequisite failure: WinGet is unavailable. Install or register Microsoft App Installer for this user, then rerun setup.' + } + $null = & winget --version 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Prerequisite failure: WinGet cannot start, exit code $LASTEXITCODE. Repair or register Microsoft App Installer for this user, then rerun setup." + } +} + +function Assert-WingetSources { + param([object[]]$Packages) + + $registeredSources = @{} + foreach ($package in $Packages) { + if (-not $package.Source) { continue } + if (-not $registeredSources.ContainsKey($package.Source)) { + $output = & winget source export $package.Source 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Prerequisite failure: WinGet source '$($package.Source)' is unavailable." } + $registeredSources[$package.Source] = ($output -join "`n") | ConvertFrom-Json -ErrorAction Stop + } + $registered = $registeredSources[$package.Source] + foreach ($property in $package.SourceDetails.PSObject.Properties) { + $field = if ($property.Name -eq 'Argument') { 'Arg' } else { $property.Name } + if ($registered.$field -cne $property.Value) { + throw "Prerequisite failure: registered WinGet source '$($package.Source)' does not match manifest $($property.Name)." + } + } + } } function Test-WingetPackageInstalled { - param([string]$PackageId) + param([string]$PackageId, [string]$Source = '', [string]$Version = '') + + if ($Version) { + # Export supplies installed versions as JSON; list's localized table has no version filter. + $inventoryPath = Join-Path $env:TEMP "winget-inventory-$([guid]::NewGuid().ToString('N')).json" + try { + $arguments = @('export', '--output', $inventoryPath, '--include-versions', '--accept-source-agreements', '--disable-interactivity') + if ($Source) { $arguments += @('--source', $Source) } + $null = & winget @arguments 2>&1 + if ($LASTEXITCODE -ne 0) { throw "WinGet inventory failed with exit code $LASTEXITCODE." } + $inventory = Get-Content -LiteralPath $inventoryPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($inventory.Sources -isnot [array]) { throw 'WinGet returned an invalid inventory.' } + foreach ($entry in $inventory.Sources) { + if ($Source -and $entry.SourceDetails.Name -cne $Source) { continue } + foreach ($package in $entry.Packages) { + if ($package.PackageIdentifier -ceq $PackageId -and $package.Version -ceq $Version) { return $true } + } + } + return $false + } + finally { + if (Test-Path -LiteralPath $inventoryPath) { Remove-Item -LiteralPath $inventoryPath -Force } + } + } - $result = winget list --id $PackageId --exact 2>&1 + $arguments = @('list', '--id', $PackageId, '--exact', '--accept-source-agreements', '--disable-interactivity') + if ($Source) { $arguments += @('--source', $Source) } + $result = & winget @arguments 2>&1 if ($LASTEXITCODE -ne 0) { return $false } - return $result -match [regex]::Escape($PackageId) + return @($result -match ("(?:^|\s){0}(?:\s|$)" -f [regex]::Escape($PackageId))).Count -gt 0 } function Write-WingetOutput { @@ -128,6 +246,10 @@ function Invoke-WingetPackageInstall { [Parameter(Mandatory)] [string]$PackageId, + [string]$Source = '', + + [string]$Version = '', + [switch]$Unelevated, [string]$Mode = 'admin', @@ -136,18 +258,20 @@ function Invoke-WingetPackageInstall { [int]$PackageTotal = 0, - [int]$TimeoutSeconds = 14400 + [int]$TimeoutSeconds = 14400, + + [Threading.CancellationToken]$CancellationToken = [Threading.CancellationToken]::None ) + $arguments = @( + 'install', '--id', $PackageId, '--exact', + '--accept-package-agreements', '--accept-source-agreements' + ) + if ($Source) { $arguments += @('--source', $Source) } + if ($Version) { $arguments += @('--version', $Version) } + if (-not $Unelevated) { $output = New-Object System.Collections.Generic.List[string] - $arguments = @( - 'install', - '--id', $PackageId, - '--exact', - '--accept-package-agreements', - '--accept-source-agreements' - ) & winget @arguments 2>&1 | ForEach-Object { $line = $_.ToString() @@ -164,11 +288,15 @@ function Invoke-WingetPackageInstall { $runnerPath = Join-Path $env:TEMP "winget-install-runner-$(Get-Random).ps1" $resultPath = Join-Path $env:TEMP "winget-install-result-$(Get-Random).json" $taskName = "WingetInstallUnelevated-$(Get-Random)" + $taskRegistered = $false + $result = [pscustomobject]@{ ExitCode = 1; Output = @(); RemainingWork = $false } try { + $CancellationToken.ThrowIfCancellationRequested() $escapedPackageId = $PackageId.Replace("'", "''") $escapedResultPath = $resultPath.Replace("'", "''") $escapedProgressFile = $ProgressFile.Replace("'", "''") + $serializedArguments = ($arguments | ForEach-Object { "'" + $_.Replace("'", "''") + "'" }) -join ', ' $runnerContent = @" `$Host.UI.RawUI.WindowTitle = 'WinGet User-Scope Retry' @@ -196,8 +324,11 @@ Write-Host 'Starting user-scope WinGet retry...' -ForegroundColor Cyan Write-Host 'This window will show package installs that cannot run as administrator.' -ForegroundColor Cyan `$output = New-Object System.Collections.Generic.List[string] +`$exitCode = 1 +try { Update-ProgressFile -Phase 'Retrying user-scope packages' -Status 'Installing package $PackageIndex of $PackageTotal' -CurrentPackage '$escapedPackageId' -PackageIndex $PackageIndex -PackageTotal $PackageTotal -winget install --id '$escapedPackageId' --exact --accept-package-agreements --accept-source-agreements 2>&1 | ForEach-Object { +`$arguments = @($serializedArguments) +& winget @arguments 2>&1 | ForEach-Object { `$line = `$_.ToString() `$output.Add(`$line) Write-Host `$line @@ -211,78 +342,141 @@ winget install --id '$escapedPackageId' --exact --accept-package-agreements --ac } `$exitCode = `$LASTEXITCODE +} +catch { `$output.Add(`$_.Exception.Message) } Write-Host "WinGet retry finished with exit code `$exitCode" -ForegroundColor Cyan -[pscustomobject]@{ +`$json = [pscustomobject]@{ + Completed = `$true ExitCode = `$exitCode Output = @(`$output) -} | ConvertTo-Json -Depth 5 | Set-Content -Path '$escapedResultPath' -Encoding UTF8 -Force +} | ConvertTo-Json -Depth 5 +`$temporaryResult = '$escapedResultPath.tmp' +`$bytes = [Text.UTF8Encoding]::new(`$false).GetBytes(`$json) +`$stream = [IO.File]::Open(`$temporaryResult, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) +try { + `$stream.Write(`$bytes, 0, `$bytes.Length) + `$stream.Flush(`$true) +} +finally { `$stream.Dispose() } +[IO.File]::Move(`$temporaryResult, '$escapedResultPath') "@ Set-Content -Path $runnerPath -Value $runnerContent -Encoding UTF8 -Force $taskUser = if ($env:USERDOMAIN) { "$($env:USERDOMAIN)\$($env:USERNAME)" } else { $env:USERNAME } $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$runnerPath`"" - $taskTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) $taskPrincipal = New-ScheduledTaskPrincipal -UserId $taskUser -LogonType Interactive -RunLevel Limited - try { - $null = Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Force -ErrorAction Stop - } - catch { - return [pscustomobject]@{ - ExitCode = 1 - Output = @("Failed to create non-admin scheduled task", $_.Exception.Message) - } - } + # On-demand only: a timer trigger can launch the installer a second time. + $null = Register-ScheduledTask -TaskName $taskName -Action $taskAction -Principal $taskPrincipal -Force -ErrorAction Stop + $taskRegistered = $true - try { - Start-ScheduledTask -TaskName $taskName -ErrorAction Stop - } - catch { - return [pscustomobject]@{ - ExitCode = 1 - Output = @("Failed to start non-admin scheduled task", $_.Exception.Message) - } - } + Start-ScheduledTask -TaskName $taskName -ErrorAction Stop $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { - if (Test-Path $resultPath) { + $CancellationToken.ThrowIfCancellationRequested() + if (Test-Path -LiteralPath $resultPath) { break } - - Start-Sleep -Seconds 2 - } - - if (-not (Test-Path $resultPath)) { - return [pscustomobject]@{ - ExitCode = 1 - Output = @("Timed out waiting for non-admin WinGet install to finish") + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop + $info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction Stop + # SCHED_S_TASK_HAS_NOT_RUN is 0x41303; a new task can briefly remain Ready. + if ($task.State -notin @('Running', 'Queued') -and $info.LastTaskResult -ne 0x41303) { + if (Test-Path -LiteralPath $resultPath) { break } + throw "Non-admin scheduled task ended without a complete result, task exit code $($info.LastTaskResult)." } + Start-Sleep -Seconds 2 } - $result = Get-Content -Path $resultPath -Raw | ConvertFrom-Json - $output = @() - if ($null -ne $result.Output) { - $output = @($result.Output) + if (-not (Test-Path -LiteralPath $resultPath)) { + throw 'Timed out waiting for non-admin WinGet install to finish' } - return [pscustomobject]@{ - ExitCode = [int]$result.ExitCode - Output = $output + $published = Get-Content -LiteralPath $resultPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($published -isnot [pscustomobject] -or $published.Completed -isnot [bool] -or -not $published.Completed -or + ($published.ExitCode -isnot [int] -and $published.ExitCode -isnot [long]) -or + $published.ExitCode -lt [int]::MinValue -or $published.ExitCode -gt [int]::MaxValue -or $published.Output -isnot [array] -or + @($published.Output | Where-Object { $_ -isnot [string] }).Count) { + throw 'Malformed or incomplete non-admin WinGet result.' } + $result.ExitCode = $published.ExitCode + $result.Output = @($published.Output) + } + catch { + $result.Output = @("Non-admin scheduled task failed: $($_.Exception.Message)") } finally { - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue - - if (Test-Path $runnerPath) { - Remove-Item -Path $runnerPath -Force -ErrorAction SilentlyContinue + $settled = -not $taskRegistered + if ($taskRegistered) { + try { + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop + if ($task.State -in @('Running', 'Queued')) { + Stop-ScheduledTask -TaskName $taskName -ErrorAction Stop + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop + } + $settled = $task.State -in @('Ready', 'Disabled') + if ($settled) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction Stop + } + } + catch { $result.Output += "Scheduled task cleanup failed: $($_.Exception.Message)" } } - - if (Test-Path $resultPath) { - Remove-Item -Path $resultPath -Force -ErrorAction SilentlyContinue + if ($settled) { + foreach ($path in @($runnerPath, $resultPath, "$resultPath.tmp")) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } + } + } + else { + $result.ExitCode = 1 + $result.RemainingWork = $true + $result.Output += "Remaining work: scheduled task '$taskName' could not be confirmed stopped. Retained runner '$runnerPath' and result '$resultPath'; settle this task before retrying." } } + return $result +} + +function Get-WingetFailureReason { + param([object]$Result) + + $outputText = $Result.Output -join "`n" + $code = '0x{0:X8}' -f ($Result.ExitCode -band 0xFFFFFFFFL) + # Summaries use known diagnostic categories, never arbitrary installer output or URLs. + $cause = if ($Result.RemainingWork) { + 'Installer task still active or unknown; settle the retained task listed in install.log before retrying' + } + elseif ($code -eq '0x8A150011' -or $outputText -match 'installer hash does not match|hash mismatch') { + 'Integrity failure: installer hash mismatch' + } + elseif ($outputText -match 'Failed to (open|update) (the )?(package )?source|source.*unavailable|dependency.*failed|dependencies.*failed|App Installer.*(unavailable|not registered)') { + 'Prerequisite failure: package source, dependency or App Installer unavailable' + } + elseif ($outputText -match 'Timed out|cancelled|scheduled task|remaining work') { + 'Installer task failure: retry did not finish cleanly' + } + else { 'Installer failure' } + return "$cause; WinGet exit code $($Result.ExitCode) ($code). See install.log for native output." +} + +function Set-WingetPackageOutcome { + param([string]$StepId, [object]$Package, [string]$Status, [object]$ExitCode = $null, [string]$Reason = '') + + if (-not $SetupState -or $DryRun) { return } + $step = $SetupState.steps[$StepId] + $outcomes = @($step.packages | Where-Object { + $_ -and ($_.PackageId -cne $Package.PackageId -or $_.Source -cne $Package.Source -or $_.Version -cne $Package.Version) + }) + $outcomes += [pscustomobject]@{ + PackageId = $Package.PackageId + Source = $Package.Source + Version = $Package.Version + Status = $Status + ExitCode = $ExitCode + Reason = $Reason + CheckedAt = (Get-Date).ToString('o') + } + $step | Add-Member -MemberType NoteProperty -Name packages -Value $outcomes -Force + Save-State -State $SetupState -StatePath $StateFile } function Invoke-WingetManifestInstall { @@ -316,39 +510,45 @@ function Invoke-WingetManifestInstall { try { Write-Log "Found $ManifestLabel at $ManifestPath" -Level INFO - $packageIds = Get-WingetPackageIdsFromJson -Path $ManifestPath - if (-not $packageIds -or $packageIds.Count -eq 0) { + $packages = Get-WingetPackagesFromJson -Path $ManifestPath + if (-not $packages -or $packages.Count -eq 0) { Write-Log "$ManifestLabel contains no packages to install" -Level WARNING Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "No packages found in $ManifestLabel" Set-StepState -StepId $StepId -Status "done" -Message "No packages found" return $true } + Assert-WingetReady + Assert-WingetSources -Packages $packages + Set-StepState -StepId $StepId -Status 'pending' -Message 'Checking package inventory' $appsHash = (Get-FileHash -Path $ManifestPath -Algorithm SHA256).Hash $markerHash = $null if (Test-Path $MarkerPath) { $markerHash = (Get-Content -Path $MarkerPath -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() } - $missingPackages = New-Object System.Collections.Generic.List[string] + $missingPackages = New-Object System.Collections.Generic.List[object] $installedCount = 0 - $totalPackages = $packageIds.Count + $totalPackages = $packages.Count Update-SetupProgress -Phase 'Scanning packages' -Status ("Checking package 1 of {0}" -f $totalPackages) -CurrentPackage '' -PackageIndex 0 -PackageTotal $totalPackages -Mode 'admin' for ($index = 0; $index -lt $totalPackages; $index++) { - $packageId = $packageIds[$index] + $package = $packages[$index] + $packageId = $package.PackageId + $packageArguments = @{ PackageId = $packageId; Source = $package.Source; Version = $package.Version } $currentNumber = $index + 1 Update-SetupProgress -Phase 'Scanning packages' -Status ("Checking package {0} of {1}" -f $currentNumber, $totalPackages) -CurrentPackage $packageId -PackageIndex $currentNumber -PackageTotal $totalPackages -Mode 'admin' Write-Log "[$currentNumber/$totalPackages] Checking package: $packageId" -Level INFO - if (Test-WingetPackageInstalled -PackageId $packageId) { + if (Test-WingetPackageInstalled @packageArguments) { $installedCount++ + Set-WingetPackageOutcome -StepId $StepId -Package $package -Status verified Write-Log "[$currentNumber/$totalPackages] Already installed: $packageId" -Level INFO } else { - $missingPackages.Add($packageId) + $missingPackages.Add($package) Write-Log "[$currentNumber/$totalPackages] Missing: $packageId" -Level INFO } } @@ -377,7 +577,12 @@ function Invoke-WingetManifestInstall { $failedPackages = [System.Collections.Generic.List[string]]::new() $packageNumber = 0 - foreach ($packageId in @($missingPackages)) { + foreach ($package in $missingPackages) { + $packageId = $package.PackageId + $packageArguments = @{ PackageId = $packageId; Source = $package.Source; Version = $package.Version } + $packageLabel = $packageId + if ($package.Source) { $packageLabel += " [source: $($package.Source)]" } + if ($package.Version) { $packageLabel += " [version: $($package.Version)]" } $packageNumber++ $installed = $false $verified = $false @@ -386,10 +591,11 @@ function Invoke-WingetManifestInstall { Write-Log ("Installing [{0}/{1}]: {2} (admin)" -f $packageNumber, $missingPackages.Count, $packageId) -Level INFO Update-SetupProgress -Phase 'Installing packages' -Status ("Installing package {0} of {1}" -f $packageNumber, $missingPackages.Count) -CurrentPackage $packageId -PackageIndex $packageNumber -PackageTotal $missingPackages.Count -Mode 'admin' - $installResult = Invoke-WingetPackageInstall -PackageId $packageId -Mode 'admin' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count + $installResult = Invoke-WingetPackageInstall @packageArguments -Mode 'admin' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count Write-WingetOutput -Output $installResult.Output -Prefix "WinGet:" + $finalResult = $installResult - if (Test-WingetPackageInstalled -PackageId $packageId) { + if (Test-WingetPackageInstalled @packageArguments) { $installed = $true $verified = $true } @@ -398,10 +604,11 @@ function Invoke-WingetManifestInstall { Write-Log "A second PowerShell window may appear for user-scope installers. Leave it open until it finishes." -Level INFO Update-SetupProgress -Phase 'Retrying user-scope packages' -Status ("Retrying package {0} of {1}" -f $packageNumber, $missingPackages.Count) -CurrentPackage $packageId -PackageIndex $packageNumber -PackageTotal $missingPackages.Count -Mode 'user' - $retryResult = Invoke-WingetPackageInstall -PackageId $packageId -Unelevated -Mode 'user' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count + $retryResult = Invoke-WingetPackageInstall @packageArguments -Unelevated -Mode 'user' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count Write-WingetOutput -Output $retryResult.Output -Prefix "WinGet (user):" + $finalResult = $retryResult - if (Test-WingetPackageInstalled -PackageId $packageId) { + if (-not $retryResult.RemainingWork -and (Test-WingetPackageInstalled @packageArguments)) { $installed = $true $verified = $true } @@ -423,41 +630,43 @@ function Invoke-WingetManifestInstall { if ($verified) { $installedPackages.Add($packageId) + Set-WingetPackageOutcome -StepId $StepId -Package $package -Status verified -ExitCode $finalResult.ExitCode Write-Log "Successfully installed and verified $packageId" -Level SUCCESS continue } if ($unverified) { $unverifiedPackages.Add($packageId) - Add-FailedItem -Category "$SummaryStep Verification" -Item $packageId -Reason "WinGet reported success but winget list did not verify the package" -Status WARN + $reason = 'Verification uncertain: WinGet returned exit code 0, but inventory did not verify the requested package. Setup will check again on the next run.' + Set-WingetPackageOutcome -StepId $StepId -Package $package -Status unverified -ExitCode $finalResult.ExitCode -Reason $reason + Add-FailedItem -Category "$SummaryStep Verification" -Item $packageLabel -Reason $reason -Status WARN Write-Log "WARNING: $packageId install reported success, but winget list did not verify it" -Level WARNING continue } if (-not $installed) { $failedPackages.Add($packageId) + $reason = Get-WingetFailureReason -Result $finalResult + Set-WingetPackageOutcome -StepId $StepId -Package $package -Status failed -ExitCode $finalResult.ExitCode -Reason $reason + Add-FailedItem -Category $SummaryStep -Item $packageLabel -Reason $reason Write-Log "WARNING: $packageId failed to install" -Level WARNING + if ($finalResult.RemainingWork) { throw $reason } } } - foreach ($packageId in $failedPackages) { - Add-FailedItem -Category $SummaryStep -Item $packageId -Reason "Not installed after per-package install from $ManifestLabel" - Write-Log "WARNING: $packageId still not installed after WinGet install from $ManifestLabel" -Level WARNING - } - $failCount = @($failedPackages).Count $unverifiedCount = @($unverifiedPackages).Count if ($failCount -eq 0) { Update-SetupProgress -Phase 'Installing packages' -Status 'Completed' -CurrentPackage '' -PackageIndex $missingPackages.Count -PackageTotal $missingPackages.Count -Mode 'admin' Write-Log "WinGet install from $ManifestLabel completed successfully" -Level SUCCESS - Set-Content -Path $MarkerPath -Value $appsHash -Force if ($unverifiedCount -gt 0) { Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "Installed $($installedPackages.Count) package(s); $unverifiedCount verification warning(s)" Set-StepState -StepId $StepId -Status "unverified" -Message "Installed with $unverifiedCount verification warning(s)" } else { + Set-Content -Path $MarkerPath -Value $appsHash -Force Add-SummaryItem -Step $SummaryStep -Status "OK" -Message "Installed $($installedPackages.Count) package(s)" Set-StepState -StepId $StepId -Status "done" -Message "Installed $($installedPackages.Count) package(s)" } diff --git a/tests/ArchitectureModules.Tests.ps1 b/tests/ArchitectureModules.Tests.ps1 index cf8776e..6714849 100644 --- a/tests/ArchitectureModules.Tests.ps1 +++ b/tests/ArchitectureModules.Tests.ps1 @@ -25,7 +25,7 @@ Describe "architecture module checks" { $bootstrap | Should -Match "WinGetInstall\.ps1" $modules.WinGetInstall | Should -Match "function Invoke-WingetManifestInstall" $modules.WinGetInstall | Should -Match "Invoke-WingetPackageInstall" - $modules.WinGetInstall | Should -Match "WinGet reported success but winget list did not verify the package" + $modules.WinGetInstall | Should -Match "Verification uncertain: WinGet returned exit code 0" $modules.WinGetInstall | Should -Match "Retrying user-scope packages" } diff --git a/tests/BootstrapState.Tests.ps1 b/tests/BootstrapState.Tests.ps1 new file mode 100644 index 0000000..3b35d9c --- /dev/null +++ b/tests/BootstrapState.Tests.ps1 @@ -0,0 +1,51 @@ +BeforeAll { + $stateModule = Join-Path $PSScriptRoot '..\modules\BootstrapRun.ps1' + . $stateModule +} + +Describe 'Exclusive and atomic bootstrap state' { + BeforeEach { + $StateFile = Join-Path $TestDrive "$([guid]::NewGuid()).json" + $DryRun = $false + $Force = $false + $SetupState = Initialize-State -StatePath $StateFile -StepIds @('repo') + } + + It 'rejects a competing process and releases ownership after disposal' { + $owner = Enter-BootstrapStateLock -StatePath $StateFile + try { + $command = ". '$($stateModule.Replace("'", "''"))'; try { `$handle = Enter-BootstrapStateLock -StatePath '$($StateFile.Replace("'", "''"))'; `$handle.Dispose(); exit 0 } catch { exit 17 }" + & powershell.exe -NoProfile -NonInteractive -Command $command + $LASTEXITCODE | Should -Be 17 + } + finally { $owner.Dispose() } + $nextOwner = Enter-BootstrapStateLock -StatePath $StateFile + $nextOwner.Dispose() + } + + It 'preserves committed completion when publication is interrupted' { + Set-StepState -StepId repo -Status done -Message 'Restored' + $original = [IO.File]::ReadAllText($StateFile) + $reader = [IO.File]::Open($StateFile, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $SetupState.steps.repo.message = 'New publication' + { Save-State -State $SetupState -StatePath $StateFile } | Should -Throw + [IO.File]::ReadAllText($StateFile) | Should -Be $original + } + finally { $reader.Dispose() } + $SetupState = Initialize-State -StatePath $StateFile -StepIds @('repo') + Should-RunStep -StepId repo | Should -BeFalse + $SetupState.steps.repo.message = 'Published after retry' + Save-State -State $SetupState -StatePath $StateFile + [IO.File]::ReadAllText("$StateFile.previous") | Should -Be $original + (Initialize-State -StatePath $StateFile -StepIds @('repo')).steps.repo.message | Should -Be 'Published after retry' + } + + It 'preserves corrupt state and refuses to restart completed actions from empty state' { + foreach ($content in @('{', 'null', '{}', '{"version":"1","steps":{"repo":{"status":"done"}}}')) { + [IO.File]::WriteAllText($StateFile, $content) + { Initialize-State -StatePath $StateFile -StepIds @('repo') } | Should -Throw '*original file is preserved*' + [IO.File]::ReadAllText($StateFile) | Should -Be $content + } + } +} diff --git a/tests/CanonicalRepoRestore.Tests.ps1 b/tests/CanonicalRepoRestore.Tests.ps1 index 7a41ff1..56a914d 100644 --- a/tests/CanonicalRepoRestore.Tests.ps1 +++ b/tests/CanonicalRepoRestore.Tests.ps1 @@ -22,6 +22,7 @@ Describe "canonical repo file restore" { }, $true) $runRepoStep = [scriptblock]::Create($repoStep.Extent.Text) function Write-Log { param($Message, $Level) } + function Update-SetupToolPath { } } BeforeEach { @@ -92,6 +93,19 @@ Describe "canonical repo file restore" { Should -Invoke Get-BackupManifestData -Times 0 } + It 'refreshes tool discovery before cloning with newly installed Git' { + $script:pathRefreshed = $false + $fakeGit = Join-Path $TestDrive 'git.ps1' + 'New-Item -ItemType Directory -Path (Join-Path $args[2] ".git") -Force | Out-Null; $global:LASTEXITCODE = 0' | Set-Content $fakeGit + Mock Update-SetupToolPath { $script:pathRefreshed = $true } + Mock Get-Command { + if ($script:pathRefreshed) { [pscustomobject]@{ Source = $fakeGit } } + } -ParameterFilter { $Name -eq 'git' } + Ensure-CanonicalRepo -Manifest ([pscustomobject]@{ repo = @{ remoteUrl = 'https://example.invalid/repo' } }) | Should -BeTrue + Should -Invoke Update-SetupToolPath -Times 1 -Exactly + Test-Path -LiteralPath (Join-Path $CanonicalRepoPath '.git') | Should -BeTrue + } + It "keeps incomplete repo restoration retryable and marks it done after a successful retry" { $stepId = 'repo' $OptionalAppsOnly = $false diff --git a/tests/OscdimgIntegrity.Tests.ps1 b/tests/OscdimgIntegrity.Tests.ps1 new file mode 100644 index 0000000..5f4b32e --- /dev/null +++ b/tests/OscdimgIntegrity.Tests.ps1 @@ -0,0 +1,66 @@ +BeforeAll { + $buildPath = Join-Path $PSScriptRoot '..\build-iso.ps1' + $ast = [Management.Automation.Language.Parser]::ParseFile($buildPath, [ref]$null, [ref]$null) + $definition = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Find-OscdImg' }, $true) + . ([scriptblock]::Create($definition.Extent.Text)) + function Write-Step { param($Message) } + function Write-Success { param($Message) } + function Write-ErrorMessage { param($Message) } + function Write-Info { param($Message) } +} + +Describe 'oscdimg download integrity' { + BeforeEach { + $TempDir = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $fixture = Join-Path $TestDrive 'fixture.exe' + Set-Content -LiteralPath $fixture -Value 'synthetic executable bytes, never executed' + $expectedHash = (Get-FileHash -LiteralPath $fixture).Hash + Mock Test-Path { + if ($Path -like '*Windows Kits*') { return $false } + return [IO.File]::Exists($Path) -or [IO.Directory]::Exists($Path) + } + Mock Get-ItemProperty { $null } + Mock Invoke-WebRequest { Copy-Item -LiteralPath $fixture -Destination $OutFile } + Mock Write-Info { } + } + + It 'requires an independent digest before downloading and records verified identity' { + { Find-OscdImg -DownloadUrl 'https://example.invalid/oscdimg.exe' } | Should -Throw '*requires -OscdimgSha256*' + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + $path = Find-OscdImg -DownloadUrl 'https://example.invalid/oscdimg.exe' -ExpectedHash $expectedHash + (Get-FileHash -LiteralPath $path).Hash | Should -Be $expectedHash + Should -Invoke Write-Info -ParameterFilter { $Message -like "*Verified oscdimg executable SHA256 $expectedHash*" } + } + + It 'rejects a mismatched executable and a truncated download' { + Mock Invoke-WebRequest { Set-Content -LiteralPath $OutFile -Value 'truncated' } + { Find-OscdImg -DownloadUrl 'https://example.invalid/oscdimg.exe' -ExpectedHash $expectedHash } | Should -Throw '*SHA-256 mismatch*' + } + + It 'verifies a ZIP before extraction and rejects a mismatched archive' { + $archiveRoot = Join-Path $TestDrive 'release' + New-Item -ItemType Directory -Path $archiveRoot -Force | Out-Null + Copy-Item -LiteralPath $fixture -Destination (Join-Path $archiveRoot 'oscdimg.exe') + $fixture = Join-Path $TestDrive 'fixture.zip' + Compress-Archive -LiteralPath $archiveRoot -DestinationPath $fixture -Force + $archiveHash = (Get-FileHash -LiteralPath $fixture).Hash + $path = Find-OscdImg -DownloadUrl 'https://example.invalid/oscdimg.zip' -ExpectedHash $archiveHash + (Get-FileHash -LiteralPath $path).Hash | Should -Be $expectedHash + Mock Expand-Archive { throw 'Must not extract mismatched content' } + { Find-OscdImg -DownloadUrl 'https://example.invalid/oscdimg.zip' -ExpectedHash $expectedHash } | Should -Throw '*SHA-256 mismatch*' + Should -Invoke Expand-Archive -Times 0 -Exactly + } + + It 'rejects an archive without oscdimg instead of reusing a stale download' { + $stale = Join-Path $TestDrive 'other-build/tools/oscdimg.exe' + New-Item -ItemType Directory -Path (Split-Path $stale -Parent) -Force | Out-Null + Set-Content -LiteralPath $stale -Value 'stale executable' + $textFile = Join-Path $TestDrive 'readme.txt' + Set-Content -LiteralPath $textFile -Value 'no executable in this archive' + $fixture = Join-Path $TestDrive 'incomplete.zip' + Compress-Archive -LiteralPath $textFile -DestinationPath $fixture -Force + $expectedHash = (Get-FileHash -LiteralPath $fixture).Hash + { Find-OscdImg -DownloadUrl 'https://example.invalid/oscdimg.zip' -ExpectedHash $expectedHash } | Should -Throw '*Failed to download oscdimg.exe*' + Get-Content -LiteralPath $stale | Should -Be 'stale executable' + } +} diff --git a/tests/SophiaExtraction.Tests.ps1 b/tests/SophiaExtraction.Tests.ps1 index d4a70b0..0f870f7 100644 --- a/tests/SophiaExtraction.Tests.ps1 +++ b/tests/SophiaExtraction.Tests.ps1 @@ -2,7 +2,7 @@ Describe 'isolated Sophia release extraction' { BeforeAll { $bootstrap = Join-Path (Split-Path $PSScriptRoot -Parent) 'bootstrap.ps1' $ast = [System.Management.Automation.Language.Parser]::ParseFile($bootstrap, [ref]$null, [ref]$null) - foreach ($name in @('Test-SophiaFramework', 'Get-SophiaScript')) { + foreach ($name in @('Test-SophiaFramework', 'Assert-SophiaReleaseIntegrity', 'Get-SophiaScript')) { $definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) . ([scriptblock]::Create($definition.Extent.Text)) } @@ -30,8 +30,11 @@ Describe 'isolated Sophia release extraction' { $stale = Join-Path $SetupPath 'unrelated\Sophia.ps1' New-Item -ItemType Directory -Path (Split-Path $stale -Parent) -Force | Out-Null Set-Content -LiteralPath $stale -Value 'unrelated script' + $fixtureZip = "$release.zip" + Compress-Archive -LiteralPath $releaseRoot -DestinationPath $fixtureZip + $SophiaArchiveSha256 = (Get-FileHash -LiteralPath $fixtureZip).Hash Mock Invoke-WebRequest { - Compress-Archive -LiteralPath $releaseRoot -DestinationPath $OutFile + Copy-Item -LiteralPath $fixtureZip -Destination $OutFile } } @@ -44,6 +47,8 @@ Describe 'isolated Sophia release extraction' { It 'rejects an incomplete archive without publishing it' { Remove-Item -LiteralPath (Join-Path $releaseRoot 'Module\Binaries\LGPO.exe') + Compress-Archive -LiteralPath $releaseRoot -DestinationPath $fixtureZip -Force + $SophiaArchiveSha256 = (Get-FileHash -LiteralPath $fixtureZip).Hash Get-SophiaScript | Should -BeNullOrEmpty Test-Path -LiteralPath $SophiaDir | Should -BeFalse Get-Content -LiteralPath $stale | Should -Be 'unrelated script' @@ -57,6 +62,22 @@ Describe 'isolated Sophia release extraction' { Get-Content -LiteralPath $stale | Should -Be 'unrelated script' } + It 'rejects mismatched or incomplete downloads before extraction' { + Mock Invoke-WebRequest { Set-Content -LiteralPath $OutFile -Value 'partial download' } + Mock Expand-Archive { throw 'Must not extract an unverified archive' } + Get-SophiaScript | Should -BeNullOrEmpty + Should -Invoke Expand-Archive -Times 0 -Exactly + Test-Path -LiteralPath $SophiaDir | Should -BeFalse + } + + It 'checks cached code against the verified archive before reuse' { + Get-SophiaScript | Should -Be $SophiaScript + Get-SophiaScript | Should -Be $SophiaScript + Set-Content -LiteralPath $SophiaScript -Value 'modified code' + Get-SophiaScript | Should -BeNullOrEmpty + Should -Invoke Invoke-WebRequest -Times 1 -Exactly + } + It 'preserves an incomplete existing directory and reports a retryable failure' { New-Item -ItemType Directory -Path $SophiaDir | Out-Null Set-Content -LiteralPath $SophiaScript -Value 'existing script' diff --git a/tests/WinGetManifest.Tests.ps1 b/tests/WinGetManifest.Tests.ps1 index 3cfbcc9..e3fbc5e 100644 --- a/tests/WinGetManifest.Tests.ps1 +++ b/tests/WinGetManifest.Tests.ps1 @@ -26,6 +26,7 @@ Describe 'WinGet manifest preflight' { Mock Update-SetupProgress { } Mock Add-FailedItem { } Mock Wait-ForNetwork { throw 'Generic network probe must not run' } + Mock Assert-WingetReady { } Mock Test-WingetPackageInstalled { $true } Mock Invoke-WingetPackageInstall { throw 'Unexpected installation' } } diff --git a/tests/WinGetOutcomes.Tests.ps1 b/tests/WinGetOutcomes.Tests.ps1 new file mode 100644 index 0000000..aa9187f --- /dev/null +++ b/tests/WinGetOutcomes.Tests.ps1 @@ -0,0 +1,167 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\modules\BootstrapRun.ps1') + . (Join-Path $PSScriptRoot '..\modules\WinGetInstall.ps1') + function Write-Log { param($Message, $Level) } + function winget { + $global:LASTEXITCODE = 0 + & $script:wingetStub @args + } +} + +Describe 'WinGet request identity and command readiness' { + BeforeEach { + $script:wingetCalls = [Collections.Generic.List[object]]::new() + $script:wingetStub = { $script:wingetCalls.Add(@($args)) } + Mock Write-Log { } + Mock Update-SetupProgress { } + Mock Update-WingetProgressFromLine { } + } + + It 'keeps identical IDs from different sources and requested versions' { + $path = Join-Path $TestDrive 'requests.json' + '{"Sources":[{"SourceDetails":{"Name":"first"},"Packages":[{"PackageIdentifier":"Vendor.App","Version":"1.2"}]},{"SourceDetails":{"Name":"second"},"Packages":[{"PackageIdentifier":"Vendor.App","Version":"2.0"}]}]}' | Set-Content $path + $packages = Get-WingetPackagesFromJson -Path $path + $packages.Count | Should -Be 2 + $packages[0].Source | Should -Be first + $packages[0].Version | Should -Be '1.2' + $packages[1].Source | Should -Be second + $packages[1].Version | Should -Be '2.0' + foreach ($json in @( + '{"Sources":[{"Packages":[{"PackageIdentifier":"Vendor.App","Scope":"machine"}]}]}', + '{"Sources":[{"Packages":[{"PackageIdentifier":"Vendor.App","Version":null}]}]}', + '{"Sources":[{"SourceDetails":{"Name":""},"Packages":[]}]}', + '{"Sources":[],"InstallOptions":{"Override":"x"}}' + )) { + Set-Content $path $json + { Get-WingetPackagesFromJson -Path $path } | Should -Throw '*Invalid WinGet manifest*' + } + } + + It 'checks registered source identity before using its name' { + $request = [pscustomobject]@{ Source = 'private'; SourceDetails = [pscustomobject]@{ Name = 'private'; Identifier = 'expected'; Argument = 'https://example.invalid/source'; Type = 'Microsoft.Rest' } } + $script:wingetStub = { '{"Name":"private","Identifier":"expected","Arg":"https://example.invalid/source","Type":"Microsoft.Rest"}' } + { Assert-WingetSources -Packages @($request) } | Should -Not -Throw + $request.SourceDetails.Identifier = 'different' + { Assert-WingetSources -Packages @($request) } | Should -Throw '*does not match manifest Identifier*' + } + + It 'passes source to inventory and source plus version to installation without bypassing integrity' { + Test-WingetPackageInstalled -PackageId Vendor.App -Source private | Should -BeFalse + $null = Invoke-WingetPackageInstall -PackageId Vendor.App -Source private -Version '1.2' + ($script:wingetCalls[0] -join '|') | Should -Be 'list|--id|Vendor.App|--exact|--accept-source-agreements|--disable-interactivity|--source|private' + ($script:wingetCalls[1] -join '|') | Should -Be 'install|--id|Vendor.App|--exact|--accept-package-agreements|--accept-source-agreements|--source|private|--version|1.2' + } + + It 'verifies the installed version from source-specific JSON export' { + $script:wingetStub = { + $args[0] | Should -Be export + ($args -join '|') | Should -Match '--include-versions.*--source\|private' + $outputPath = $args[[array]::IndexOf($args, '--output') + 1] + '{"Sources":[{"SourceDetails":{"Name":"private"},"Packages":[{"PackageIdentifier":"Vendor.App","Version":"1.2"}]}]}' | Set-Content -LiteralPath $outputPath + } + Test-WingetPackageInstalled -PackageId Vendor.App -Source private -Version '1.2' | Should -BeTrue + Test-WingetPackageInstalled -PackageId Vendor.App -Source private -Version '2.0' | Should -BeFalse + } + + It 'reports missing or unregistered WinGet as a prerequisite failure' { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'winget' } + { Assert-WingetReady } | Should -Throw '*Prerequisite failure: WinGet is unavailable*' + $script:wingetCalls.Count | Should -Be 0 + } + + It 'reports a WinGet executable that cannot start' { + $script:wingetStub = { $global:LASTEXITCODE = 1 } + { Assert-WingetReady } | Should -Throw '*Prerequisite failure: WinGet cannot start*' + } + + It 'rediscovers tools from the registered PATH in the existing process' { + $oldPath = $env:Path + try { + $registeredPath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + if (-not $registeredPath) { throw 'The Windows fixture requires a registered machine PATH.' } + $env:Path = $TestDrive + Update-SetupToolPath + ($env:Path -split ';')[0] | Should -Be $TestDrive + foreach ($entry in ($registeredPath -split ';' | Where-Object { $_ })) { + ($env:Path -split ';') | Should -Contain ([Environment]::ExpandEnvironmentVariables($entry)) + } + } + finally { $env:Path = $oldPath } + } +} + +Describe 'Persistent package outcomes and summaries' { + BeforeEach { + $StateFile = Join-Path $TestDrive "$([guid]::NewGuid()).state.json" + $SetupState = Initialize-State -StatePath $StateFile -StepIds @('winget') + $DryRun = $false + $Force = $false + $SummaryItems = [Collections.Generic.List[object]]::new() + $FailedItems = [Collections.Generic.List[object]]::new() + $manifestPath = Join-Path $TestDrive 'apps.json' + '{"Sources":[{"Packages":[{"PackageIdentifier":"Vendor.App"}]}]}' | Set-Content $manifestPath + $installArgs = @{ ManifestPath = $manifestPath; StepId = 'winget'; SummaryStep = 'WinGet'; MarkerPath = "$StateFile.marker"; ManifestLabel = 'apps.json'; MissingManifestMessage = 'missing' } + Mock Assert-WingetReady { } + Mock Assert-WingetSources { } + Mock Write-Log { } + Mock Update-SetupProgress { } + Mock Test-WingetPackageInstalled { $false } + Mock Invoke-WingetPackageInstall { [pscustomobject]@{ ExitCode = 0; Output = @('Installed successfully') } } + } + + It 'fails once at the prerequisite without attempting any package' { + Mock Assert-WingetReady { throw 'Prerequisite failure: WinGet is unavailable' } + Invoke-WingetManifestInstall @installArgs | Should -BeFalse + Should -Invoke Test-WingetPackageInstalled -Times 0 -Exactly + Should -Invoke Invoke-WingetPackageInstall -Times 0 -Exactly + $SummaryItems.Count | Should -Be 1 + $SummaryItems[0].Message | Should -Match 'Prerequisite failure' + $FailedItems.Count | Should -Be 0 + } + + It 'persists uncertainty then verifies delayed discovery without reinstalling' { + Invoke-WingetManifestInstall @installArgs | Should -BeTrue + $SetupState = Initialize-State -StatePath $StateFile -StepIds @('winget') + $SetupState.steps.winget.status | Should -Be unverified + $SetupState.steps.winget.packages[0].Status | Should -Be unverified + $SetupState.steps.winget.packages[0].ExitCode | Should -Be 0 + $FailedItems[0].Status | Should -Be WARN + Test-Path -LiteralPath $installArgs.MarkerPath | Should -BeFalse + Should-RunStep winget | Should -BeTrue + + Mock Test-WingetPackageInstalled { $true } + Invoke-WingetManifestInstall @installArgs | Should -BeTrue + $persisted = Initialize-State -StatePath $StateFile -StepIds @('winget') + $persisted.steps.winget.status | Should -Be done + $persisted.steps.winget.packages.Count | Should -Be 1 + $persisted.steps.winget.packages[0].Status | Should -Be verified + Should -Invoke Invoke-WingetPackageInstall -Times 1 -Exactly + } + + It 'retains source and version through scanning and user-scope retry' { + '{"Sources":[{"SourceDetails":{"Name":"private"},"Packages":[{"PackageIdentifier":"Vendor.App","Version":"1.2"}]}]}' | Set-Content $manifestPath + Mock Invoke-WingetPackageInstall { + if (-not $Unelevated) { return [pscustomobject]@{ ExitCode = 1; Output = @('cannot be run from an administrator context') } } + [pscustomobject]@{ ExitCode = 0; Output = @('Installed successfully') } + } + Invoke-WingetManifestInstall @installArgs | Should -BeTrue + Should -Invoke Test-WingetPackageInstalled -Times 3 -Exactly -ParameterFilter { $PackageId -eq 'Vendor.App' -and $Source -eq 'private' -and $Version -eq '1.2' } + Should -Invoke Invoke-WingetPackageInstall -Times 1 -Exactly -ParameterFilter { $Unelevated -and $Source -eq 'private' -and $Version -eq '1.2' } + $SetupState.steps.winget.packages[0].Source | Should -Be private + $SetupState.steps.winget.packages[0].Version | Should -Be '1.2' + } + + It 'preserves a hash mismatch and numeric exit status without leaking raw installer output' { + Mock Invoke-WingetPackageInstall { [pscustomobject]@{ ExitCode = -1978335215; Output = @('Installer hash does not match', 'https://example.invalid/?token=secret') } } + Invoke-WingetManifestInstall @installArgs | Should -BeFalse + $FailedItems[0].Reason | Should -Match 'Integrity failure: installer hash mismatch.*-1978335215.*0x8A150011' + $FailedItems[0].Reason | Should -Not -Match 'token=secret' + $SetupState.steps.winget.packages[0].Status | Should -Be failed + $SetupState.steps.winget.packages[0].ExitCode | Should -Be -1978335215 + } + + It 'distinguishes prerequisite failure from other installer failures' { + Get-WingetFailureReason -Result ([pscustomobject]@{ ExitCode = 1; Output = @('Failed to open the package source: network unavailable') }) | Should -Match '^Prerequisite failure' + Get-WingetFailureReason -Result ([pscustomobject]@{ ExitCode = 1603; Output = @('Installation failed') }) | Should -Match '^Installer failure.*1603' + } +} diff --git a/tests/WinGetTask.Tests.ps1 b/tests/WinGetTask.Tests.ps1 new file mode 100644 index 0000000..d76b6cc --- /dev/null +++ b/tests/WinGetTask.Tests.ps1 @@ -0,0 +1,134 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\modules\WinGetInstall.ps1') + function New-ScheduledTaskAction { param($Execute, $Argument) } + function New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel) } + function Register-ScheduledTask { param($TaskName, $Action, $Principal, [switch]$Force) } + function Start-ScheduledTask { param($TaskName) } + function Get-ScheduledTask { param($TaskName) } + function Get-ScheduledTaskInfo { param($TaskName) } + function Stop-ScheduledTask { param($TaskName) } + function Unregister-ScheduledTask { param($TaskName, $Confirm) } + function winget { $global:LASTEXITCODE = 0; 'Synthetic installer success'; $args -join '|' } +} + +Describe 'User-scope task result publication and cleanup' { + BeforeEach { + $originalTemp = $env:TEMP + $env:TEMP = $TestDrive + $ProgressFile = Join-Path $TestDrive 'progress.json' + $script:taskState = 'Ready' + $script:runnerPath = $null + $script:resultPath = $null + Mock New-ScheduledTaskAction { + $script:runnerPath = [regex]::Match($Argument, '-File "(.+)"').Groups[1].Value + $runner = Get-Content -LiteralPath $script:runnerPath -Raw + $script:resultPath = [regex]::Match($runner, "\[IO.File\]::Move\(.+, '(.+)'\)").Groups[1].Value + [pscustomobject]@{ Argument = $Argument } + } + Mock New-ScheduledTaskPrincipal { [pscustomobject]@{} } + Mock Register-ScheduledTask { } + Mock Start-ScheduledTask { } + Mock Get-ScheduledTask { [pscustomobject]@{ State = $script:taskState } } + Mock Get-ScheduledTaskInfo { [pscustomobject]@{ LastTaskResult = 0x41303 } } + Mock Stop-ScheduledTask { $script:taskState = 'Ready' } + Mock Unregister-ScheduledTask { } + Mock Start-Sleep { throw 'Unexpected wait in fixture' } + } + + AfterEach { $env:TEMP = $originalTemp } + + It 'runs the generated publisher with the exact source and version arguments' { + Mock Start-ScheduledTask { & $script:runnerPath } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Source private -Version '1.2' -Unelevated + $result.ExitCode | Should -Be 0 + $result.Output | Should -Contain 'Synthetic installer success' + $result.Output | Should -Contain 'install|--id|Vendor.App|--exact|--accept-package-agreements|--accept-source-agreements|--source|private|--version|1.2' + Test-Path -LiteralPath $script:runnerPath | Should -BeFalse + Test-Path -LiteralPath $script:resultPath | Should -BeFalse + Test-Path -LiteralPath "$script:resultPath.tmp" | Should -BeFalse + } + + It 'waits for complete publication while a partial temporary result exists' { + Mock Start-ScheduledTask { + $script:taskState = 'Running' + Set-Content -LiteralPath "$script:resultPath.tmp" -Value '{"Completed":' + } + Mock Start-Sleep { + Test-Path -LiteralPath $script:resultPath | Should -BeFalse + Set-Content -LiteralPath "$script:resultPath.tmp" -Value '{"Completed":true,"ExitCode":0,"Output":["published"]}' + [IO.File]::Move("$script:resultPath.tmp", $script:resultPath) + $script:taskState = 'Ready' + } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Unelevated + $result.ExitCode | Should -Be 0 + $result.Output | Should -Contain published + Should -Invoke Start-Sleep -Times 1 -Exactly + } + + It 'rejects malformed or incomplete published results' { + foreach ($json in @('{', '{}', '{"Completed":true,"ExitCode":"0","Output":[]}', '{"Completed":true,"ExitCode":0,"Output":[null]}')) { + $script:fixtureResult = $json + Mock Start-ScheduledTask { Set-Content -LiteralPath $script:resultPath -Value $script:fixtureResult } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Unelevated + $result.ExitCode | Should -Be 1 + Test-Path -LiteralPath $script:runnerPath | Should -BeFalse + } + } + + It 'accepts a result published between the file check and task completion check' { + Mock Get-ScheduledTaskInfo { + Set-Content -LiteralPath $script:resultPath -Value '{"Completed":true,"ExitCode":0,"Output":["published during task check"]}' + [pscustomobject]@{ LastTaskResult = 0 } + } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Unelevated + $result.ExitCode | Should -Be 0 + $result.Output | Should -Contain 'published during task check' + } + + It 'reports task startup failure and removes files after confirming the task is idle' { + Mock Start-ScheduledTask { throw 'Synthetic startup failure' } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Unelevated + $result.ExitCode | Should -Be 1 + ($result.Output -join ';') | Should -Match 'Synthetic startup failure' + Should -Invoke Get-ScheduledTask -Times 1 -Exactly + Should -Invoke Unregister-ScheduledTask -Times 1 -Exactly + Test-Path -LiteralPath $script:runnerPath | Should -BeFalse + } + + It 'reports a task that exits before it can publish a result' { + Mock Get-ScheduledTaskInfo { [pscustomobject]@{ LastTaskResult = 1 } } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Unelevated + $result.ExitCode | Should -Be 1 + ($result.Output -join ';') | Should -Match 'ended without a complete result' + Should -Invoke Start-Sleep -Times 0 -Exactly + } + + It 'stops running work on timeout before removing its supporting files' { + Mock Start-ScheduledTask { $script:taskState = 'Running' } + Mock Stop-ScheduledTask { + Test-Path -LiteralPath $script:runnerPath | Should -BeTrue + $script:taskState = 'Ready' + } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Unelevated -TimeoutSeconds 0 + $result.ExitCode | Should -Be 1 + ($result.Output -join ';') | Should -Match 'Timed out' + Should -Invoke Stop-ScheduledTask -Times 1 -Exactly + Test-Path -LiteralPath $script:runnerPath | Should -BeFalse + } + + It 'settles a cancelled task and retains evidence when it cannot confirm termination' { + $script:cancellation = [Threading.CancellationTokenSource]::new() + try { + Mock Start-ScheduledTask { $script:taskState = 'Running'; $script:cancellation.Cancel() } + Mock Stop-ScheduledTask { } + $result = Invoke-WingetPackageInstall -PackageId Vendor.App -Unelevated -CancellationToken $script:cancellation.Token + $result.ExitCode | Should -Be 1 + $result.RemainingWork | Should -BeTrue + ($result.Output -join ';') | Should -Match 'Remaining work: scheduled task' + Should -Invoke Stop-ScheduledTask -Times 1 -Exactly + Should -Invoke Unregister-ScheduledTask -Times 0 -Exactly + Test-Path -LiteralPath $script:runnerPath | Should -BeTrue + } + finally { $script:cancellation.Dispose() } + } +}