Public/Extract-SCOMMSI.ps1

Function Extract-SCOMMSI {
  <#
      .SYNOPSIS
      Extracts sealed Management Pack files (.mp, .mpb) from SCOM MSI installer packages.
 
      .DESCRIPTION
      Extract-SCOMMSI processes one or more .msi installer packages and extracts any sealed
      Management Pack files (.mp, .mpb) contained within them. Extraction uses msiexec admin
      install (msiexec /a) to reliably unpack the MSI contents.
 
      The function supports two input modes:
      - Path mode (default): Specify paths to .msi files or directories containing .msi files.
      - Pipeline mode: Pipe FileInfo objects (e.g., from Get-ChildItem) for selective processing.
 
      Use -PassThru to emit FileInfo objects for each extracted .mp/.mpb file, enabling pipeline
      composition with Unseal-SCOMMP.
 
      .PARAMETER Path
      One or more paths to .msi files or directories containing .msi files. Directories are
      searched recursively for .msi files.
 
      .PARAMETER InputObject
      A System.IO.FileInfo object received from the pipeline. Non-.msi files are skipped with
      a warning.
 
      .PARAMETER OutputDir
      Path to the output directory where extracted .mp/.mpb files will be written. Created
      automatically if it does not exist.
 
      .PARAMETER PerPackage
      Creates a subfolder per MSI file (named after the MSI basename) in the output directory.
      If a subfolder with that name already exists, a numeric suffix is appended (2, 3, 4...).
 
      .PARAMETER Force
      Overwrites existing files in the output directory. Without this switch, existing files
      with the same name are skipped.
 
      .PARAMETER PassThru
      Emits a System.IO.FileInfo object for each extracted .mp/.mpb file. This enables piping
      directly to Unseal-SCOMMP for end-to-end extraction and unsealing.
 
      .INPUTS
      System.IO.FileInfo — .msi file objects from Get-ChildItem.
 
      .OUTPUTS
      System.IO.FileInfo — when -PassThru is specified; one object per extracted .mp/.mpb file.
      None — when -PassThru is not specified.
 
      .EXAMPLE
      Extract-SCOMMSI -Path 'C:\Downloads\SCOM_MPs.msi' -OutputDir 'C:\Extracted'
 
      Extracts all .mp and .mpb files from a single MSI package.
 
      .EXAMPLE
      Extract-SCOMMSI -Path 'C:\Downloads\MSIs' -OutputDir 'C:\Extracted' -PerPackage
 
      Extracts from all MSI files in the directory, creating a subfolder per MSI.
 
      .EXAMPLE
      Get-ChildItem 'C:\Downloads' -Filter '*.msi' | Extract-SCOMMSI -OutputDir 'C:\Extracted'
 
      Pipes MSI files from a directory into Extract-SCOMMSI.
 
      .EXAMPLE
      Extract-SCOMMSI -Path 'C:\Downloads\*.msi' -OutputDir 'C:\Extracted' -PassThru
 
      Extracts and emits FileInfo objects for each extracted MP file.
 
      .EXAMPLE
      Extract-SCOMMSI -Path 'C:\Downloads' -OutputDir 'C:\Extracted' -PassThru | Unseal-SCOMMP -OutputDir 'C:\Unsealed'
 
      End-to-end pipeline: extracts .mp/.mpb from MSI packages then pipes them to Unseal-SCOMMP
      for unsealing to XML.
 
      .EXAMPLE
      Extract-SCOMMSI -Path 'C:\MSI1.msi','C:\MSI2.msi' -OutputDir 'C:\Out' -PerPackage -Force
 
      Processes multiple MSI files, creates per-package subfolders, and overwrites existing output.
 
      .NOTES
      Author: Tyson Paul
      Blog: https://monitoringguys.com/
      Requires: msiexec.exe (included with Windows)
      History:
      2025.07.14 - Initial. Extracted from Export-SCOMManagementPackHelper as a focused MSI extraction function.
      2026.04.10 - Added temp-copy for long input paths (>200 chars) to avoid msiexec 1603 failures.
 
      .LINK
      Unseal-SCOMMP
      Get-SCOMMPFileInfo
  #>


  [CmdletBinding(DefaultParameterSetName = 'ByPath')]
  param(
    [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByPath')]
    [ValidateScript({ Test-Path $_ })]
    [string[]]$Path,

    [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByPipeline')]
    [System.IO.FileInfo]$InputObject,

    [Parameter(Mandatory, Position = 1)]
    [string]$OutputDir,

    [Parameter()]
    [switch]$PerPackage,

    [Parameter()]
    [switch]$Force,

    [Parameter()]
    [switch]$PassThru
  )

  Begin {
    #region Internal helper: Get unique subfolder name
    function Get-UniqueSubfolder {
      param([string]$ParentDir, [string]$BaseName)
      $candidate = Join-Path $ParentDir $BaseName
      if (-not (Test-Path $candidate)) { return $candidate }
      $counter = 2
      while (Test-Path (Join-Path $ParentDir "$BaseName$counter")) { $counter++ }
      return (Join-Path $ParentDir "$BaseName$counter")
    }
    #endregion

    # Create output directory if needed
    if (-not (Test-Path $OutputDir)) {
      Write-Verbose "Creating output directory: $OutputDir"
      New-Item -Path $OutputDir -ItemType Directory -Force | Out-Null
    }

    $allFailed = @()
    $processedCount = 0

    # Collect MSI files for Path mode
    $msiFiles = @()
    if ($PSCmdlet.ParameterSetName -eq 'ByPath') {
      foreach ($p in $Path) {
        $item = Get-Item $p
        if ($item.PSIsContainer) {
          $msiFiles += @(Get-ChildItem $item.FullName -Filter '*.msi' -Recurse -File -ErrorAction SilentlyContinue)
        }
        elseif ($item.Extension -eq '.msi') {
          $msiFiles += $item
        }
        else {
          Write-Warning "Skipping '$($item.Name)' - not an .msi file or directory."
        }
      }
      if ($msiFiles.Count -eq 0) {
        Write-Warning "No .msi files found in the specified path(s)."
        return
      }
      Write-Verbose "Found $($msiFiles.Count) .msi file(s) to process."
    }
  }

  Process {
    if ($PSCmdlet.ParameterSetName -eq 'ByPipeline') {
      if ($InputObject.Extension -ne '.msi') {
        Write-Warning "Skipping '$($InputObject.Name)' - not an .msi file."
        return
      }
      $msiFiles = @($InputObject)
    }

    foreach ($msiFile in $msiFiles) {
      $processedCount++
      $tempExtractDir = $null
      $tempMsiCopy = $null
      try {
        Write-Verbose "Processing MSI: $($msiFile.FullName)"

        # Copy MSI to temp if input path is long (msiexec fails >~200 chars)
        $msiPath = $msiFile.FullName
        if ($msiPath.Length -gt 200) {
          $tempMsiCopy = Join-Path $env:TEMP $msiFile.Name
          Write-Verbose " Input path is $($msiPath.Length) chars — copying to temp to avoid msiexec path limits."
          Copy-Item -LiteralPath $msiPath -Destination $tempMsiCopy -Force
          $msiPath = $tempMsiCopy
        }

        # Determine output directory for this MSI
        if ($PerPackage) {
          $msiBaseName = [System.IO.Path]::GetFileNameWithoutExtension($msiFile.Name)
          $msiOutDir = Get-UniqueSubfolder -ParentDir $OutputDir -BaseName $msiBaseName
        }
        else {
          $msiOutDir = $OutputDir
        }
        New-Item -Path $msiOutDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null

        # Create temp working directory for extraction
        $tempExtractDir = Join-Path $msiOutDir "_msi_extract_temp_$([guid]::NewGuid().ToString('N').Substring(0,8))"
        New-Item -Path $tempExtractDir -ItemType Directory -Force | Out-Null

        # Extract MSI contents using msiexec admin install
        Write-Verbose " Extracting MSI contents via msiexec /a ..."
        $msiexecArgs = "/a `"$msiPath`" /qn TARGETDIR=`"$tempExtractDir`""
        $proc = Start-Process msiexec.exe -ArgumentList $msiexecArgs -Wait -PassThru -NoNewWindow
        if ($proc.ExitCode -ne 0) {
          throw "msiexec admin install failed with exit code $($proc.ExitCode)"
        }

        # Find all MP/MPB files in extracted content
        $mpFiles = @(Get-ChildItem $tempExtractDir -Include '*.mp','*.mpb' -Recurse -File -ErrorAction SilentlyContinue)
        Write-Verbose " Found $($mpFiles.Count) Management Pack file(s) in MSI."

        if ($mpFiles.Count -eq 0) {
          Write-Warning "No .mp or .mpb files found in MSI '$($msiFile.Name)'."
          $allFailed += $msiFile.Name
          continue
        }

        # Copy extracted MP/MPB files to output
        foreach ($mpf in $mpFiles) {
          $destFile = Join-Path $msiOutDir $mpf.Name
          if ((Test-Path -LiteralPath $destFile) -and -not $Force) {
            Write-Verbose " Skipping '$($mpf.Name)' - exists. Use -Force to overwrite."
          }
          else {
            Copy-Item -LiteralPath $mpf.FullName -Destination $destFile -Force
            Write-Verbose " -> Extracted: $destFile"
          }
          if ($PassThru) {
            Get-Item -LiteralPath $destFile
          }
        }
      }
      catch {
        Write-Warning "Failed to process MSI '$($msiFile.Name)': $_"
        $allFailed += $msiFile.Name
      }
      finally {
        # Clean up temp extraction directory
        if ($tempExtractDir -and (Test-Path $tempExtractDir)) {
          Remove-Item -Path $tempExtractDir -Recurse -Force -ErrorAction SilentlyContinue
        }
        # Clean up temp MSI copy
        if ($tempMsiCopy -and (Test-Path $tempMsiCopy)) {
          Remove-Item -Path $tempMsiCopy -Force -ErrorAction SilentlyContinue
        }
      }
    }

    # Reset for next pipeline iteration
    if ($PSCmdlet.ParameterSetName -eq 'ByPipeline') {
      $msiFiles = @()
    }
  }

  End {
    $successCount = $processedCount - $allFailed.Count
    if ($processedCount -gt 0) {
      Write-Verbose "Extraction complete. $successCount of $processedCount MSI(s) succeeded."
    }
    if ($allFailed.Count -gt 0) {
      Write-Warning "The following $($allFailed.Count) MSI(s) failed to process:"
      $allFailed | ForEach-Object { Write-Warning " - $_" }
    }
  }

}