errors.ps1

#Requires -Version 5.0

function ConvertTo-PSFErrorCode {
  [CmdletBinding()]
  [OutputType([string])]
  param ([object]$Code)

  $text = [Convert]::ToString($Code, [Globalization.CultureInfo]::InvariantCulture).Trim()
  if ($text -match '^0[xX][0-9a-fA-F]{1,8}$') {
    $number = [Convert]::ToInt64($text.Substring(2), 16)
  }
  elseif ($text -match '^-?\d{1,10}$') { $number = [long]::Parse($text, [Globalization.CultureInfo]::InvariantCulture) }
  else { throw 'Error code must be a 32-bit integer or hexadecimal code.' }
  if ($number -lt [int]::MinValue -or $number -gt [uint32]::MaxValue) {
    throw 'Error code is outside the signed/unsigned 32-bit range.'
  }
  if ($number -lt 0) { $number += 4294967296L }
  return $number.ToString('X8', [Globalization.CultureInfo]::InvariantCulture)
}

function Get-ErrorTranslation {
  <#
    .SYNOPSIS
      Translates known deployment error codes into operator guidance.
    .DESCRIPTION
      Uses separate Appx, Winget, Dism, and Msi tables. Returns Code, Domain,
      Benign, Detail, and Matched, or no result for an unknown/ambiguous error.
      Code is an uppercase hexadecimal HRESULT, or a decimal MSI exit code.
      Recognized exception HRESULTs take precedence (outer to inner), followed
      by codes in ErrorDetails and exception messages. Multiple distinct known
      codes in text are ambiguous and produce no result. Text matching accepts
      hexadecimal HRESULTs and signed/unsigned decimal HRESULTs, never short
      decimal substrings that could be counts or identifiers.
      Benign means an unconditional success code, not permission to suppress
      an error merely because it is recognized. Conditional cases, including
      already-installed versions, remain false; the caller decides whether its
      intended state is satisfied. Reboot success codes retain guidance about
      completing the restart. This function does not log, retry, or change state.
    .PARAMETER ErrorRecord
      A caught or pipeline ErrorRecord. Defaults to searching all domains.
    .PARAMETER Code
      A native exit code or HRESULT, as an integer or hexadecimal string.
      A domain is required for this parameter set to disambiguate native codes.
    .PARAMETER Domain
      Restrict lookup to Appx, Winget, Dism, or Msi. WinGet's table describes
      its own codes; translate wrapped installer failures in their own domain.
    .EXAMPLE
      PS> Get-ErrorTranslation -ErrorRecord $_ -Domain Appx
    .EXAMPLE
      PS> Get-ErrorTranslation -Code 3010 -Domain Msi
    .OUTPUTS
      PSCustomObject, or no output when a translation cannot be selected.
    .LINK
      https://learn.microsoft.com/en-us/windows/win32/appxpkg/troubleshooting
    .LINK
      https://learn.microsoft.com/en-us/windows/win32/msi/error-codes
    .LINK
      https://github.com/microsoft/winget-cli/blob/master/doc/windows/package-manager/winget/returnCodes.md
    .LINK
      https://learn.microsoft.com/en-us/troubleshoot/windows-client/application-management/dotnet-framework-35-installation-error
  #>

  [CmdletBinding(DefaultParameterSetName = 'Record')]
  [OutputType([PSCustomObject])]
  param (
    [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Record')]
    [System.Management.Automation.ErrorRecord]$ErrorRecord,
    [Parameter(Mandatory = $true, ParameterSetName = 'Code')]
    [ValidateNotNullOrEmpty()]
    [object]$Code,
    [Parameter(Mandatory = $true, ParameterSetName = 'Code')]
    [Parameter(ParameterSetName = 'Record')]
    [ValidateSet('Appx', 'Winget', 'Dism', 'Msi')]
    [string]$Domain
  )

  begin {
    # Small, intentionally curated tables. Source references are in public help.
    $tables = [ordered]@{
      Appx   = @{
        '80073CF0' = @($false, 'The package could not be opened. Check its path, access, signature, and the AppxPackagingOM log.')
        '80073CF3' = @($false, 'Dependency or conflict validation failed. Check dependencies, architecture, and AppXDeployment-Server logs.')
        '80073CF9' = @($false, 'Package installation failed. Inspect AppXDeployment-Server logs for the specific cause.')
        '80073CFB' = @($false, 'A package is already installed and blocks reinstalling this package. Verify identity and version before deciding to skip.')
        '80073D02' = @($false, 'Resources needed by the package are in use. Close affected applications and retry when the locks are released.')
        '80073D06' = @($false, 'A newer package version is installed. Skip only if that version satisfies the requested state; downgrades remain failures.')
      }
      Winget = @{
        '8A150011' = @($false, 'The installer hash differs from the manifest. Refresh the source and investigate the mismatch; do not bypass verification.')
        '8A15002B' = @($false, 'No applicable update was found. Verify the installed version and requested target before deciding to skip.')
        '8A15002C' = @($false, 'One or more upgrades failed. Review the individual package results and WinGet logs.')
      }
      Dism   = @{
        '800F081F' = @($false, 'Required source files were not found. Provide a repair source matching the target Windows image and inspect DISM/CBS logs.')
        '800F0906' = @($false, 'Required source files could not be downloaded. Check connectivity and servicing-source policy, or provide a matching local source.')
      }
      Msi    = @{
        '00000000' = @($true, 'The installer completed successfully.')
        '00000642' = @($false, 'The user cancelled installation. Retry only when installation is still intended.')
        '00000643' = @($false, 'Installation failed. Enable verbose MSI logging and inspect the failure before retrying.')
        '00000652' = @($false, 'Another installation is running. Wait for it to finish before retrying.')
        '00000666' = @($false, 'Another version of the product is installed. Check the requested version and upgrade path.')
        '00000669' = @($true, 'Installation succeeded and initiated a restart. Resume dependent work after restart.')
        '00000BC2' = @($true, 'Installation succeeded; a restart is required to complete it. Schedule a restart before dependent work.')
      }
    }
  }
  process {
    $domains = @($tables.Keys)
    if ($Domain) { $domains = @($tables.Keys | Where-Object { $_ -eq $Domain }) }
    $structured = [Collections.Generic.List[string]]::new()
    $textCodes = [Collections.Generic.List[string]]::new()
    if ($PSCmdlet.ParameterSetName -eq 'Code') {
      $structured.Add((ConvertTo-PSFErrorCode -Code $Code))
    }
    else {
      $messages = [Collections.Generic.List[string]]::new()
      if ($ErrorRecord.ErrorDetails) { $messages.Add($ErrorRecord.ErrorDetails.Message) }
      $exception = $ErrorRecord.Exception
      # Bound traversal even for unusual exception implementations.
      for ($depth = 0; $null -ne $exception -and $depth -lt 32; $depth++) {
        $structured.Add((ConvertTo-PSFErrorCode -Code $exception.HResult))
        $messages.Add($exception.Message)
        $exception = $exception.InnerException
      }
      foreach ($message in $messages) {
        foreach ($match in [regex]::Matches($message, '(?i)(?<![\w])(?:0x[0-9a-f]{8}|-\d{10}|[23]\d{9})(?![\w])')) {
          try { $textCodes.Add((ConvertTo-PSFErrorCode -Code $match.Value)) }
          catch { continue }
        }
      }
    }

    $selected = $null
    foreach ($candidate in $structured) {
      $hits = @($domains | Where-Object { $tables[$_].ContainsKey($candidate) })
      if ($hits.Count -eq 1) { $selected = @($candidate, $hits[0]); break }
      if ($hits.Count -gt 1) { return }
    }
    if ($null -eq $selected) {
      $matchesFound = @(
        foreach ($candidate in @($textCodes | Select-Object -Unique)) {
          foreach ($candidateDomain in $domains) {
            if ($tables[$candidateDomain].ContainsKey($candidate)) {
              , @($candidate, $candidateDomain)
            }
          }
        }
      )
      if ($matchesFound.Count -ne 1) { return }
      $selected = $matchesFound[0]
    }
    $translation = $tables[$selected[1]][$selected[0]]
    $displayCode = '0x' + $selected[0]
    if ($selected[1] -eq 'Msi') { $displayCode = [Convert]::ToUInt32($selected[0], 16).ToString([Globalization.CultureInfo]::InvariantCulture) }
    [PSCustomObject]@{
      Code    = $displayCode
      Domain  = $selected[1]
      Benign  = [bool]$translation[0]
      Detail  = $translation[1]
      Matched = $true
    }
  }
}