Modules/businessdev.ALbuild.Core/Public/Expand-BcAppFile.ps1

function Expand-BcAppFile {
    <#
    .SYNOPSIS
        Extracts a Business Central .app/.runtime.app package and reads its manifest.
 
    .DESCRIPTION
        A BC .app file is a ZIP archive preceded by a small binary header. This function locates the ZIP
        payload (by its local-file-header signature), extracts it, and parses the embedded
        NavxManifest.xml to return the app's identity and dependencies - all without a running container.
        Namespace/casing differences are tolerated by matching element local-names.
 
        A **runtime package** (an ISV's protected on-prem distribution) is not encrypted, only lightly
        obfuscated: after an 8-byte '.NEA' marker at offset 40, the ZIP is scrambled with an RC4 keystream
        using a fixed, public 6-byte key. This is de-obfuscated in-memory so a runtime package's real
        id/name/version is read on the host, exactly like an ordinary .app. (The .app runtime container
        format is documented by the community project SimonOfHH/D365BCAppHelper.)
 
    .PARAMETER Path
        Path to the .app or .runtime.app file.
 
    .PARAMETER DestinationPath
        Optional folder to extract into. Defaults to a unique temp folder (returned as
        ExtractedPath); the caller owns cleanup.
 
    .EXAMPLE
        $info = Expand-BcAppFile -Path .\Publisher_App_1.0.0.0.app
        $info.Id; $info.Application; $info.Dependencies
 
    .OUTPUTS
        PSCustomObject with Id, Name, Publisher, Version, Application, Platform, Dependencies,
        ManifestPath, ExtractedPath, Manifest (xml).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [string] $DestinationPath
    )

    process {
        if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
            throw "App file not found: '$Path'."
        }

        $tempZip = $null
        try {
            $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path).ProviderPath)

            # A runtime package has an 8-byte '.NEA' marker at offset 40 and an RC4-scrambled ZIP from
            # offset 48 (fixed, public key - obfuscation, not encryption). De-obfuscate it in-memory so the
            # rest of this function reads it like an ordinary .app. Compiled once per session for speed
            # (an RC4 pass over a multi-MB package is too slow in pure PowerShell).
            if (-not ('ALbuild.BcRuntimePackage' -as [type])) {
                Add-Type -TypeDefinition @'
namespace ALbuild {
    public static class BcRuntimePackage {
        // Fixed key / header marker of the BC .app runtime-package format (public; format documented by
        // the community project SimonOfHH/D365BCAppHelper). RC4 keystream, ZIP begins at offset 48.
        static readonly byte[] Key = { 15, 11, 81, 137, 184, 120 };
        static readonly byte[] Marker = { 46, 78, 69, 65, 0, 0, 0, 1 }; // ".NEA\0\0\0\x01" at offset 40
        public static bool IsRuntimePackage(byte[] b) {
            if (b == null || b.Length < 48) return false;
            for (int i = 0; i < Marker.Length; i++) if (b[40 + i] != Marker[i]) return false;
            return true;
        }
        public static byte[] Decode(byte[] b) {
            byte[] s = new byte[256];
            for (int i = 0; i < 256; i++) s[i] = (byte)i;
            int j = 0;
            for (int i = 0; i < 256; i++) { j = (j + s[i] + Key[i % Key.Length]) & 255; byte t = s[i]; s[i] = s[j]; s[j] = t; }
            int n = b.Length - 48;
            byte[] o = new byte[n];
            int x = 0, y = 0;
            for (int k = 0; k < n; k++) {
                x = (x + 1) & 255; y = (y + s[x]) & 255;
                byte t = s[x]; s[x] = s[y]; s[y] = t;
                o[k] = (byte)(b[48 + k] ^ s[(s[x] + s[y]) & 255]);
            }
            return o;
        }
    }
}
'@

            }
            if ([ALbuild.BcRuntimePackage]::IsRuntimePackage($bytes)) {
                Write-Verbose "'$Path' is a runtime package; de-obfuscating its payload."
                $bytes = [ALbuild.BcRuntimePackage]::Decode($bytes)
            }

            # Find the ZIP local-file-header signature: 0x50 0x4B 0x03 0x04 ("PK..").
            # Use Array.IndexOf to locate candidate 0x50 bytes (native scan) rather than a
            # per-element PowerShell loop, which is slow over large packages.
            $zipStart = -1
            $from = 0
            while (($candidate = [System.Array]::IndexOf($bytes, [byte]0x50, $from)) -ge 0 -and $candidate -le ($bytes.Length - 4)) {
                if ($bytes[$candidate + 1] -eq 0x4B -and $bytes[$candidate + 2] -eq 0x03 -and $bytes[$candidate + 3] -eq 0x04) {
                    $zipStart = $candidate
                    break
                }
                $from = $candidate + 1
            }
            if ($zipStart -lt 0) {
                throw "'$Path' does not contain a ZIP payload; it may not be a valid .app file."
            }

            if (-not $DestinationPath) {
                $DestinationPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'ALbuild-app-' + [System.Guid]::NewGuid().ToString())
            }
            if (-not (Test-Path -LiteralPath $DestinationPath)) {
                New-Item -Path $DestinationPath -ItemType Directory -Force | Out-Null
            }

            # Read ONLY NavxManifest.xml from the ZIP payload in-memory: a package can be tens of MB, and a
            # full extraction (Expand-Archive) is both slow and unnecessary - the manifest is all we need.
            if (-not ('System.IO.Compression.ZipArchive' -as [type])) { Add-Type -AssemblyName System.IO.Compression | Out-Null }
            $manifestPath = Join-Path $DestinationPath 'NavxManifest.xml'
            $zipMs = [System.IO.MemoryStream]::new($bytes, [int] $zipStart, [int] ($bytes.Length - $zipStart))
            try {
                $zip = New-Object System.IO.Compression.ZipArchive($zipMs, [System.IO.Compression.ZipArchiveMode]::Read)
                try {
                    $entry = $zip.Entries | Where-Object { [System.IO.Path]::GetFileName($_.FullName) -ieq 'NavxManifest.xml' } | Select-Object -First 1
                    if (-not $entry) { throw "NavxManifest.xml was not found inside '$Path'." }
                    $entryStream = $entry.Open()
                    try {
                        $fileStream = [System.IO.File]::Create($manifestPath)
                        try { $entryStream.CopyTo($fileStream) } finally { $fileStream.Dispose() }
                    }
                    finally { $entryStream.Dispose() }
                }
                finally { $zip.Dispose() }
            }
            finally { $zipMs.Dispose() }

            $manifestFile = Get-Item -LiteralPath $manifestPath
            [xml] $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8

            $appNode = $manifest.SelectSingleNode("//*[local-name()='App']")
            if (-not $appNode) {
                throw "NavxManifest.xml in '$Path' has no <App> element."
            }

            $getAttr = {
                param($node, [string] $name)
                if ($null -eq $node -or $null -eq $node.Attributes) { return $null }
                foreach ($attr in $node.Attributes) {
                    if ($attr.Name -ieq $name) { return $attr.Value }
                }
                return $null
            }

            $dependencies = @()
            foreach ($dep in $manifest.SelectNodes("//*[local-name()='Dependency']")) {
                $minVersion = & $getAttr $dep 'MinVersion'
                if (-not $minVersion) { $minVersion = & $getAttr $dep 'Version' }
                $dependencies += [PSCustomObject]@{
                    Id        = & $getAttr $dep 'Id'
                    Name      = & $getAttr $dep 'Name'
                    Publisher = & $getAttr $dep 'Publisher'
                    Version   = $minVersion
                }
            }

            return [PSCustomObject]@{
                Id            = & $getAttr $appNode 'Id'
                Name          = & $getAttr $appNode 'Name'
                Publisher     = & $getAttr $appNode 'Publisher'
                Version       = & $getAttr $appNode 'Version'
                Application   = & $getAttr $appNode 'Application'
                Platform      = & $getAttr $appNode 'Platform'
                Dependencies  = $dependencies
                ManifestPath  = $manifestFile.FullName
                ExtractedPath = $DestinationPath
                Manifest      = $manifest
            }
        }
        finally {
            if ($tempZip -and (Test-Path -LiteralPath $tempZip)) {
                Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue
            }
        }
    }
}