Public/Get-MsiSequence.ps1

function Get-MsiSequence {
    <#
    .SYNOPSIS
        Extracts installation sequences, file information, and custom actions from an MSI file, or extracts its contents.
    .DESCRIPTION
        An advanced PowerShell function that inspects an MSI file's database to extract its operational details or unpacks its contents.

        It uses the Windows Installer COM object to show:
        - The actions, conditions, and order of operations in any sequence table.
        - A complete list of files that the MSI will install and their destinations.
        - A detailed list of custom actions, including scripts, EXEs, and DLL calls that run during installation.

        Alternatively, it can perform an administrative install to extract all files to a specified directory.
    .PARAMETER MsiPath
        The full path to the MSI file to inspect. This parameter accepts pipeline input.
    .PARAMETER SequenceTable
        The names of the sequence tables to query. Specifying it turns on -ShowSequences.
    .PARAMETER ShowSequences
        A switch to display the installation sequence actions. Enabled by default.
    .PARAMETER ShowFiles
        A switch to display the list of files and their destination paths.
    .PARAMETER ShowCustomActions
        A switch to display the custom actions (e.g., scripts, EXEs) that the installer runs.
    .PARAMETER ShowInstallFlow
        A switch to output the ordered installation flow from the InstallExecuteSequence table as Msi.InstallFlowStep objects.
    .PARAMETER ExtractTo
        Specifies a directory path to extract the contents of the MSI file to.
    .EXAMPLE
        Get-MsiSequence -MsiPath "C:\installers\7z-x64.msi" -ExtractTo "C:\MsiExtract\7zip"

        Description:
        -----------
        This command extracts all files from '7z-x64.msi' into the 'C:\MsiExtract\7zip' directory.
    .EXAMPLE
        Get-MsiSequence "C:\installers\7z-x64.msi" -ShowInstallFlow

        Description:
        -----------
        This command inspects '7z-x64.msi' and displays the ordered sequence of actions that occur during installation.
    .INPUTS
        System.String. Path to an MSI file; accepts pipeline input (MsiPath).
    .OUTPUTS
        [PSCustomObject]
        Depending on the parameters used, the function outputs Msi.SequenceAction, Msi.FileInfo, Msi.CustomAction or Msi.InstallFlowStep objects. No object output is produced when extracting files.
    .NOTES
        Windows only. This function requires access to the Windows Installer service and uses COM objects. It's important that these objects are properly released to avoid leaving file locks on the MSI files.
        Extraction (msiexec /a, an administrative installation) requires administrative privileges; Microsoft's documentation describes administrative installations as run by administrators.
    #>

    [CmdletBinding(DefaultParameterSetName = 'All')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [ValidateScript({
                if (-not (Test-Path -Path $_ -PathType Leaf)) {
                    throw "File not found: $_"
                }
                if ($_.ToLower() -notlike "*.msi") {
                    throw "File is not an MSI: $_"
                }
                return $true
            })]
        [string]$MsiPath,

        [Parameter(ParameterSetName = 'All')]
        [string[]]$SequenceTable,

        [Parameter(ParameterSetName = 'All')]
        [switch]$ShowSequences,

        [Parameter(ParameterSetName = 'All')]
        [switch]$ShowFiles,

        [Parameter(ParameterSetName = 'All')]
        [switch]$ShowCustomActions,

        [Parameter(ParameterSetName = 'InstallFlow')]
        [switch]$ShowInstallFlow,

        [Parameter(ParameterSetName = 'Extract', Mandatory = $true)]
        [string]$ExtractTo
    )

    begin {
        $TelemetryArgs = @{
            ModuleName    = $MyInvocation.MyCommand.Module.Name
            ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version
            CommandName   = $MyInvocation.MyCommand.Name
            ExecutionID   = [guid]::NewGuid().ToString()
        }
        Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer
        $TelemetryFailed = $false

        try {
            if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) {
                throw 'Get-MsiSequence requires Windows (it uses the Windows Installer COM object and msiexec.exe).'
            }

            # Helper for decoding CustomAction types
            $customActionTypeMap = @{
                1 = 'DLL entry point'; 2 = 'EXE file (path in Source)'; 5 = 'JScript (from property)'; 6 = 'VBScript (from property)';
                17 = 'DLL entry point (deferred)'; 18 = 'EXE file (path in Target)'; 21 = 'JScript (from property, deferred)'; 22 = 'VBScript (from property, deferred)';
                34 = 'EXE file (path and cmd in Target)'; 37 = 'JScript (text in Target)'; 38 = 'VBScript (text in Target)';
                257 = 'DLL entry point (commit)'; 273 = 'DLL entry point (commit, deferred)';
                513 = 'DLL entry point (rollback)'; 529 = 'DLL entry point (rollback, deferred)';
            }
            $getActionType = {
                param([int]$Type)
                if ($customActionTypeMap.ContainsKey($Type)) {
                    return $customActionTypeMap[$Type]
                }
                "Unknown ($Type)"
            }
            # Setting a sequence table only makes sense when sequences are shown
            if ($PSBoundParameters.ContainsKey('SequenceTable')) {
                $ShowSequences = [switch]$true
            }
            else {
                $SequenceTable = @(
                    'InstallExecuteSequence',
                    'InstallUISequence',
                    'AdminExecuteSequence',
                    'AdminUISequence',
                    'AdvtExecuteSequence'
                )
            }
        }
        catch {
            $TelemetryFailed = $true
            Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_
            throw
        }
    }

    process {
        try {
            $resolvedPath = Resolve-Path -Path $MsiPath
            Write-Verbose "Processing MSI file: $resolvedPath"

            # --- Extract Files Logic ---
            if ($PSCmdlet.ParameterSetName -eq 'Extract') {
                $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent()
                $principal = [System.Security.Principal.WindowsPrincipal]$currentUser
                if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
                    Write-Error "Administrative privileges are required for MSI extraction. Please re-run this command in a PowerShell terminal that is running as an Administrator."
                    return
                }

                # Get-Item returns a DirectoryInfo (with FullName); Resolve-Path would return a PathInfo without it
                $destination = $null
                if (Test-Path -Path $ExtractTo -PathType Container) {
                    $destination = Get-Item -Path $ExtractTo
                }
                if (-not $destination) {
                    Write-Verbose "Destination directory '$ExtractTo' does not exist. Creating it."
                    try {
                        $destination = New-Item -Path $ExtractTo -ItemType Directory -ErrorAction Stop
                    }
                    catch {
                        Write-Error "Failed to create destination directory '$ExtractTo': $($_.Exception.Message)"
                        return
                    }
                }

                Write-Information "Extracting files from '$($resolvedPath.Path)' to '$($destination.FullName)'..." -InformationAction Continue
                # Start-Process does not quote array arguments, so pass one string with the paths quoted.
                # A trailing backslash would escape the closing quote, so it is removed (except for a drive root).
                $targetDir = $destination.FullName
                if ($targetDir.Length -gt 3) {
                    $targetDir = $targetDir.TrimEnd('\')
                }
                $msiexecArgs = '/a "{0}" /qb TARGETDIR="{1}"' -f $resolvedPath.Path, $targetDir

                Write-Verbose "Running command: msiexec.exe $msiexecArgs"

                try {
                    $process = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiexecArgs -Wait -PassThru -ErrorAction Stop

                    if ($process.ExitCode -ne 0) {
                        Write-Error "MSI extraction failed with exit code $($process.ExitCode). This may be due to insufficient permissions or an issue with the MSI package."
                    }
                    else {
                        Write-Information "Extraction completed successfully." -InformationAction Continue
                    }
                }
                catch {
                    Write-Error "Failed to start the MSI extraction process: $($_.Exception.Message)"
                }
                return # Stop further processing
            }

            $Installer = $null
            $Database = $null
            $view = $null

            try {
                $Installer = New-Object -ComObject 'WindowsInstaller.Installer'
                # Open the MSI database in read-only mode (1)
                $Database = $Installer.OpenDatabase($resolvedPath.Path, 1)

                # Determine which parameter set was used
                $paramSetName = $PSCmdlet.ParameterSetName
                if ($paramSetName -eq 'All' -and -not ($ShowSequences -or $ShowFiles -or $ShowCustomActions)) {
                    # Default to showing sequences if no specific switch is provided in the 'All' set
                    $ShowSequences = $true
                }

                # --- Show Install Flow Logic ---
                if ($ShowInstallFlow) {
                    Write-Verbose "Extracting installation flow (InstallExecuteSequence)..."
                    $customActions = @{}
                    # Pre-load custom actions for context. $caView is reset for every pipeline item so a view released
                    # while processing an earlier MSI is never closed again.
                    $caView = $null
                    try {
                        $caView = $Database.OpenView('SELECT `Action`, `Type`, `Source`, `Target` FROM `CustomAction`')
                        $caView.Execute()
                        do {
                            $record = $caView.Fetch()
                            if ($null -ne $record) {
                                $actionName = $record.StringData(1)
                                $type = $record.IntegerData(2)
                                $customActions[$actionName] = [PSCustomObject]@{
                                    ActionType = & $getActionType $type
                                    Source     = $record.StringData(3)
                                    Target     = $record.StringData(4)
                                }
                            }
                        } while ($null -ne $record)
                    }
                    catch {
                        Write-Verbose "CustomAction table not found or could not be read; skipping custom action context."
                    }
                    finally {
                        if ($caView) {
                            $caView.Close(); [System.Runtime.InteropServices.Marshal]::ReleaseComObject($caView) | Out-Null; $caView = $null
                        }
                    }

                    $installFlow = [System.Collections.Generic.List[PSObject]]::new()
                    try {
                        $view = $Database.OpenView('SELECT `Action`, `Condition`, `Sequence` FROM `InstallExecuteSequence`')
                        $view.Execute()
                        do {
                            $record = $view.Fetch()
                            if ($null -ne $record) {
                                $actionName = $record.StringData(1)
                                $installFlow.Add([PSCustomObject]@{
                                        PSTypeName  = 'Msi.InstallFlowStep'
                                        Sequence    = $record.IntegerData(3)
                                        Action      = $actionName
                                        Condition   = if ([string]::IsNullOrEmpty($record.StringData(2))) { $null } else { $record.StringData(2) }
                                        Description = if ($customActions.ContainsKey($actionName)) { "Custom Action: $($customActions[$actionName].ActionType)" } else { "Standard Action" }
                                        Details     = if ($customActions.ContainsKey($actionName)) { "Source: $($customActions[$actionName].Source), Target: $($customActions[$actionName].Target)" } else { "" }
                                    })
                            }
                        } while ($null -ne $record)

                        # Output the installation flow in sequence order
                        $installFlow | Sort-Object -Property Sequence
                    }
                    catch {
                        if ($_.Exception.Message -like "*Table not found*") {
                            Write-Verbose "Table 'InstallExecuteSequence' does not exist in this MSI."
                        }
                        else {
                            Write-Error "An error occurred while querying the InstallExecuteSequence table: $($_.Exception.Message)"
                        }
                    }
                    finally {
                        if ($view) {
                            $view.Close(); [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null; $view = $null
                        }
                    }
                }

                # --- Show Sequences Logic ---
                if ($ShowSequences) {
                    foreach ($table in $SequenceTable) {
                        Write-Verbose "Querying sequence table: $table"
                        try {
                            $query = 'SELECT `Action`, `Condition`, `Sequence` FROM `{0}`' -f $table
                            $view = $Database.OpenView($query)
                            $view.Execute()

                            do {
                                $record = $view.Fetch()
                                if ($null -ne $record) {
                                    $sequenceNumber = $record.IntegerData(3)
                                    if ($sequenceNumber -ne 0) {
                                        [PSCustomObject]@{
                                            PSTypeName = 'Msi.SequenceAction'
                                            MsiPath    = $resolvedPath.Path
                                            TableName  = $table
                                            Sequence   = $sequenceNumber
                                            Action     = $record.StringData(1)
                                            Condition  = if ([string]::IsNullOrEmpty($record.StringData(2))) { $null } else { $record.StringData(2) }
                                        }
                                    }
                                }
                            } while ($null -ne $record)
                        }
                        catch {
                            if ($_.Exception.Message -like "*Table not found*") {
                                Write-Verbose "Table '$table' does not exist in this MSI."
                            }
                            else {
                                Write-Error "An error occurred while querying table '$table' in '$resolvedPath': $($_.Exception.Message)"
                            }
                        }
                        finally {
                            if ($view) {
                                $view.Close(); [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null; $view = $null
                            }
                        }
                    }
                }

                # --- Show Files Logic ---
                if ($ShowFiles) {
                    Write-Verbose "Extracting file information..."

                    # 1. Read Directory table to build a path map
                    $directoryMap = @{}
                    $unresolvedDirs = [System.Collections.Generic.List[PSCustomObject]]::new()
                    try {
                        $view = $Database.OpenView('SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`')
                        $view.Execute()
                        do {
                            $record = $view.Fetch()
                            if ($null -ne $record) {
                                $dirId = $record.StringData(1)
                                $parentId = $record.StringData(2)
                                $dirName = $record.StringData(3).Split('|')[-1] # Get long name if available

                                if (-not $parentId) {
                                    # Root directory
                                    $directoryMap[$dirId] = $dirName
                                }
                                else {
                                    $unresolvedDirs.Add([PSCustomObject]@{ DirId = $dirId; ParentId = $parentId; DirName = $dirName })
                                }
                            }
                        } while ($null -ne $record)
                    }
                    finally {
                        if ($view) {
                            $view.Close(); [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null; $view = $null
                        }
                    }

                    # Resolve directory paths iteratively
                    $loopLimit = $unresolvedDirs.Count + 5
                    while ($unresolvedDirs.Count -gt 0 -and $loopLimit-- -gt 0) {
                        for ($i = $unresolvedDirs.Count - 1; $i -ge 0; $i--) {
                            $dir = $unresolvedDirs[$i]
                            if ($directoryMap.ContainsKey($dir.ParentId)) {
                                $directoryMap[$dir.DirId] = Join-Path -Path $directoryMap[$dir.ParentId] -ChildPath $dir.DirName
                                $unresolvedDirs.RemoveAt($i)
                            }
                        }
                    }

                    # 2. Map components to directories
                    $componentDirMap = @{}
                    try {
                        $view = $Database.OpenView('SELECT `Component`, `Directory_` FROM `Component`')
                        $view.Execute()
                        do {
                            $record = $view.Fetch()
                            if ($null -ne $record) {
                                $componentDirMap[$record.StringData(1)] = $record.StringData(2)
                            }
                        } while ($null -ne $record)
                    }
                    finally {
                        if ($view) {
                            $view.Close(); [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null; $view = $null
                        }
                    }

                    # 3. Read File table and output file info
                    try {
                        $view = $Database.OpenView('SELECT `File`, `Component_`, `FileName`, `FileSize` FROM `File`')
                        $view.Execute()
                        do {
                            $record = $view.Fetch()
                            if ($null -ne $record) {
                                $componentId = $record.StringData(2)
                                $fileName = $record.StringData(3).Split('|')[-1] # Get long name
                                $dirId = $componentDirMap[$componentId]
                                $installPath = $directoryMap[$dirId]

                                [PSCustomObject]@{
                                    PSTypeName  = 'Msi.FileInfo'
                                    MsiPath     = $resolvedPath.Path
                                    FileName    = $fileName
                                    FileSize    = $record.IntegerData(4)
                                    InstallPath = if ($installPath) { Join-Path -Path $installPath -ChildPath $fileName } else { $fileName }
                                }
                            }
                        } while ($null -ne $record)
                    }
                    catch {
                        if ($_.Exception.Message -like "*Table not found*") {
                            Write-Verbose "Table 'File' does not exist in this MSI."
                        }
                        else {
                            Write-Error "An error occurred while querying the File table: $($_.Exception.Message)"
                        }
                    }
                    finally {
                        if ($view) {
                            $view.Close(); [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null; $view = $null
                        }
                    }
                }

                # --- Show Custom Actions Logic ---
                if ($ShowCustomActions) {
                    Write-Verbose "Extracting custom action information..."
                    try {
                        $view = $Database.OpenView('SELECT `Action`, `Type`, `Source`, `Target` FROM `CustomAction`')
                        $view.Execute()
                        do {
                            $record = $view.Fetch()
                            if ($null -ne $record) {
                                $type = $record.IntegerData(2)
                                [PSCustomObject]@{
                                    PSTypeName = 'Msi.CustomAction'
                                    MsiPath    = $resolvedPath.Path
                                    ActionName = $record.StringData(1)
                                    ActionType = & $getActionType $type
                                    Source     = $record.StringData(3)
                                    Target     = $record.StringData(4)
                                }
                            }
                        } while ($null -ne $record)
                    }
                    catch {
                        if ($_.Exception.Message -like "*Table not found*") {
                            Write-Verbose "Table 'CustomAction' does not exist in this MSI."
                        }
                        else {
                            Write-Error "An error occurred while querying the CustomAction table: $($_.Exception.Message)"
                        }
                    }
                    finally {
                        if ($view) {
                            $view.Close(); [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null; $view = $null
                        }
                    }
                }
            }
            catch {
                Write-Error "Failed to process MSI file '$resolvedPath': $($_.Exception.Message)"
            }
            finally {
                # Clean up database COM object for the current file
                if ($Database) {
                    [System.Runtime.InteropServices.Marshal]::ReleaseComObject($Database) | Out-Null
                }
                if ($Installer) {
                    [System.Runtime.InteropServices.Marshal]::ReleaseComObject($Installer) | Out-Null
                }
                [System.GC]::Collect()
            }
        }
        catch {
            if (-not $TelemetryFailed) {
                $TelemetryFailed = $true
                Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_
            }
            throw
        }
    }

    end {
        if (-not $TelemetryFailed) {
            Invoke-TelemetryCollection @TelemetryArgs -Stage End
        }
    }
}