Private/Download-OldVersionInstaller.ps1

function Download-OldVersionInstaller {
<#
.SYNOPSIS
    Downloads a specific older version of a Winget package into
    <ProjectPath>\Sandbox\OldVersion\ and returns installer details.
.OUTPUTS
    PSCustomObject with:
      InstallerPath — full path to the downloaded installer file
      InstallerName — file name only (used to build the sandbox path)
      InstallerType — extension without dot (exe, msi, msix, appx)
      SilentArgs — silent install switches for this installer
#>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$AppId,

        # Omit to download the LATEST version (used when staging a dependency, where any current version
        # of e.g. the VC++ redistributable will do). The Update baseline always pins a version.
        [Parameter(Mandatory = $false)]
        [string]$Version,

        [Parameter(Mandatory = $true)]
        [string]$ProjectPath,

        # Where the installer lands. Defaults to the Update baseline folder (Sandbox\OldVersion); the
        # dependency stager points it at Sandbox\Dependencies\<id> instead. The folder is always emptied
        # first, so callers must NOT share one.
        [Parameter(Mandatory = $false)]
        [string]$DestinationDir,

        [Parameter(Mandatory = $false)]
        [string]$Architecture,

        # Variant pins from the packaged project's YAML (scope/installer-type/locale) so the baseline
        # matches the packaged variant (e.g. machine-scope MSI, not the user-scope EXE). If the pinned
        # download fails (the old version may not publish that variant), we retry unpinned with a warning.
        [Parameter(Mandatory = $false)]
        [string]$Scope,

        [Parameter(Mandatory = $false)]
        [string]$InstallerType,

        [Parameter(Mandatory = $false)]
        [string]$Locale
    )

    # Fail fast (before any download) on a pinned type that has no silent installer — otherwise the
    # sandbox would launch the app itself and hang until the 30-minute assertion timeout.
    $verLabel = if ($Version) { "v$Version" } else { 'the latest version' }

    if ($InstallerType -match '^(portable|zip|pwa)$') {
        throw "The baseline for '$AppId' $verLabel is a '$InstallerType' winget package — it has no silent installer (it would launch the app and hang the sandbox). Pick a different baseline with -SpecificVersion, or test with -Scenario InstallUninstall."
    }

    $oldVersionDir = if ($DestinationDir) { $DestinationDir } else { Join-Path $ProjectPath 'Sandbox\OldVersion' }

    # Always start fresh so we don't accidentally pick up a stale installer. -LiteralPath enumeration
    # (not a wildcard -Path) so files with [ ] etc. are removed too; a survivor is a hard error rather
    # than a silent stale baseline.
    if (Test-Path -LiteralPath $oldVersionDir) {
        Get-ChildItem -LiteralPath $oldVersionDir -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
        if (@(Get-ChildItem -LiteralPath $oldVersionDir -Force).Count -gt 0) {
            throw "Could not clear $oldVersionDir (files in use? a sandbox still running?). Close the sandbox and retry."
        }
    } else {
        New-Item -ItemType Directory -Path $oldVersionDir -Force | Out-Null
    }

    # ── Pipeline cache (PINNED versions only) ────────────────────────────────────────────────────────
    # A released version's installer is immutable, so a hash-validated cached copy is strictly stronger
    # than re-downloading it every Update test (which is what happened before). The key encodes the full
    # requested variant; the WHOLE download directory is cached — the installer manifest next to the
    # binary carries SilentSwitches/InstallerType that the resolution below needs. Unpinned 'latest'
    # (dependency staging) is deliberately NEVER cached here: it is a moving target.
    $cacheDir = $null
    if ($Version) {
        $cacheRoot = $null
        try { $cacheRoot = Get-Win32ToolkitPipelineCacheRoot } catch { $cacheRoot = $null }   # fail open: no cache
        if ($cacheRoot) {
            # Sanitize [ and ] too: they are legal in winget ids/versions but are WILDCARD character
            # classes to any -Path parameter downstream.
            $san = { param($v) if ([string]::IsNullOrWhiteSpace($v)) { 'any' } else { ($v -replace '[\\/:*?"<>|\[\]]', '_').ToLowerInvariant() } }
            $keyLeaf  = ('{0}_{1}_{2}_{3}' -f (& $san $Architecture), (& $san $Scope), (& $san $InstallerType), (& $san $Locale))
            $cacheDir = Join-Path $cacheRoot (Join-Path ($AppId -replace '[\\/:*?"<>|\[\]]', '_') (Join-Path ($Version -replace '[\\/:*?"<>|\[\]]', '_') $keyLeaf))
        }
    }

    $cacheHit = $false
    if ($cacheDir -and (Test-Win32ToolkitCachedInstaller -Path $cacheDir)) {
        # FAIL OPEN: a cache-layer copy failure must fall through to the real download, never abort it.
        try {
            Get-ChildItem -LiteralPath $cacheDir -Force -ErrorAction Stop |
                Copy-Item -Destination $oldVersionDir -Recurse -Force -ErrorAction Stop
            Write-Host "✓ Using the cached download for '$AppId' $verLabel (SHA256-validated against its manifest)." -ForegroundColor Green
            $cacheHit = $true
        }
        catch {
            Write-Verbose "Cache hit could not be materialized ($($_.Exception.Message)) — downloading."
            Get-ChildItem -LiteralPath $oldVersionDir -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
            $cacheHit = $false
        }
    }

    $usedFallback = $false
    if (-not $cacheHit) {
        Write-Verbose "Downloading $verLabel of '$AppId'..."

        $downloadArgs = @(
            'download',
            '--id',                        $AppId,
            '--download-directory',        $oldVersionDir,
            '--accept-source-agreements',
            '--accept-package-agreements'
        )
        # No -Version => let winget pick the latest (the dependency-staging case).
        if ($Version) { $downloadArgs += '--version'; $downloadArgs += $Version }

        if ($Architecture -and $Architecture -ne 'all') {
            $downloadArgs += '--architecture'
            $downloadArgs += $Architecture
        }

        # Pin the packaged variant so the baseline matches (machine vs user scope, msi vs exe, locale).
        $pinArgs = @()
        if ($Scope -and $Scope -in @('machine', 'user')) { $pinArgs += '--scope';          $pinArgs += $Scope }
        if ($InstallerType)                              { $pinArgs += '--installer-type'; $pinArgs += $InstallerType }
        if ($Locale)                                     { $pinArgs += '--locale';         $pinArgs += $Locale }

        & winget @downloadArgs @pinArgs

        if ($LASTEXITCODE -ne 0 -and $pinArgs.Count -gt 0) {
            # The old version may not publish the pinned variant (or the winget build may not support a
            # pin flag) — retry unpinned rather than failing the whole test, but say so clearly.
            Write-Warning "Pinned download (scope/installer-type/locale of the packaged variant) failed for '$AppId' $verLabel — retrying without pins. The baseline may be a DIFFERENT variant than the packaged app; check the installed baseline in the sandbox."
            Get-ChildItem -LiteralPath $oldVersionDir -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
            & winget @downloadArgs
            $usedFallback = $true
        }

        if ($LASTEXITCODE -ne 0) {
            throw "winget download exited with code $LASTEXITCODE for '$AppId' $verLabel."
        }

        # Cache the download — but NEVER a fallback result under the pinned key: the unpinned retry can
        # legitimately deliver a DIFFERENT variant, and caching it would poison every future pinned run
        # (unlike today's re-download, a cache would never self-heal when the vendor publishes the
        # pinned variant). The write is validated and rolled back on any inconsistency (fail open).
        if ($cacheDir -and -not $usedFallback) {
            try {
                # Publish ~atomically: stage into a temp sibling, validate, then swap into place — a
                # concurrent run reading the key sees either the old complete entry or the new one,
                # never a half-written directory (which the SHA check would reject as a miss anyway).
                $stage = "$cacheDir.tmp-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
                New-Item -ItemType Directory -Path $stage -Force | Out-Null
                Get-ChildItem -LiteralPath $oldVersionDir -Force -ErrorAction Stop |
                    Copy-Item -Destination $stage -Recurse -Force -ErrorAction Stop
                if (Test-Win32ToolkitCachedInstaller -Path $stage) {
                    if (Test-Path -LiteralPath $cacheDir) { Remove-Item -LiteralPath $cacheDir -Recurse -Force -ErrorAction SilentlyContinue }
                    Move-Item -LiteralPath $stage -Destination $cacheDir -Force -ErrorAction Stop
                }
                else {
                    Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
                }
            }
            catch {
                Write-Verbose "Could not cache the download (non-fatal): $($_.Exception.Message)"
                if ($stage -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue }
            }
        }
    }

    # Locate the downloaded installer file
    $installer = Get-ChildItem -Path $oldVersionDir -File |
        Where-Object { $_.Extension -in (Get-Win32ToolkitInstallerExtension) } |
        Select-Object -First 1

    # Read the manifest installer type first — it lets the "no installer found" message below name the
    # type (e.g. a 'zip' package downloads no .exe/.msi/.msix/.appx), and drives the silent-args choice.
    # InstallerType lives in *.installer.yaml — NOT in the locale manifest that winget also writes here
    # (and that often sorts first). Read it as UTF-8 like every other manifest read.
    $installerTypeName = $null
    $yamlFile = Get-WingetManifestFile -Path $oldVersionDir -Kind Installer
    if ($yamlFile) {
        $yamlContent = Get-Content -LiteralPath $yamlFile.FullName -Raw -Encoding UTF8
        if ($yamlContent -match '(?m)^\s*InstallerType:\s*(\S+)') {
            $installerTypeName = $matches[1].Trim().ToLowerInvariant()
        }
    }

    if (-not $installer) {
        $typeHint = if ($installerTypeName) { " (manifest InstallerType: '$installerTypeName')" } else { '' }
        throw "winget download completed but no installer file (.exe/.msi/.msix/.appx/.msixbundle/.appxbundle) was found in $oldVersionDir$typeHint. This baseline may be a zip/portable/store package with no silent installer — use -SpecificVersion for a different version, or test with -Scenario InstallUninstall."
    }

    $yamlInfo   = Get-YAMLInstallerInfo -FilesPath $oldVersionDir
    $yamlSilent = if ($yamlInfo) { $yamlInfo.SilentArgs } else { $null }
    $resolved = Resolve-Win32ToolkitBaselineSilentArgs `
        -InstallerTypeName $installerTypeName `
        -Extension         $installer.Extension `
        -YamlSilentArgs    $yamlSilent
    $silentArgs = $resolved.SilentArgs
    if ($resolved.Guessed) {
        Write-Warning "No silent switches found in the winget manifest for '$($installer.Name)' — guessing '/S'. If the baseline install shows UI or hangs in the sandbox, the update run will be INCONCLUSIVE; use -SpecificVersion for a version whose manifest has silent switches, or watch the sandbox."
    }

    Write-Host "✓ Downloaded : $($installer.Name)"  -ForegroundColor Green
    Write-Host " Installer type: $installerTypeName"  -ForegroundColor Gray
    Write-Host " Silent args : $silentArgs"         -ForegroundColor Gray

    return [PSCustomObject]@{
        InstallerPath = $installer.FullName
        InstallerName = $installer.Name
        # Normalized to INSTALL SEMANTICS, never the raw extension: a '.msixbundle' must report 'msix'.
        # Consumers key off this — the guest dependency script dispatches on `-eq 'msix' -or -eq 'appx'`
        # (a raw 'msixbundle' would fall through to Start-Process and hang the sandbox on the App
        # Installer GUI) and Get-Win32ToolkitBaselineInstallCommand ValidateSets exe|msi|msix|appx.
        InstallerType = Get-Win32ToolkitInstallerType -Extension $installer.Extension
        SilentArgs    = $silentArgs
    }
}