core/scripts/update-operatingsystems.ps1

<#
.SYNOPSIS
    Downloads, validates, and publishes the Windows 11 25H2 operating system catalog
 
.DESCRIPTION
    Queries the Microsoft Update Metadata Service for the current Windows 11 25H2
    products catalog, verifies the downloaded CAB size and SHA256 digest, and validates
    the extracted MCT XML before changing repository content.
 
    Publishes the validated catalog to core\operatingsystems using the name
    <build>-win11-25h2.xml. Existing catalogs remain in that directory and are not
    moved or deleted. Existing catalog names are never overwritten with different content.
 
    WhatIf still performs the network request, CAB download, extraction, and validation.
    It previews only the repository publication operation.
 
.PARAMETER MinimumItemCount
    Specifies the minimum number of ESD records required before the catalog is accepted.
    This safety threshold detects incomplete catalogs. The default is 50. Valid values
    are 1 through 100000.
 
.EXAMPLE
    PS> & .\OSDeploy\core\scripts\update-operatingsystems.ps1 -WhatIf
 
    Downloads and validates the current catalog, then previews publication without
    changing repository catalog files.
 
.EXAMPLE
    PS> & .\OSDeploy\core\scripts\update-operatingsystems.ps1 -MinimumItemCount 75 -Confirm
 
    Requires at least 75 ESD records and requests confirmation before publishing the new
    catalog.
 
.INPUTS
    None. This script does not accept pipeline input.
 
.OUTPUTS
    System.Management.Automation.PSCustomObject. Returns the detected build, destination
    path, publication status, ESD item count, and source XML SHA256 hash. Under WhatIf,
    the destination path is prospective and Published remains False.
 
.NOTES
    Author: David Segura
    Company: Recast Software
    Version: 1.0.0
    Date: 2026-09-17
 
    Requires Windows, PowerShell 7.4 or later, internet access, expand.exe, writable
    temporary storage, and write access to the catalog directories when publishing.
    This script accepts only Windows 11 25H2 catalogs with build major 26200.
#>

#Requires -PSEdition Core
#Requires -Version 7.4

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param (
    [Parameter()]
    [ValidateRange(1, 100000)]
    [int]
    $MinimumItemCount = 50
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if (-not $IsWindows) {
    throw 'This script requires Windows.'
}

$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'
$expectedBuildMajor = 26200
$coreDirectory = Split-Path -Path $PSScriptRoot -Parent
$catalogDirectory = Join-Path -Path $coreDirectory -ChildPath 'operatingsystems'
$temporaryDirectory = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('osdeploy-catalog-' + [guid]::NewGuid())
$publishedPath = $null

try {
    $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

    # Normalize the API response before enforcing the single-payload contract.
    if ($response -is [array]) {
        if ($response.Count -ne 1) {
            throw "Microsoft metadata returned $($response.Count) response records; expected one."
        }
        $response = $response[0]
    }

    # Establish the metadata trust boundary before downloading any catalog content.
    $cabRecords = @($response.FileLocations | Where-Object FileName -EQ 'products.cab')
    if ($cabRecords.Count -ne 1) {
        throw "Microsoft metadata returned $($cabRecords.Count) products.cab records; expected one."
    }

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

    [long]$expectedCabSize = 0
    if (-not [long]::TryParse([string]$cabRecord.Size, [ref]$expectedCabSize) -or $expectedCabSize -le 0) {
        throw "Microsoft metadata returned an invalid products.cab size: $($cabRecord.Size)"
    }

    try {
        $expectedDigest = [Convert]::FromBase64String([string]$cabRecord.Digest)
    }
    catch {
        throw 'Microsoft metadata returned an invalid products.cab SHA256 digest.'
    }
    if ($expectedDigest.Length -ne 32) {
        throw "Microsoft metadata returned a $($expectedDigest.Length)-byte digest; expected SHA256."
    }

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

    $actualCabSize = (Get-Item -LiteralPath $cabPath).Length
    if ($actualCabSize -ne $expectedCabSize) {
        throw "Downloaded products.cab size mismatch. Expected $expectedCabSize bytes, got $actualCabSize bytes."
    }

    $actualDigest = [System.Security.Cryptography.SHA256]::HashData(
        [System.IO.File]::ReadAllBytes($cabPath)
    )
    if ([Convert]::ToBase64String($actualDigest) -cne [Convert]::ToBase64String($expectedDigest)) {
        throw 'Downloaded products.cab SHA256 digest does not match Microsoft metadata.'
    }

    $extractDirectory = Join-Path -Path $temporaryDirectory -ChildPath 'expanded'
    $null = New-Item -Path $extractDirectory -ItemType Directory -WhatIf:$false
    $expandPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\expand.exe'
    if (-not (Test-Path -LiteralPath $expandPath -PathType Leaf)) {
        throw "Windows expand.exe was not found at '$expandPath'."
    }

    $processInfo = [System.Diagnostics.ProcessStartInfo]::new()
    $processInfo.FileName = $expandPath
    $processInfo.UseShellExecute = $false
    $processInfo.RedirectStandardOutput = $true
    $processInfo.RedirectStandardError = $true
    $processInfo.ArgumentList.Add($cabPath)
    $sourceXmlPath = Join-Path -Path $extractDirectory -ChildPath 'products.xml'
    $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) {
        throw "expand.exe failed with exit code $($process.ExitCode): $standardError $standardOutput"
    }

    if (-not (Test-Path -LiteralPath $sourceXmlPath -PathType Leaf)) {
        throw 'expand.exe did not produce products.xml.'
    }

    [xml]$catalog = Get-Content -LiteralPath $sourceXmlPath -Raw
    $fileNodes = @($catalog.MCT.Catalogs.Catalog.PublishedMedia.Files.File)
    $esdNodes = @($fileNodes | Where-Object { [string]$_.FileName -like '*.esd' })

    # Reject incomplete or malformed catalogs before evaluating repository changes.
    if ($esdNodes.Count -lt $MinimumItemCount) {
        throw "Catalog contains $($esdNodes.Count) ESD records; expected at least $MinimumItemCount."
    }

    $requiredProperties = @('FileName', 'LanguageCode', 'Edition', 'Architecture', 'Size', 'Sha256', 'FilePath')
    $builds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($node in $esdNodes) {
        foreach ($property in $requiredProperties) {
            if ([string]::IsNullOrWhiteSpace([string]$node.$property)) {
                throw "Catalog record '$($node.FileName)' is missing required property '$property'."
            }
        }

        if ([string]$node.Sha256 -notmatch '^[a-fA-F0-9]{64}$') {
            throw "Catalog record '$($node.FileName)' has an invalid SHA256 value."
        }

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

        $buildMatch = [regex]::Match([string]$node.FileName, '^(?<Build>\d{5}\.\d+)\..+\.esd$', 'IgnoreCase')
        if (-not $buildMatch.Success) {
            throw "Catalog record '$($node.FileName)' does not contain a supported build identity."
        }
        $null = $builds.Add($buildMatch.Groups['Build'].Value)
    }

    if ($builds.Count -ne 1) {
        throw "Catalog contains $($builds.Count) build identities; expected one."
    }

    $build = @($builds)[0]
    $buildParts = $build.Split('.')
    if ([int]$buildParts[0] -ne $expectedBuildMajor) {
        throw "Catalog contains build '$build'; expected Windows 11 25H2 build $expectedBuildMajor.x."
    }

    # OSDeploy requires both architectures for its en-US Enterprise download targets.
    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) {
            throw "Catalog does not contain an en-us Enterprise $architecture ESD record."
        }
    }

    $destinationName = "$build-win11-25h2.xml"
    $destinationPath = Join-Path -Path $catalogDirectory -ChildPath $destinationName
    $sourceHash = (Get-FileHash -LiteralPath $sourceXmlPath -Algorithm SHA256).Hash
    $destinationExists = Test-Path -LiteralPath $destinationPath -PathType Leaf

    # Catalog names are immutable; identical content makes repeated runs idempotent.
    if ($destinationExists) {
        $destinationHash = (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256).Hash
        if ($sourceHash -cne $destinationHash) {
            throw "Catalog '$destinationName' already exists with different content."
        }
    }

    $existingCatalogs = @(
        Get-ChildItem -LiteralPath $catalogDirectory -Filter '*-win11-25h2.xml' -File |
        Where-Object Name -NE $destinationName
    )

    # Keep existing catalogs in place, but reject a stale response when a newer build exists.
    foreach ($existingCatalog in $existingCatalogs) {
        if ($existingCatalog.BaseName -notmatch '^(?<Major>\d+)\.(?<Ubr>\d+)-win11-25h2$') {
            throw "Existing catalog '$($existingCatalog.Name)' does not use the expected naming convention."
        }

        $existingBuild = [version]("$($Matches['Major']).$($Matches['Ubr'])")
        if ($existingBuild -gt [version]$build) {
            throw "Existing catalog '$($existingCatalog.Name)' is newer than downloaded build $build."
        }
    }

    $operation = "Publish $destinationName"

    # WhatIf gates repository mutations only; acquisition and validation have already run.
    if ($PSCmdlet.ShouldProcess($catalogDirectory, $operation)) {
        if (-not $destinationExists) {
            # Stage beside the destination so the final move cannot expose a partial copy.
            $stagedPath = Join-Path -Path $catalogDirectory -ChildPath ('.' + $destinationName + '.' + [guid]::NewGuid() + '.tmp')
            [System.IO.File]::Copy($sourceXmlPath, $stagedPath, $false)
            Move-Item -LiteralPath $stagedPath -Destination $destinationPath
            $publishedPath = $destinationPath
        }
    }

    [pscustomobject]@{
        Build = $build
        DestinationPath = $destinationPath
        Published = [bool]$publishedPath
        ItemCount = $esdNodes.Count
        Sha256 = $sourceHash
    }
}
finally {
    # Temporary acquisition files are cleaned even when repository changes use WhatIf.
    if (Test-Path -LiteralPath $temporaryDirectory) {
        Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false
    }
}