Runtime/Invoke-OSDStageDefaultAssociations.ps1
|
[CmdletBinding()] param([string]$ConfigPath) function ConvertTo-OSDStageDefaultAssociations { param([Parameter(Mandatory)][object[]]$Associations) if ($Associations.Count -eq 0 -or $Associations.Count -gt 2048) { throw 'Specify 1 to 2048 default associations.' } $seen=@{} foreach ($item in $Associations) { if ($null -eq $item) { throw 'An association cannot be null.' } $id=$item.Identifier;$progId=$item.ProgId;$name=$item.AppName if ($id -isnot [string] -or $id -notmatch '^(?:\.[\p{L}\p{N}][\p{L}\p{N}._+-]{0,253}|[A-Za-z][A-Za-z0-9+.-]{0,254})$') { throw 'Identifier must be a file extension including its dot, or a URI scheme without a colon.' } # Handler IDs are opaque registry names, sometimes containing relative subkeys. # They are XML data only: never execute them or resolve them as filesystem paths. if ($progId -isnot [string] -or [string]::IsNullOrWhiteSpace($progId) -or $progId.Length -gt 512 -or $progId -ne $progId.Trim() -or $progId -match '[\x00-\x1F"<>|:/*?]' -or @($progId -split '\\' | Where-Object { $_ -in @('','.','..') }).Count) { throw 'ProgId must be a nonempty literal handler ID, without drive paths, quotes, wildcards or control characters.' } $null=[Xml.XmlConvert]::VerifyXmlChars($progId) if ($name -isnot [string] -or [string]::IsNullOrWhiteSpace($name) -or $name.Length -gt 512 -or $name -ne $name.Trim() -or $name -match '[\x00-\x1F]') { throw 'AppName must be nonempty display text of at most 512 characters without control characters.' } $null=[Xml.XmlConvert]::VerifyXmlChars($name) if ($seen.ContainsKey($id)) { throw ('Duplicate association identifier: ' + $id) } $seen[$id]=$true [pscustomobject]@{Identifier=$id.ToLowerInvariant();ProgId=$progId;AppName=$name} } } function Read-OSDStageDefaultAssociationsXml { param([Parameter(Mandatory)][string]$Path) $file=Get-Item -LiteralPath $Path -ErrorAction Stop if ($file.PSIsContainer -or $file.Length -gt 1MB -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Association XML must be a regular file no larger than 1 MiB.' } $settings=[Xml.XmlReaderSettings]::new() $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=1MB $reader=[Xml.XmlReader]::Create($file.FullName,$settings) try { $doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) } finally { $reader.Dispose() } $root=$doc.DocumentElement if ($null -eq $root -or $root.Name -cne 'DefaultAssociations' -or $root.NamespaceURI -ne '' -or $root.Attributes.Count -ne 0) { throw 'Expected DefaultAssociations without namespaces or policy attributes. Version and Suggested policy XML are not supported by this initial-default import.' } $items=@(foreach ($node in $root.ChildNodes) { if ($node.NodeType -in @([Xml.XmlNodeType]::Comment,[Xml.XmlNodeType]::Whitespace,[Xml.XmlNodeType]::SignificantWhitespace)) { continue } if ($node.NodeType -ne [Xml.XmlNodeType]::Element -or $node.Name -cne 'Association' -or $node.HasChildNodes -or $node.Attributes.Count -ne 3) { throw 'Only empty Association elements with Identifier, ProgId and ApplicationName are supported.' } foreach ($attribute in $node.Attributes) { if ($attribute.Name -cnotin @('Identifier','ProgId','ApplicationName')) { throw ('Unsupported association attribute: ' + $attribute.Name) } } [pscustomobject]@{Identifier=$node.GetAttribute('Identifier');ProgId=$node.GetAttribute('ProgId');AppName=$node.GetAttribute('ApplicationName')} }) ConvertTo-OSDStageDefaultAssociations -Associations $items } function ConvertTo-OSDStageDefaultAssociationsXml { param([Parameter(Mandatory)][object[]]$Associations) $items=@(ConvertTo-OSDStageDefaultAssociations -Associations $Associations) $builder=[Text.StringBuilder]::new() $settings=[Xml.XmlWriterSettings]::new();$settings.OmitXmlDeclaration=$true;$settings.Indent=$true $writer=[Xml.XmlWriter]::Create($builder,$settings) try { $writer.WriteStartElement('DefaultAssociations') foreach ($item in $items) { $writer.WriteStartElement('Association') $writer.WriteAttributeString('Identifier',$item.Identifier) $writer.WriteAttributeString('ProgId',$item.ProgId) $writer.WriteAttributeString('ApplicationName',$item.AppName) $writer.WriteEndElement() } $writer.WriteEndElement();$writer.Flush() $builder.ToString() } finally { $writer.Dispose() } } function Merge-OSDStageDefaultAssociations { param([object[]]$Baseline=@(),[Parameter(Mandatory)][object[]]$Overrides) $result=@() if ($Baseline.Count) { $result=@(ConvertTo-OSDStageDefaultAssociations -Associations $Baseline) } $updates=@(ConvertTo-OSDStageDefaultAssociations -Associations $Overrides) foreach ($item in $updates) { $result=@($result | Where-Object Identifier -ine $item.Identifier) + @($item) } ConvertTo-OSDStageDefaultAssociations -Associations $result } function Assert-OSDStageDefaultAssociationConfiguration { param([Parameter(Mandatory)]$Configuration) $id=[guid]::Empty if ([int]$Configuration.SchemaVersion -ne 2 -or -not [guid]::TryParse([string]$Configuration.DeploymentId,[ref]$id) -or [string]::IsNullOrWhiteSpace([string]$Configuration.ResultPath) -or $Configuration.Associations -isnot [System.Collections.IList]) { throw 'Invalid default associations configuration.' } $null=@(ConvertTo-OSDStageDefaultAssociations -Associations @($Configuration.Associations)) } function Assert-OSDStageDefaultAssociationContext { if (Test-Path -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT') { throw 'Default associations runtime requires full Windows.' } if (-not [Environment]::Is64BitProcess -or [Security.Principal.WindowsIdentity]::GetCurrent().User.Value -ne 'S-1-5-18') { throw 'Default associations runtime requires native 64-bit PowerShell running as SYSTEM during SetupComplete.' } $cv=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop if ($cv.InstallationType -ne 'Client' -or [int]$cv.CurrentBuildNumber -lt 22000) { throw 'This initial-default implementation requires Windows 11 client.' } foreach ($path in @('HKLM:\SOFTWARE\Policies\Microsoft\Windows\System','HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\ApplicationDefaults')) { if (Test-Path -LiteralPath $path) { $policy=Get-ItemProperty -LiteralPath $path -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace([string]$policy.DefaultAssociationsConfiguration)) { throw 'An existing default-associations policy can override initial defaults. Resolve that policy before this import.' } } } } function Invoke-OSDStageAssociationDism { param( [Parameter(Mandatory)][ValidateSet('Export','Import')][string]$Action, [Parameter(Mandatory)][string]$XmlPath, [Parameter(Mandatory)][string]$LogPath, [ValidateRange(1,600)][int]$TimeoutSeconds=300 ) foreach ($path in @($XmlPath,$LogPath)) { if (-not [IO.Path]::IsPathRooted($path) -or $path -match '["\x00-\x1F]' -or $path.EndsWith('\')) { throw 'DISM paths must be absolute file paths without quotes or control characters.' } } $arguments='/Online /{0}-DefaultAppAssociations:"{1}" /NoRestart /English /LogPath:"{2}"' -f $Action,$XmlPath,$LogPath $process=[Diagnostics.Process]::new() try { $process.StartInfo.FileName=Join-Path $env:SystemRoot 'System32\dism.exe' $process.StartInfo.Arguments=$arguments $process.StartInfo.UseShellExecute=$false;$process.StartInfo.CreateNoWindow=$true $process.StartInfo.RedirectStandardOutput=$true;$process.StartInfo.RedirectStandardError=$true if (-not $process.Start()) { throw 'Unable to start DISM.' } $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit($TimeoutSeconds*1000)) { $process.Kill();$null=$process.WaitForExit(5000) throw 'DISM timed out. Servicing may already have changed the image; no rollback, retry or reboot is requested.' } if (-not $stdout.Wait(5000) -or -not $stderr.Wait(5000)) { throw 'DISM output capture timed out; no retry is requested.' } [pscustomobject]@{Action=$Action;ExitCode=$process.ExitCode;StandardOutput=$stdout.Result;StandardError=$stderr.Result;LogPath=$LogPath} } finally { $process.Dispose() } } function Invoke-OSDStageDefaultAssociationConfiguration { param([Parameter(Mandatory)]$Configuration,[Parameter(Mandatory)][string]$WorkDirectory) Assert-OSDStageDefaultAssociationConfiguration -Configuration $Configuration $record=[ordered]@{ SchemaVersion=2;DeploymentId=[string]$Configuration.DeploymentId;Status='Failed';Outcome='Failed' RequestedCount=@($Configuration.Associations).Count;BaselineCount=0;ImportedCount=0 Export=$null;Import=$null;RestartNeeded=$false;RebootRequested=$false;UserDefaultsVerified=$false Associations=@($Configuration.Associations);Error=$null } try { Assert-OSDStageDefaultAssociationContext # All files belong to this single attempt; never reuse an earlier export after a failed command. $attempt=Join-Path $WorkDirectory ('Attempt-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $attempt -ErrorAction Stop | Out-Null $baselinePath=Join-Path $attempt 'Baseline.xml' $mergedPath=Join-Path $attempt 'Merged.xml' $record.Export=Invoke-OSDStageAssociationDism -Action Export -XmlPath $baselinePath -LogPath (Join-Path $attempt 'Export.log') if ($record.Export.ExitCode -ne 0) { throw ('DISM baseline export failed with exit code {0}; import was not attempted.' -f $record.Export.ExitCode) } $baseline=@(Read-OSDStageDefaultAssociationsXml -Path $baselinePath) $record.BaselineCount=$baseline.Count $merged=@(Merge-OSDStageDefaultAssociations -Baseline $baseline -Overrides @($Configuration.Associations)) [IO.File]::WriteAllText($mergedPath,(ConvertTo-OSDStageDefaultAssociationsXml -Associations $merged),[Text.UTF8Encoding]::new($false)) $record.Import=Invoke-OSDStageAssociationDism -Action Import -XmlPath $mergedPath -LogPath (Join-Path $attempt 'Import.log') if ($record.Import.ExitCode -notin @(0,3010)) { throw ('DISM association import failed with exit code {0}; no retry or rollback is requested.' -f $record.Import.ExitCode) } $record.ImportedCount=$merged.Count $record.RestartNeeded=($record.Import.ExitCode -eq 3010) $record.Status='Succeeded';$record.Outcome='ImportedForFirstSignIn' } catch { $record.Error=$_.Exception.Message } [pscustomobject]$record } if ($MyInvocation.InvocationName -eq '.') { return } $ErrorActionPreference='Stop' try { if (-not $ConfigPath) { $ConfigPath=Join-Path $PSScriptRoot 'config.json' } $configuration=Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop $record=Invoke-OSDStageDefaultAssociationConfiguration -Configuration $configuration -WorkDirectory $PSScriptRoot $resultPath=[Environment]::ExpandEnvironmentVariables([string]$configuration.ResultPath) New-Item -ItemType Directory -Path (Split-Path $resultPath -Parent) -Force -ErrorAction Stop | Out-Null $record | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $resultPath -Encoding UTF8 -ErrorAction Stop if ($record.Status -eq 'Failed') { Write-Error $record.Error -ErrorAction Continue;exit 1 } if ($record.RestartNeeded) { exit 3010 } exit 0 } catch { Write-Error $_.Exception.Message -ErrorAction Continue;exit 1 } |