Private/Get-AzLocalSideloadCatalog.ps1

function Get-AzLocalSideloadCatalog {
    <#
    .SYNOPSIS
        Parses the source-controlled sideload catalog YAML into package entries
        describing the Azure Local solution-update media (and OEM SBE packages)
        available to the on-prem sideloading automation.
 
    .DESCRIPTION
        Private helper for the v0.8.7 on-prem sideloading automation feature.
        Zero external YAML dependency - parses only the narrow shape below.
 
        Two package classes are supported (PackageType):
 
          - Solution : a Microsoft CombinedSolutionBundle.<build>.zip that is
            downloadable from a direct DownloadUri (published in the Microsoft
            Learn 'import and discover updates offline' table). Sha256 is
            REQUIRED so the download / pre-staged copy can be verified.
 
          - SBE : an OEM Solution Builder Extension package that Microsoft does
            NOT host. The operator stages the OEM files manually and records a
            SourceFolder (local or UNC path). DownloadUri is not applicable;
            Sha256 is optional (verified only when supplied).
 
        Expected YAML shape (2-space indentation):
 
          schemaVersion: 1
          packages:
            - version: '12.2605.1003.210'
              packageType: Solution
              buildNumber: '12.2605.1003.210'
              osBuild: '26100.4061'
              downloadUri: 'https://.../CombinedSolutionBundle.12.2605.1003.210.zip'
              sha256: 'ABCD...'
              availabilityDate: '2026-05-13'
              localPath: ''
            - version: 'DellSBE-4.1.2412.1'
              packageType: SBE
              sourceFolder: '\\fileserver\sbe\Dell\4.1.2412.1'
              sha256: ''
              availabilityDate: '2026-05-20'
              notes: 'Dell OEM SBE package, staged manually'
 
        Validation:
          - At least the 'version' key is required per package; duplicates are a
            hard error.
          - packageType defaults to 'Solution' when omitted; must be
            Solution or SBE (case-insensitive).
          - Solution entries require a non-empty DownloadUri OR LocalPath, plus a
            Sha256 matching ^[0-9A-Fa-f]{64}$.
          - SBE entries require a non-empty SourceFolder. Sha256, when present,
            must match ^[0-9A-Fa-f]{64}$.
 
        Returns an array of [PSCustomObject] package entries (empty array when
        the catalog has no packages).
 
    .PARAMETER Path
        Path to the catalog YAML file.
 
    .OUTPUTS
        [PSCustomObject[]] one entry per package.
    #>

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

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Sideload catalog YAML not found at '$Path'. Set SIDELOAD_CATALOG_PATH (or pass -Path) to a catalog file. Generate / refresh one with Update-AzLocalSideloadCatalog."
    }

    $lines = @(Get-Content -LiteralPath $Path -ErrorAction Stop)
    $packages = New-Object System.Collections.Generic.List[PSCustomObject]
    $seenVersions = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase)

    $inPackages = $false
    $current = $null
    $lineNo = 0

    # Strips one matching pair of surrounding quotes and a trailing inline
    # comment. Bare values are returned trimmed.
    $parseScalar = {
        param([string]$raw)
        $v = $raw.Trim()
        if ($v.Length -ge 2) {
            $first = $v[0]; $last = $v[$v.Length - 1]
            if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
                return $v.Substring(1, $v.Length - 2)
            }
        }
        # Strip trailing ' # comment' only on unquoted scalars.
        $hashIndex = $v.IndexOf(' #')
        if ($hashIndex -ge 0) { $v = $v.Substring(0, $hashIndex).Trim() }
        return $v
    }

    $commit = {
        param($entry)
        if ($null -eq $entry) { return }
        $version = ([string]$entry['version']).Trim()
        if ([string]::IsNullOrWhiteSpace($version)) {
            throw "Sideload catalog '$Path' has a package entry with no 'version'."
        }
        if (-not $seenVersions.Add($version)) {
            throw "Sideload catalog '$Path' contains a DUPLICATE package version '$version'. Each version must be unique."
        }

        $packageType = ([string]$entry['packageType']).Trim()
        if ([string]::IsNullOrWhiteSpace($packageType)) { $packageType = 'Solution' }
        if ($packageType -notmatch '^(?i:Solution|SBE)$') {
            throw "Sideload catalog '$Path' package '$version' has an invalid packageType '$packageType'. Must be 'Solution' or 'SBE'."
        }
        $packageType = if ($packageType -match '^(?i:SBE)$') { 'SBE' } else { 'Solution' }

        $downloadUri = ([string]$entry['downloadUri']).Trim()
        $localPath = ([string]$entry['localPath']).Trim()
        $sourceFolder = ([string]$entry['sourceFolder']).Trim()
        $sha256 = ([string]$entry['sha256']).Trim()

        if ($packageType -eq 'Solution') {
            if ([string]::IsNullOrWhiteSpace($downloadUri) -and [string]::IsNullOrWhiteSpace($localPath)) {
                throw "Sideload catalog '$Path' Solution package '$version' must have a non-empty downloadUri or localPath."
            }
            if ($sha256 -notmatch '^[0-9A-Fa-f]{64}$') {
                throw "Sideload catalog '$Path' Solution package '$version' must have a valid SHA256 (64 hex chars). Got: '$sha256'."
            }
        }
        else {
            if ([string]::IsNullOrWhiteSpace($sourceFolder)) {
                throw "Sideload catalog '$Path' SBE package '$version' must have a non-empty sourceFolder (the operator-staged OEM content path)."
            }
            if (-not [string]::IsNullOrWhiteSpace($sha256) -and $sha256 -notmatch '^[0-9A-Fa-f]{64}$') {
                throw "Sideload catalog '$Path' SBE package '$version' has an invalid SHA256 (must be 64 hex chars or empty). Got: '$sha256'."
            }
        }

        $packages.Add([PSCustomObject]@{
            Version          = $version
            PackageType      = $packageType
            BuildNumber      = ([string]$entry['buildNumber']).Trim()
            OsBuild          = ([string]$entry['osBuild']).Trim()
            DownloadUri      = $downloadUri
            Sha256           = $sha256
            AvailabilityDate = ([string]$entry['availabilityDate']).Trim()
            LocalPath        = $localPath
            SourceFolder     = $sourceFolder
            Notes            = ([string]$entry['notes']).Trim()
        })
    }

    foreach ($rawLine in $lines) {
        $lineNo++
        $line = $rawLine.TrimEnd()
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        $trimmed = $line.Trim()
        if ($trimmed.StartsWith('#')) { continue }

        if (-not $inPackages) {
            if ($trimmed -match '^packages:\s*$') {
                $inPackages = $true
            }
            elseif ($trimmed -match '^schemaVersion:\s*(.+)$') {
                # Guard against a catalog written by a NEWER module. A higher
                # schemaVersion may carry fields/semantics this reader does
                # not understand, so refuse rather than silently mis-parse.
                # Mirrors the schedule-file behaviour. Missing schemaVersion
                # is tolerated (treated as v1) for back-compat.
                $svRaw = (& $parseScalar $Matches[1])
                $sv = 0
                if (-not [int]::TryParse($svRaw, [ref]$sv)) {
                    throw "Sideload catalog '$Path' has a non-integer schemaVersion '$svRaw'."
                }
                if ($sv -gt $script:SideloadCatalogSchemaCurrentVersion) {
                    throw "Sideload catalog '$Path' is on schemaVersion=$sv but this module only supports up to $($script:SideloadCatalogSchemaCurrentVersion). Upgrade the AzLocal.UpdateManagement module (Update-Module AzLocal.UpdateManagement), then re-read the catalog."
                }
            }
            # Other top-level scalars are ignored - only the packages list
            # is consumed.
            continue
        }

        if ($trimmed -match '^-\s*(.*)$') {
            # New list item - commit the previous entry first.
            & $commit $current
            $current = @{}
            $rest = $Matches[1].Trim()
            if (-not [string]::IsNullOrWhiteSpace($rest)) {
                if ($rest -match '^([A-Za-z0-9_]+):\s*(.*)$') {
                    $current[$Matches[1]] = (& $parseScalar $Matches[2])
                }
            }
            continue
        }

        if ($trimmed -match '^([A-Za-z0-9_]+):\s*(.*)$') {
            if ($null -eq $current) {
                throw "Sideload catalog '$Path' line $lineNo has a key outside a package list item: '$trimmed'."
            }
            $current[$Matches[1]] = (& $parseScalar $Matches[2])
            continue
        }
    }
    & $commit $current

    return $packages.ToArray()
}