public/Update-OSDeployCoreESD.ps1

#Requires -PSEdition Core
#Requires -Version 7.4

function Update-OSDeployCoreESD {
    <#
    .SYNOPSIS
        Downloads Windows Enterprise ESD files from the latest OSDeploy OS catalog.
 
    .DESCRIPTION
        Locates the most recent XML catalog file in the OSDeploy operatingsystem catalogs
        directory, resolves the en-US Enterprise ESD entries, and downloads them to
        C:\ProgramData\OSDeployCore\OSDCloud\OS\<Version> where <Version> is derived
        from the catalog name (e.g. 'Windows 11 25H2' from '26200.8457-win11-25h2.xml').
 
        On ARM64 Windows only the ARM64 ESD is offered. On AMD64 Windows the AMD64 ESD
        is offered first, then the ARM64 ESD (each confirmed separately).
 
        Each file is skipped silently when it is already present and its SHA256 checksum
        matches the catalog value. When the latest catalog ESD is not cached but an older
        version is found in the cache with a verified checksum, the user is informed that
        a newer version is available and offered the choice to download it or keep the
        older cached version. For files that need downloading, the URL is first tested for
        reachability. All pending downloads are then confirmed with the user before any
        transfer begins.
 
        SHA256 verification is performed after every download. A terminating error is
        thrown when the checksum does not match or when curl.exe fails.
 
    .PARAMETER Force
        Re-downloads each ESD file even when it already exists in the cache with a
        matching SHA256 checksum.
 
    .OUTPUTS
        System.IO.FileInfo. One FileInfo object per successfully downloaded or already-cached
        ESD file (up to two objects: x64 and ARM64).
 
    .EXAMPLE
        Update-OSDeployCoreESD
 
        Tests reachability, prompts to confirm both files, then downloads them to
        C:\ProgramData\OSDeployCore\OSDCloud\OS\Windows 11 25H2 (version-specific
        folder derived from the catalog name).
 
    .EXAMPLE
        Update-OSDeployCoreESD -Force
 
        Re-downloads both ESD files regardless of whether they are already cached.
 
    .EXAMPLE
        Update-OSDeployCoreESD -WhatIf
 
        Shows which ESD files would be downloaded without performing any downloads.
 
    .NOTES
        Requires curl.exe to be available on the system PATH.
        Files are sourced from the Microsoft Content Delivery Network via URLs stored in
        the catalog XML.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([System.IO.FileInfo[]])]
    param (
        [Parameter()]
        [switch]$Force
    )

    Write-OSDeployBanner
    $Error.Clear()

    Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Start"

    # Requires Run as Administrator
    if (-not (Test-IsAdministrator)) {
        Write-Warning "[$($MyInvocation.MyCommand.Name)] This function must be Run as Administrator"
        return
    }

    # -------------------------------------------------------------------------
    # Resolve catalog files (latest first)
    # -------------------------------------------------------------------------
    $catalogDir  = Join-Path $script:OSDeployModuleBase 'catalogs\operatingsystem'
    $allXmlFiles = Get-ChildItem -Path $catalogDir -Filter '*.xml' -File |
        Sort-Object Name -Descending
    $latestXml   = $allXmlFiles | Select-Object -First 1
    $olderXmls   = @($allXmlFiles | Select-Object -Skip 1)

    if (-not $latestXml) {
        $PSCmdlet.ThrowTerminatingError(
            [System.Management.Automation.ErrorRecord]::new(
                [System.IO.FileNotFoundException]::new("No OS catalog XML files found in '$catalogDir'."),
                'CatalogNotFound',
                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                $catalogDir
            )
        )
    }

    Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Using catalog: $($latestXml.Name)"
    if ($olderXmls.Count -gt 0) {
        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Older catalog(s) available: $($olderXmls.Name -join ', ')"
    }
    Write-OSDeployCoreProgress "Checking Operating System Catalog at $($latestXml.FullName)"

    # -------------------------------------------------------------------------
    # Parse the catalog
    # -------------------------------------------------------------------------
    [xml]$catalog = Get-Content -Path $latestXml.FullName -Raw
    $allFiles = $catalog.MCT.Catalogs.Catalog.PublishedMedia.Files.File

    # -------------------------------------------------------------------------
    # Resolve target ESD entries
    # On ARM64 Windows only offer ARM64; on AMD64 offer AMD64 first then ARM64.
    # -------------------------------------------------------------------------
    $isArm64 = $env:PROCESSOR_ARCHITECTURE -eq 'ARM64'

    $targets = if ($isArm64) {
        @(
            [pscustomobject]@{ Architecture = 'ARM64'; Edition = 'Enterprise'; LanguageCode = 'en-us' }
        )
    }
    else {
        @(
            [pscustomobject]@{ Architecture = 'x64';   Edition = 'Enterprise'; LanguageCode = 'en-us' }
            [pscustomobject]@{ Architecture = 'ARM64'; Edition = 'Enterprise'; LanguageCode = 'en-us' }
        )
    }

    $resolvedEntries = foreach ($target in $targets) {
        $entry = $allFiles | Where-Object {
            $_.LanguageCode  -eq $target.LanguageCode  -and
            $_.Edition       -eq $target.Edition       -and
            $_.Architecture  -eq $target.Architecture
        } | Select-Object -First 1

        if (-not $entry) {
            Write-Warning "[$($MyInvocation.MyCommand.Name)] No catalog entry found for $($target.Edition) $($target.Architecture) $($target.LanguageCode). Skipping."
            continue
        }

        $entry
    }

    if (-not $resolvedEntries) {
        Write-Warning "[$($MyInvocation.MyCommand.Name)] No matching ESD entries found in catalog '$($latestXml.Name)'."
        return
    }

    # -------------------------------------------------------------------------
    # Derive OS folder name from the catalog filename
    # e.g. '26200.8457-win11-25h2.xml' → 'Windows 11 25H2'
    # -------------------------------------------------------------------------
    $catalogBase = [System.IO.Path]::GetFileNameWithoutExtension($latestXml.Name)
    if ($catalogBase -notmatch '^\d+\.\d+-win(\d+)-(.+)$') {
        $PSCmdlet.ThrowTerminatingError(
            [System.Management.Automation.ErrorRecord]::new(
                [System.FormatException]::new("Cannot parse OS version from catalog name '$($latestXml.Name)'. Expected format: '<build>-win<version>-<release>.xml' (e.g. '26200.8457-win11-25h2.xml')."),
                'CatalogNameUnrecognized',
                [System.Management.Automation.ErrorCategory]::InvalidData,
                $latestXml.Name
            )
        )
    }
    $osFolderName = "Windows $($Matches[1]) $($Matches[2].ToUpper())"
    Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] OS folder name: $osFolderName"

    # -------------------------------------------------------------------------
    # Prepare download directory
    # -------------------------------------------------------------------------
    $downloadDir = Join-Path $script:OSDeployCorePath 'OSDCloud' 'OS' $osFolderName

    if (-not (Test-Path -Path $downloadDir)) {
        New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null
        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Created download directory: $downloadDir"
    }

    $normalizeHash  = { param([string]$hash) ($hash -replace '\s+', '').ToUpperInvariant() }
    $results        = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
    $pendingEntries = [System.Collections.Generic.List[object]]::new()

    # -------------------------------------------------------------------------
    # Phase 1: Check cache and test URL reachability
    # -------------------------------------------------------------------------
    foreach ($entry in $resolvedEntries) {
        $destPath        = Join-Path $downloadDir $entry.FileName
        $expectedSha256  = & $normalizeHash $entry.Sha256
        $checkForOlder   = $false

        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Checking: $($entry.FileName)"
        Write-OSDeployCoreProgress "Checking cache for $($entry.FileName)"

        # Return cached file immediately when checksum matches (and -Force is not set)
        if (-not $Force -and (Test-Path -Path $destPath -PathType Leaf)) {
            $actualHash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpperInvariant()
            if ($actualHash -eq $expectedSha256) {
                Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.FileName)' already cached and SHA256 verified. Skipping."
                Write-OSDeployCoreProgress "ESD already cached and verified: $($entry.FileName)"
                Write-OSDeployCoreProgress " SHA256 : $actualHash"
                $results.Add((Get-Item -Path $destPath))
                continue
            }
            Write-Warning "[$($MyInvocation.MyCommand.Name)] '$($entry.FileName)' exists but SHA256 does not match the catalog."
            Write-OSDeployCoreProgress "SHA256 mismatch: $($entry.FileName)"
            Write-OSDeployCoreProgress " Expected : $expectedSha256"
            Write-OSDeployCoreProgress " Actual : $actualHash"
            $deleteCaption = "SHA256 Mismatch – Delete and Re-download?"
            $deleteMessage  = "File : $destPath`nExpected SHA256 : $expectedSha256`nActual SHA256 : $actualHash`n`nThe cached file does not match the catalog checksum. Delete it to the Recycle Bin and re-download?"
            if ($PSCmdlet.ShouldContinue($deleteMessage, $deleteCaption)) {
                Add-Type -AssemblyName Microsoft.VisualBasic
                [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($destPath, 'OnlyErrorDialogs', 'SendToRecycleBin')
                Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Sent to Recycle Bin: $destPath"
                Write-OSDeployCoreProgress "Sent to Recycle Bin: $($entry.FileName)"
            }
            else {
                Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User declined to delete mismatched file. Skipping: $($entry.FileName)"
                continue
            }
        }
        else {
            # Latest ESD is not cached; check for an older cached version below
            $checkForOlder = -not $Force
        }

        # -----------------------------------------------------------------------
        # Check for a valid older cached version when the latest ESD is not present
        # -----------------------------------------------------------------------
        if ($checkForOlder -and $olderXmls.Count -gt 0) {
            $olderCachedFile = $null
            foreach ($olderXml in $olderXmls) {
                [xml]$olderCatalogXml = Get-Content -Path $olderXml.FullName -Raw
                $olderEntry = $olderCatalogXml.MCT.Catalogs.Catalog.PublishedMedia.Files.File |
                    Where-Object {
                        $_.LanguageCode -eq $entry.LanguageCode -and
                        $_.Edition      -eq $entry.Edition      -and
                        $_.Architecture -eq $entry.Architecture
                    } | Select-Object -First 1

                if ($olderEntry -and $olderEntry.FileName -ne $entry.FileName) {
                    $olderDestPath = Join-Path $downloadDir $olderEntry.FileName
                    if (Test-Path -Path $olderDestPath -PathType Leaf) {
                        $olderExpectedSha256 = & $normalizeHash $olderEntry.Sha256
                        $olderActualHash     = (Get-FileHash -Path $olderDestPath -Algorithm SHA256).Hash.ToUpperInvariant()
                        if ($olderActualHash -eq $olderExpectedSha256) {
                            $olderCachedFile = Get-Item -Path $olderDestPath
                            break
                        }
                    }
                }
            }

            if ($olderCachedFile) {
                $fileSizeMB     = [Math]::Round([long]$entry.Size / 1MB, 1)
                $fileLabel      = "$($entry.Architecture) – $($entry.Edition) ($($entry.LanguageCode))"
                $upgradeCaption = "Newer Version Available – $fileLabel"
                $upgradeMessage = "Cached : $($olderCachedFile.Name)`nNewer : $($entry.FileName) ($fileSizeMB MB)`n`nA newer version is available from catalog '$($latestXml.Name)'.`nDownload the newer version?"

                Write-OSDeployCoreProgress "Newer ESD available: $($entry.FileName)"
                Write-OSDeployCoreProgress " Cached : $($olderCachedFile.Name)"
                Write-OSDeployCoreProgress " Newer : $($entry.FileName)"

                if (-not $PSCmdlet.ShouldContinue($upgradeMessage, $upgradeCaption)) {
                    Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User kept older cached ESD: $($olderCachedFile.Name)"
                    $results.Add($olderCachedFile)
                    continue
                }
                Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User chose to download newer ESD: $($entry.FileName)"
            }
        }

        # Test URL reachability before adding to the prompt queue
        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Testing reachability: $($entry.FilePath)"
        Write-OSDeployCoreProgress "Testing availability of ESD:"
        Write-OSDeployCoreProgress "$($entry.FilePath)"
        $null = curl.exe --head --fail --silent --location --max-time 15 $entry.FilePath 2>&1
        if ($LASTEXITCODE -ne 0) {
            Write-Warning "[$($MyInvocation.MyCommand.Name)] '$($entry.FileName)' is not reachable (curl exit $LASTEXITCODE). Skipping."
            continue
        }

        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Reachable: $($entry.FileName)"
        Write-OSDeployCoreProgress "ESD is available: $($entry.FileName)"
        $pendingEntries.Add($entry)
    }

    # -------------------------------------------------------------------------
    # Phase 2: Prompt for all pending downloads before starting any transfer
    # -------------------------------------------------------------------------
    $confirmedEntries = [System.Collections.Generic.List[object]]::new()

    foreach ($entry in $pendingEntries) {
        $fileSizeMB     = [Math]::Round([long]$entry.Size / 1MB, 1)
        $fileLabel      = "$($entry.Architecture) – $($entry.Edition) ($($entry.LanguageCode))"
        $destPath       = Join-Path $downloadDir $entry.FileName
        $confirmCaption = "Download $fileLabel"
        $confirmMessage = "File : $($entry.FileName)`nSize : $fileSizeMB MB`nDest : $destPath`n`nThis download can take between 5 - 30 minutes depending on your internet connection.`n`nDownload this file?"

        if ($PSCmdlet.ShouldContinue($confirmMessage, $confirmCaption)) {
            $confirmedEntries.Add($entry)
        }
        else {
            Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Download declined by user: $($entry.FileName)"
        }
    }

    # -------------------------------------------------------------------------
    # Phase 3: Download all confirmed entries
    # -------------------------------------------------------------------------
    if ($confirmedEntries.Count -gt 0) {
        Write-OSDeployCoreProgress "Downloading $($confirmedEntries.Count) ESD file(s) from Microsoft. This process can take between 5 - 30 minutes depending on your internet connection."
    }

    $downloadedCount = 0

    foreach ($entry in $confirmedEntries) {
        $destPath       = Join-Path $downloadDir $entry.FileName
        $expectedSha256 = & $normalizeHash $entry.Sha256

        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Downloading: $($entry.FileName)"
        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Source: $($entry.FilePath)"
        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Destination: $destPath"
        Write-OSDeployCoreProgress "Downloading $($entry.FileName)"
        Write-OSDeployCoreProgress "$destPath"

        $curlArgs = @(
            '--location', '--fail', '--retry', '3',
            '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
            '--output', $destPath,
            $entry.FilePath
        )

        curl.exe @curlArgs

        if ($LASTEXITCODE -ne 0) {
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.IO.IOException]::new("curl.exe failed (exit $LASTEXITCODE) downloading '$($entry.FileName)'."),
                    'DownloadFailed',
                    [System.Management.Automation.ErrorCategory]::ConnectionError,
                    $entry.FilePath
                )
            )
        }

        $actualHash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpperInvariant()
        if ($actualHash -ne $expectedSha256) {
            Write-Warning "[$($MyInvocation.MyCommand.Name)] SHA256 mismatch for '$($entry.FileName)'."
            Write-OSDeployCoreProgress "SHA256 mismatch: $($entry.FileName)"
            Write-OSDeployCoreProgress " Expected : $expectedSha256"
            Write-OSDeployCoreProgress " Actual : $actualHash"
            Write-OSDeployCoreProgress "Sending to Recycle Bin: $($entry.FileName)"
            Add-Type -AssemblyName Microsoft.VisualBasic
            [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($destPath, 'OnlyErrorDialogs', 'SendToRecycleBin')
            Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Sent to Recycle Bin: $destPath"

            $retryCaption = "SHA256 Mismatch – Retry Download?"
            $retryMessage  = "File : $destPath`nExpected SHA256 : $expectedSha256`nActual SHA256 : $actualHash`n`nThe downloaded file does not match the catalog checksum. Retry the download?"
            if (-not $PSCmdlet.ShouldContinue($retryMessage, $retryCaption)) {
                Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User declined retry for: $($entry.FileName)"
                continue
            }

            Write-OSDeployCoreProgress "Retrying download of $($entry.FileName) ..."
            curl.exe @curlArgs

            if ($LASTEXITCODE -ne 0) {
                $PSCmdlet.ThrowTerminatingError(
                    [System.Management.Automation.ErrorRecord]::new(
                        [System.IO.IOException]::new("curl.exe failed on retry (exit $LASTEXITCODE) downloading '$($entry.FileName)'."),
                        'DownloadFailed',
                        [System.Management.Automation.ErrorCategory]::ConnectionError,
                        $entry.FilePath
                    )
                )
            }

            $actualHash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpperInvariant()
            if ($actualHash -ne $expectedSha256) {
                Write-OSDeployCoreProgress "SHA256 mismatch after retry: $($entry.FileName). Sending to Recycle Bin ..."
                [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($destPath, 'OnlyErrorDialogs', 'SendToRecycleBin')
                $PSCmdlet.ThrowTerminatingError(
                    [System.Management.Automation.ErrorRecord]::new(
                        [System.IO.IOException]::new("SHA256 mismatch after retry for '$($entry.FileName)'. Expected: $expectedSha256, Actual: $actualHash"),
                        'ChecksumMismatch',
                        [System.Management.Automation.ErrorCategory]::SecurityError,
                        $destPath
                    )
                )
            }
        }

        Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] SHA256 verified: $($entry.FileName)"
        Write-OSDeployCoreProgress "SHA256 verified: $($entry.FileName)"
        $results.Add((Get-Item -Path $destPath))
        $downloadedCount++
    }

    Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Complete. $($results.Count) file(s) ready."

    $cachedCount     = $results.Count - $downloadedCount
    $summaryParts    = [System.Collections.Generic.List[string]]::new()
    if ($downloadedCount -gt 0) { $summaryParts.Add("$downloadedCount file(s) downloaded") }
    if ($cachedCount -gt 0)     { $summaryParts.Add("$cachedCount file(s) already cached") }
    $summary = if ($summaryParts.Count -gt 0) { $summaryParts -join ', ' } else { 'No ESD files were downloaded or cached' }
    Write-OSDeployCoreProgress "Complete. $summary."

    return $results.ToArray()
}