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 } } } } |