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" } } |