Private/Get-AzLocalSolutionUpdateDownload.ps1

function Get-AzLocalSolutionUpdateDownload {
    <#
    .SYNOPSIS
        Resolves a sideload catalog entry to a verified, locally available media
        path - serving from a shared cache, downloading the Microsoft
        CombinedSolutionBundle when needed, or returning the operator-staged OEM
        SBE source folder.
 
    .DESCRIPTION
        Private helper for the v0.8.7 on-prem sideloading automation.
 
        For a 'Solution' catalog entry:
          1. If LocalPath is set and exists, it is the pre-staged bundle - the
             SHA256 is still verified.
          2. Else, if the shared cache already holds the bundle and its
             Get-FileHash matches the catalog SHA256, it is served from cache.
          3. Else the bundle is downloaded from DownloadUri to a per-process temp
             file in the cache directory and atomically moved into place
             (concurrent-agent safe), then SHA256-verified.
 
        For an 'SBE' catalog entry: Microsoft does not host the package. The
        operator-staged SourceFolder is validated to exist; its SHA256 is
        verified only when the catalog supplies one (folder hashing is not
        attempted). No download occurs.
 
        Network + hashing calls are isolated in Invoke-AzLocalFileDownload and
        Get-AzLocalFileHashText so they can be mocked in unit tests.
 
    .PARAMETER CatalogEntry
        A catalog entry [PSCustomObject] from Get-AzLocalSideloadCatalog.
 
    .PARAMETER CacheRoot
        Directory used as the shared verified cache for downloaded Solution
        bundles. Created if missing.
 
    .OUTPUTS
        [PSCustomObject] with:
          Version the catalog version
          PackageType 'Solution' | 'SBE'
          MediaPath the verified bundle .zip (Solution) or staged folder (SBE)
          Sha256 the verified hash (when applicable)
          FromCache [bool] served from the shared cache without downloading
          Downloaded [bool] a download occurred this call
          Verified [bool] a SHA256 comparison succeeded this call
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [PSCustomObject]$CatalogEntry,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$CacheRoot
    )

    $version = [string]$CatalogEntry.Version
    $packageType = if ($CatalogEntry.PackageType) { [string]$CatalogEntry.PackageType } else { 'Solution' }

    # ---- SBE (operator-staged, no download) ----------------------------
    if ($packageType -eq 'SBE') {
        $sourceFolder = [string]$CatalogEntry.SourceFolder
        if ([string]::IsNullOrWhiteSpace($sourceFolder)) {
            throw "Sideload SBE package '$version' has no SourceFolder. Stage the OEM files and set SourceFolder in the catalog."
        }
        if (-not (Test-Path -LiteralPath $sourceFolder)) {
            throw "Sideload SBE package '$version' SourceFolder '$sourceFolder' does not exist or is not reachable from this runner/agent."
        }
        $verified = $false
        # Folder-level hashing is not performed; only a single-file SourceFolder
        # (pointing at a .zip) is hash-verified when a SHA256 is supplied.
        if (-not [string]::IsNullOrWhiteSpace([string]$CatalogEntry.Sha256) -and (Test-Path -LiteralPath $sourceFolder -PathType Leaf)) {
            $actual = Get-AzLocalFileHashText -Path $sourceFolder
            if (-not [string]::Equals($actual, [string]$CatalogEntry.Sha256, [System.StringComparison]::OrdinalIgnoreCase)) {
                throw "Sideload SBE package '$version' SHA256 mismatch. Expected $($CatalogEntry.Sha256), got $actual."
            }
            $verified = $true
        }
        return [PSCustomObject]@{
            Version     = $version
            PackageType = 'SBE'
            MediaPath   = $sourceFolder
            Sha256      = [string]$CatalogEntry.Sha256
            FromCache   = $false
            Downloaded  = $false
            Verified    = $verified
        }
    }

    # ---- Solution (cache / pre-staged / download) ----------------------
    $expectedSha = [string]$CatalogEntry.Sha256
    if ([string]::IsNullOrWhiteSpace($expectedSha)) {
        throw "Sideload Solution package '$version' has no SHA256 in the catalog; refusing to stage unverifiable media."
    }

    # 1. Pre-staged LocalPath.
    $localPath = [string]$CatalogEntry.LocalPath
    if (-not [string]::IsNullOrWhiteSpace($localPath) -and (Test-Path -LiteralPath $localPath -PathType Leaf)) {
        $actual = Get-AzLocalFileHashText -Path $localPath
        if (-not [string]::Equals($actual, $expectedSha, [System.StringComparison]::OrdinalIgnoreCase)) {
            throw "Sideload Solution package '$version' pre-staged LocalPath '$localPath' SHA256 mismatch. Expected $expectedSha, got $actual."
        }
        return [PSCustomObject]@{
            Version     = $version
            PackageType = 'Solution'
            MediaPath   = $localPath
            Sha256      = $expectedSha
            FromCache   = $false
            Downloaded  = $false
            Verified    = $true
        }
    }

    if (-not (Test-Path -LiteralPath $CacheRoot)) {
        New-Item -ItemType Directory -Path $CacheRoot -Force | Out-Null
    }

    $fileName = "CombinedSolutionBundle.$version.zip"
    if ($CatalogEntry.BuildNumber) { $fileName = "CombinedSolutionBundle.$($CatalogEntry.BuildNumber).zip" }
    $cachePath = Join-Path -Path $CacheRoot -ChildPath $fileName

    # 2. Shared cache hit.
    if (Test-Path -LiteralPath $cachePath -PathType Leaf) {
        $actual = Get-AzLocalFileHashText -Path $cachePath
        if ([string]::Equals($actual, $expectedSha, [System.StringComparison]::OrdinalIgnoreCase)) {
            return [PSCustomObject]@{
                Version     = $version
                PackageType = 'Solution'
                MediaPath   = $cachePath
                Sha256      = $expectedSha
                FromCache   = $true
                Downloaded  = $false
                Verified    = $true
            }
        }
        Write-Log -Message "Sideload cache file '$cachePath' failed SHA256 verification (expected $expectedSha, got $actual); re-downloading." -Level Warning
    }

    # 3. Download. Write to a per-process temp file then atomically move into
    # place so concurrent runners/agents never read a partial file.
    $downloadUri = [string]$CatalogEntry.DownloadUri
    if ([string]::IsNullOrWhiteSpace($downloadUri)) {
        throw "Sideload Solution package '$version' has no DownloadUri and is not present (verified) in the cache or a pre-staged LocalPath."
    }
    $tempPath = Join-Path -Path $CacheRoot -ChildPath ("{0}.{1}.partial" -f $fileName, ([guid]::NewGuid().ToString('N')))
    try {
        Write-Log -Message "Downloading sideload bundle '$version' from '$downloadUri'." -Level Info
        Invoke-AzLocalFileDownload -Uri $downloadUri -OutFile $tempPath

        $actual = Get-AzLocalFileHashText -Path $tempPath
        if (-not [string]::Equals($actual, $expectedSha, [System.StringComparison]::OrdinalIgnoreCase)) {
            throw "Sideload Solution package '$version' downloaded SHA256 mismatch. Expected $expectedSha, got $actual."
        }
        Move-Item -LiteralPath $tempPath -Destination $cachePath -Force
    }
    finally {
        if (Test-Path -LiteralPath $tempPath -PathType Leaf) {
            Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
        }
    }

    return [PSCustomObject]@{
        Version     = $version
        PackageType = 'Solution'
        MediaPath   = $cachePath
        Sha256      = $expectedSha
        FromCache   = $false
        Downloaded  = $true
        Verified    = $true
    }
}

function Invoke-AzLocalFileDownload {
    <#
    .SYNOPSIS
        Thin wrapper around Invoke-WebRequest -OutFile, isolated for mocking.
    .OUTPUTS
        [void]
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true)][string]$Uri,
        [Parameter(Mandatory = $true)][string]$OutFile
    )

    try {
        Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -ErrorAction Stop
    }
    catch {
        throw "Failed to download '$Uri': $($_.Exception.Message)"
    }
}

function Get-AzLocalFileHashText {
    <#
    .SYNOPSIS
        Thin wrapper around Get-FileHash (SHA256), isolated for mocking.
    .OUTPUTS
        [string] the uppercase SHA256 hex string.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)][string]$Path
    )

    $h = Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop
    return ([string]$h.Hash).ToUpperInvariant()
}