MSIX.Core.ps1

# Resolved once per module load; overridable via $env:MSIX_TOOLS_PATH
$script:ToolsRoot = $null

function _MsixSetVerifiedToolsRoot {
    <#
    .SYNOPSIS
        Authenticode-verifies the SDK tools under a resolved root, then caches
        and returns it.
 
    .DESCRIPTION
        SECURITY (#54): Get-MsixToolsRoot discovers signtool.exe / MakeAppx.exe
        by env override, parent-walk, or SDK glob and the module then EXECUTES
        them — signtool signs the output package, so a planted binary is a
        high-value target. Every resolved root is therefore verified (fail-closed)
        against the trusted-publisher allowlist before it is trusted, regardless
        of how it was found.
 
        Verification can be disabled by setting the MSIX_SKIP_TOOL_VERIFICATION
        environment variable — intended ONLY for offline / air-gapped build
        agents where CRL/OCSP chain checks cannot complete for a legitimately
        Microsoft-signed binary. A loud warning is logged when it is bypassed.
 
        Note: msixmgr is NOT resolved through this path (it lives under its own
        folder via MSIX.AppAttach.ps1) and keeps its documented unsigned/preview
        exception (microsoft/msix-packaging#710).
 
    .PARAMETER Root
        The candidate tools root. signtool.exe / MakeAppx.exe are looked for
        both directly under it and under a Tools\ subfolder (the two layouts
        Get-MsixToolsRoot produces).
    #>

    [OutputType([string])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param([Parameter(Mandatory)][string]$Root)

    if ($env:MSIX_SKIP_TOOL_VERIFICATION) {
        # Real Warning stream as well as the module log: Write-MsixLog routes to
        # Write-Information, which is invisible to -WarningVariable and is dropped
        # entirely under Set-MsixLogLevel -Level Error. Disabling the control that
        # protects the signing toolchain must not be silenceable (issue #147).
        Write-Warning "MSIX: tool Authenticode verification BYPASSED (MSIX_SKIP_TOOL_VERIFICATION is set) for '$Root'."
        Write-MsixLog -Level Warning -Message "Tool Authenticode verification BYPASSED (MSIX_SKIP_TOOL_VERIFICATION is set). SDK tools under '$Root' are trusted without a signature check. Unset this variable to restore fail-closed verification."
    } elseif (Get-Command -Name _MsixVerifyAuthenticode -ErrorAction SilentlyContinue) {
        # Verify the tools we EXECUTE plus signtool's private side-by-side load
        # surface (issue #147).
        #
        # Checking only signtool/MakeAppx/makepri was not enough: SDK signtool.exe
        # is SxS-manifest-bound to load wintrust.dll / mssign32.dll / AppxSip.dll
        # from its OWN directory, so an attacker able to write that directory could
        # keep the three genuine Microsoft-signed executables (passing the check)
        # and plant a trojaned dependency DLL beside them, running their code
        # inside the process that holds the organisation's code-signing key.
        #
        # Verifying EVERY file in the root is NOT viable and was tried first:
        # Microsoft itself ships unsigned binaries in these directories - 9 in the
        # real Windows SDK bin\x64 (gamesaveutil.exe, SirepClient.dll,
        # WinAppDeployCmd.exe, ...) and 5 in the NuGet BuildTools layout
        # (PackageEditor.exe, Microsoft.Packaging.SDKUtils.dll, ...). That rejects
        # every legitimate SDK install, so the check must be targeted.
        #
        # This list is deliberately the executables we invoke plus the documented
        # signing load surface; it is not a claim that every other file in the
        # folder is irrelevant, only that nothing else is loaded by the tools this
        # module runs.
        $verifyNames = @(
            'signtool.exe', 'MakeAppx.exe', 'makepri.exe',   # executed directly
            'wintrust.dll', 'mssign32.dll', 'AppxSip.dll',   # signtool SxS bindings
            'msisip.dll', 'opcservices.dll'                  # SIP/OPC helpers
        )
        $toolsFound = 0
        foreach ($name in $verifyNames) {
            $candidate = @(
                (Join-Path -Path $Root -ChildPath "Tools\$name"),
                (Join-Path -Path $Root -ChildPath $name)
            ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
            if (-not $candidate) { continue }
            if ($name -like '*.exe') { $toolsFound++ }
            # Throws (fail-closed) if unsigned, untrusted, or the chain cannot be
            # validated.
            $null = _MsixVerifyAuthenticode -Path $candidate -ToolName $name
        }
        if ($toolsFound -eq 0) {
            throw "Tool verification failed: none of signtool.exe / MakeAppx.exe / makepri.exe were found under '$Root'. Refusing to trust an empty or unexpected tools root."
        }
    } else {
        # Fail CLOSED. Previously this branch silently cached and trusted the root
        # with no warning at all, defeating the whole control if the verifier was
        # not in scope for any reason (issue #147).
        throw 'Tool verification unavailable: _MsixVerifyAuthenticode is not loaded. Re-import the MSIX module; set MSIX_SKIP_TOOL_VERIFICATION only for a deliberate air-gapped bypass.'
    }

    $script:ToolsRoot = $Root
    return $Root
}

function Get-MsixToolsRoot {
    <#
    .SYNOPSIS
        Returns a folder that contains Tools\MakeAppx.exe.
 
    .DESCRIPTION
        Search order (first hit wins, result cached for the session):
 
          1. $env:MSIX_TOOLS_PATH explicit override
          2. <module folder>\Tools\ installed by Install-MsixSdkTool
          3. Sibling / parent-walk e.g. ..\0.56\Tools\
          4. Windows 10/11 SDK %ProgramFiles(x86)%\Windows Kits\10\bin
          5. Auto-install (if -AutoInstall) one-call download from NuGet
 
    .PARAMETER AutoInstall
        If set and nothing was found, run Install-MsixSdkTool to fetch
        Microsoft.Windows.SDK.BuildTools and use that.
 
    .PARAMETER Refresh
        Drop the cached result and re-resolve from scratch.
 
    .OUTPUTS
        [string] Absolute path that contains a Tools\MakeAppx.exe.
 
    .EXAMPLE
        # First call resolves and caches; later calls are O(1)
        $root = Get-MsixToolsRoot
        & (_MsixToolPath -Name 'MakeAppx.exe' -Root $root) /?
 
    .EXAMPLE
        # Force a one-shot install if nothing is found
        Get-MsixToolsRoot -AutoInstall
 
    .EXAMPLE
        # Pin a specific layout via env var (overrides every other source)
        $env:MSIX_TOOLS_PATH = 'C:\tools\msix-sdk'
        Get-MsixToolsRoot -Refresh
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [switch]$AutoInstall,
        [switch]$Refresh
    )
    if ($Refresh) { $script:ToolsRoot = $null }
    if ($script:ToolsRoot) { return $script:ToolsRoot }

    # 1) Explicit env override
    if ($env:MSIX_TOOLS_PATH -and (Test-Path "$env:MSIX_TOOLS_PATH\Tools\MakeAppx.exe")) {
        return _MsixSetVerifiedToolsRoot -Root $env:MSIX_TOOLS_PATH
    }

    # 2) Tools folder next to this module file (Install-MsixSdkTool default)
    if (Test-Path "$PSScriptRoot\Tools\MakeAppx.exe") {
        return _MsixSetVerifiedToolsRoot -Root $PSScriptRoot
    }

    # 3) Look for a vendored toolchain NEXT TO the module only (one level up),
    # e.g. C:\temp\msix\0.56\ beside C:\temp\msix\MSIX\.
    #
    # SECURITY (issue #147): this used to walk up to FOUR parent levels and
    # accept any subdirectory containing Tools\MakeAppx.exe, ordered by
    # Sort-Object Name -Descending - lexically highest wins, not most
    # trustworthy. For a -Scope CurrentUser install the 4th hop reaches
    # ~\Documents, which is fully user-writable: creating
    # ~\Documents\zzz\Tools\ beat every legitimate candidate on every later
    # session. Combined with the SxS-DLL gap above that handed an unprivileged
    # attacker code execution inside signing. One level keeps the intended
    # side-by-side layout working without reaching a user profile root.
    $parent = Split-Path -Path $PSScriptRoot -Parent
    if ($parent) {
        $sibling = Get-ChildItem -LiteralPath $parent -Directory -ErrorAction SilentlyContinue |
                   Where-Object { Test-Path -LiteralPath "$($_.FullName)\Tools\MakeAppx.exe" } |
                   Sort-Object Name -Descending |
                   Select-Object -First 1
        if ($sibling) {
            return _MsixSetVerifiedToolsRoot -Root $sibling.FullName
        }
        if (Test-Path "$parent\Tools\MakeAppx.exe") {
            return _MsixSetVerifiedToolsRoot -Root $parent
        }
    }

    # 4) Windows SDK default paths — pick the highest-versioned bin dir.
    #
    # This is a FALLBACK, not the intended workflow. It resolves to a FLAT
    # root (makeappx.exe directly, no Tools\ subfolder), which every call site
    # used to mishandle - so a machine with the SDK installed but no module
    # toolchain failed with "Executable not found: ...\x64\Tools\MakeAppx.exe".
    # _MsixToolPath now resolves both layouts (#151), but the toolchain version
    # then depends on whatever SDK that machine happens to have. Warn, so an
    # operator who wants reproducible builds knows to pin one.
    foreach ($arch in @('x64','x86')) {
        $kitBin = "${env:ProgramFiles(x86)}\Windows Kits\10\bin"
        if (Test-Path -LiteralPath $kitBin) {
            # Versioned subfolders + a flat <arch> root (older SDKs)
            $candidate = Get-ChildItem -LiteralPath $kitBin -Directory -ErrorAction SilentlyContinue |
                         Where-Object { Test-Path "$($_.FullName)\$arch\makeappx.exe" } |
                         Sort-Object Name -Descending |
                         Select-Object -First 1
            $sdkRoot = $null
            if ($candidate) {
                $sdkRoot = "$($candidate.FullName)\$arch"
            } elseif (Test-Path "$kitBin\$arch\makeappx.exe") {
                $sdkRoot = "$kitBin\$arch"
            }
            if ($sdkRoot) {
                Write-MsixLog -Level Warning -Message "Using the Windows SDK already installed on this machine ($sdkRoot). The toolchain version is therefore whatever this host has, which is not reproducible across build agents. Run Initialize-MsixToolchain (or Install-MsixSdkTool) to pin a downloaded toolchain under the module."
                return _MsixSetVerifiedToolsRoot -Root $sdkRoot
            }
        }
    }

    # 5) One-shot auto-install
    if ($AutoInstall) {
        if (-not (Get-Command Install-MsixSdkTool -ErrorAction SilentlyContinue)) {
            throw 'Install-MsixSdkTool is not available; cannot auto-install. Make sure the module loaded fully.'
        }
        Write-MsixLog -Level Info -Message 'No SDK tools found; auto-installing via Install-MsixSdkTool.'
        Install-MsixSdkTool | Out-Null
        if (Test-Path "$PSScriptRoot\Tools\MakeAppx.exe") {
            return _MsixSetVerifiedToolsRoot -Root $PSScriptRoot
        }
    }

    throw @"
MakeAppx.exe not found. Pick ONE of these:
 
  # Easiest -- auto-download MakeAppx + signtool from the official Microsoft
  # NuGet package (Microsoft.Windows.SDK.BuildTools), once per machine:
  Install-MsixSdkTool
 
  # Or do everything (PSF + Procmon + msixmgr + SDK tools) in a single call:
  Initialize-MsixToolchain
 
  # Or point at an existing layout (must contain Tools\MakeAppx.exe):
  `$env:MSIX_TOOLS_PATH = 'C:\path\to\toolsroot'
  Set-MsixToolsRoot -Path 'C:\path\to\toolsroot'
"@

}

function Set-MsixToolsRoot {
    <#
    .SYNOPSIS
        Pins the tools root used by every cmdlet in this session.
 
    .DESCRIPTION
        Validates that <Path>\Tools\MakeAppx.exe exists, then sets the
        session-level cache that Get-MsixToolsRoot returns. Use this when
        you have a vendored SDK layout and don't want to set
        $env:MSIX_TOOLS_PATH globally.
 
        Equivalent to setting $env:MSIX_TOOLS_PATH and then calling
        Get-MsixToolsRoot -Refresh, but scoped to the current session only.
 
    .PARAMETER Path
        Folder that directly contains a Tools subfolder with MakeAppx.exe.
 
    .EXAMPLE
        Set-MsixToolsRoot -Path 'C:\tools\msix-sdk'
        # Get-MsixToolsRoot now returns 'C:\tools\msix-sdk'.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )
    # Accept BOTH layouts, matching _MsixToolPath: <Path>\Tools\MakeAppx.exe
    # (vendored / Install-MsixSdkTool) and <Path>\makeappx.exe (a system Windows
    # SDK bin\<ver>\<arch> root). Requiring only the first rejected a perfectly
    # usable SDK root (#151).
    $probe = @(
        (Join-Path -Path $Path -ChildPath 'Tools\MakeAppx.exe'),
        (Join-Path -Path $Path -ChildPath 'MakeAppx.exe')
    ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
    if (-not $probe) {
        throw "MakeAppx.exe not found under '$Path' (checked '$Path\Tools\' and the root). Verify the path."
    }
    # Authenticode-verify (fail-closed) before pinning — same gate as the
    # auto-discovery paths (#54).
    $null = _MsixSetVerifiedToolsRoot -Root $Path
    Write-MsixLog -Level Info -Message "Tools root set to: $Path"
}

function New-MsixWorkspace {
    <#
    .SYNOPSIS
        Creates a fresh, GUID-stamped temp folder for an unpack/repack cycle.
 
    .DESCRIPTION
        Primarily used internally by Invoke-MsixPipeline, Add-MsixPsfV2, and
        the context-menu cmdlets to keep multiple concurrent runs isolated.
        Exposed for callers who script custom unpack/edit/repack flows
        outside the high-level pipeline.
 
        The caller is responsible for removing the workspace when done
        (Remove-Item -Recurse -Force).
 
    .PARAMETER PackageName
        Short label baked into the folder name. Use the package base name to
        make the workspace easy to identify while it exists.
 
    .OUTPUTS
        [string] Absolute path of the new directory.
 
    .EXAMPLE
        $ws = New-MsixWorkspace -PackageName 'Contoso.App'
        try {
            # unpack, edit, repack into $ws
        } finally {
            Remove-Item -LiteralPath $ws -Recurse -Force
        }
    #>

    [OutputType([string])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param(
        [Parameter(Mandatory)]
        [string]$PackageName
    )
    $id   = [guid]::NewGuid().ToString('N').Substring(0, 8)
    $path = Join-Path -Path $env:TEMP -ChildPath "msix-$PackageName-$id"
    # -WhatIf:$false is REQUIRED. The workspace is private scratch, not a
    # user-visible side effect, but New-Item honours the $WhatIfPreference
    # inherited through the module scope chain. Without this, running any
    # mutator with -WhatIf skips the create, the Get-Item below fails, and this
    # function returns an EMPTY STRING - which broke -WhatIf across all ~46
    # _MsixMutateManifest call sites plus _MsixMutatePackage (issue #145).
    New-Item -ItemType Directory -Path $path -Force -WhatIf:$false | Out-Null
    # Return the LONG-form path. $env:TEMP can carry an 8.3 short segment
    # (SANDER~1 vs SanderdeWit) while Get-ChildItem returns long-form
    # FullNames; any relative-path Substring against a short-form workspace
    # then chops the wrong number of characters (this shipped as the
    # '08\VFS\...' nested-package corruption in Import-MsixSparseShellExtension).
    $path = (Get-Item -LiteralPath $path).FullName
    Write-MsixLog -Level Debug -Message "Workspace created: $path"
    return $path
}

function _MsixToolPath {
    <#
    .SYNOPSIS
        Resolves an SDK tool (MakeAppx / signtool / makepri) inside a tools root,
        supporting BOTH layouts Get-MsixToolsRoot can return.
 
    .DESCRIPTION
        Get-MsixToolsRoot resolves a root from five sources. Two layouts result:
 
          <root>\Tools\MakeAppx.exe vendored / Install-MsixSdkTool
          <root>\makeappx.exe a system Windows SDK bin\<ver>\<arch>
 
        Every call site used to hardcode the first form, so search path 4 (the
        installed Windows SDK) returned a root the module could then never use -
        "Executable not found: ...\bin\10.0.26100.0\x64\Tools\MakeAppx.exe". The
        test-suite tooling gate happened to require the Tools\ form too, so on a
        system-SDK host every integration test skipped and the defect stayed
        invisible until the gate was corrected (#151).
 
        When neither layout has the file, the Tools\ form is returned so callers
        still produce the familiar "Executable not found" message.
    #>

    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [string]$Root
    )
    if (-not $Root) { $Root = Get-MsixToolsRoot }
    $nested = Join-Path -Path $Root -ChildPath (Join-Path -Path 'Tools' -ChildPath $Name)
    if (Test-Path -LiteralPath $nested -PathType Leaf) { return $nested }
    $flat = Join-Path -Path $Root -ChildPath $Name
    if (Test-Path -LiteralPath $flat -PathType Leaf) { return $flat }
    return $nested
}

function _MsixWriteUtf8 {
    <#
    .SYNOPSIS
        Writes a text file as UTF-8 with a DETERMINISTIC byte-order mark,
        identical under Windows PowerShell 5.1 and PowerShell 7.
 
    .DESCRIPTION
        `-Encoding utf8` does not mean the same thing on both editions:
        5.1 writes UTF-8 **with** a BOM, 7 writes it **without** (issue #146).
        That divergence is silent and only shows up downstream:
 
          - The Trusted Signing metadata JSON is parsed by
            Azure.CodeSigning.Dlib.dll via `signtool /dmdf`. System.Text.Json
            rejects a leading BOM outright ("'0xEF' is an invalid start of a
            value"), so packages signed from 5.1 failed on the module's DEFAULT
            signing backend while 7 worked.
          - PSF `config.json` is parsed by the PSF runtime at every app launch.
            `Test-MsixPsfConfig` reads it with `Get-Content -Raw`, which strips
            the BOM on 5.1, so the module's own validator could not see the
            defect and it shipped inside customer packages.
 
        Rule of thumb for -WithBom:
          - OFF for anything a non-PowerShell parser consumes (JSON, XML, HTML).
          - ON for generated .ps1 files, where Windows PowerShell 5.1 otherwise
            reads a BOM-less UTF-8 file as CP-1252 and mis-parses non-ASCII
            (the hazard documented in CLAUDE.md).
 
    .PARAMETER WithBom
        Emit the UTF-8 BOM (EF BB BF). Default is no BOM.
 
    .PARAMETER NoNewline
        Do not append a trailing newline. Set-Content/Out-File add one by
        default; this keeps that behaviour unless suppressed.
    #>

    [OutputType([void])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Text,
        [switch]$WithBom,
        [switch]$NoNewline
    )
    $body = if ($null -eq $Text) { '' } else { $Text }
    if (-not $NoNewline -and -not $body.EndsWith("`n")) { $body += [Environment]::NewLine }
    [IO.File]::WriteAllText($Path, $body, [Text.UTF8Encoding]::new([bool]$WithBom))
}

function _MsixPreserveUnsigned {
    <#
    .SYNOPSIS
        Copies a scratch package to -UnsignedOutputPath after a signing failure,
        and reports honestly whether that actually succeeded.
 
    .DESCRIPTION
        Every repack site used to preserve the artifact like this:
 
            Copy-Item -LiteralPath $scratch -Destination $dest -Force -ErrorAction SilentlyContinue
            Write-MsixLog -Level Warning -Message "... Unsigned package preserved at: $dest"
 
        with the enclosing finally deleting $scratch immediately after. If the
        copy failed - destination directory missing, volume full, file locked -
        the error was fully suppressed, the log still claimed the package had
        been preserved, and the only copy was then destroyed. The operator
        followed the log to an empty path and the build was unrecoverable: the
        exact inverse of what the parameter promises (issue #145).
 
        This helper creates the destination directory when needed, copies with
        -ErrorAction Stop, and logs at Error - not Warning - when preservation
        genuinely failed.
    #>

    [OutputType([void])]
    param(
        [Parameter(Mandatory)][string]$Scratch,
        [Parameter(Mandatory)][string]$Destination
    )
    try {
        $dir = Split-Path -Parent -Path $Destination
        if ($dir -and -not (Test-Path -LiteralPath $dir)) {
            New-Item -ItemType Directory -Path $dir -Force -WhatIf:$false -ErrorAction Stop | Out-Null
        }
        Copy-Item -LiteralPath $Scratch -Destination $Destination -Force -ErrorAction Stop
        Write-MsixLog -Level Warning -Message "Signing failed. Unsigned package preserved at: $Destination"
    } catch {
        Write-MsixLog -Level Error -Message "Signing failed AND the unsigned package could NOT be preserved at '$Destination': $($_.Exception.Message). The scratch build is being discarded; re-run after fixing the destination."
    }
}

function Invoke-MsixProcess {
    <#
    .SYNOPSIS
        Runs an external executable and captures its exit code, stdout, and stderr.
 
    .DESCRIPTION
        Arguments are passed as an array (one element per argument) so each argument
        is correctly quoted by the .NET process API. This prevents argument injection
        from filenames or values that contain spaces, quotes, or shell metacharacters.
 
    .PARAMETER FilePath
        Absolute path to the executable.
 
    .PARAMETER ArgumentList
        Array of arguments. Each element is one argument; do not pre-concatenate.
        Example: @('unpack', '/p', $path, '/d', $workspace, '/o')
 
    .PARAMETER Arguments
        DEPRECATED. Legacy single-string argument form. Internally split with a
        naive parser for backward compatibility -- new callers MUST use -ArgumentList.
        Logs a warning to encourage migration.
 
    .OUTPUTS
        [pscustomobject] with ExitCode (int), StdOut (string), StdErr (string).
 
    .EXAMPLE
        # Preferred: array form (each argument quoted correctly)
        Invoke-MsixProcess -FilePath (_MsixToolPath -Name 'MakeAppx.exe' -Root $root) -ArgumentList @(
            'unpack', '/p', $packagePath, '/d', $workspace, '/o'
        )
 
    .EXAMPLE
        # DEPRECATED legacy single-string form — emits a warning. New callers
        # MUST use -ArgumentList; this is retained only for older scripts.
        Invoke-MsixProcess -FilePath (_MsixToolPath -Name 'MakeAppx.exe' -Root $root) `
            -Arguments "unpack /p `"$packagePath`" /d `"$workspace`" /o"
    #>

    [CmdletBinding(DefaultParameterSetName = 'ArgumentList')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$FilePath,

        [Parameter(Mandatory, ParameterSetName = 'ArgumentList', Position = 1)]
        [AllowEmptyCollection()]
        [string[]]$ArgumentList,

        [Parameter(Mandatory, ParameterSetName = 'LegacyString', Position = 1)]
        [string]$Arguments
    )

    if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) {
        throw "Executable not found: $FilePath"
    }

    # Backward-compat: split the legacy single string into an array using a
    # quote-aware tokenizer. Issues a deprecation warning so callers migrate.
    if ($PSCmdlet.ParameterSetName -eq 'LegacyString') {
        Write-MsixLog -Level Warning -Message "Invoke-MsixProcess: -Arguments (single string) is deprecated. Pass -ArgumentList @(...) instead. Caller: $((Get-PSCallStack)[1].Command)"
        $ArgumentList = @()
        if ($Arguments) {
            # Honour double-quoted segments containing spaces; otherwise split on whitespace.
            $regex = [regex]'(?<=^|\s)"([^"]*)"(?=\s|$)|\S+'
            foreach ($m in $regex.Matches($Arguments)) {
                $ArgumentList += if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Value }
            }
        }
    }

    # SECURITY (issue #148): redact the value FOLLOWING a secret-bearing switch
    # before logging. The SignTool-PFX backend passes '/p', <plaintext password>
    # in this vector; Write-MsixLog also appends to the file configured by
    # Set-MsixLogFile, so the documented troubleshooting flow
    # (Set-MsixLogLevel Debug + Set-MsixLogFile) wrote the code-signing password
    # to disk in clear text, where CI artifact upload and log shippers collect it.
    $secretSwitches = @('/p', '-p', '--password', '/password', '-Password')
    $redacted = New-Object System.Collections.Generic.List[string]
    $hideNext = $false
    foreach ($a in $ArgumentList) {
        $s = [string]$a
        if ($hideNext) {
            $redacted.Add('***REDACTED***')
            $hideNext = $false
            continue
        }
        if ($secretSwitches -contains $s) { $hideNext = $true }
        $redacted.Add($(if ($s -match '\s') { '"' + $s + '"' } else { $s }))
    }
    Write-MsixLog -Level Debug -Message "Exec: $FilePath $([string]::Join(' ', $redacted))"

    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    $psi.FileName               = $FilePath
    $psi.RedirectStandardError  = $true
    $psi.RedirectStandardOutput = $true
    $psi.UseShellExecute        = $false
    $psi.WorkingDirectory       = (Get-Location).Path

    # PowerShell 5.1 / .NET Framework 4.x does not expose ProcessStartInfo.ArgumentList.
    # Fall back to safely quoting into the single Arguments string. The quoting rules
    # match CommandLineToArgvW: wrap in double quotes; escape embedded " as \" ; double
    # trailing backslashes before closing quote.
    if ($null -ne $psi.PSObject.Properties['ArgumentList']) {
        foreach ($a in $ArgumentList) { [void]$psi.ArgumentList.Add([string]$a) }
    } else {
        $psi.Arguments = [string]::Join(' ', ($ArgumentList | ForEach-Object {
            $s = [string]$_
            if ($s -eq '') { return '""' }
            if ($s -notmatch '[\s"]') { return $s }
            # Escape embedded backslashes-before-quotes per CommandLineToArgvW rules.
            $escaped = $s -replace '(\\*)"', '$1$1\"'
            $escaped = $escaped -replace '(\\+)$', '$1$1'
            return '"' + $escaped + '"'
        }))
    }

    $p = [System.Diagnostics.Process]::new()
    $p.StartInfo = $psi
    try {
        $null = $p.Start()
        # Read both streams concurrently to prevent buffer deadlocks
        $stdoutTask = $p.StandardOutput.ReadToEndAsync()
        $stderrTask = $p.StandardError.ReadToEndAsync()
        $p.WaitForExit()
        return [pscustomobject]@{
            ExitCode = $p.ExitCode
            StdOut   = $stdoutTask.Result
            StdErr   = $stderrTask.Result
        }
    } finally {
        $p.Dispose()
    }
}

function Get-MsixPublisherId {
    <#
    .SYNOPSIS
        Computes the Crockford-Base32-encoded SHA-256 publisher hash used by
        MSIX for VFS paths and package family names.
 
    .DESCRIPTION
        Implements the algorithm Windows uses to derive PublisherId from a
        certificate Subject (e.g. 'CN=Contoso, O=Contoso, C=NL'):
          1. Encode Publisher as UTF-16LE.
          2. SHA-256 the bytes; keep the first 8 bytes.
          3. Re-encode those 8 bytes as 13 Crockford-Base32 characters.
 
        Useful for predicting the install path under
        %ProgramFiles%\WindowsApps\<Name>_<Version>_<Arch>__<PublisherId>
        without having to install the package first.
 
        Available under the legacy alias Get-PublisherIdFromPublisher.
 
    .PARAMETER Publisher
        Full publisher Distinguished Name exactly as it appears in
        AppxManifest.xml's Identity/Publisher attribute. Matching is
        case-sensitive — even a space difference yields a different ID.
 
    .OUTPUTS
        [string] 13-character lowercase publisher ID.
 
    .EXAMPLE
        Get-MsixPublisherId -Publisher 'CN=Contoso, O=Contoso, C=NL'
        # -> e.g. 8wekyb3d8bbwe-style id
    #>

    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Publisher
    )

    $encUtf16  = [System.Text.Encoding]::Unicode
    $encSha256 = [System.Security.Cryptography.HashAlgorithm]::Create('SHA256')

    $bytes = @()
    ($encSha256.ComputeHash($encUtf16.GetBytes($Publisher)))[0..7] |
        ForEach-Object { $bytes += '{0:x2}' -f $_ }

    $bin = (-join $bytes.ForEach{
        [convert]::ToString([convert]::ToByte($_, 16), 2).PadLeft(8, '0')
    }).PadRight(65, '0')

    $table  = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
    $coded  = ''
    for ($i = 0; $i -lt $bin.Length; $i += 5) {
        $coded += $table[[convert]::ToInt32($bin.Substring($i, 5), 2)]
    }
    return $coded.ToLower()
}

Set-Alias -Name Get-PublisherIdFromPublisher -Value Get-MsixPublisherId