MSIX.Pipeline.ps1

function Invoke-MsixPipeline {
    <#
    .SYNOPSIS
        Runs a full unpack → validate → modify → repack → sign pipeline on an MSIX,
        signing only ONCE at the very end (no per-stage re-signs).
 
    .DESCRIPTION
        All modifications happen in an isolated GUID workspace. Behaviour:
 
          - With -OutputPath: original file is preserved; pipeline result is
            written there. Use this for staged/dry-run runs.
          - Without -OutputPath: file is overwritten in-place after success.
 
        Stages run in order, each touching the SAME workspace, and signing is
        deferred until everything is repacked. This avoids the previous
        per-stage resign that wasted time and risked publisher drift.
 
    .PARAMETER PackagePath
        Path to the .msix file. Overwritten in place unless -OutputPath is
        also supplied.
 
    .PARAMETER OutputPath
        Optional output path. Defaults to overwriting -PackagePath.
 
    .PARAMETER Config
        Hashtable controlling pipeline stages. Supported keys:
 
          Publisher [string] New publisher DN. Omit to skip.
 
          PSF [hashtable] Keys:
            Fixups [hashtable[]]
            PsfSourcePath [string]
            WorkingDirectory [string]
            AppOptions [hashtable[]]
            AdditionalFiles [string[]]
 
          AppIsolation [hashtable] Keys:
            Capabilities [string[]] Add Win32 isolation capabilities
 
          Signing [hashtable] Keys:
            Pfx [string]
            PfxPassword [SecureString]
            TimestampUrl [string]
            Skip [bool] Skip signing entirely (default: false)
            UnsignedOutputPath [string] When signing fails, copy the
                                                unsigned scratch package here so
                                                the operator can manually re-sign.
                                                The original target is never
                                                overwritten when signing fails.
 
    .OUTPUTS
        [System.IO.FileInfo] for the final signed package, or $null in
        WhatIf preview mode.
 
    .EXAMPLE
        # Publisher rename only — minimal config
        Invoke-MsixPipeline -PackagePath app.msix -Config @{
            Publisher = 'CN=Contoso, O=Contoso, C=NL'
            Signing = @{ Pfx = 'cert.pfx'; PfxPassword = $pw }
        }
 
    .EXAMPLE
        # PSF injection only (file redirection)
        $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log'
        Invoke-MsixPipeline -PackagePath app.msix -Config @{
            PSF = @{ Fixups = @($fixup) }
            Signing = @{ Pfx = 'cert.pfx'; PfxPassword = $pw }
        }
 
    .EXAMPLE
        # Full pipeline: Publisher rename + PSF + Signing + UnsignedOutputPath
        # (preserves the unsigned package if signing fails)
        $fixup = New-MsixPsfFileRedirectionConfig -Base 'logs' -Patterns '.*\.log'
        Invoke-MsixPipeline -PackagePath app.msix -OutputPath app-fixed.msix `
            -Config @{
                Publisher = 'CN=Contoso, O=Contoso, C=NL'
                PSF = @{ Fixups = @($fixup) }
                Signing = @{
                    Pfx = 'cert.pfx'
                    PfxPassword = $pw
                    UnsignedOutputPath = 'C:\drop\app-unsigned.msix'
                }
            }
 
    .EXAMPLE
        # Preview mode: -WhatIf still runs unpack/edit/pack so you can inspect
        # the would-be result; signing and the final Move-Item are skipped.
        Invoke-MsixPipeline -WhatIf -PackagePath app.msix -Config $cfg
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [Parameter(Mandatory)]
        [hashtable]$Config,
        [string]$OutputPath
    )

    $toolsRoot = Get-MsixToolsRoot
    $fileinfo  = Get-Item -LiteralPath $PackagePath -ErrorAction Stop
    $workspace = New-MsixWorkspace $fileinfo.BaseName
    $target    = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName }

    # Compute WhatIf semantics once. In WhatIf mode the unpack + edit + pack
    # stages still run (so the user can preview the modified package), but the
    # destructive signing + final Move-Item to $target are skipped. If
    # Config.Signing.UnsignedOutputPath is set, the scratch package is copied
    # there so the user can inspect what would have been produced.
    $isWhatIf = -not $PSCmdlet.ShouldProcess($PackagePath, 'Run MSIX pipeline')

    try {
        Write-MsixLog Info "=== MSIX Pipeline: $($fileinfo.Name) -> $target ==="
        if ($isWhatIf) {
            Write-MsixLog Info '[WhatIf] Preview mode: unpack/edit/pack will run; signing and final replacement will be skipped.'
        }

        # ── Unpack into workspace ────────────────────────────────────────
        Write-MsixLog Info 'Stage: Unpack'
        $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o')
        Assert-MsixProcessSuccess $r 'MakeAppx unpack'

        # ── Validate ─────────────────────────────────────────────────────
        Write-MsixLog Info 'Stage: Validate'
        $null = Test-MsixManifest "$workspace\AppxManifest.xml"
        [xml]$manifest = Get-MsixManifest "$workspace\AppxManifest.xml"
        $manifestDirty = $false

        # ── Publisher update ─────────────────────────────────────────────
        if ($Config.Publisher) {
            $oldPublisher = $manifest.Package.Identity.Publisher
            if ($oldPublisher -cne $Config.Publisher) {
                Set-MsixManifestPublisher -Manifest $manifest -Publisher $Config.Publisher | Out-Null
                $manifestDirty = $true
                Write-MsixLog Info "Publisher: $oldPublisher → $($Config.Publisher)"
            } else {
                Write-MsixLog Info 'Publisher unchanged (already matches)'
            }
        }

        # ── App Isolation ────────────────────────────────────────────────
        if ($Config.AppIsolation -and $Config.AppIsolation.Capabilities) {
            Add-MsixManifestNamespace $manifest 'rescap'
            Set-MsixManifestMaxVersionTested $manifest -MinBuild 26100
            $rescapUri = Get-MsixManifestNamespaceUri 'rescap'
            $capsNode  = $manifest.Package.Capabilities
            if (-not $capsNode) {
                $capsNode = $manifest.CreateElement('Capabilities', $manifest.Package.NamespaceURI)
                $null     = $manifest.Package.AppendChild($capsNode)
            }
            foreach ($cap in @($Config.AppIsolation.Capabilities)) {
                $existing = $capsNode.ChildNodes | Where-Object { $_.LocalName -eq 'Capability' -and $_.Name -eq $cap }
                if (-not $existing) {
                    $node = $manifest.CreateElement('rescap:Capability', $rescapUri)
                    $node.SetAttribute('Name', $cap)
                    $null = $capsNode.AppendChild($node)
                    Write-MsixLog Info "Capability added: $cap"
                    $manifestDirty = $true
                }
            }
        }

        if ($manifestDirty) {
            Save-MsixManifest $manifest "$workspace\AppxManifest.xml"
        }

        # ── Repack to SCRATCH (never to $target until sign succeeds) ─────
        # Atomic pack-then-sign: original target is preserved if signing fails.
        $scratchExt = [System.IO.Path]::GetExtension($target)
        if (-not $scratchExt) { $scratchExt = '.msix' }
        $scratch = Join-Path $env:TEMP ("msix-pipeline-{0}-{1}{2}" -f `
            $fileinfo.BaseName, ([guid]::NewGuid().ToString('N').Substring(0,8)), $scratchExt)

        $needsPsf      = [bool]$Config.PSF
        $packSucceeded = $false
        $signSucceeded = $false

        try {
            if ($needsPsf) {
                Write-MsixLog Info 'Stage: PSF injection'
                $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $scratch, '/d', $workspace, '/o')
                Assert-MsixProcessSuccess $r 'MakeAppx pack (pre-PSF scratch)'

                $psfArgs = @{
                    PackagePath  = $scratch
                    Fixups       = $Config.PSF.Fixups
                    SkipSigning  = $true        # we sign once at the end
                }
                foreach ($k in 'PsfSourcePath','WorkingDirectory','AppOptions','AdditionalFiles') {
                    if ($Config.PSF.ContainsKey($k)) { $psfArgs[$k] = $Config.PSF[$k] }
                }
                Add-MsixPsfV2 @psfArgs
            } else {
                Write-MsixLog Info 'Stage: Repack'
                $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $scratch, '/d', $workspace, '/o')
                Assert-MsixProcessSuccess $r 'MakeAppx pack'
            }
            $packSucceeded = $true

            if ($isWhatIf) {
                Write-MsixLog Info "[WhatIf] Would replace '$target' with pipeline output. Signing and Move-Item skipped."
                if ($Config.Signing -and $Config.Signing.UnsignedOutputPath) {
                    Copy-Item -LiteralPath $scratch -Destination $Config.Signing.UnsignedOutputPath -Force -ErrorAction Stop
                    Write-MsixLog Info "[WhatIf] Preview package copied to: $($Config.Signing.UnsignedOutputPath)"
                }
                return $null
            }

            # ── Sign (once, at the end, AT THE SCRATCH PATH) ──────────────
            $skipSign = $Config.Signing -and $Config.Signing.Skip
            if ($Config.Signing -and -not $skipSign) {
                Write-MsixLog Info 'Stage: Sign (final)'
                $signArgs = @{ PackagePath = $scratch }
                foreach ($k in 'Pfx','PfxPassword','TimestampUrl','Signer',
                              'TrustedSigningAccount','TrustedSigningProfile',
                              'TrustedSigningEndpoint','TrustedSigningClientDll',
                              'KeyVaultUrl','KeyVaultCertificate','KeyVaultTenantId',
                              'KeyVaultClientId','KeyVaultClientSecret') {
                    if ($Config.Signing.ContainsKey($k)) { $signArgs[$k] = $Config.Signing[$k] }
                }
                Invoke-MsixSigning @signArgs
            } elseif (-not $Config.Signing) {
                Write-MsixLog Info 'No Signing block in config; output is unsigned.'
            } else {
                Write-MsixLog Info 'Signing.Skip=true; output is unsigned.'
            }
            $signSucceeded = $true

            # ── Atomic move: only NOW does the target change ─────────────
            Move-Item -LiteralPath $scratch -Destination $target -Force
            Write-MsixLog Info "=== Pipeline complete: $target ==="
            return Get-Item -LiteralPath $target -ErrorAction Stop

        } catch {
            if ($packSucceeded -and -not $signSucceeded -and `
                $Config.Signing -and $Config.Signing.UnsignedOutputPath) {
                try {
                    Copy-Item -LiteralPath $scratch -Destination $Config.Signing.UnsignedOutputPath -Force -ErrorAction Stop
                    Write-MsixLog Warning "Signing failed. Unsigned package preserved at: $($Config.Signing.UnsignedOutputPath)"
                } catch {
                    Write-MsixLog Error "Signing failed AND unsigned-output copy to '$($Config.Signing.UnsignedOutputPath)' failed: $_"
                }
            } elseif ($packSucceeded -and -not $signSucceeded) {
                Write-MsixLog Warning "Signing failed. Original target '$target' is unchanged. Set Config.Signing.UnsignedOutputPath to preserve the unsigned package next time."
            }
            throw
        } finally {
            if (Test-Path -LiteralPath $scratch) {
                Remove-Item -LiteralPath $scratch -Force -ErrorAction SilentlyContinue
            }
        }

    } finally {
        Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue
    }
}


# =============================================================================
# In-place package mutator scaffolding (issue #37)
# -----------------------------------------------------------------------------
# Every heuristic mutator (Add-MsixCapability + Remove-Msix*Artifact in
# MSIX.Heuristics.ps1) repeats the same unpack -> mutate -> atomic-repack-
# sign-move skeleton. The helper below centralises it so:
#
# 1. The atomic-repack-sign-move semantics (issue #34) are enforced by
# construction -- no future wrapper can accidentally skip the scratch
# step and overwrite the user's signed package on signing failure.
# 2. Adding a new mutator drops to ~30 lines of payload logic.
# 3. Bug fixes to the unpack / cleanup / signing-error-handling paths
# apply in one place instead of four.
# =============================================================================

function _MsixMutatePackage {
    <#
    .SYNOPSIS
        Internal helper: unpack an .msix, hand the workspace to a caller-
        supplied script block, atomic-repack-sign-move the result.
 
    .DESCRIPTION
        Standard scaffolding used by every in-place package mutator in
        MSIX.Heuristics.ps1.
 
        Flow:
          1. Resolve $toolsRoot and the package fileinfo.
          2. New-MsixWorkspace -> isolated GUID temp folder.
          3. MakeAppx unpack into the workspace.
          4. Invoke -Mutator { param($workspace) ... }. The mutator returns:
               * `$null` (or `$false`, or an empty hashtable's literal `@{}`)
                 -> "nothing to do". Helper logs -NoChangeMessage, returns `$null`,
                 nothing is repacked or signed.
               * A [hashtable] / [pscustomobject] of summary fields ->
                 "package was mutated, please repack". Helper packs to a
                 scratch path, signs at scratch, Move-Item to target on
                 success. Returns the mutator's summary merged with
                 { Output = $target }.
          5. On signing failure (and only after a successful pack), if
             -UnsignedOutputPath was supplied, the scratch is copied there
             for inspection. The user's -PackagePath is byte-equal to
             before the call.
          6. The workspace is always cleaned up in a finally.
 
    .PARAMETER PackagePath
        .msix to mutate.
 
    .PARAMETER Mutator
        Script block invoked with the workspace path as positional arg 0.
        See DESCRIPTION for the return-value contract.
 
    .PARAMETER Operation
        Short label used in the scratch filename (e.g. 'cap', 'uninstrm',
        'updrm', 'shellreg'). Helps with debugging when multiple mutators
        are operating in parallel.
 
    .PARAMETER WorkspaceSuffix
        Optional suffix for New-MsixWorkspace's directory name. Default
        is the package base name plus '-' plus $Operation. Some legacy
        mutators used different suffixes so we expose this for parity.
 
    .PARAMETER NoChangeMessage
        Log line emitted at Info level when -Mutator reports no work.
        Default: "No changes for $Operation."
 
    .PARAMETER OutputPath
        Where to write the repacked package. Defaults to overwriting
        -PackagePath.
 
    .PARAMETER SkipSigning
        Skip the final Invoke-MsixSigning pass. Alias: -NoSign.
 
    .PARAMETER Pfx
        Signing certificate path.
 
    .PARAMETER PfxPassword
        SecureString password for the .pfx.
 
    .PARAMETER UnsignedOutputPath
        If signing fails, copy the unsigned scratch package here for
        inspection. The user's -PackagePath is left byte-equal in this
        failure case (provided the helper got past the pack step).
 
    .OUTPUTS
        $null when -Mutator reported no changes. Otherwise a
        [pscustomobject] carrying every key the mutator returned plus an
        Output = <final-path> property.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [string]$PackagePath,
        [Parameter(Mandatory)] [scriptblock]$Mutator,
        [Parameter(Mandatory)] [string]$Operation,
        [string]$WorkspaceSuffix,
        [string]$NoChangeMessage,
        [string]$OutputPath,
        [Alias('NoSign')] [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword,
        [string]$UnsignedOutputPath
    )

    if (-not $NoChangeMessage) { $NoChangeMessage = "No changes for $Operation." }

    $toolsRoot = Get-MsixToolsRoot
    $fileinfo  = Get-Item $PackagePath
    $wsName    = if ($WorkspaceSuffix) { "$($fileinfo.BaseName)$WorkspaceSuffix" } else { "$($fileinfo.BaseName)-$Operation" }
    $workspace = New-MsixWorkspace $wsName

    try {
        $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o')
        Assert-MsixProcessSuccess $r 'MakeAppx unpack'

        $summary = & $Mutator $workspace

        # No-change contract: $null / $false / empty collection -> bail.
        $hasChanges = $false
        if ($null -ne $summary -and $summary -isnot [bool]) {
            if ($summary -is [System.Collections.IDictionary]) {
                $hasChanges = $summary.Count -gt 0 -or $summary.PSObject.Properties['__forcePack']
            } elseif ($summary -is [pscustomobject]) {
                $hasChanges = ($summary.PSObject.Properties | Where-Object MemberType -eq 'NoteProperty' | Measure-Object).Count -gt 0
            } else {
                # Any non-collection truthy value also counts as "changed" so
                # mutators with no per-call summary (e.g. Add-MsixCapability)
                # can return $true.
                $hasChanges = [bool]$summary
            }
        } elseif ($summary -is [bool]) {
            $hasChanges = $summary
        }

        if (-not $hasChanges) {
            Write-MsixLog Info $NoChangeMessage
            return $null
        }

        # ── Atomic repack ──────────────────────────────────────────────────
        $target  = if ($OutputPath) { $OutputPath } else { $fileinfo.FullName }
        $scratch = Join-Path $env:TEMP ("msix-{0}-{1}{2}" -f $Operation, ([guid]::NewGuid().ToString('N').Substring(0,8)), ([System.IO.Path]::GetExtension($target)))
        $packOk = $false
        try {
            $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('pack', '/p', $scratch, '/d', $workspace, '/o')
            Assert-MsixProcessSuccess $r 'MakeAppx pack'
            $packOk = $true
            if (-not $SkipSigning) {
                Invoke-MsixSigning -PackagePath $scratch -Pfx $Pfx -PfxPassword $PfxPassword
            }
            Move-Item -LiteralPath $scratch -Destination $target -Force
        } catch {
            if ($packOk -and $UnsignedOutputPath) {
                Copy-Item -LiteralPath $scratch -Destination $UnsignedOutputPath -Force -ErrorAction SilentlyContinue
                Write-MsixLog Warning "Signing failed. Unsigned package preserved at: $UnsignedOutputPath"
            }
            throw
        } finally {
            if (Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Force -ErrorAction SilentlyContinue }
        }

        # Merge mutator's summary + Output into a single pscustomobject.
        $out = [ordered]@{}
        if ($summary -is [System.Collections.IDictionary]) {
            foreach ($k in $summary.Keys) { $out[$k] = $summary[$k] }
        } elseif ($summary -is [pscustomobject]) {
            foreach ($p in $summary.PSObject.Properties | Where-Object MemberType -eq 'NoteProperty') {
                $out[$p.Name] = $p.Value
            }
        }
        $out['Output'] = $target
        return [pscustomobject]$out

    } finally {
        Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue
    }
}