MSIX.Accelerator.ps1

# =============================================================================
# MSIX Accelerator support
# -----------------------------------------------------------------------------
# Implements a thin parser for the Accelerator YAML schema documented at
# https://learn.microsoft.com/windows/msix/toolkit/accelerators.
#
# An accelerator captures the conversion recipe for a specific Win32 product:
# its eligibility, the sequence of fixes, and (for FixType=PSF) a YAML-encoded
# config.json. Sample accelerators:
# https://github.com/microsoft/MSIX-Labs/tree/master/DeveloperLabs/SampleAccelerators
#
# We support PSF FixType natively. Other FixTypes (Capability, Dependency,
# Services, etc.) are surfaced as findings for human review.
# =============================================================================

function ConvertFrom-MsixYamlAccelerator {
    <#
    .SYNOPSIS
        Parses an accelerator YAML file using an intentionally-restricted
        scalar parser.
 
    .DESCRIPTION
        Reads an accelerator YAML file from -Path and returns a hashtable of
        the top-level keys. Only flat scalar (key: value) and inline list
        (key: [a, b, c]) forms are recognised. Quoting with single or double
        quotes around scalar values is honoured (stripped); everything else
        is treated as a literal string.
 
        Nested mappings, anchors/aliases, tags (e.g. !!python/object/apply,
        !!binary, !!set), multi-document streams, flow mappings, and any
        other YAML feature that could instantiate a .NET object are NOT
        supported. By design, hostile constructs degrade to literal text
        rather than causing object instantiation.
 
    .PARAMETER Path
        Path to the accelerator .yaml / .yml file.
 
    .OUTPUTS
        [hashtable] with one entry per recognised top-level key. Inline-list
        values are returned as string arrays; everything else is a string.
 
    .EXAMPLE
        $raw = ConvertFrom-MsixYamlAccelerator -Path .\line.yaml
        $raw.PackageName
 
    .NOTES
        SECURITY: Accelerator YAML is parsed by an intentionally-restricted scalar
        parser. Only flat key:value and key:[value1,value2] forms are supported. Tags,
        references, multi-document streams, and any YAML feature that could
        instantiate .NET objects are NOT supported -- by design. Do not switch to a
        full third-party YAML library for this input: accelerator files are
        user-supplied and a full YAML parser would be a code-execution vector on
        untrusted accelerator authors.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    if (-not (Test-Path $Path)) { throw "Accelerator not found: $Path" }

    # SECURITY (H5): We deliberately do NOT invoke any external YAML
    # deserialiser here, even if one is installed. Full YAML parsers honour
    # YAML type tags such as !!python/object/apply or .NET type tags that
    # can instantiate arbitrary objects during deserialisation -- a
    # well-known code-execution vector when the YAML comes from an
    # untrusted source. Accelerator files ARE untrusted (third-party
    # authors publish them), so we parse with a tiny purpose-built scalar
    # parser instead. Do not "improve" this by routing through a real YAML
    # library.
    $text   = Get-Content $Path -Raw
    $result = @{}

    # Match: <indent><key>: <value> (one line, no nested mappings).
    # The key is restricted to a conservative identifier set so we never
    # accidentally capture a tag like "!!python/object/apply:os.system".
    foreach ($line in ($text -split "`r?`n")) {
        if ($line -match '^\s*([A-Za-z_][A-Za-z0-9_\-]*)\s*:\s*(.*)$') {
            $key = $matches[1]
            $val = $matches[2].Trim()

            # Strip a trailing comment that is clearly outside a quoted string.
            if ($val -notmatch '^["''].*["'']$' -and $val -match '^(.*?)\s+#') {
                $val = $matches[1].Trim()
            }

            if ($val -match '^\[(.*)\]\s*$') {
                # Inline list: key: [a, b, c]
                $items = @()
                foreach ($item in ($matches[1] -split ',')) {
                    $t = $item.Trim()
                    if ($t -match '^"(.*)"$' -or $t -match "^'(.*)'$") { $t = $matches[1] }
                    $items += $t
                }
                $result[$key] = $items
            }
            elseif ($val -match '^"(.*)"$' -or $val -match "^'(.*)'$") {
                $result[$key] = $matches[1]
            }
            else {
                # Everything else -- including hostile YAML tag syntax such as
                # "!!python/object/apply:os.system [`"whoami`"]" -- is kept as a
                # literal string. No tag resolution, no object instantiation.
                $result[$key] = $val
            }
        }
    }
    return $result
}


function Import-MsixAccelerator {
    <#
    .SYNOPSIS
        Loads an accelerator YAML file and returns an object describing the
        recipe and any PSF fixups it contains.
 
    .DESCRIPTION
        Parses an Accelerator YAML file via ConvertFrom-MsixYamlAccelerator,
        then translates the recipe into the module's PSF builder shapes:
        FileRedirectionFixup, RegLegacyFixups, EnvVarFixup configs, plus
        any per-app arguments / working directory. The resulting object
        feeds Invoke-MsixAccelerator (or can be inspected and applied
        manually).
 
        Non-PSF fix types (Capability, Dependency, anything unrecognised)
        are surfaced under ManualNotes so the operator can action them.
 
        LIMITATION: The underlying parser is intentionally restricted to
        flat scalars and inline lists (see ConvertFrom-MsixYamlAccelerator
        .NOTES). Accelerators that express their RemediationApproach as a
        nested YAML mapping tree -- the format generated by some authoring
        tools -- will NOT be walked correctly here. Support for nested
        trees is deferred to issue #18. For now, supply accelerators whose
        RemediationApproach is provided in flat-key form, or pre-process
        the YAML into that shape.
 
    .PARAMETER Path
        Path to the accelerator .yaml / .yml file.
 
    .OUTPUTS
        [pscustomobject] with Source, PackageName, PackageVersion, Publisher,
        Eligible, Status, Architecture, FixSteps[], SuggestedFixups[] (PSF
        hashtables ready for Add-MsixPsfV2), AppOptions[] (workingDirectory /
        arguments), Capabilities[], Dependencies[], ManualNotes[].
 
    .EXAMPLE
        $accel = Import-MsixAccelerator -Path .\line.yaml
        $accel.SuggestedFixups
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    $raw = ConvertFrom-MsixYamlAccelerator -Path $Path

    $report = [pscustomobject]@{
        Source           = (Resolve-Path $Path).Path
        PackageName      = $raw.PackageName
        PackageVersion   = $raw.PackageVersion
        Publisher        = $raw.PublisherName
        Eligible         = $raw.EligibleForConversion
        Status           = $raw.ConversionStatus
        Architecture     = $raw.Architecture
        FixSteps         = @()
        SuggestedFixups  = @()
        AppOptions       = @()
        Capabilities     = @()
        Dependencies     = @()
        ManualNotes      = @()
    }

    foreach ($step in @($raw.RemediationApproach)) {
        if (-not $step) { continue }
        $fix = $step.Fix
        if (-not $fix) { continue }

        $report.FixSteps += [pscustomobject]@{
            Sequence   = $step.SequenceNumber
            Issue      = $step.Issue.Description
            FixType    = $fix.FixType
            Reference  = $fix.Reference
        }

        switch ($fix.FixType) {
            'PSF' {
                $cfg = $fix.FixDetails.PSFConfig
                if ($cfg) {
                    foreach ($app in @($cfg.applications)) {
                        if ($app.workingDirectory -or $app.arguments) {
                            $report.AppOptions += New-MsixPsfArgument `
                                -AppId            $app.id `
                                -Arguments        $app.arguments `
                                -WorkingDirectory $app.workingDirectory
                        }
                    }
                    foreach ($proc in @($cfg.processes)) {
                        foreach ($f in @($proc.fixups)) {
                            $dll = ($f.dll -replace '\d+\.dll$', '.dll')
                            if ($dll -eq 'FileRedirectionFixup.dll' -and $f.config.redirectedPaths.packageRelative) {
                                foreach ($pr in @($f.config.redirectedPaths.packageRelative)) {
                                    $report.SuggestedFixups += New-MsixPsfFileRedirectionConfig `
                                        -Base $pr.base -Patterns @($pr.patterns)
                                }
                            }
                            elseif ($dll -eq 'RegLegacyFixups.dll' -and $f.config.remediation) {
                                foreach ($rem in @($f.config.remediation)) {
                                    $report.SuggestedFixups += New-MsixPsfRegLegacyConfig `
                                        -Hive $rem.hive -Access $rem.access -Patterns @($rem.patterns)
                                }
                            }
                            elseif ($dll -eq 'EnvVarFixup.dll' -and $f.config.envVars) {
                                $h = @{}
                                foreach ($k in $f.config.envVars.Keys) { $h[$k] = $f.config.envVars[$k] }
                                $report.SuggestedFixups += New-MsixPsfEnvVarConfig -Variables $h
                            }
                        }
                    }
                }
            }
            'Capability'  { $report.Capabilities += @($fix.FixDetails.Capabilities) }
            'Dependency'  { $report.Dependencies += @($fix.FixDetails.Dependencies) }
            default {
                $report.ManualNotes += [pscustomobject]@{
                    FixType  = $fix.FixType
                    Issue    = $step.Issue.Description
                    Detail   = ($fix.FixDetails | ConvertTo-Json -Depth 5 -Compress)
                }
            }
        }
    }
    return $report
}


function Invoke-MsixAccelerator {
    <#
    .SYNOPSIS
        Applies an accelerator recipe to an .msix file: runs Add-MsixPsfV2 with
        the synthesised fixups, AppOptions, and signs the result.
 
    .DESCRIPTION
        Non-PSF fix steps (Capability, Dependency, Services, EntryPoint, etc.)
        cannot be applied automatically and are returned in the output as
        ManualSteps for the operator to action.
 
    .PARAMETER PackagePath
        Existing .msix to which the accelerator's PSF block will be applied.
 
    .PARAMETER AcceleratorPath
        Path to the accelerator YAML.
 
    .PARAMETER Pfx
        Path to the signing PFX. Forwarded to Add-MsixPsfV2.
 
    .PARAMETER PfxPassword
        SecureString password for -Pfx.
 
    .OUTPUTS
        [pscustomobject] the same report shape produced by
        Import-MsixAccelerator. When the accelerator contained nothing
        applicable, the report is returned without modifying the package.
 
    .EXAMPLE
        Invoke-MsixAccelerator -PackagePath .\line.msix `
            -AcceleratorPath .\line.yaml `
            -Pfx .\cert.pfx -PfxPassword (Read-Host -AsSecureString)
 
    .EXAMPLE
        # Dry-run via -WhatIf to see what would be applied
        Invoke-MsixAccelerator -PackagePath .\line.msix `
            -AcceleratorPath .\line.yaml -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [Parameter(Mandatory)]
        [string]$AcceleratorPath,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    $accel = Import-MsixAccelerator -Path $AcceleratorPath

    if ($accel.Status -in 'Failed','Not Eligible') {
        Write-MsixLog Warning "Accelerator declares ConversionStatus '$($accel.Status)'. Proceeding anyway, but review FixSteps first."
    }

    if ($accel.SuggestedFixups.Count -eq 0 -and $accel.AppOptions.Count -eq 0) {
        Write-MsixLog Warning 'Accelerator contains no PSF fixups; nothing to inject. Returning report only.'
        return $accel
    }

    if ($PSCmdlet.ShouldProcess($PackagePath, "Apply accelerator $($accel.Source)")) {
        Add-MsixPsfV2 -PackagePath $PackagePath `
                      -Fixups     ([hashtable[]]$accel.SuggestedFixups) `
                      -AppOptions ([hashtable[]]$accel.AppOptions) `
                      -Pfx        $Pfx `
                      -PfxPassword $PfxPassword
    }

    return $accel
}