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. Dependencies: Module Functions: Test-IsAdministrator, Write-OSDeployBanner, Write-OSDeployCoreProgress Executables: curl.exe .NET Classes: [System.IO.FileInfo], [System.IO.FileNotFoundException], [System.IO.IOException], [System.IO.Path], [System.Management.Automation.ErrorCategory], [System.Management.Automation.ErrorRecord] #> [CmdletBinding(SupportsShouldProcess)] [OutputType([System.IO.FileInfo[]])] param ( [Parameter()] [switch]$Force, [Parameter()] [ValidateSet('amd64', 'arm64')] [System.String] $Architecture ) 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' } ) } if ($Architecture) { $archMap = @{ amd64 = 'x64'; arm64 = 'ARM64' } $targets = @($targets | Where-Object { $_.Architecture -eq $archMap[$Architecture] }) Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Architecture filter applied: $Architecture" } $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() } |