public/Update-OSDeployCoreCatalogOS.ps1

#Requires -PSEdition Core
#Requires -Version 7.4

function Update-OSDeployCoreCatalogOS {
    <#
    .SYNOPSIS
        Updates the Windows 11 products operating system catalog
 
    .DESCRIPTION
        Queries the Microsoft Update Metadata Service for the current Windows 11 products
        catalog, verifies the downloaded CAB size and SHA256 digest, and validates
        the extracted catalog before changing persistent content.
 
        Publishes the validated catalog to the module operating system catalog directory using
        the build and release timestamp from its ESD filenames, for example
        26200.9457.260913-0221.xml, and synchronizes it to the OSDeploy Core OSDCloud catalog
        directory. Existing module catalogs remain in place, and an existing catalog name is
        never overwritten with different content.
 
        Any prerequisite, download, validation, publication, or cache-copy failure is reported
        as a warning and does not produce a terminating error. WhatIf still performs the
        network request, CAB download, extraction, and validation; it previews only publication
        and cache synchronization.
 
    .PARAMETER MinimumItemCount
        Specifies the minimum number of ESD records required before a catalog is accepted.
        The default is 50. Valid values are 1 through 100000.
 
    .EXAMPLE
        PS> Update-OSDeployCoreCatalogOS
 
        Downloads and validates the current Windows 11 products catalog, publishes it
        to the module, and synchronizes it to the OSDeploy Core catalog cache.
 
    .EXAMPLE
        PS> Update-OSDeployCoreCatalogOS -WhatIf
 
        Downloads and validates the current catalog, then previews module publication and
        cache synchronization without changing either location.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject. Returns the detected build, module and
        cache catalog paths, publication and cache-copy status, item count, and SHA256 hash
        after successful catalog validation.
 
    .NOTES
        Author: David Segura
        Company: Recast Software
        Version: 1.0.0
        Date: 2026-09-24
 
        Requires Windows, PowerShell 7.4 or later, internet access, expand.exe, writable
        temporary storage, and write access to the module catalog and OSDeploy Core cache
        directories when updating.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param (
        [Parameter()]
        [ValidateRange(1, 100000)]
        [int]
        $MinimumItemCount = 50
    )

    $functionName = $MyInvocation.MyCommand.Name
    Write-Verbose "[$functionName] Start"

    $ErrorActionPreference = 'Stop'
    $metadataUri = 'https://fe3.delivery.mp.microsoft.com/UpdateMetadataService/updates/search/v1/bydeviceinfo'
    $products = 'PN=Windows.Products.Cab.amd64&V=0.0.0.0'
    $deviceAttributes = 'DUScan=1;OSVersion=10.0.26100.1'
    $moduleCatalogDirectory = Join-Path $script:OSDeployModuleBase 'core\operatingsystems'
    $cacheCatalogDirectory = Join-Path $script:OSDeployCorePath 'OSDCloud\catalogs\operatingsystems'
    $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('osdeploy-catalog-' + [guid]::NewGuid())
    $writeProbePath = $null
    $stagedModulePath = $null
    $stagedCachePath = $null
    $getCatalogNameMetadata = {
        param ([string]$Name)

        $nameMatch = [regex]::Match(
            $Name,
            '^(?<Identity>(?<Major>\d{5})\.(?<Ubr>0|[1-9]\d*)\.(?<Timestamp>\d{6}-\d{4}))\.xml$',
            [System.Text.RegularExpressions.RegexOptions]::CultureInvariant
        )
        if (-not $nameMatch.Success) {
            return
        }

        $releaseDateTime = [datetime]::MinValue
        if (-not [datetime]::TryParseExact(
            $nameMatch.Groups['Timestamp'].Value,
            'yyMMdd-HHmm',
            [System.Globalization.CultureInfo]::InvariantCulture,
            [System.Globalization.DateTimeStyles]::None,
            [ref]$releaseDateTime
        )) {
            return
        }

        $build = $null
        if (-not [version]::TryParse(
            "$($nameMatch.Groups['Major'].Value).$($nameMatch.Groups['Ubr'].Value)",
            [ref]$build
        )) {
            return
        }

        [pscustomobject]@{
            Identity = $nameMatch.Groups['Identity'].Value
            Build = $build
            ReleaseDateTime = $releaseDateTime
        }
    }

    try {
        if (-not $IsWindows) {
            Write-Warning "[$functionName] Catalog update was not successful. Windows is required."
            return
        }
        if (-not (Test-Path -LiteralPath $moduleCatalogDirectory -PathType Container)) {
            Write-Warning "[$functionName] Catalog update was not successful. Module catalog directory was not found at '$moduleCatalogDirectory'."
            return
        }

        # Verify effective write access before performing network work during a real update.
        if (-not $WhatIfPreference) {
            $writeProbePath = Join-Path $moduleCatalogDirectory ('.write-test-' + [guid]::NewGuid() + '.tmp')
            $writeProbe = [System.IO.File]::Open(
                $writeProbePath,
                [System.IO.FileMode]::CreateNew,
                [System.IO.FileAccess]::Write,
                [System.IO.FileShare]::None
            )
            $writeProbe.Dispose()
            Remove-Item -LiteralPath $writeProbePath -Force -WhatIf:$false
            $writeProbePath = $null
        }

        $null = New-Item -Path $temporaryDirectory -ItemType Directory -WhatIf:$false

        $body = [ordered]@{
            Products = $products
            DeviceAttributes = $deviceAttributes
        } | ConvertTo-Json -Compress

        $response = Invoke-RestMethod `
            -Uri $metadataUri `
            -Method Post `
            -ContentType 'application/json' `
            -Headers @{ Accept = '*/*' } `
            -Body $body

        if ($response -is [array]) {
            if ($response.Count -ne 1) {
                Write-Warning "[$functionName] Catalog update was not successful. Microsoft metadata returned $($response.Count) response records; expected one."
                return
            }
            $response = $response[0]
        }

        $cabRecords = @($response.FileLocations | Where-Object FileName -EQ 'products.cab')
        if ($cabRecords.Count -ne 1) {
            Write-Warning "[$functionName] Catalog update was not successful. Microsoft metadata returned $($cabRecords.Count) products.cab records; expected one."
            return
        }

        $cabRecord = $cabRecords[0]
        $cabUri = [uri]$cabRecord.Url
        if (-not $cabUri.IsAbsoluteUri -or $cabUri.Scheme -notin @('http', 'https')) {
            Write-Warning "[$functionName] Catalog update was not successful. Microsoft metadata returned an invalid products.cab URL: $($cabRecord.Url)"
            return
        }

        [long]$expectedCabSize = 0
        if (-not [long]::TryParse([string]$cabRecord.Size, [ref]$expectedCabSize) -or $expectedCabSize -le 0) {
            Write-Warning "[$functionName] Catalog update was not successful. Microsoft metadata returned an invalid products.cab size: $($cabRecord.Size)"
            return
        }

        try {
            $expectedDigest = [Convert]::FromBase64String([string]$cabRecord.Digest)
        }
        catch {
            Write-Warning "[$functionName] Catalog update was not successful. Microsoft metadata returned an invalid products.cab SHA256 digest."
            return
        }
        if ($expectedDigest.Length -ne 32) {
            Write-Warning "[$functionName] Catalog update was not successful. Microsoft metadata returned a $($expectedDigest.Length)-byte digest; expected SHA256."
            return
        }

        $cabPath = Join-Path $temporaryDirectory 'products.cab'
        Invoke-WebRequest -Uri $cabUri -OutFile $cabPath -Headers @{ Accept = '*/*' }

        $actualCabSize = (Get-Item -LiteralPath $cabPath).Length
        if ($actualCabSize -ne $expectedCabSize) {
            Write-Warning "[$functionName] Catalog update was not successful. Downloaded products.cab size mismatch. Expected $expectedCabSize bytes, got $actualCabSize bytes."
            return
        }

        $actualDigest = [System.Security.Cryptography.SHA256]::HashData(
            [System.IO.File]::ReadAllBytes($cabPath)
        )
        if ([Convert]::ToBase64String($actualDigest) -cne [Convert]::ToBase64String($expectedDigest)) {
            Write-Warning "[$functionName] Catalog update was not successful. Downloaded products.cab SHA256 digest does not match Microsoft metadata."
            return
        }

        $extractDirectory = Join-Path $temporaryDirectory 'expanded'
        $null = New-Item -Path $extractDirectory -ItemType Directory -WhatIf:$false
        $expandPath = Join-Path $env:SystemRoot 'System32\expand.exe'
        if (-not (Test-Path -LiteralPath $expandPath -PathType Leaf)) {
            Write-Warning "[$functionName] Catalog update was not successful. Windows expand.exe was not found at '$expandPath'."
            return
        }

        $sourceXmlPath = Join-Path $extractDirectory 'products.xml'
        $processInfo = [System.Diagnostics.ProcessStartInfo]::new()
        $processInfo.FileName = $expandPath
        $processInfo.UseShellExecute = $false
        $processInfo.RedirectStandardOutput = $true
        $processInfo.RedirectStandardError = $true
        $processInfo.ArgumentList.Add($cabPath)
        $processInfo.ArgumentList.Add($sourceXmlPath)

        $process = [System.Diagnostics.Process]::Start($processInfo)
        $standardOutput = $process.StandardOutput.ReadToEnd()
        $standardError = $process.StandardError.ReadToEnd()
        $process.WaitForExit()
        if ($process.ExitCode -ne 0) {
            Write-Warning "[$functionName] Catalog update was not successful. expand.exe failed with exit code $($process.ExitCode): $standardError $standardOutput"
            return
        }
        if (-not (Test-Path -LiteralPath $sourceXmlPath -PathType Leaf)) {
            Write-Warning "[$functionName] Catalog update was not successful. expand.exe did not produce products.xml."
            return
        }

        [xml]$catalog = Get-Content -LiteralPath $sourceXmlPath -Raw
        $fileNodes = @($catalog.MCT.Catalogs.Catalog.PublishedMedia.Files.File)
        $esdNodes = @($fileNodes | Where-Object { [string]$_.FileName -like '*.esd' })
        if ($esdNodes.Count -lt $MinimumItemCount) {
            Write-Warning "[$functionName] Catalog update was not successful. Catalog contains $($esdNodes.Count) ESD records; expected at least $MinimumItemCount."
            return
        }

        $requiredProperties = @('FileName', 'LanguageCode', 'Edition', 'Architecture', 'Size', 'Sha256', 'FilePath')
        $catalogIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($node in $esdNodes) {
            foreach ($property in $requiredProperties) {
                if ([string]::IsNullOrWhiteSpace([string]$node.$property)) {
                    Write-Warning "[$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' is missing required property '$property'."
                    return
                }
            }

            if ([string]$node.Sha256 -notmatch '^[a-fA-F0-9]{64}$') {
                Write-Warning "[$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' has an invalid SHA256 value."
                return
            }

            [long]$fileSize = 0
            if (-not [long]::TryParse([string]$node.Size, [ref]$fileSize) -or $fileSize -le 0) {
                Write-Warning "[$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' has an invalid size."
                return
            }

            $identityMatch = [regex]::Match(
                [string]$node.FileName,
                '^(?<Identity>\d{5}\.(?:0|[1-9]\d*)\.\d{6}-\d{4})\..+\.esd$',
                [System.Text.RegularExpressions.RegexOptions]::CultureInvariant
            )
            if (-not $identityMatch.Success) {
                Write-Warning "[$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' does not contain a supported build identity."
                return
            }
            $null = $catalogIdentities.Add($identityMatch.Groups['Identity'].Value)
        }

        if ($catalogIdentities.Count -ne 1) {
            Write-Warning "[$functionName] Catalog update was not successful. Catalog contains $($catalogIdentities.Count) build and release identities; expected one."
            return
        }

        $catalogIdentity = @($catalogIdentities)[0]
        $catalogMetadata = & $getCatalogNameMetadata "$catalogIdentity.xml"
        if (-not $catalogMetadata) {
            Write-Warning "[$functionName] Catalog update was not successful. Catalog identity '$catalogIdentity' is not valid."
            return
        }
        $build = $catalogMetadata.Build.ToString()

        foreach ($architecture in @('x64', 'ARM64')) {
            $target = @(
                $esdNodes | Where-Object {
                    [string]$_.LanguageCode -eq 'en-us' -and
                    [string]$_.Edition -eq 'Enterprise' -and
                    [string]$_.Architecture -eq $architecture
                }
            )
            if ($target.Count -lt 1) {
                Write-Warning "[$functionName] Catalog update was not successful. Catalog does not contain an en-us Enterprise $architecture ESD record."
                return
            }
        }

        $destinationName = "$catalogIdentity.xml"
        $moduleCatalogPath = Join-Path $moduleCatalogDirectory $destinationName
        $cacheCatalogPath = Join-Path $cacheCatalogDirectory $destinationName
        $sourceHash = (Get-FileHash -LiteralPath $sourceXmlPath -Algorithm SHA256).Hash
        $destinationExists = Test-Path -LiteralPath $moduleCatalogPath -PathType Leaf

        if ($destinationExists) {
            $destinationHash = (Get-FileHash -LiteralPath $moduleCatalogPath -Algorithm SHA256).Hash
            if ($sourceHash -cne $destinationHash) {
                Write-Warning "[$functionName] Catalog update was not successful. Catalog '$destinationName' already exists with different content."
                return
            }
        }

        foreach ($existingCatalog in Get-ChildItem -LiteralPath $moduleCatalogDirectory -Filter '*.xml' -File) {
            if ($existingCatalog.Name -eq $destinationName) {
                continue
            }

            $existingMetadata = & $getCatalogNameMetadata $existingCatalog.Name
            if (-not $existingMetadata) {
                Write-Verbose "[$functionName] Ignoring unrecognized catalog filename: $($existingCatalog.FullName)"
                continue
            }

            $isNewerBuild = $existingMetadata.Build -gt $catalogMetadata.Build
            $isNewerRelease = $existingMetadata.Build -eq $catalogMetadata.Build -and
                $existingMetadata.ReleaseDateTime -gt $catalogMetadata.ReleaseDateTime
            if ($isNewerBuild -or $isNewerRelease) {
                Write-Warning "[$functionName] Catalog update was not successful. Existing catalog '$($existingCatalog.Name)' is newer than downloaded catalog $destinationName."
                return
            }
        }

        $published = $false
        $cached = $false
        $targetDescription = "$moduleCatalogPath; $cacheCatalogPath"
        $operation = 'Publish the OS catalog and synchronize the OSDeploy Core catalog cache'
        if ($PSCmdlet.ShouldProcess($targetDescription, $operation)) {
            if (-not $destinationExists) {
                $stagedModulePath = Join-Path $moduleCatalogDirectory ('.' + $destinationName + '.' + [guid]::NewGuid() + '.tmp')
                [System.IO.File]::Copy($sourceXmlPath, $stagedModulePath, $false)
                [System.IO.File]::Move($stagedModulePath, $moduleCatalogPath, $false)
                $stagedModulePath = $null
                $published = $true
            }

            $null = New-Item -Path $cacheCatalogDirectory -ItemType Directory -Force
            $stagedCachePath = Join-Path $cacheCatalogDirectory ('.' + $destinationName + '.' + [guid]::NewGuid() + '.tmp')
            [System.IO.File]::Copy($moduleCatalogPath, $stagedCachePath, $false)
            [System.IO.File]::Move($stagedCachePath, $cacheCatalogPath, $true)
            $stagedCachePath = $null
            $cached = $true
        }

        [pscustomobject]@{
            Build = $build
            ModuleCatalogPath = $moduleCatalogPath
            CacheCatalogPath = $cacheCatalogPath
            Published = $published
            Cached = $cached
            ItemCount = $esdNodes.Count
            Sha256 = $sourceHash
        }
    }
    catch {
        Write-Warning "[$functionName] Catalog update was not successful. $($_.Exception.Message)"
    }
    finally {
        foreach ($cleanupPath in @($stagedCachePath, $stagedModulePath, $writeProbePath)) {
            if ($cleanupPath -and (Test-Path -LiteralPath $cleanupPath -PathType Leaf)) {
                Remove-Item -LiteralPath $cleanupPath -Force -ErrorAction SilentlyContinue -WhatIf:$false
            }
        }
        if (Test-Path -LiteralPath $temporaryDirectory) {
            Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false
        }
        Write-Verbose "[$functionName] End"
    }
}