hermes-agent/apps/desktop/scripts/audit-bundle-arch.mjs

282 lines
11 KiB
JavaScript

// audit-bundle-arch.mjs: prove every native binary inside a packed desktop
// bundle was built for the target architecture.
//
// Why: wrong-arch binaries run FINE on the build runner (Windows-on-ARM and
// Rosetta emulate x64 silently) and only fail — or quietly burn cycles in
// emulation — on user machines. One x64 pip launcher shim inside an arm64
// payload shipped exactly this way. The build must fail loudly instead.
//
// The scan walks every file, sniffs the magic bytes (PE / ELF / Mach-O,
// including fat headers), and reports any binary whose architecture does
// not match the target. Non-binaries are skipped by content, not by file
// extension — a mis-extensioned .exe still gets caught.
//
// CLI:
// node audit-bundle-arch.mjs --arch=arm64 --root=release
//
// --root is scanned for the unpacked app dirs electron-builder produces
// (win-unpacked, win-arm64-unpacked, linux-unpacked, mac/, mac-arm64/, …).
// Auditing the unpacked tree covers the exact bytes the installer embeds:
// NSIS/DMG/AppImage add compression around it, not content.
import fs from "node:fs"
import path from "node:path"
import { isMain } from "./utils.mjs"
// ─── pure classification (unit-tested, no I/O) ─────────────────────────────
const PE_MACHINES = {
0x014c: "ia32",
0x01c0: "arm",
0x01c4: "arm",
0x8664: "x64",
0xaa64: "arm64",
0xa641: "arm64ec", // ARM64EC: arm64-ABI code, runs only on arm64 Windows
}
const ELF_MACHINES = {
0x03: "ia32",
0x28: "arm",
0x3e: "x64",
0xb7: "arm64",
}
const MACHO_CPUTYPES = {
0x01000007: "x64",
0x0100000c: "arm64",
0x00000007: "ia32",
0x0000000c: "arm",
}
/**
* Classify a buffer holding the head of a file (>= 64 bytes is plenty for
* every format except PE, whose COFF header lives at an offset named at
* 0x3c — pass headerAt for that case; classifyFile below handles it).
*
* Returns null for non-binaries, or { format, arches } where arches lists
* every architecture the file carries (>1 only for Mach-O fat binaries).
*/
export function classifyHeader(buf) {
if (buf.length >= 0x40 && buf[0] === 0x4d && buf[1] === 0x5a) {
// PE: the real machine field needs a second read at e_lfanew; caller
// resolves it. Report the format and the offset to read.
return { format: "pe", peHeaderOffset: buf.readUInt32LE(0x3c) }
}
if (buf.length >= 20 && buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
const machine = buf.readUInt16LE(18)
return { format: "elf", arches: [ELF_MACHINES[machine] || `unknown(0x${machine.toString(16)})`] }
}
if (buf.length >= 8) {
const be = buf.readUInt32BE(0)
// Thin Mach-O, either endianness.
if (be === 0xfeedface || be === 0xfeedfacf) {
const cputype = buf.readUInt32BE(4)
return { format: "macho", arches: [MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`] }
}
if (be === 0xcefaedfe || be === 0xcffaedfe) {
const cputype = buf.readUInt32LE(4)
return { format: "macho", arches: [MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`] }
}
// Fat/universal: big-endian header, one arch entry per slice.
if (be === 0xcafebabe) {
const count = buf.readUInt32BE(4)
// A Java .class file shares the magic; its "count" here is the
// bytecode version (>= 45). Real fat binaries carry a few slices.
if (count > 0 && count < 30) {
const arches = []
for (let i = 0; i < count && 8 + i * 20 + 4 <= buf.length; i++) {
const cputype = buf.readUInt32BE(8 + i * 20)
arches.push(MACHO_CPUTYPES[cputype] || `unknown(0x${cputype.toString(16)})`)
}
return { format: "macho-fat", arches }
}
}
}
return null
}
/**
* Resolve a PE machine code (read at peHeaderOffset+4) to an arch name.
* Split from classifyHeader so the walker can do the second read lazily.
*/
export function peArch(machineCode) {
return PE_MACHINES[machineCode] || `unknown(0x${machineCode.toString(16)})`
}
/**
* Does a classified binary satisfy the target arch?
* - exact match, always
* - macOS fat binary: any slice matching is enough (universal ships both)
* - arm64 targets accept arm64ec PEs (arm64-ABI by definition)
* Everything else — including "unknown(...)" — is a mismatch: an
* unclassifiable machine code in a shipped binary deserves a loud failure,
* not a shrug.
*/
export function archMatches(arches, targetArch) {
return arches.some((a) => a === targetArch || (targetArch === "arm64" && a === "arm64ec"))
}
/**
* The unpacked-app directories electron-builder leaves under release/ for
* a target arch. Names differ per platform: Windows/Linux use
* <platform>[-<arch>]-unpacked with the HOST-default arch unsuffixed; mac
* uses mac[-<arch>]/. Match by shape, not an exhaustive list, so a rename
* in electron-builder fails the audit (no dirs found) instead of silently
* auditing nothing.
*/
export function findUnpackedDirs(entries) {
return entries.filter((name) => /-unpacked$/.test(name) || /^mac(-[a-z0-9]+)?$/.test(name))
}
// ─── impure walker + CLI ────────────────────────────────────────────────────
function classifyFile(filePath) {
const fd = fs.openSync(filePath, "r")
try {
const head = Buffer.alloc(4096)
const n = fs.readSync(fd, head, 0, head.length, 0)
const sniffed = classifyHeader(head.subarray(0, n))
if (!sniffed) return null
if (sniffed.format !== "pe") return sniffed
// PE: read the 6 bytes at e_lfanew — "PE\0\0" + machine.
const peHead = Buffer.alloc(6)
const m = fs.readSync(fd, peHead, 0, 6, sniffed.peHeaderOffset)
if (m < 6 || peHead.readUInt32LE(0) !== 0x00004550) {
return null // MZ without a PE header: DOS-era stub or corrupt — not a shippable binary format we know.
}
return { format: "pe", arches: [peArch(peHead.readUInt16LE(4))] }
} finally {
fs.closeSync(fd)
}
}
function* walkFiles(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue // targets are scanned as files where they live
if (entry.isDirectory()) {
yield* walkFiles(full)
} else if (entry.isFile()) {
yield full
}
}
}
/**
* Paths allowed to carry foreign-arch binaries. pip and setuptools ship
* Windows launcher STUB TEMPLATES as package data (distlib t32/t64/w32/
* w64.exe, setuptools cli*.exe / gui*.exe). They are not executed from
* here; pip copies one to build a console-script shim at install time.
* They exist on every platform and are always x86 PEs — inside an arm64
* or linux payload they are inert bytes, not a wrong-arch bug.
*
* Anchored at agent-payload (not the tree root: mac nests the payload
* under Hermes.app/Contents/Resources/). The lib segment is `Lib` on
* Windows and `lib/python3.11` elsewhere; both separators appear in
* relative paths depending on the build host.
*
* PortableGit carries .NET AnyCPU/MSIL assemblies (Git Credential Manager:
* Avalonia.*, Atlassian.*, Microsoft.*, System.*, etc.) across mingw64/bin,
* mingw64/lib, and mingw64/libexec/git-core. Their PE machine field is 0x14c
* (ia32) because .NET assemblies are format-neutral — the CLR JITs them to
* the native arch at load time. It also ships usr/libexec/getprocaddr32.exe,
* a 32-bit MSYS2 helper. The staging script already PE-probes cmd/git.exe
* itself; the bundle audit does not need to re-audit PortableGit's internal
* MSYS2/.NET layout.
*/
const EXEMPT_PATTERNS = [
/agent-payload[/\\]python[/\\]cpython-[^/\\]+[/\\]lib([/\\]python[\d.]+)?[/\\]site-packages[/\\](setuptools|pip[/\\]_vendor[/\\]distlib)[/\\]/i,
// electron-builder's NSIS finalize task drops its elevation helper into
// resources/ (see e-b #9852; electron-updater runs it for elevated
// installs). It is ia32 BY DESIGN: one binary that runs on every
// Windows arch through the always-present x86 emulation layer.
/^resources[/\\]elevate\.exe$/i,
// PortableGit — see comment above. The staging script's own PE probe on
// cmd/git.exe is the authoritative arch check for the bundled git.
//
// Scoped to the WINDOWS layout (mingw64/ on x64, clangarm64/ on arm64,
// plus usr/, cmd/) rather than the whole git/ tree: the .NET-assembly
// and MSYS2-helper reasoning is PortableGit's alone. dugite-native
// (macOS/Linux) is plain Mach-O and ELF with no format-neutral binaries
// in it, so exempting it would hide a genuinely wrong-arch git — the
// exact defect this audit exists to catch, in the payload that cannot
// fall back to a system git.
/agent-payload[/\\]git[/\\](mingw64|clangarm64|usr|cmd)[/\\]/i,
]
export function isExemptPath(relPath) {
return EXEMPT_PATTERNS.some((p) => p.test(relPath))
}
export function auditTree(rootDir, targetArch) {
const mismatches = []
let binaries = 0
let exempted = 0
for (const file of walkFiles(rootDir)) {
let info
try {
info = classifyFile(file)
} catch {
continue // unreadable file: electron-builder would have failed on it already
}
if (!info) continue
binaries += 1
if (!archMatches(info.arches, targetArch)) {
const rel = path.relative(rootDir, file)
if (isExemptPath(rel)) {
exempted += 1
continue
}
mismatches.push({ file: rel, format: info.format, arches: info.arches })
}
}
return { binaries, exempted, mismatches }
}
function main() {
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, ...v] = a.replace(/^--/, "").split("=")
return [k, v.join("=")]
})
)
const targetArch = args.arch
const root = args.root
if (!targetArch || !root) {
console.error("usage: audit-bundle-arch.mjs --arch=<x64|arm64> --root=<release dir>")
process.exit(2)
}
const dirs = findUnpackedDirs(fs.readdirSync(root)).map((d) => path.join(root, d))
if (dirs.length === 0) {
console.error(`audit-bundle-arch: no unpacked app directory under ${root} — nothing was audited, failing`)
process.exit(1)
}
let failed = false
for (const dir of dirs) {
const { binaries, exempted, mismatches } = auditTree(dir, targetArch)
if (binaries === 0) {
console.error(`audit-bundle-arch: ${dir}: found no native binaries at all — the scan is broken, failing`)
failed = true
continue
}
if (mismatches.length > 0) {
failed = true
console.error(`audit-bundle-arch: ${dir}: ${mismatches.length} binaries do not match --arch=${targetArch}:`)
for (const m of mismatches) {
console.error(` [${m.format} ${m.arches.join("+")}] ${m.file}`)
}
} else {
const exemptNote = exempted > 0 ? ` (${exempted} exempt launcher stubs)` : ""
console.log(`audit-bundle-arch: ${dir}: ${binaries} native binaries, all ${targetArch}${exemptNote}`)
}
}
process.exit(failed ? 1 : 0)
}
if (isMain(import.meta.url)) {
main()
}