Runtime/Test-OSDAppOfficeCacheCompatibility.ps1
|
# Shared by WinPE staging and the standalone Windows PreInstall runtime. function Get-OSDAppOfficePayloadProfile { [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) $settings = [System.Xml.XmlReaderSettings]::new() $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit $settings.XmlResolver = $null $document = [System.Xml.XmlDocument]::new() $document.XmlResolver = $null $reader = [System.Xml.XmlReader]::Create($Path, $settings) try { $document.Load($reader) } finally { $reader.Dispose() } $adds = @($document.SelectNodes('/Configuration/Add')) if ($adds.Count -ne 1) { throw 'Office payload comparison requires exactly one Add element.' } $add = $adds[0] $edition = $add.GetAttribute('OfficeClientEdition') $architecture = switch ($edition) { '64' { 'x64' } '32' { 'x86' } default { '' } } $channel = $add.GetAttribute('Channel') $version = $add.GetAttribute('Version') $products = @($add.SelectNodes('Product')) $languages = @($add.SelectNodes('Product/Language')) $static = ($architecture -ne '' -and $channel -ne '' -and $products.Count -gt 0 -and $languages.Count -gt 0) foreach ($product in $products) { if (-not $product.GetAttribute('ID') -or $product.HasAttribute('MSICondition') -or @($product.SelectNodes('Language')).Count -eq 0) { $static = $false } } foreach ($language in $languages) { if ($language.GetAttribute('ID') -notmatch '^[a-z]{2,3}-[a-z0-9]{2,4}$') { $static = $false } } if ($version -and $version -notmatch '^\d+\.\d+\.\d+\.\d+$') { $static = $false } # Compare the entire Add subtree conservatively. SourcePath is managed by # OSDApps; an explicit version is checked against the actual cached build. # Installation-only settings outside Add (licensing, updates, display) do # not change the downloaded payload. Unknown Add settings remain included. function ConvertTo-OfficePayloadNode { param([System.Xml.XmlElement]$Node) $attributes = @( foreach ($attribute in @($Node.Attributes | Sort-Object Name)) { if ($Node.Name -eq 'Add' -and $attribute.Name -in @('SourcePath','Version')) { continue } $value = $attribute.Value if (($Node.Name -eq 'Add' -and $attribute.Name -eq 'Channel') -or ($Node.Name -in @('Product','Language','ExcludeApp') -and $attribute.Name -eq 'ID')) { $value = $value.ToLowerInvariant() } @($attribute.Name, $value) -join '=' } ) $children = @( foreach ($child in $Node.ChildNodes) { if ($child -is [System.Xml.XmlElement]) { ConvertTo-OfficePayloadNode -Node $child } elseif ($child -is [System.Xml.XmlText] -or $child -is [System.Xml.XmlCDataSection]) { $child.Value } } ) # Preserve element order: unsupported custom constructs must never be # treated as equivalent merely because their values can be sorted. [ordered]@{ Name=$Node.Name; Attributes=$attributes; Children=$children } | ConvertTo-Json -Depth 50 -Compress } $canonical = ConvertTo-OfficePayloadNode -Node $add $sha = [System.Security.Cryptography.SHA256]::Create() try { $key = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical)))).Replace('-', '') } finally { $sha.Dispose() } [pscustomobject]@{ SchemaVersion = 1 Architecture = $architecture Channel = $channel Version = $version Products = @($products | ForEach-Object { $_.GetAttribute('ID') }) Languages = @($languages | ForEach-Object { $_.GetAttribute('ID') }) CustomDownloadSource = $add.HasAttribute('DownloadPath') Static = [bool]$static PayloadKey = $key } } function Test-OSDAppOfficeCacheCompatibility { [CmdletBinding()] param( [Parameter(Mandatory)][string]$OfficeRoot, [Parameter(Mandatory)][string]$ConfigurationPath, [switch]$AllowLegacyConfiguration ) $reasons = [System.Collections.Generic.List[string]]::new() $cached = $null try { $requested = Get-OSDAppOfficePayloadProfile -Path $ConfigurationPath $info = Get-Content -LiteralPath (Join-Path $OfficeRoot 'CacheInfo.json') -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json $profileProperty = $info.PSObject.Properties['PayloadProfile'] if ($profileProperty) { $cached = $profileProperty.Value } elseif ($AllowLegacyConfiguration) { # Only the untouched USB cache XML may describe a legacy payload. # The staged XML is deployment intent and may have been replaced. $cached = Get-OSDAppOfficePayloadProfile -Path (Join-Path $OfficeRoot 'configuration.xml') } if (-not $cached -or $cached.SchemaVersion -ne 1) { throw 'Office cache has no supported payload configuration record.' } if (-not $requested.Static -or -not $cached.Static) { $reasons.Add('Dynamic or implicit Office configuration cannot be proven compatible for fallback.') } if ($requested.Architecture -ne $cached.Architecture) { $reasons.Add('Office architecture differs.') } if ($requested.Channel -ne $cached.Channel) { $reasons.Add('Office channel differs.') } if (($requested.Products -join ',') -ne ($cached.Products -join ',')) { $reasons.Add('Office products or their order differ.') } if (($requested.Languages -join ',') -ne ($cached.Languages -join ',')) { $reasons.Add('Office languages or their order differ.') } if ($requested.Version -and $requested.Version -ne [string]$info.Version) { $reasons.Add('The requested Office version is not the cached version.') } if ($requested.PayloadKey -cne $cached.PayloadKey) { $reasons.Add('Office download settings differ; a fresh acquisition is required.') } if ([string]$info.Architecture -ne [string]$cached.Architecture) { $reasons.Add('Office cache architecture metadata is inconsistent.') } } catch { # Do not include XML values, paths or licensing keys in diagnostics. $reasons.Add('Office payload configuration could not be verified. Refresh the cache for this configuration.') } [pscustomobject]@{ Compatible=($reasons.Count -eq 0); Reasons=@($reasons); CachedProfile=$cached } } # Read only the selected architecture's advertised build. Available/@Build can # refer to an old Windows release even when I640Version advertises a current build. function Get-OSDAppOfficeDescriptorVersion { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][ValidateSet('x64','x86')][string]$Architecture, [Parameter(Mandatory)][string]$ChannelId ) $settings = [System.Xml.XmlReaderSettings]::new() $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit $settings.XmlResolver = $null $settings.MaxCharactersInDocument = 1MB $document = [System.Xml.XmlDocument]::new() $document.XmlResolver = $null $reader = [System.Xml.XmlReader]::Create($Path, $settings) try { $document.Load($reader) } finally { $reader.Dispose() } $available = @($document.SelectNodes('/Version/Available')) $delivery = @($document.SelectNodes('/Version/DeliveryMechanism')) if ($available.Count -ne 1 -or $delivery.Count -ne 1 -or $delivery[0].GetAttribute('FFNRoot') -ne $ChannelId) { throw 'Office CDN descriptor has an ambiguous version or unexpected channel.' } $attribute = if ($Architecture -eq 'x86') { 'I320Version' } else { 'I640Version' } $build = $available[0].GetAttribute($attribute) if ($build -notmatch '^\d+\.\d+\.\d+\.\d+$') { throw 'Office CDN descriptor does not advertise a concrete build for the requested architecture.' } $defaults = @($document.SelectNodes('/Version/AvailableBuilds/AvailableBuild[@RestrictionType="Default"]')) if ($defaults.Count -gt 1 -or ($defaults.Count -eq 1 -and $defaults[0].GetAttribute('Version') -ne $build)) { throw 'Office CDN descriptor advertises conflicting default and architecture builds.' } return $build } function Expand-OSDAppOfficeDescriptor { [CmdletBinding()] param( [Parameter(Mandatory)][string]$CabPath, [Parameter(Mandatory)][string]$DestinationPath ) $expand = Join-Path $env:SystemRoot 'System32\expand.exe' $extractedPath = Join-Path $DestinationPath 'Extracted' New-Item -ItemType Directory -Path $extractedPath -ErrorAction Stop | Out-Null # -R preserves the archived XML name even for a single-file cabinet. $arguments = '-R "{0}" -F:VersionDescriptor.xml "{1}"' -f $CabPath,$extractedPath # Own the process handle directly: Start-Process -NoNewWindow can lose # ExitCode on Windows PowerShell 5.1 after a bounded WaitForExit call. $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $expand $startInfo.Arguments = $arguments $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $process = [System.Diagnostics.Process]::Start($startInfo) try { if (-not $process.WaitForExit(10000)) { try { $process.Kill() } catch { Write-Verbose 'Office descriptor expansion could not be terminated.' } throw 'Office descriptor expansion exceeded its 10 second timeout.' } if ($process.ExitCode -ne 0) { throw ('Office CDN version descriptor extraction failed with exit code {0}.' -f $process.ExitCode) } $xmlPath = Join-Path $extractedPath 'VersionDescriptor.xml' if (-not (Test-Path -LiteralPath $xmlPath -PathType Leaf)) { throw 'Office CDN version descriptor XML is missing.' } return $xmlPath } finally { $process.Dispose() } } # Freshness only: callers must separately validate the generation and installer. # Never write a CDN descriptor into the active cache or repair individual CABs. function Test-OSDAppOfficeCacheCurrent { [CmdletBinding()] param( [Parameter(Mandatory)][string]$OfficeRoot, [Parameter(Mandatory)][string]$ConfigurationPath ) $compatibility = Test-OSDAppOfficeCacheCompatibility -OfficeRoot $OfficeRoot -ConfigurationPath $ConfigurationPath if (-not $compatibility.Compatible) { return $false } $payloadProfile = Get-OSDAppOfficePayloadProfile -Path $ConfigurationPath if ($payloadProfile.CustomDownloadSource) { return $false } # Compatibility already proved that an explicit build pin matches the cache. if ($payloadProfile.Version -match '^\d+\.\d+\.\d+\.\d+$') { return $true } $channels = @{ Current = '492350f6-3a01-4f97-b9c0-c7c6ddf67d60' MonthlyEnterprise = '55336b82-a18d-4dd6-b5f6-9e5095c314a6' SemiAnnual = '7ffbc6bf-bc32-4f92-8982-f9dd17fd3114' CurrentPreview = '64256afe-f5d9-4f86-8936-8840a6a4f5be' SemiAnnualPreview = 'b8f9b850-328d-4355-9145-c59439a0c4cf' BetaChannel = '5440fd1f-7ecb-4221-8110-145efaa6372f' } if (-not $channels.ContainsKey($payloadProfile.Channel)) { return $false } $cabName = if ($payloadProfile.Architecture -eq 'x86') { 'v32.cab' } else { 'v64.cab' } $cachedCab = Join-Path (Join-Path $OfficeRoot 'Office\Data') $cabName if (-not (Test-Path -LiteralPath $cachedCab -PathType Leaf)) { return $false } $probeRoot = Join-Path ([IO.Path]::GetTempPath()) ("OSDApps-OfficeFreshness-{0}" -f [guid]::NewGuid().ToString('N')) $probe = Join-Path $probeRoot $cabName try { New-Item -ItemType Directory -Path $probeRoot -ErrorAction Stop | Out-Null $uri = 'https://officecdn.microsoft.com/pr/{0}/Office/Data/{1}' -f $channels[$payloadProfile.Channel],$cabName Invoke-WebRequest -Uri $uri -OutFile $probe -UseBasicParsing -TimeoutSec 30 -Headers @{ 'Cache-Control'='no-cache' } -ErrorAction Stop | Out-Null if ((Get-Item -LiteralPath $probe).Length -eq 0) { throw 'Office CDN returned an empty version descriptor.' } if ((Get-Item -LiteralPath $probe).Length -gt 1MB) { throw 'Office CDN version descriptor exceeds the supported size.' } $descriptorPath = Expand-OSDAppOfficeDescriptor -CabPath $probe -DestinationPath $probeRoot $advertisedVersion = Get-OSDAppOfficeDescriptorVersion -Path $descriptorPath -Architecture $payloadProfile.Architecture -ChannelId $channels[$payloadProfile.Channel] $cacheInfo = Get-Content -LiteralPath (Join-Path $OfficeRoot 'CacheInfo.json') -Raw -Encoding UTF8 | ConvertFrom-Json Write-Verbose ("Office advertised build: {0}; cached build: {1}." -f $advertisedVersion,$cacheInfo.Version) return ($advertisedVersion -eq [string]$cacheInfo.Version) } finally { if (Test-Path -LiteralPath $probeRoot) { Remove-Item -LiteralPath $probeRoot -Recurse -Force -ErrorAction SilentlyContinue } } } |