Runtime/OSDStageOnlineApps.ps1
|
function Sync-OSDStageMicrosoftCompanyPortalGeneration { [CmdletBinding()] param([Parameter(Mandatory)][string]$CachePath, [ValidateSet('x64','arm64')][string]$Architecture = 'x64', [string]$KitPath, [switch]$NoFallback, [scriptblock]$Download = { param($uri,$destination) Save-OSDStageDownload -Uri $uri -DestinationDirectory $destination -ExpectedExtension '.zip' -Activity 'Downloading Microsoft Company Portal Preinstall Kit' }) $target = Join-Path $CachePath ('BuiltIn\MicrosoftCompanyPortal\' + $Architecture) $automatic = [string]::IsNullOrWhiteSpace($KitPath) $downloadUri = 'https://download.microsoft.com/download/ac93b367-7b17-4838-a079-c6f3377bf582/CompanyPortal-Universal-Production_x64_x86_ARM_ARM64.appxupload_Windows10_PreinstallKit.zip' $remote = $null if ($automatic) { try { $response = Invoke-WebRequest -Uri $downloadUri -Method Head -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop $remote = @{ ETag=[string]$response.Headers['ETag']; LastModified=[string]$response.Headers['Last-Modified']; Length=[string]$response.Headers['Content-Length'] } } catch { Write-Verbose 'Kit HTTP metadata unavailable; download and validate the complete kit.' } try { $current = Get-OSDStageOnlineAppCacheEntry -CachePath $CachePath -Id MicrosoftCompanyPortal -Architecture $Architecture $previous = Get-Content -LiteralPath (Join-Path $target 'CacheInfo.json') -Raw -Encoding UTF8 | ConvertFrom-Json $etag = $previous.PSObject.Properties['RemoteETag'] $modified = $previous.PSObject.Properties['RemoteLastModified'] $length = $previous.PSObject.Properties['RemoteContentLength'] $matches = $remote -and (($etag -and $remote.ETag -and $etag.Value -ceq $remote.ETag) -or (-not $remote.ETag -and $modified -and $length -and $remote.LastModified -and $remote.Length -and $modified.Value -ceq $remote.LastModified -and [string]$length.Value -eq $remote.Length)) if ($matches -and $previous.SourceUri -ceq $downloadUri) { return [pscustomobject]@{PSTypeName='OSDStage.BuiltInCache';Id='MicrosoftCompanyPortal';Version=$current.Version;Architecture=$Architecture;Cached=$true;Updated=$false;CacheReused=$false;CachePath=$target;Files=7} } } catch { Write-Verbose 'Company Portal cache requires a validated refresh.' } } $kit = $null $kitDirectory = $null Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop $candidate = $target + '.osdstage-new-' + [guid]::NewGuid().ToString('N') $backup = $target + '.osdstage-old-' + [guid]::NewGuid().ToString('N') try { if ($automatic) { $kitDirectory = Join-Path $cachePath ('.osdstage-kit-' + [guid]::NewGuid().ToString('N')) $downloadResult = & $Download $downloadUri $kitDirectory $kit = [string]$downloadResult.FullName } else { $kit = (Resolve-Path -LiteralPath $KitPath -ErrorAction Stop).Path } if ([IO.Path]::GetExtension($kit) -ne '.zip') { throw 'Expected the official Microsoft Preinstall Kit ZIP.' } $zip = [IO.Compression.ZipFile]::OpenRead($kit) try { # Only select vendor files at the ZIP root. Reject ambiguous versions. $entries = @($zip.Entries | Where-Object { $_.Name -eq $_.FullName -and $_.Length -gt 0 }) $bundles = @($entries | Where-Object { $_.Name -match '^[a-zA-Z0-9_-]+\.appxbundle$' }) $licenses = @($entries | Where-Object { $_.Name -match '^[a-zA-Z0-9_-]+_License[0-9]+\.xml$' }) if ($bundles.Count -ne 1 -or $licenses.Count -ne 1) { throw 'The Preinstall Kit must contain exactly one root appxbundle and one offline license.' } $families = @( 'Microsoft.NET.Native.Framework.2.2_', 'Microsoft.NET.Native.Runtime.2.2_', 'Microsoft.Services.Store.Engagement_', 'Microsoft.UI.Xaml.2.7_', 'Microsoft.VCLibs.140.00_' ) $dependencies = @() foreach ($family in $families) { $matches = @($entries | Where-Object { $_.Name -like ($family + '*_' + $Architecture + '__*.appx') }) if ($matches.Count -ne 1) { throw ('Expected one {0} dependency for {1}; found {2}.' -f $family,$Architecture,$matches.Count) } $dependencies += $matches[0] } New-Item -ItemType Directory -Path $candidate -Force -ErrorAction Stop | Out-Null New-Item -ItemType Directory -Path (Join-Path $candidate 'Dependencies') -Force -ErrorAction Stop | Out-Null $selected = @( @{ Entry=$bundles[0]; Relative=[string]$bundles[0].Name }, @{ Entry=$licenses[0]; Relative=[string]$licenses[0].Name } ) foreach ($dep in $dependencies) { $selected += @{ Entry=$dep; Relative=('Dependencies\' + $dep.Name) } } $files = @() foreach ($entry in $selected) { $destination = Join-Path $candidate $entry.Relative [IO.Compression.ZipFileExtensions]::ExtractToFile($entry.Entry, $destination, $true) $files += [pscustomobject]@{ Path = [string]$entry.Relative Sha256 = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash } } } finally { $zip.Dispose() } # Read app identity and version from the actual vendor-signed bundle metadata. $bundle = [IO.Compression.ZipFile]::OpenRead((Join-Path $candidate ([string]$bundles[0].Name))) try { $manifestEntry = $bundle.GetEntry('AppxMetadata/AppxBundleManifest.xml') if (-not $manifestEntry) { throw 'AppxBundleManifest.xml was not found.' } $reader = [IO.StreamReader]::new($manifestEntry.Open()) try { [xml]$bundleXml = $reader.ReadToEnd() } finally { $reader.Dispose() } $identity = $bundleXml.SelectSingleNode("//*[local-name()='Identity']") if (-not $identity -or $identity.GetAttribute('Name') -ne 'Microsoft.CompanyPortal' -or $identity.GetAttribute('Publisher') -notmatch 'Microsoft Corporation') { throw 'Unexpected Microsoft Company Portal bundle identity or publisher.' } $version = $identity.GetAttribute('Version') if ($version -notmatch '^\d+\.\d+\.\d+\.\d+$') { throw 'Invalid Company Portal version.' } } finally { $bundle.Dispose() } [ordered]@{ PSTypeName='OSDStage.BuiltInCache' Id='MicrosoftCompanyPortal'; Version=$version; Architecture=$Architecture PayloadFileName=[string]$bundles[0].Name; LicenseFileName=[string]$licenses[0].Name Sha256=[string]$files[0].Sha256; Size=(Get-Item -LiteralPath (Join-Path $candidate ([string]$bundles[0].Name))).Length Source=$(if($automatic){'Microsoft Download Center'}else{'Microsoft Preinstall Kit (operator supplied)'}) SourceUri=$(if($automatic){$downloadUri}else{$null}) RemoteETag=$(if($remote){$remote.ETag}else{$null}) RemoteLastModified=$(if($remote){$remote.LastModified}else{$null}) RemoteContentLength=$(if($remote){$remote.Length}else{$null}) SourceKitSha256=(Get-FileHash -LiteralPath $kit -Algorithm SHA256).Hash Files=@($files); DependencyCount=$dependencies.Count SyncedAt=(Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $candidate 'CacheInfo.json') -Encoding UTF8 -ErrorAction Stop $candidateInfo = Get-Content -LiteralPath (Join-Path $candidate 'CacheInfo.json') -Raw -Encoding UTF8 | ConvertFrom-Json New-OSDStageCompanyPortalManifestEntry -CacheInfo $candidateInfo -Architecture $Architecture | Out-Null if (Test-Path -LiteralPath $target) { Move-Item -LiteralPath $target -Destination $backup -ErrorAction Stop } try { Move-Item -LiteralPath $candidate -Destination $target -ErrorAction Stop } catch { if (Test-Path -LiteralPath $backup) { Move-Item -LiteralPath $backup -Destination $target -ErrorAction SilentlyContinue } throw } if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop } [pscustomobject]@{ PSTypeName='OSDStage.BuiltInCache' Id='MicrosoftCompanyPortal'; Version=$version; Architecture=$Architecture Cached=$true; Updated=$true; CacheReused=$false; CachePath=$target; Files=$files.Count } } catch { $originalError = $_ if ($automatic -and -not $NoFallback -and (Test-Path -LiteralPath (Join-Path $target 'CacheInfo.json') -PathType Leaf)) { try { $previous = Get-Content (Join-Path $target 'CacheInfo.json') -Raw | ConvertFrom-Json $entry = New-OSDStageCompanyPortalManifestEntry -CacheInfo $previous -Architecture $Architecture Assert-OSDStageCompanyPortalFiles -App $entry -StagedPath $cachePath $healthy = $true if ($healthy) { Write-Warning ('Company Portal download failed; verified cache reused: {0}' -f $originalError.Exception.Message) return [pscustomobject]@{PSTypeName='OSDStage.BuiltInCache';Id='MicrosoftCompanyPortal';Version=$previous.Version;Architecture=$Architecture;Cached=$true;Updated=$false;CacheReused=$true;CachePath=$target;Files=7} } } catch { } } throw $originalError } finally { if ($kitDirectory -and (Test-Path -LiteralPath $kitDirectory)) { Remove-Item -LiteralPath $kitDirectory -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $candidate) { Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction SilentlyContinue } } } function Sync-OSDStageCMTraceOpenGeneration { [CmdletBinding()] param([Parameter(Mandatory)][string]$CachePath, [ValidateSet('x64','arm64')][string]$Architecture = 'x64') $target = Join-Path $CachePath ('BuiltIn\CMTraceOpen\' + $Architecture) [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $release = Invoke-RestMethod -Uri 'https://api.github.com/repos/adamgell/cmtraceopen/releases/latest' -Headers @{ 'User-Agent'='OSDStage'; Accept='application/vnd.github+json' } -TimeoutSec 60 -ErrorAction Stop if ($release.draft -or $release.prerelease -or [string]$release.tag_name -notmatch '^v(?<Version>\d+\.\d+\.\d+)$') { throw 'CMTrace Open latest release is not a supported stable release.' } $version = $Matches.Version $name = 'CMTrace-Open_{0}_{1}.msi' -f $version,$Architecture $assets = @($release.assets | Where-Object { $_.name -ceq $name }) if ($assets.Count -ne 1) { throw ('Expected one MSI asset: {0}' -f $name) } $asset = $assets[0] $uri = 'https://github.com/adamgell/cmtraceopen/releases/download/v' + $version + '/' + $name if ([string]$asset.browser_download_url -cne $uri -or [string]$asset.digest -notmatch '^sha256:(?<Hash>[a-fA-F0-9]{64})$') { throw 'Unexpected release asset URI or missing official SHA-256 digest.' } $expected = $Matches.Hash.ToUpperInvariant() $active = Join-Path $target $name $infoPath = Join-Path $target 'CacheInfo.json' $info = if (Test-Path -LiteralPath $infoPath -PathType Leaf) { try { Get-Content -LiteralPath $infoPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $null } } else { $null } $existing = if (Test-Path -LiteralPath $active -PathType Leaf) { $active } else { $null } if ($info -and [string]$info.Id -eq 'CMTraceOpen' -and [string]$info.Architecture -eq $Architecture -and $info.Version -eq $version -and $info.Sha256 -eq $expected -and [string]$info.PayloadFileName -ceq $name -and $existing -and (Get-FileHash -LiteralPath $existing -Algorithm SHA256).Hash -eq $expected) { $signature = Get-AuthenticodeSignature -FilePath $existing if ($signature.Status -eq 'Valid') { return [pscustomobject]@{PSTypeName='OSDStage.BuiltInCache';Id='CMTraceOpen';Version=$version;Architecture=$Architecture;Updated=$false;CachePath=$target} } } $candidate = $target + '.osdstage-new-' + [guid]::NewGuid().ToString('N') $backup = $target + '.osdstage-old-' + [guid]::NewGuid().ToString('N') try { New-Item -ItemType Directory -Path $candidate -Force -ErrorAction Stop | Out-Null $msi = Join-Path $candidate $name Invoke-WebRequest -Uri $uri -UseBasicParsing -OutFile $msi -TimeoutSec 1800 -ErrorAction Stop if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash -ne $expected) { throw 'Downloaded CMTrace Open MSI hash does not match GitHub release digest.' } $signature = Get-AuthenticodeSignature -FilePath $msi if ($signature.Status -ne 'Valid') { throw ('MSI Authenticode validation failed: {0}' -f $signature.Status) } [ordered]@{ Id='CMTraceOpen'; Version=$version; Architecture=$Architecture Sha256=$expected; PayloadFileName=$name; Size=(Get-Item -LiteralPath $msi).Length; SourceUri=$uri Signer=[string]$signature.SignerCertificate.Subject SyncedAt=(Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $candidate 'CacheInfo.json') -Encoding UTF8 if (Test-Path -LiteralPath $target) { Move-Item -LiteralPath $target -Destination $backup -ErrorAction Stop } try { Move-Item -LiteralPath $candidate -Destination $target -ErrorAction Stop } catch { if (Test-Path -LiteralPath $backup) { Move-Item -LiteralPath $backup -Destination $target -ErrorAction SilentlyContinue } throw } if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Recurse -Force } [pscustomobject]@{PSTypeName='OSDStage.BuiltInCache';Id='CMTraceOpen';Version=$version;Architecture=$Architecture;Updated=$true;CachePath=$target} } finally { if (Test-Path -LiteralPath $candidate) { Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction SilentlyContinue } } } function Get-OSDStageOnlineAppCacheEntry { [CmdletBinding()] param( [Parameter(Mandatory)][string]$CachePath, [Parameter(Mandatory)][ValidateSet('MicrosoftCompanyPortal','CMTraceOpen')][string]$Id, [Parameter(Mandatory)][ValidateSet('x64','arm64')][string]$Architecture, [switch]$VerifySignature ) $relative = 'BuiltIn\' + $Id + '\' + $Architecture $root = Join-Path $CachePath $relative $info = Get-Content -LiteralPath (Join-Path $root 'CacheInfo.json') -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if ($Id -eq 'MicrosoftCompanyPortal') { $entry = New-OSDStageCompanyPortalManifestEntry -CacheInfo $info -Architecture $Architecture Assert-OSDStageCompanyPortalFiles -App $entry -StagedPath $CachePath return $entry } $name = 'CMTrace-Open_{0}_{1}.msi' -f $info.Version,$Architecture if ([string]$info.Id -ne $Id -or [string]$info.Architecture -ne $Architecture -or [string]$info.Version -notmatch '^\d+\.\d+\.\d+$' -or [string]$info.PayloadFileName -cne $name -or [string]$info.Sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw [IO.InvalidDataException]::new('Invalid CMTrace Open cache metadata or official release filename.') } $file = Join-Path $root $name if (-not (Test-Path -LiteralPath $file -PathType Leaf) -or (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash -ne [string]$info.Sha256) { throw [IO.InvalidDataException]::new('CMTrace Open cached MSI is missing or failed SHA-256 verification.') } if ($VerifySignature -and (Get-AuthenticodeSignature -FilePath $file -ErrorAction Stop).Status -ne 'Valid') { throw [Security.SecurityException]::new('CMTrace Open cached MSI signature invalid.') } [pscustomobject]@{ Id=$Id; DisplayName='CMTrace Open'; Source='BuiltIn'; PackageType='App'; Type='VendorMsi' Version=[string]$info.Version; Architecture=$Architecture; Vendor='CMTrace Open' Package=(Join-Path $relative $name); ExpectedSha256=[string]$info.Sha256 } } |