From a8f24f4d1a643b520a02c78272248cdfc10fd6a3 Mon Sep 17 00:00:00 2001 From: Softhe <157214175+Softhe@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:32:36 +0200 Subject: [PATCH 1/2] Fix installer isolation, package selection, and offline reliability --- .github/workflows/tests.yml | 21 ++ docs/installer-reliability.md | 46 ++++ install.ps1 | 424 ++++++++++++++++++++++++++------ tests/Run-Tests.ps1 | 14 ++ tests/Test-Downloads.ps1 | 65 +++++ tests/Test-InstallerFlow.ps1 | 65 +++++ tests/Test-PackageSelection.ps1 | 84 +++++++ tests/Test-ScopeAndCleanup.ps1 | 80 ++++++ 8 files changed, 725 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 docs/installer-reliability.md create mode 100644 tests/Run-Tests.ps1 create mode 100644 tests/Test-Downloads.ps1 create mode 100644 tests/Test-InstallerFlow.ps1 create mode 100644 tests/Test-PackageSelection.ps1 create mode 100644 tests/Test-ScopeAndCleanup.ps1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..382fcb1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,21 @@ +name: Installer regression tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + regression: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + shell: [powershell, pwsh] + steps: + - uses: actions/checkout@v4 + - name: Run isolated installer tests + shell: ${{ matrix.shell }} + run: ./tests/Run-Tests.ps1 diff --git a/docs/installer-reliability.md b/docs/installer-reliability.md new file mode 100644 index 0000000..669bd4c --- /dev/null +++ b/docs/installer-reliability.md @@ -0,0 +1,46 @@ +# Installer reliability + +## Installation and downloads + +Use the named options in `install.ps1`. The script supports Windows PowerShell 5.1 and relaunches in Windows PowerShell when Appx deployment requires it. Relaunch runs a snapshot of the executing script, with the same parameters, rather than downloading another revision from GitHub. + +The online command runs parameter binding and implementation variables in child scopes. It reports its result through `$LASTEXITCODE` without deleting existing variables from the calling session. + +Each default download folder has a unique name under the temporary directory. The script deletes that folder only when its path and ownership marker match the current run. `-KeepDownloads`, `-DownloadOnly`, and an explicit `-DownloadDir` preserve downloads. The script prints the selected folder in its log. + +`-DownloadTimeoutSeconds` limits each package download attempt. The default is 1800 seconds, and the accepted range is 1 through 86400 seconds. The downloader cancels a transfer that exceeds this limit. Metadata requests have separate timeouts. Retries mean the total run can take longer than one download deadline. + +Downloads use unique temporary filenames. The script checks the service-provided hash and Microsoft signature before replacing the destination package. Failed attempts remove their temporary files. + +If a requested companion app is unavailable, the script records a partial failure. This applies to download-only runs and local installations as well. `-SkipWinget` intentionally excludes App Installer from the requested set. + +## Offline package folders + +Online downloads produce `store-packages.json` beside the packages. It records the downloading machine's architecture and build, the requested and missing app families, and each file's size and SHA-256 hash. + +When this manifest is present, local installation validates its schema, filenames, target architecture, requested components, sizes, hashes, and Microsoft signatures. It installs only files listed in the manifest. A manifest that records a missing requested app is rejected. A missing winget package does not block a run that uses `-SkipWinget`. + +The JSON manifest detects corruption and incomplete transfers. Its hashes do not establish authenticity because users can edit the file. Microsoft signature validation establishes package authenticity. Keep the manifest with the downloaded packages when copying an offline folder. + +Legacy folders without a JSON manifest remain supported. They do not receive the downloader's independent hash and Microsoft-signer verification. Windows still performs its deployment validation. The script reads identities and dependencies from package contents, including renamed files and bundle payloads. + +Local selection skips packages with an unsupported architecture or Windows version before choosing the newest usable app. Equal versions prefer the native architecture. Bundles use their highest-ranked compatible architecture payload for dependency and Windows-version checks. + +Frameworks install in dependency order. Before deployment, the script checks required names, publishers, versions, and architectures against the supplied frameworks and applicable installed or provisioned packages. Missing dependencies and cycles stop the run before installation. Keep the complete framework set in an offline folder. + +## Exit codes + +Existing exit codes remain in use. Code 4 also covers invalid or incompatible package metadata and missing dependencies. Code 5 means a requested app or deployment step failed, including an incomplete download-only set. Code 7 covers invalid offline manifests and other fatal errors. Code 8 covers online lookup, transfer, and verification failures for required downloads. + +## Validation + +Run the isolated regression suite on Windows: + +```powershell +powershell.exe -NoProfile -NonInteractive -File .\tests\Run-Tests.ps1 +pwsh.exe -NoProfile -NonInteractive -File .\tests\Run-Tests.ps1 +``` + +The tests use the installer's actual functions and main orchestration with fixture packages and mocked deployment calls. They cover caller scope, relaunch snapshots and arguments, cleanup ownership, package selection, dependencies, offline manifests, incomplete requested sets, and cancellation of a stalled loopback HTTP transfer. GitHub Actions runs the suite under both PowerShell engines. + +These tests do not install packages or exercise UAC. They do not prove that Microsoft's live delivery service offers an installable package set for a particular LTSC image. Before release, test online and offline installation on disposable LTSC 2021 and LTSC 2024 systems, including SYSTEM provisioning and a new user's first sign-in. Check that Microsoft Store opens, winget runs, and the expected packages register for the intended users. diff --git a/install.ps1 b/install.ps1 index 49c88ba..bf887f4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,8 +12,8 @@ - Downloads the latest packages straight from Microsoft's Store delivery service (displaycatalog.mp.microsoft.com + fe3.delivery.mp.microsoft.com, the same backend the - Store app uses). Every file is checked against the SHA-256 published by the service and - must carry a valid Microsoft Corporation signature. + Store app uses). Every download is checked against the published SHA-256 or SHA-1 hash + and must carry a valid Microsoft Corporation signature. - Can also install from a local folder of packages (-Source Local / Auto). - Non-interactive, CI/MDT/SSH friendly (also runs as SYSTEM: provisions only) - Logging with timestamps @@ -37,7 +37,7 @@ Local only use packages in WorkDir. .PARAMETER DownloadDir - Where downloaded packages go. Default: %TEMP%\StoreInstall-Packages (deleted after install + Where downloaded packages go. Default: a unique %TEMP%\StoreInstall-Packages- folder (deleted after install unless -KeepDownloads). A custom -DownloadDir is always kept. .PARAMETER DownloadOnly @@ -48,6 +48,9 @@ .PARAMETER KeepDownloads Do not delete the default download folder after installing. +.PARAMETER DownloadTimeoutSeconds + Maximum duration of each package download attempt. Default: 1800 seconds. + .PARAMETER NoProvision Only Add-AppxPackage for current user (skip Add-AppxProvisionedPackage). @@ -63,11 +66,11 @@ 1 not elevated (or UAC prompt declined) 2 unsupported OS build 3 PowerShell language mode not FullLanguage - 4 Store package missing + 4 required package or dependency missing, incompatible, or invalid 5 partial failure 6 Store install failed 7 fatal/internal - 8 download failed (no internet / Microsoft service unavailable) + 8 download or download verification failed Tips: 1. Needs admin rights; packages are downloaded from Microsoft (or taken from -WorkDir). @@ -77,12 +80,19 @@ 5. Reboot after clean LTSC/IoT restores before relying on Store/winget. #> +& { +param([object[]] $InvocationArguments, [string] $OriginalScriptPath) + +# Keep parameter binding and all implementation variables out of the caller's scope. +$OriginalScriptText = '#Requires -Version 5.1' + [Environment]::NewLine + '& {' + [Environment]::NewLine + $MyInvocation.MyCommand.ScriptBlock.ToString() + [Environment]::NewLine + '} $args $PSCommandPath' +function Invoke-StoreLtsc { [CmdletBinding()] param( [string] $WorkDir = $PSScriptRoot, [string] $LogDir, [ValidateSet('Auto', 'Local', 'Online')][string] $Source = 'Online', [string] $DownloadDir, + [ValidateRange(1, 86400)][int] $DownloadTimeoutSeconds = 1800, [switch] $DownloadOnly, [switch] $KeepDownloads, [switch] $NoProvision, @@ -96,11 +106,13 @@ param( $StoreLtscInvocation = @{ Params = @{ WorkDir = $WorkDir; LogDir = $LogDir; Source = $Source; DownloadDir = $DownloadDir + DownloadTimeoutSeconds = $DownloadTimeoutSeconds DownloadOnly = [bool]$DownloadOnly; KeepDownloads = [bool]$KeepDownloads NoProvision = [bool]$NoProvision; SkipWinget = [bool]$SkipWinget } Bound = @{} - ScriptPath = $PSCommandPath + ScriptPath = $OriginalScriptPath + ScriptText = $OriginalScriptText } foreach ($kv in $PSBoundParameters.GetEnumerator()) { $StoreLtscInvocation.Bound[$kv.Key] = $kv.Value } @@ -115,12 +127,13 @@ $ProgressPreference = 'SilentlyContinue' # Appx/DISM progress bars slow down a foreach ($kv in $Invocation.Params.GetEnumerator()) { Set-Variable -Name $kv.Key -Value $kv.Value } $script:BoundParams = $Invocation.Bound $script:ScriptPath = $Invocation.ScriptPath -$script:SelfUrl = 'https://raw.githubusercontent.com/Softhe/Store-LTSC/main/install.ps1' +$script:ScriptText = $Invocation.ScriptText $script:LogFile = $null $script:FailCount = 0 $script:LicenseDirs = @() $script:CleanupDir = $null +$script:CleanupToken = $null $script:OnlineFrameworkNames = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) # Package family names of everything this script installs (looked up in the Store catalog for online mode) @@ -158,7 +171,17 @@ function Complete-Run { param([Parameter(Mandatory)][int] $Code, [string] $Reason) if ($script:CleanupDir -and (Test-Path -LiteralPath $script:CleanupDir)) { try { - Remove-Item -LiteralPath $script:CleanupDir -Recurse -Force + $resolved = [IO.Path]::GetFullPath($script:CleanupDir) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + $marker = Join-Path $resolved '.store-ltsc-owner' + if (-not $resolved.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetFileName($resolved) -notmatch '^StoreInstall-Packages-[a-f0-9]{32}$' -or + -not $script:CleanupToken -or -not (Test-Path -LiteralPath $marker -PathType Leaf) -or + (Get-Content -LiteralPath $marker -Raw).Trim() -ne $script:CleanupToken -or + ((Get-Item -LiteralPath $resolved -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Refusing cleanup because the temporary directory ownership check failed.' + } + Remove-Item -LiteralPath $resolved -Recurse -Force Write-Log "Removed downloaded packages: $script:CleanupDir" } catch { Write-Log "Could not remove ${script:CleanupDir}: $($_.Exception.Message)" 'WARN' } @@ -182,36 +205,29 @@ function Invoke-Relaunch { $null = $bound.Remove('PauseAtEnd') $tempDir = $null - $file = $script:ScriptPath - if (-not $file) { - # Started via irm | iex: there is no script file to relaunch, so fetch a copy into a private temp folder. + try { + # Use the executing revision, including when a local file changes during this run. $tempDir = Join-Path ([IO.Path]::GetTempPath()) ('Store-LTSC-' + [guid]::NewGuid().ToString('N')) - $null = New-Item -ItemType Directory -Path $tempDir -Force + $null = New-Item -ItemType Directory -Path $tempDir $file = Join-Path $tempDir 'install.ps1' - Initialize-Network - Write-Host "Fetching a copy of the script: $script:SelfUrl" - Invoke-WebRequest -Uri $script:SelfUrl -OutFile $file -UseBasicParsing -TimeoutSec 60 + if (-not $script:ScriptText) { throw 'The executing script content is unavailable for relaunch.' } + [IO.File]::WriteAllText($file, $script:ScriptText, [Text.UTF8Encoding]::new($true)) + if (-not $bound.ContainsKey('WorkDir')) { $bound['WorkDir'] = $WorkDir } if (-not $bound.ContainsKey('LogDir')) { $bound['LogDir'] = Join-Path ([IO.Path]::GetTempPath()) 'StoreInstall-Logs' } - } - $argList = @('-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass') - if (-not $Elevate) { $argList += '-NonInteractive' } - $argList += '-File', $file - foreach ($kv in $bound.GetEnumerator()) { - if ($kv.Value -is [System.Management.Automation.SwitchParameter] -or $kv.Value -is [bool]) { - if ([bool]$kv.Value) { $argList += "-$($kv.Key)" } + foreach ($key in @($bound.Keys)) { + if ($bound[$key] -is [System.Management.Automation.SwitchParameter]) { $bound[$key] = [bool]$bound[$key] } } - else { - $v = [string]$kv.Value - if ($v.EndsWith('\')) { $v += '.' } # avoid a trailing \" escaping the closing quote - $argList += "-$($kv.Key)", $v - } - } + if ($Elevate) { $bound['PauseAtEnd'] = $true } + # Encode a parameter splat instead of constructing native command-line quoting. + $parameterData = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes([Management.Automation.PSSerializer]::Serialize($bound))) + $bootstrap = '$parameters = [Management.Automation.PSSerializer]::Deserialize([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String(''' + $parameterData + '''))); & ''' + $file.Replace("'", "''") + ''' @parameters 6>&1; exit $LASTEXITCODE' + $argList = @('-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-InputFormat', 'Text', '-OutputFormat', 'Text') + if (-not $Elevate) { $argList += '-NonInteractive' } + $argList += '-EncodedCommand', [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($bootstrap)) - try { if ($Elevate) { - $argList += '-PauseAtEnd' - $cmdLine = ($argList | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $cmdLine = $argList -join ' ' Write-Host 'Requesting administrator rights (UAC). The install continues in a new window...' $proc = Start-Process -FilePath $psExe -ArgumentList $cmdLine -Verb RunAs -Wait -PassThru Write-Host "Elevated run finished with exit code $($proc.ExitCode)." @@ -272,6 +288,125 @@ function Get-PackageIdentity { } } +function Read-PackageManifest { + param([Parameter(Mandatory)][IO.Stream] $Stream) + $settings = New-Object System.Xml.XmlReaderSettings + $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $reader = [System.Xml.XmlReader]::Create($Stream, $settings) + try { + $xml = New-Object System.Xml.XmlDocument + $xml.XmlResolver = $null + $xml.Load($reader) + return ,$xml + } + finally { $reader.Dispose() } +} + +function Get-PublisherId { + param([Parameter(Mandatory)][string] $Publisher) + $sha = [Security.Cryptography.SHA256]::Create() + try { $hash = $sha.ComputeHash([Text.Encoding]::Unicode.GetBytes($Publisher)) } finally { $sha.Dispose() } + $alphabet = '0123456789abcdefghjkmnpqrstvwxyz' + $bits = ($hash[0..7] | ForEach-Object { [Convert]::ToString($_, 2).PadLeft(8, '0') }) -join '' + $bits += '0' + $result = '' + for ($i = 0; $i -lt 65; $i += 5) { $result += $alphabet[[Convert]::ToInt32($bits.Substring($i, 5), 2)] } + return $result +} + +function Get-PackageMetadata { + # Read identities from package contents so renamed offline files remain usable. + param([Parameter(Mandatory)][string] $Path, [string] $OsArch, [int] $Build, [int] $Revision = 0) + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $manifest = $zip.GetEntry('AppxManifest.xml') + if ($manifest) { + $stream = $manifest.Open() + try { $xml = Read-PackageManifest $stream } finally { $stream.Dispose() } + $identity = $xml.SelectSingleNode('/*[local-name()="Package"]/*[local-name()="Identity"]') + if (-not $identity) { throw "Package identity missing in '$Path'." } + $arch = $identity.GetAttribute('ProcessorArchitecture') + if (-not $arch) { $arch = 'neutral' } + $deps = @($xml.SelectNodes('/*[local-name()="Package"]/*[local-name()="Dependencies"]/*[local-name()="PackageDependency"]') | ForEach-Object { + [pscustomobject]@{ Name = $_.GetAttribute('Name'); Publisher = $_.GetAttribute('Publisher'); MinVersion = $(if ($_.GetAttribute('MinVersion')) { [version]$_.GetAttribute('MinVersion') } else { [version]'0.0.0.0' }); Arch = $arch } + }) + $framework = $xml.SelectSingleNode('/*[local-name()="Package"]/*[local-name()="Properties"]/*[local-name()="Framework"]') + $targets = @($xml.SelectNodes('/*[local-name()="Package"]/*[local-name()="Dependencies"]/*[local-name()="TargetDeviceFamily"]') | Where-Object { $_.GetAttribute('Name') -in @('Windows.Universal', 'Windows.Desktop') }) + $supported = $targets.Count -eq 0 -or @($targets | Where-Object { -not $_.GetAttribute('MinVersion') -or [version]$_.GetAttribute('MinVersion') -le [version]("10.0.$Build.$Revision") }).Count -gt 0 + return [pscustomobject]@{ Name = $identity.GetAttribute('Name'); Version = [version]$identity.GetAttribute('Version'); Arch = $arch; Family = ($identity.GetAttribute('Name') + '_' + (Get-PublisherId $identity.GetAttribute('Publisher'))); Publisher = $identity.GetAttribute('Publisher'); IsFramework = ($framework -and $framework.InnerText -eq 'true'); Dependencies = $deps; Supported = $supported } + } + $manifest = $zip.GetEntry('AppxMetadata/AppxBundleManifest.xml') + if (-not $manifest) { throw "No Appx package or bundle manifest in '$Path'." } + $stream = $manifest.Open() + try { $xml = Read-PackageManifest $stream } finally { $stream.Dispose() } + $identity = $xml.SelectSingleNode('/*[local-name()="Bundle"]/*[local-name()="Identity"]') + if (-not $identity) { throw "Bundle identity missing in '$Path'." } + $payloads = @($xml.SelectNodes('/*[local-name()="Bundle"]/*[local-name()="Packages"]/*[local-name()="Package"]') | Where-Object { $_.GetAttribute('Type') -eq 'application' -and (Get-ArchRank -Arch $_.GetAttribute('Architecture') -OsArch $OsArch -Build $Build) -gt 0 }) + $best = $payloads | Sort-Object @{ Expression = { Get-ArchRank -Arch $_.GetAttribute('Architecture') -OsArch $OsArch -Build $Build }; Descending = $true } | Select-Object -First 1 + $deps = @() + $supported = $true + if ($best) { + $entry = $zip.GetEntry($best.GetAttribute('FileName')) + if (-not $entry) { throw "Bundle payload '$($best.GetAttribute('FileName'))' missing in '$Path'." } + $memory = New-Object IO.MemoryStream + $stream = $entry.Open() + try { $stream.CopyTo($memory) } finally { $stream.Dispose() } + $memory.Position = 0 + $inner = New-Object IO.Compression.ZipArchive($memory, [IO.Compression.ZipArchiveMode]::Read) + try { + $entry = $inner.GetEntry('AppxManifest.xml') + if (-not $entry) { throw "Bundle payload manifest missing in '$Path'." } + $stream = $entry.Open() + try { $payloadXml = Read-PackageManifest $stream } finally { $stream.Dispose() } + $targets = @($payloadXml.SelectNodes('/*[local-name()="Package"]/*[local-name()="Dependencies"]/*[local-name()="TargetDeviceFamily"]') | Where-Object { $_.GetAttribute('Name') -in @('Windows.Universal', 'Windows.Desktop') }) + # Check the preferred payload. Do not assume Windows will fall back to an emulated payload. + $supported = $targets.Count -eq 0 -or @($targets | Where-Object { -not $_.GetAttribute('MinVersion') -or [version]$_.GetAttribute('MinVersion') -le [version]("10.0.$Build.$Revision") }).Count -gt 0 + $deps = @($payloadXml.SelectNodes('/*[local-name()="Package"]/*[local-name()="Dependencies"]/*[local-name()="PackageDependency"]') | ForEach-Object { + [pscustomobject]@{ Name = $_.GetAttribute('Name'); Publisher = $_.GetAttribute('Publisher'); MinVersion = $(if ($_.GetAttribute('MinVersion')) { [version]$_.GetAttribute('MinVersion') } else { [version]'0.0.0.0' }); Arch = $best.GetAttribute('Architecture') } + }) + } + finally { $inner.Dispose(); $memory.Dispose() } + } + return [pscustomobject]@{ Name = $identity.GetAttribute('Name'); Version = [version]$identity.GetAttribute('Version'); Arch = $(if ($best) { $best.GetAttribute('Architecture') } else { 'unsupported' }); Family = ($identity.GetAttribute('Name') + '_' + (Get-PublisherId $identity.GetAttribute('Publisher'))); Publisher = $identity.GetAttribute('Publisher'); IsFramework = $false; Dependencies = $deps; Supported = $supported } + } + finally { $zip.Dispose() } +} + +function Get-DependencyInstallOrder { + param([object[]] $Frameworks, [object[]] $Applications) + $ordered = New-Object System.Collections.Generic.List[object] + $pending = New-Object System.Collections.Generic.List[object] + foreach ($pkg in $Frameworks) { $pending.Add($pkg) } + $allPackages = @($Frameworks) + @($Applications) + $edges = @{} + foreach ($pkg in $allPackages) { + $required = @() + foreach ($dep in $pkg.Identity.Dependencies) { + $matches = @($Frameworks | Where-Object { $_.Identity.Name -eq $dep.Name -and $_.Identity.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Identity.Publisher -eq $dep.Publisher) -and ($_.Identity.Arch -eq 'neutral' -or $_.Identity.Arch -eq $dep.Arch) }) + if ($matches.Count -gt 0) { $required += $matches; continue } + if ($script:DoProvision) { + $expectedFamily = if ($dep.Publisher) { $dep.Name + '_' + (Get-PublisherId $dep.Publisher) } else { $null } + $installed = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object { + $provisionedIdentity = Get-PackageIdentity -FileName ($_.PackageName + '.appx') + $provisionedIdentity.Name -eq $dep.Name -and $provisionedIdentity.Version -ge $dep.MinVersion -and (-not $expectedFamily -or $provisionedIdentity.Family -eq $expectedFamily) -and ($provisionedIdentity.Arch -eq 'neutral' -or $provisionedIdentity.Arch -eq $dep.Arch) + }) + } + else { $installed = @(Get-AppxPackage -Name $dep.Name -ErrorAction SilentlyContinue | Where-Object { [version]$_.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Publisher -eq $dep.Publisher) -and ($_.Architecture.ToString() -eq 'Neutral' -or $_.Architecture.ToString() -eq $dep.Arch) }) } + if ($installed.Count -eq 0) { throw "Missing dependency '$($dep.Name)' version $($dep.MinVersion) or newer for '$($pkg.Identity.Name)' ($($dep.Arch)). Add the matching framework package to the package directory." } + } + $edges[$pkg.File.FullName] = $required + } + while ($pending.Count -gt 0) { + $next = @($pending | Where-Object { $blocked = $false; foreach ($dependency in $edges[$_.File.FullName]) { if ($pending.Contains($dependency)) { $blocked = $true } }; -not $blocked } | Sort-Object { $_.File.Name } | Select-Object -First 1) + if ($next.Count -eq 0) { throw "Framework dependency cycle detected: $(($pending | ForEach-Object { $_.Identity.Name }) -join ', ')." } + $ordered.Add($next[0]); $null = $pending.Remove($next[0]) + } + return $ordered.ToArray() +} + function Find-License { param( [Parameter(Mandatory)][string] $PackagePath, @@ -328,24 +463,12 @@ function Get-ArchRank { return 0 } -function Get-FrameworkSortKey { - param([string] $Name) - $l = $Name.ToLowerInvariant() - if ($l -match 'vclibs\.140\.00\.uwpdesktop') { return 10 } - if ($l -match 'vclibs') { return 20 } - if ($l -match 'net\.native\.framework') { return 30 } - if ($l -match 'net\.native\.runtime') { return 40 } - if ($l -match 'ui\.xaml') { return 50 } - if ($l -match 'windowsappruntime') { return 60 } - return 80 -} - function Select-NewerPackage { param($Current, [Parameter(Mandatory)] $Candidate) if (-not $Current) { return $Candidate } $curVer = if ($Current.Identity.Version) { $Current.Identity.Version } else { [version]'0.0' } $candVer = if ($Candidate.Identity.Version) { $Candidate.Identity.Version } else { [version]'0.0' } - $keep, $drop = if ($candVer -gt $curVer) { $Candidate, $Current } else { $Current, $Candidate } + $keep, $drop = if ($candVer -gt $curVer -or ($candVer -eq $curVer -and $Candidate.Rank -gt $Current.Rank)) { $Candidate, $Current } else { $Current, $Candidate } Write-Log "Multiple packages for $($keep.Identity.Name); using $($keep.File.Name), ignoring $($drop.File.Name)" 'WARN' return $keep } @@ -606,6 +729,58 @@ function Test-PackageFile { return $null } +function Save-PackageDownload { + param([string] $Url, [string] $Path, [int] $TimeoutSeconds = 1800) + # Cancellation aborts the underlying request before the client and temporary file are released. + $client = New-Object Net.WebClient + try { + $task = $client.DownloadFileTaskAsync([uri]$Url, $Path) + if (-not $task.Wait([TimeSpan]::FromSeconds($TimeoutSeconds))) { + $client.CancelAsync() + try { $task.GetAwaiter().GetResult() } catch { } + throw "Download exceeded the $TimeoutSeconds second time limit" + } + $task.GetAwaiter().GetResult() + } + finally { $client.CancelAsync(); $client.Dispose() } +} + +function Test-OfflineManifest { + param([string] $Directory, [string] $OsArch, [int] $Build, [string[]] $Families) + $manifestPath = Join-Path $Directory 'store-packages.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { return } + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($manifest.SchemaVersion -ne 1) { throw 'Unsupported offline package manifest schema' } + if ($manifest.OsArch -ne $OsArch) { throw "Offline packages target $($manifest.OsArch), not $OsArch" } + foreach ($family in $Families) { + if (@($manifest.RequestedFamilies) -notcontains $family) { throw "Offline manifest does not include requested app $family" } + if (@($manifest.MissingFamilies) -contains $family) { throw "Offline package manifest records a missing requested app: $family" } + } + $seen = @{} + $files = foreach ($entry in $manifest.Files) { + $name = [string]$entry.Name + if (-not $name -or $name -match '[\\/:*?"<>|]' -or $name -eq '.' -or $name -eq '..' -or + [IO.Path]::GetFileName($name) -ne $name -or $seen.ContainsKey($name) -or + [IO.Path]::GetExtension($name) -notin @('.appx', '.msix', '.appxbundle', '.msixbundle')) { + throw "Invalid or duplicate filename in offline manifest: $name" + } + $seen[$name] = $true + $file = Get-Item -LiteralPath (Join-Path $Directory $name) -ErrorAction Stop + if ($file.PSIsContainer -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Offline package must be a regular file: $name" } + if ($entry.Sha256 -notmatch '^[A-Fa-f0-9]{64}$' -or $file.Length -ne $entry.Size -or + (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash -ne $entry.Sha256) { throw "Offline package hash or size mismatch: $name" } + # A user-editable manifest detects corruption. Microsoft signatures establish package authenticity. + $sig = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($sig.Status -ne 'Valid' -or -not $sig.SignerCertificate -or $sig.SignerCertificate.Subject -notmatch '^CN=Microsoft Corporation,') { + throw "Offline package does not have a valid Microsoft signature: $name" + } + $file + } + if (@($files).Count -eq 0) { throw 'Offline package manifest has no files' } + Write-Log "Verified offline manifest: $(@($files).Count) package(s), SHA256 and Microsoft signatures" + $files +} + function Save-OnlinePackages { # Resolves, selects and downloads packages for the given families. Returns the downloaded FileInfo objects. param( @@ -619,6 +794,7 @@ function Save-OnlinePackages { $cookie = Get-Fe3Cookie $offered = @{} + $lookupFailed = @{} foreach ($fam in $Families) { try { $cat = Get-WuCategoryId -PackageFamilyName $fam @@ -628,6 +804,7 @@ function Save-OnlinePackages { } catch { if ($fam -eq $script:StoreFamily) { throw } + $lookupFailed[$fam] = $true Write-Log "Online lookup failed for ${fam}: $($_.Exception.Message)" 'WARN' } } @@ -658,6 +835,11 @@ function Save-OnlinePackages { if (-not ($selected | Where-Object { $_.Identity.Family -eq $script:StoreFamily })) { throw 'The Store service did not offer a usable Microsoft.WindowsStore package' } + $missing = @($Families | Where-Object { $family = $_; $lookupFailed.ContainsKey($family) -or -not ($selected | Where-Object { $_.Identity.Family -eq $family }) }) + foreach ($family in $missing) { + $script:FailCount++ + Write-Log "Requested app unavailable: $family. This run is incomplete." 'WARN' + } $totalMb = [math]::Round(($selected | Measure-Object -Property Size -Sum).Sum / 1MB, 1) Write-Log "Selected $($selected.Count) package(s), $totalMb MB, download folder: $Destination" @@ -666,7 +848,11 @@ function Save-OnlinePackages { $null = New-Item -ItemType Directory -Path $Destination -Force } - foreach ($p in $selected) { + # Remove stale metadata first so an interrupted replacement cannot look complete. + $manifestPath = Join-Path $Destination 'store-packages.json' + if (Test-Path -LiteralPath $manifestPath) { Remove-Item -LiteralPath $manifestPath -Force } + $downloaded = @(foreach ($p in $selected) { + if (($p.Moniker + $p.Extension) -match '[\\/:*?"<>|]' -or $p.Moniker -match '^\.') { throw 'Service returned an unsafe package filename' } $target = Join-Path $Destination ($p.Moniker + $p.Extension) if ((Test-Path -LiteralPath $target -PathType Leaf) -and -not (Test-PackageFile -Path $target -Package $p)) { Write-Log "Already downloaded (verified): $([IO.Path]::GetFileName($target))" @@ -674,28 +860,43 @@ function Save-OnlinePackages { continue } - $part = "$target.part" + $part = Join-Path $Destination (([guid]::NewGuid().ToString('N')) + '.part' + $p.Extension) + try { $null = Invoke-WithRetry -What "Download of $($p.Moniker)" -Action { # URLs are short-lived; request a fresh one per attempt. $url = Get-Fe3FileUrl -Package $p -DeviceAttributes $attrs Write-Log ('Downloading {0} ({1:N1} MB)' -f [IO.Path]::GetFileName($target), ($p.Size / 1MB)) - $wc = New-Object Net.WebClient - try { $wc.DownloadFile($url, $part) } finally { $wc.Dispose() } + Save-PackageDownload -Url $url -Path $part -TimeoutSeconds $DownloadTimeoutSeconds $problem = Test-PackageFile -Path $part -Package $p -SkipSignature if ($problem) { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue throw "verification failed: $problem" } - Move-Item -LiteralPath $part -Destination $target -Force - $problem = Test-PackageFile -Path $target -Package $p + $problem = Test-PackageFile -Path $part -Package $p if ($problem) { - Remove-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue throw "verification failed: $problem" } + Move-Item -LiteralPath $part -Destination $target -Force } + } finally { Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue } Write-Log "Downloaded + verified (hash, Microsoft signature): $([IO.Path]::GetFileName($target))" Get-Item -LiteralPath $target + }) + $manifest = [ordered]@{ + SchemaVersion = 1 + CreatedUtc = [DateTime]::UtcNow.ToString('o') + OsArch = $OsArch + Build = $Build + RequestedFamilies = @($Families) + MissingFamilies = @($missing) + Files = @($downloaded | ForEach-Object { [ordered]@{ Name = $_.Name; Size = $_.Length; Sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash } }) } + $manifestPart = "$manifestPath.$([guid]::NewGuid().ToString('N')).tmp" + try { + $manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPart -Encoding UTF8 + Move-Item -LiteralPath $manifestPart -Destination $manifestPath -Force + } finally { Remove-Item -LiteralPath $manifestPart -Force -ErrorAction SilentlyContinue } + $downloaded } # endregion @@ -728,7 +929,10 @@ try { return (Invoke-Relaunch) } - $build = [int](Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').CurrentBuildNumber + $osVersionInfo = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + $build = [int]$osVersionInfo.CurrentBuildNumber + $revision = 0 + if ($osVersionInfo.PSObject.Properties['UBR']) { $revision = [int]$osVersionInfo.UBR } if (-not $DownloadOnly -and $build -lt 19041) { Write-Host 'ERROR: Requires Windows build 19041 or later.' return 2 @@ -753,7 +957,7 @@ try { # Log dir: requested/default location, falling back to %TEMP% (e.g. packages on read-only media) $tempLogDir = Join-Path ([IO.Path]::GetTempPath()) 'StoreInstall-Logs' - $logName = 'StoreInstall-{0:yyyyMMdd-HHmmss}.log' -f (Get-Date) + $logName = 'StoreInstall-{0:yyyyMMdd-HHmmss}-{1}.log' -f (Get-Date), ([guid]::NewGuid().ToString('N')) $logCandidates = @( $(if ($LogDir) { [IO.Path]::GetFullPath($LogDir) } elseif ($WorkDir) { Join-Path $WorkDir 'Logs' } else { $tempLogDir }), $tempLogDir @@ -780,7 +984,16 @@ try { Where-Object { $extensions -contains $_.Extension.ToLowerInvariant() } | Sort-Object Name }) - $hasLocalStore = [bool]($localPkgs | Where-Object { (Get-PackageIdentity -FileName $_.Name).Name -eq 'Microsoft.WindowsStore' }) + $hasLocalStore = $false + if ($Source -eq 'Auto' -and -not $DownloadOnly) { + foreach ($localPackage in $localPkgs) { + try { + $localIdentity = Get-PackageMetadata -Path $localPackage.FullName -OsArch $osArch -Build $build -Revision $revision + if ($localIdentity.Supported -and $localIdentity.Name -eq 'Microsoft.WindowsStore' -and (Get-ArchRank -Arch $localIdentity.Arch -OsArch $osArch -Build $build) -gt 0) { $hasLocalStore = $true; break } + } + catch { Write-Log "Cannot inspect local candidate '$($localPackage.Name)': $($_.Exception.Message)" 'WARN' } + } + } $online = $DownloadOnly -or $Source -eq 'Online' -or ($Source -eq 'Auto' -and -not $hasLocalStore) Write-Log '==== Microsoft Store unattended install starting ====' @@ -796,18 +1009,24 @@ try { $script:LicenseDirs = @($WorkDir | Where-Object { $_ }) + $families = @($script:StoreFamily) + $script:OptionalFamilies + if (-not $SkipWinget) { $families += $script:WingetFamily } + if ($online) { if ($DownloadDir) { $pkgDir = [IO.Path]::GetFullPath($DownloadDir) } else { - $pkgDir = Join-Path ([IO.Path]::GetTempPath()) 'StoreInstall-Packages' - if (-not $KeepDownloads -and -not $DownloadOnly) { $script:CleanupDir = $pkgDir } + $runToken = [guid]::NewGuid().ToString('N') + $pkgDir = Join-Path ([IO.Path]::GetTempPath()) ('StoreInstall-Packages-' + $runToken) + $null = New-Item -ItemType Directory -Path $pkgDir + Set-Content -LiteralPath (Join-Path $pkgDir '.store-ltsc-owner') -Value $runToken -Encoding ASCII + if (-not $KeepDownloads -and -not $DownloadOnly) { + $script:CleanupDir = $pkgDir + $script:CleanupToken = $runToken + } } - $families = @($script:StoreFamily) + $script:OptionalFamilies - if (-not $SkipWinget) { $families += $script:WingetFamily } - Write-Log '---- Downloading packages from Microsoft ----' try { $all = @(Save-OnlinePackages -Destination $pkgDir -Families $families -OsArch $osArch -Build $build) @@ -821,6 +1040,7 @@ try { if ($DownloadOnly) { Write-Log "Downloaded $($all.Count) package(s) to $pkgDir" Write-Log "Install later with: -Source Local -WorkDir `"$pkgDir`"" + if ($script:FailCount -gt 0) { return (Complete-Run 5 'download set incomplete') } return (Complete-Run 0 'download only') } } @@ -830,7 +1050,12 @@ try { Write-Log 'No WorkDir for local packages (use -WorkDir).' 'ERROR' return (Complete-Run 4) } - Get-ChildItem -LiteralPath $WorkDir -File | Unblock-File -ErrorAction SilentlyContinue -ErrorVariable unblockErr + if (Test-Path -LiteralPath (Join-Path $WorkDir 'store-packages.json') -PathType Leaf) { + $all = @(Test-OfflineManifest -Directory $WorkDir -OsArch $osArch -Build $build -Families $families) + } + # Only unblock packages selected for this installation, not unrelated files in WorkDir. + $unblockErr = @() + $all | Unblock-File -ErrorAction SilentlyContinue -ErrorVariable unblockErr if ($unblockErr.Count -gt 0) { foreach ($e in $unblockErr) { Write-Log "Unblock-File warning: $($e.Exception.Message)" 'WARN' } } @@ -853,13 +1078,16 @@ try { $winget = $null foreach ($f in $all) { - $id = Get-PackageIdentity -FileName $f.Name - $pkg = [pscustomobject]@{ File = $f; Identity = $id } - - if (Test-IsFrameworkPackage -Name $id.Name) { + try { $id = Get-PackageMetadata -Path $f.FullName -OsArch $osArch -Build $build -Revision $revision } + catch { Write-Log "Cannot read package '$($f.Name)': $($_.Exception.Message)" 'ERROR'; return (Complete-Run 4) } + if (-not $id.Supported) { Write-Log "Skipping package requiring a newer Windows version: $($f.Name)" 'WARN'; continue } + $rank = Get-ArchRank -Arch $id.Arch -OsArch $osArch -Build $build + if ($rank -eq 0) { Write-Log "Skipping package (arch '$($id.Arch)' not usable on $osArch build $build): $($f.Name)"; continue } + $pkg = [pscustomobject]@{ File = $f; Identity = $id; Rank = $rank } + + if ($id.IsFramework -or (Test-IsFrameworkPackage -Name $id.Name)) { $rank = Get-ArchRank -Arch $id.Arch -OsArch $osArch -Build $build if ($rank -gt 0) { - $pkg | Add-Member -NotePropertyName Rank -NotePropertyValue $rank $frameworks.Add($pkg) } else { @@ -882,6 +1110,19 @@ try { return (Complete-Run 4) } + if (-not $online) { + foreach ($component in @( + @{ Name = 'Store Purchase App'; Package = $purchase; Skip = $false }, + @{ Name = 'Xbox Identity Provider'; Package = $xboxId; Skip = $false }, + @{ Name = 'App Installer'; Package = $winget; Skip = [bool]$SkipWinget } + )) { + if (-not $component.Package -and -not $component.Skip) { + $script:FailCount++ + Write-Log "Requested app missing from local packages: $($component.Name). This run is incomplete." 'WARN' + } + } + } + Write-Log "Store : $($store.File.Name)" Write-Log ('PurchaseApp : {0}' -f $(if ($purchase) { $purchase.File.Name } else { '(not present)' })) Write-Log ('XboxIdentityProvider: {0}' -f $(if ($xboxId) { $xboxId.File.Name } else { '(not present)' })) @@ -893,11 +1134,17 @@ try { Write-Log ('DesktopAppInstaller : {0}' -f $(if ($winget) { $winget.File.Name } else { '(not present)' })) } - $sortedFw = @($frameworks | Sort-Object -Property @( - @{ Expression = { Get-FrameworkSortKey -Name $_.Identity.Name } }, - @{ Expression = { $_.Rank }; Descending = $true }, - @{ Expression = { $_.File.Name } } - )) + # Keep the newest package per identity and architecture before dependency validation. + $uniqueFw = @{} + foreach ($fw in $frameworks) { + $key = '{0}|{1}|{2}' -f $fw.Identity.Name, $fw.Identity.Publisher, $fw.Identity.Arch + $uniqueFw[$key] = Select-NewerPackage $uniqueFw[$key] $fw + } + try { + $apps = @(@($store, $purchase, $xboxId, $winget) | Where-Object { $null -ne $_ }) + $sortedFw = @(Get-DependencyInstallOrder -Frameworks @($uniqueFw.Values) -Applications @($apps + @($other.ToArray()))) + } + catch { Write-Log $_.Exception.Message 'ERROR'; return (Complete-Run 4) } Write-Log '---- Installing framework dependencies ----' foreach ($fw in $sortedFw) { @@ -954,9 +1201,38 @@ if ($PauseAtEnd) { Write-Host '' $null = Read-Host "Finished with exit code $StoreLtscExitCode. Press Enter to close" } -if ($PSCommandPath) { exit $StoreLtscExitCode } +if ($OriginalScriptPath) { exit $StoreLtscExitCode } -# irm | iex: never `exit` (it would close the user's window); report via $LASTEXITCODE and tidy up. +# irm | iex: report the result without closing the caller's PowerShell window. $global:LASTEXITCODE = $StoreLtscExitCode -Remove-Variable -Name StoreLtscInvocation, StoreLtscExitCode, WorkDir, LogDir, Source, DownloadDir, DownloadOnly, - KeepDownloads, NoProvision, SkipWinget, PauseAtEnd, kv -ErrorAction SilentlyContinue +} + +# An outer param block would bind variables in the caller's scope under Invoke-Expression. +# Convert supported named arguments to a splat inside this private child scope instead. +$forward = @{} +$stringParameters = @('WorkDir', 'LogDir', 'Source', 'DownloadDir', 'DownloadTimeoutSeconds', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable') +$switchParameters = @('DownloadOnly', 'KeepDownloads', 'NoProvision', 'SkipWinget', 'PauseAtEnd', 'Verbose', 'Debug') +for ($index = 0; $index -lt $InvocationArguments.Count; $index++) { + $argument = [string]$InvocationArguments[$index] + if ($argument -notmatch '^-(?[a-zA-Z]+)(?::(?.*))?$') { throw "Expected a named parameter, received '$argument'." } + $name = $Matches.Name + $inlineValue = $Matches.Value + if ($stringParameters -contains $name) { + if ($argument.Contains(':') -and $inlineValue -ne '') { $forward[$name] = $inlineValue } + else { + if (++$index -ge $InvocationArguments.Count) { throw "Missing value for -$name." } + $forward[$name] = $InvocationArguments[$index] + } + } + elseif ($switchParameters -contains $name) { + if (-not $argument.Contains(':')) { $forward[$name] = $true } + elseif ($inlineValue -eq '' -and ($index + 1) -lt $InvocationArguments.Count -and $InvocationArguments[$index + 1] -is [bool]) { + $forward[$name] = $InvocationArguments[++$index] + } + elseif ($inlineValue -match '^\$?(true|false)$') { $forward[$name] = $Matches[1] -eq 'true' } + else { throw "Invalid switch value for -$name." } + } + else { throw "Unknown parameter -$name." } +} +Invoke-StoreLtsc @forward +} $args $PSCommandPath diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 new file mode 100644 index 0000000..a93aafd --- /dev/null +++ b/tests/Run-Tests.ps1 @@ -0,0 +1,14 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$testFiles = @(Get-ChildItem -LiteralPath $PSScriptRoot -Filter 'Test-*.ps1' -File | Sort-Object Name) +if ($testFiles.Count -eq 0) { throw 'No regression tests found.' } +$engine = (Get-Process -Id $PID).Path +foreach ($file in $testFiles) { + Write-Host "Running $($file.Name)" + & $engine -NoLogo -NoProfile -NonInteractive -File $file.FullName + if ($LASTEXITCODE -ne 0) { throw "$($file.Name) failed with exit code $LASTEXITCODE." } +} +Write-Host "Passed $($testFiles.Count) regression test scripts." diff --git a/tests/Test-Downloads.ps1 b/tests/Test-Downloads.ps1 new file mode 100644 index 0000000..b1d013e --- /dev/null +++ b/tests/Test-Downloads.ps1 @@ -0,0 +1,65 @@ +#Requires -Version 5.1 +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null; $parseErrors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '..\install.ps1'), [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw ($parseErrors | Out-String) } +foreach ($name in @('Test-OfflineManifest', 'Save-OnlinePackages', 'Test-PackageFile', 'Save-PackageDownload')) { + $node = $ast.Find({ param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name }.GetNewClosure(), $true) + . ([scriptblock]::Create($node.Extent.Text)) +} +function Write-Log { param($Message, $Level) } +function Get-AuthenticodeSignature { param($LiteralPath) [pscustomobject]@{Status='Valid'; SignerCertificate=[pscustomobject]@{Subject='CN=Microsoft Corporation, O=Microsoft Corporation'}} } +function Assert-Throws { param([scriptblock]$Action, [string]$Pattern) try { & $Action; throw 'Expected rejection was absent' } catch { if ($_.Exception.Message -notmatch $Pattern) { throw } } } +$folder = Join-Path ([IO.Path]::GetTempPath()) ('StoreLtsc-test-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $folder +try { + $package = Join-Path $folder 'sample.appx' + Set-Content -LiteralPath $package -Value 'test bytes' + $manifest = @{SchemaVersion=1; OsArch='x64'; Build=19041; RequestedFamilies=@('Store'); MissingFamilies=@(); Files=@(@{Name='sample.appx'; Size=(Get-Item $package).Length; Sha256=(Get-FileHash $package).Hash})} + $manifestPath = Join-Path $folder 'store-packages.json' + function Save-Manifest { $manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 } + Save-Manifest + $result = @(Test-OfflineManifest -Directory $folder -OsArch x64 -Build 19041 -Families Store) + if ($result.Count -ne 1) { throw 'Expected one verified file' } + Assert-Throws { Test-OfflineManifest -Directory $folder -OsArch arm64 -Build 19041 -Families Store } 'target' + Assert-Throws { Test-OfflineManifest -Directory $folder -OsArch x64 -Build 19041 -Families Winget } 'does not include' + $manifest.MissingFamilies = @('Winget'); Save-Manifest + $null = Test-OfflineManifest -Directory $folder -OsArch x64 -Build 19041 -Families Store + $manifest.MissingFamilies = @('Store'); Save-Manifest + Assert-Throws { Test-OfflineManifest -Directory $folder -OsArch x64 -Build 19041 -Families Store } 'missing requested' + $manifest.MissingFamilies = @(); $manifest.Files[0].Name = '..\sample.appx'; Save-Manifest + Assert-Throws { Test-OfflineManifest -Directory $folder -OsArch x64 -Build 19041 -Families Store } 'Invalid' + $manifest.Files[0].Name = 'sample.appx'; Save-Manifest + Add-Content -LiteralPath $package -Value 'changed' + Assert-Throws { Test-OfflineManifest -Directory $folder -OsArch x64 -Build 19041 -Families Store } 'mismatch' + Write-Host 'PASS: offline manifest verifies files and rejects incompatible, missing, unsafe, or corrupt inputs.' + # A loopback server accepts the request but never sends a response. + $listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, 0) + $listener.Start() + try { + $port = $listener.LocalEndpoint.Port + $watch = [Diagnostics.Stopwatch]::StartNew() + Assert-Throws { Save-PackageDownload -Url "http://127.0.0.1:$port/stall" -Path (Join-Path $folder 'stall.part') -TimeoutSeconds 1 } 'time limit' + if ($watch.Elapsed.TotalSeconds -gt 10) { throw 'Download cancellation took too long' } + } finally { $listener.Stop() } + Write-Host 'PASS: stalled download cancels within its deadline.' + function Initialize-Network { } + function Get-Fe3DeviceAttributes { param($OsArch, $Build) 'test' } + function Get-Fe3Cookie { 'test' } + function Get-WuCategoryId { param($PackageFamilyName) if ($PackageFamilyName -eq 'Winget') { throw 'mock lookup failure' }; 'store-category' } + function Get-ArchRank { param($Arch, $OsArch, $Build) 1 } + function Get-Fe3PackageList { + param($CategoryId, $Cookie, $DeviceAttributes) + [pscustomobject]@{Moniker='sample'; Extension='.appx'; Size=(Get-Item $package).Length; IsFramework=$false; PackageRank=1; Identity=[pscustomobject]@{Name='Store'; Family='Store'; Arch='x64'; Version=[version]'1.0'}; Sha256=[Convert]::ToBase64String([byte[]](1..32)); Sha1='unused'} + } + function Test-PackageFile { param($Path, $Package, [switch]$SkipSignature) return $null } + $script:StoreFamily = 'Store'; $script:FailCount = 0 + $script:OnlineFrameworkNames = New-Object 'System.Collections.Generic.HashSet[string]' + $DownloadTimeoutSeconds = 2 + $result = @(Save-OnlinePackages -Destination $folder -Families @('Store', 'Winget') -OsArch x64 -Build 19041) + if ($script:FailCount -ne 1 -or $result.Count -ne 1) { throw 'Missing requested app did not record partial failure' } + $saved = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if (@($saved.MissingFamilies) -notcontains 'Winget' -or @($saved.Files).Count -ne 1) { throw 'Incomplete download was not recorded in manifest' } + Write-Host 'PASS: missing requested companion records partial failure and incomplete manifest.' +} finally { Remove-Item -LiteralPath $folder -Recurse -Force } diff --git a/tests/Test-InstallerFlow.ps1 b/tests/Test-InstallerFlow.ps1 new file mode 100644 index 0000000..7ec3fec --- /dev/null +++ b/tests/Test-InstallerFlow.ps1 @@ -0,0 +1,65 @@ +#Requires -Version 5.1 +$ErrorActionPreference = 'Stop' +# Deployment orchestration runs in Windows PowerShell in production, including PS7 entry points. +if ($PSVersionTable.PSEdition -eq 'Core') { + & (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -NoProfile -NonInteractive -File $PSCommandPath + exit $LASTEXITCODE +} +$source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\install.ps1') -Raw +$tokens = $null; $errors = $null +$ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +$command = $ast.Find({ param($n) $n -is [Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'New-Module' }, $true) +$body = @($command.CommandElements | Where-Object { $_ -is [Management.Automation.Language.ScriptBlockExpressionAst] })[0].ScriptBlock +$mainTry = @($body.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] })[-1] +$mocks = @' +function Test-IsElevated { $true } +function Test-IsSystemAccount { $false } +function Get-OsArch { 'x64' } +function Get-ItemProperty { param($Path) [pscustomobject]@{ CurrentBuildNumber = '26100'; UBR = 0 } } +function Unblock-File { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$InputObject) process {} } +function Install-OnePackage { param($Package, [switch]$IsFramework) $global:ReviewInstallCalls++; $true } +function Get-AppxPackage { param($Name, [switch]$AllUsers, $ErrorAction) } +function Get-AppxProvisionedPackage { param([switch]$Online, $ErrorAction) } +'@ +$bodyText = $body.Extent.Text +$offset = $mainTry.Extent.StartOffset - $body.Extent.StartOffset +$bodyText = $bodyText.Insert($offset, $mocks + "`n") +$bodyText = $bodyText.Substring(1, $bodyText.Length - 2) +$moduleScript = [scriptblock]::Create($bodyText) +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('store-flow-test-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $testRoot +function New-TestPackage($Name, $Arch = 'x64', $Version = '1.0.0.0', $Dependencies = '') { + $path = Join-Path $testRoot ($Name + '_' + $Version + '_' + $Arch + '__8wekyb3d8bbwe.appx') + $archive = [IO.Compression.ZipFile]::Open($path, [IO.Compression.ZipArchiveMode]::Create) + try { + $writer = New-Object IO.StreamWriter($archive.CreateEntry('AppxManifest.xml').Open()) + try { $writer.Write('' + $Dependencies + '') } finally { $writer.Dispose() } + } finally { $archive.Dispose() } + $path +} +function Invoke-Flow { + $global:ReviewInstallCalls = 0 + $invocation = @{ Params = @{ WorkDir=$testRoot; LogDir=(Join-Path $testRoot 'Logs'); Source='Local'; DownloadDir=''; DownloadTimeoutSeconds=1; DownloadOnly=$false; KeepDownloads=$false; NoProvision=$true; SkipWinget=$false }; Bound=@{}; ScriptPath=$null; ScriptText='' } + $result = @(New-Module -Name StoreFlowTest -ArgumentList $invocation -ReturnResult -ScriptBlock $moduleScript) + return $result[-1] +} +try { + $null = New-TestPackage Microsoft.WindowsStore + $code = Invoke-Flow + if ($code -ne 5 -or $global:ReviewInstallCalls -ne 1) { throw "Store-only local flow expected code5 and one install, got $code / $global:ReviewInstallCalls" } + foreach ($name in 'Microsoft.StorePurchaseApp','Microsoft.XboxIdentityProvider','Microsoft.DesktopAppInstaller') { $null = New-TestPackage $name } + $null = New-TestPackage Microsoft.WindowsStore arm64 9.0.0.0 + $code = Invoke-Flow + if ($code -ne 0 -or $global:ReviewInstallCalls -ne 4) { throw "Complete mixed-architecture flow expected code0/four installs, got $code / $global:ReviewInstallCalls" } + $null = New-TestPackage Microsoft.WindowsStore x64 2.0.0.0 '' + $code = Invoke-Flow + if ($code -ne 4 -or $global:ReviewInstallCalls -ne 0) { throw "Missing dependency should reject before any install, got $code / $global:ReviewInstallCalls" } + Write-Host 'PASS: actual installer orchestration reports partial sets, selects usable apps, and rejects missing dependencies before installation.' +} finally { + $resolved = [IO.Path]::GetFullPath($testRoot) + if ($resolved.StartsWith([IO.Path]::GetFullPath([IO.Path]::GetTempPath()), [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $resolved -Leaf) -like 'store-flow-test-*') { Remove-Item -LiteralPath $resolved -Recurse -Force } + Remove-Variable -Name ReviewInstallCalls -Scope Global -ErrorAction SilentlyContinue +} diff --git a/tests/Test-PackageSelection.ps1 b/tests/Test-PackageSelection.ps1 new file mode 100644 index 0000000..98cb5d1 --- /dev/null +++ b/tests/Test-PackageSelection.ps1 @@ -0,0 +1,84 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$source = Join-Path $PSScriptRoot '..\install.ps1' +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($source, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +$names = @('Get-PackageIdentity', 'Get-PublisherId', 'Read-PackageManifest', 'Get-PackageMetadata', 'Get-ArchRank', 'Get-DependencyInstallOrder', 'Select-NewerPackage') +foreach ($node in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { + if ($node.Name -in $names) { Invoke-Expression $node.Extent.Text } +} +function Write-Log { param($Message, $Level) } +function Get-AppxPackage { param($Name, $ErrorAction) return @() } +function Get-AppxProvisionedPackage { param([switch]$Online, $ErrorAction) return $script:ProvisionedFixtures } +function Assert { param($Condition, $Message) if (-not $Condition) { throw $Message } } +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$temp = Join-Path ([IO.Path]::GetTempPath()) ('store-selection-test-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $temp +function New-Fixture { + param($Name, $Arch, $Version = '1.0.0.0', $Dependencies = '', [switch]$Framework) + $path = Join-Path $temp ([guid]::NewGuid().ToString('N') + '.appx') + $archive = [IO.Compression.ZipFile]::Open($path, [IO.Compression.ZipArchiveMode]::Create) + try { + $entry = $archive.CreateEntry('AppxManifest.xml') + $writer = New-Object IO.StreamWriter($entry.Open()) + try { $writer.Write('' + $Framework.IsPresent.ToString().ToLowerInvariant() + '' + $Dependencies + '') } finally { $writer.Dispose() } + } finally { $archive.Dispose() } + return [pscustomobject]@{ File = Get-Item $path; Identity = Get-PackageMetadata $path 'x64' 26100; Rank = Get-ArchRank $Arch 'x64' 26100 } +} +try { + $script:ProvisionedFixtures = @() + $script:DoProvision = $false + Assert ((Get-PublisherId 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US') -eq '8wekyb3d8bbwe') 'Publisher ID must match the known Microsoft publisher.' + $native = New-Fixture 'Microsoft.WindowsStore' 'x64' + $foreign = New-Fixture 'Microsoft.WindowsStore' 'arm64' '2.0.0.0' + Assert ($native.Identity.Name -eq 'Microsoft.WindowsStore') 'Renamed package must use manifest identity.' + $selected = $null + foreach ($pkg in @($native, $foreign)) { if ($pkg.Rank -gt 0) { $selected = Select-NewerPackage $selected $pkg } } + Assert ($selected.Identity.Arch -eq 'x64') 'Incompatible newer app must not win.' + $emulated = New-Fixture 'Microsoft.WindowsStore' 'x86' + Assert ((Select-NewerPackage $emulated $native).Identity.Arch -eq 'x64') 'Equal-version app must prefer native architecture.' + $base = New-Fixture 'Test.Base' 'x64' -Framework + $dep = '' + $top = New-Fixture 'Test.Top' 'x64' -Framework -Dependencies $dep + $app = New-Fixture 'Test.App' 'x64' -Dependencies '' + $order = @(Get-DependencyInstallOrder @($top, $base) @($app)) + Assert ($order[0].Identity.Name -eq 'Test.Base' -and $order[1].Identity.Name -eq 'Test.Top') 'Frameworks must install in dependency order.' + $failed = $false + try { $null = Get-DependencyInstallOrder @($top) @($app) } catch { $failed = $_.Exception.Message -like '*Missing dependency*Test.Base*' } + Assert $failed 'Missing dependency must fail before installation.' + $base.Identity.Dependencies = @([pscustomobject]@{ Name='Test.Top'; Publisher='CN=Test'; MinVersion=[version]'1.0.0.0'; Arch='x64' }) + $failed = $false + try { $null = Get-DependencyInstallOrder @($top, $base) @() } catch { $failed = $_.Exception.Message -like '*cycle*' } + Assert $failed 'Framework dependency cycle must fail.' + $newOS = New-Fixture 'Test.NewOS' 'x64' -Dependencies '' + Assert (-not $newOS.Identity.Supported) 'Unsupported minimum OS version must remain a filterable candidate.' + Assert $native.Identity.Supported 'Compatible candidate in a mixed folder must remain usable.' + $revisionPackage = New-Fixture 'Test.Revision' 'x64' -Dependencies '' + Assert (-not $revisionPackage.Identity.Supported) 'A package requiring a later update revision must remain unsupported.' + $revisionMetadata = Get-PackageMetadata $revisionPackage.File.FullName 'x64' 26100 -Revision 1000 + Assert $revisionMetadata.Supported 'Installed update revision must satisfy the exact minimum version.' + $olderRevisionMetadata = Get-PackageMetadata $revisionPackage.File.FullName 'x64' 26100 -Revision 999 + Assert (-not $olderRevisionMetadata.Supported) 'An older update revision must not satisfy the minimum version.' + $script:DoProvision = $true + $publisherId = Get-PublisherId 'CN=Test' + $script:ProvisionedFixtures = @([pscustomobject]@{ PackageName = "Test.Top_1.0.0.0_x64__$publisherId" }) + $provisionedOrder = @(Get-DependencyInstallOrder @() @($app)) + Assert ($provisionedOrder.Count -eq 0) 'Provisioned framework must satisfy dependencies without per-user registration.' + $script:DoProvision = $false + # A bundle with an incompatible payload listed first must choose its native payload. + $bundlePath = Join-Path $temp 'renamed.msixbundle' + $archive = [IO.Compression.ZipFile]::Open($bundlePath, [IO.Compression.ZipArchiveMode]::Create) + try { + $entry = $archive.CreateEntry('AppxMetadata/AppxBundleManifest.xml') + $writer = New-Object IO.StreamWriter($entry.Open()) + try { $writer.Write('') } finally { $writer.Dispose() } + $null = [IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $native.File.FullName, 'native.appx') + $null = [IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $foreign.File.FullName, 'arm.appx') + } finally { $archive.Dispose() } + $bundle = Get-PackageMetadata $bundlePath 'x64' 26100 + Assert ($bundle.Arch -eq 'x64' -and $bundle.Name -eq 'Microsoft.WindowsStore') 'Bundle must select compatible native payload.' + Write-Output 'Package selection tests passed.' +} +finally { Remove-Item -LiteralPath $temp -Recurse -Force } diff --git a/tests/Test-ScopeAndCleanup.ps1 b/tests/Test-ScopeAndCleanup.ps1 new file mode 100644 index 0000000..1dbb7e7 --- /dev/null +++ b/tests/Test-ScopeAndCleanup.ps1 @@ -0,0 +1,80 @@ +#Requires -Version 5.1 +$ErrorActionPreference = 'Stop' +$installerText = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\install.ps1') -Raw +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseInput($installerText, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw ($parseErrors | Out-String) } +function Assert-True($Value, $Message) { if (-not $Value) { throw $Message } } + +# This combination exits before OS, elevation, logging, networking, or package operations. +& { + $WorkDir = 'original work directory'; $Source = 'original source'; $kv = 'original loop value' + $DownloadOnly = 'original switch'; $StoreLtscInvocation = 'original invocation' + & ([scriptblock]::Create($installerText)) -DownloadOnly -Source Local -SkipWinget:$false -DownloadTimeoutSeconds 17 + Assert-True ($LASTEXITCODE -eq 7) 'Scriptblock options did not reach the preflight guard.' + Assert-True ($WorkDir -eq 'original work directory' -and $Source -eq 'original source' -and $kv -eq 'original loop value') 'Caller variables changed.' + Assert-True ($DownloadOnly -eq 'original switch' -and $StoreLtscInvocation -eq 'original invocation') 'Caller parameter or wrapper variables changed.' + Assert-True (-not (Get-Command Invoke-StoreLtsc -ErrorAction SilentlyContinue)) 'Entry function leaked into caller scope.' + $splat = @{ DownloadOnly = $true; Source = 'Local'; WorkDir = ''; SkipWinget = $false } + & ([scriptblock]::Create($installerText)) @splat + Assert-True ($LASTEXITCODE -eq 7) 'Hashtable forwarding lost an empty string or boolean argument.' +} + +# Substitute only the module invocation with a harmless result to exercise the actual iex wrapper. +$moduleCommand = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'New-Module' }, $true) | Select-Object -First 1 +$safeSource = $installerText.Remove($moduleCommand.Extent.StartOffset, $moduleCommand.Extent.EndOffset - $moduleCommand.Extent.StartOffset).Insert($moduleCommand.Extent.StartOffset, '5') +& { + $WorkDir = 'retained'; $Source = 'retained'; $kv = 'retained' + Invoke-Expression $safeSource + Assert-True ($LASTEXITCODE -eq 5) 'Invoke-Expression did not publish the exit code.' + Assert-True ($WorkDir -eq 'retained' -and $Source -eq 'retained' -and $kv -eq 'retained') 'Invoke-Expression changed caller variables.' +} + +# Load the actual functions without running the installer. +foreach ($name in @('Complete-Run', 'Invoke-Relaunch')) { + $definition = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Where-Object Name -eq $name | Select-Object -First 1 + Invoke-Expression $definition.Extent.Text +} +function Write-Log { param($Message, $Level) } +$script:CleanupToken = [guid]::NewGuid().ToString('N') +$script:CleanupDir = Join-Path ([IO.Path]::GetTempPath()) ('StoreInstall-Packages-' + $script:CleanupToken) +$null = New-Item -ItemType Directory -Path $script:CleanupDir +try { + Set-Content -LiteralPath (Join-Path $script:CleanupDir '.store-ltsc-owner') -Value 'someone-else' + $null = Complete-Run 8 + Assert-True (Test-Path -LiteralPath $script:CleanupDir) 'Cleanup removed a directory owned by another run.' + Set-Content -LiteralPath (Join-Path $script:CleanupDir '.store-ltsc-owner') -Value $script:CleanupToken + $null = Complete-Run 0 + Assert-True (-not (Test-Path -LiteralPath $script:CleanupDir)) 'Cleanup did not remove the owned temporary directory.' +} finally { + if (Test-Path -LiteralPath $script:CleanupDir) { Remove-Item -LiteralPath $script:CleanupDir -Recurse -Force } +} + +$script:ScriptText = $installerText +$script:ScriptPath = 'C:\directory with spaces\install.ps1' +$script:BoundParams = @{ Source = 'Local'; WorkDir = 'C:\offline packages' } +$WorkDir = $script:BoundParams.WorkDir +# Mock only process creation. Capture and inspect the real snapshot before its cleanup. +function Start-Process { + param($FilePath, $ArgumentList, $Verb, [switch]$Wait, [switch]$PassThru) + $bootstrap = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String(($ArgumentList -split ' ')[-1])) + Assert-True ($bootstrap -match "& '([^']+install\.ps1)' @parameters") 'Missing relaunch script path.' + $snapshotPath = $Matches[1] + Assert-True ((Get-Content -LiteralPath $snapshotPath -Raw) -ceq $installerText) 'Relaunch changed the executing revision.' + Assert-True ($bootstrap -match "FromBase64String\('([^']+)'\)") 'Missing serialized parameters.' + $parameters = [Management.Automation.PSSerializer]::Deserialize([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($Matches[1]))) + Assert-True ($parameters.WorkDir -eq 'C:\offline packages') 'Relaunch lost a path containing spaces.' + $script:CapturedSnapshot = $snapshotPath + [pscustomobject]@{ ExitCode = 5 } +} +$result = Invoke-Relaunch -Elevate +Assert-True ($result -eq 5) 'Relaunch lost the child exit code.' +Assert-True (-not (Test-Path -LiteralPath $script:CapturedSnapshot)) 'Relaunch snapshot was not cleaned up.' + +# Exercise the native process boundary too. The invalid combination exits before any +# OS, admin, network, log, or package operation in the real Windows PowerShell child. +$script:BoundParams = @{ Source = 'Local'; DownloadOnly = $true; WorkDir = ''; SkipWinget = $false; ErrorAction = 'Stop' } +$WorkDir = '' +$result = Invoke-Relaunch +Assert-True ($result -eq 7) 'Real child relaunch lost serialized parameters or its exit code.' +Write-Host 'PASS: scope isolation, argument forwarding, owned cleanup, and revision-preserving relaunch.' From bb927a82c2babf83febc480c0d64c8245ef5607a Mon Sep 17 00:00:00 2001 From: Softhe <157214175+Softhe@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:33:20 +0200 Subject: [PATCH 2/2] Invoke test engine matrix through a fixed Actions shell --- .github/workflows/tests.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 382fcb1..489b86f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,9 +13,11 @@ jobs: strategy: fail-fast: false matrix: - shell: [powershell, pwsh] + engine: [powershell, pwsh] steps: - uses: actions/checkout@v4 - name: Run isolated installer tests - shell: ${{ matrix.shell }} - run: ./tests/Run-Tests.ps1 + shell: pwsh + run: | + & '${{ matrix.engine }}.exe' -NoProfile -NonInteractive -File ./tests/Run-Tests.ps1 + exit $LASTEXITCODE