From 5d691374c3eed83bd08aa8b460cd59115370c4af Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 13 Jul 2026 17:16:46 -0400 Subject: [PATCH] fix(desktop): recognize little-endian Mach-O magic in native binary classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit classifyNativeBinary only checked big-endian Mach-O/Fat magic bytes (feedfacf, feedface, cafebabe). Real Darwin .node files from node-pty prebuilds are stored little-endian on disk (cffaedfe = MH_CIGAM_64), so every Darwin prebuild classified as null, and validateStagedBinaries threw a platform mismatch on macOS — breaking npm run check for every macOS contributor. CI didn't catch it because runners are Linux (ELF path was correct) and tests only planted big-endian fake headers. Add recognition for all six Mach-O/Fat byte orderings: - MH_CIGAM (cefaedfe) — LE 32-bit - MH_CIGAM_64 (cffaedfe) — LE 64-bit [the one real prebuilds use] - FAT_CIGAM (bebafeca) — LE universal Update makeFakeNode to write LE CIGAM_64 bytes for the darwin fixture (matching real on-disk format) and add regression tests for all new magic forms. --- apps/desktop/scripts/stage-native-deps.mjs | 37 ++++++++++--- .../scripts/stage-native-deps.test.mjs | 52 +++++++++++++++++-- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs index f06f5bef5..8966fece6 100644 --- a/apps/desktop/scripts/stage-native-deps.mjs +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -86,11 +86,20 @@ function copyBuildRelease(srcDir, destDir) { // format. By reading the first few bytes (magic) we can determine which // platform a given .node was compiled for, without shelling out to `file`. // -// ELF (\x7fELF) → linux -// Mach-O 32-bit (feedface) → darwin -// Mach-O 64-bit (feedfacf) → darwin -// Fat/Universal (cafebabe) → darwin -// PE (MZ DOS header) → win32 +// ELF (\x7fELF) → linux +// Mach-O 32-bit BE (feedface) → darwin +// Mach-O 64-bit BE (feedfacf) → darwin +// Mach-O 32-bit LE (cefaedfe — CIGAM) → darwin +// Mach-O 64-bit LE (cffaedfe — CIGAM_64) → darwin +// Fat/Universal BE (cafebabe) → darwin +// Fat/Universal LE (bebafeca — FAT_CIGAM) → darwin +// PE (MZ DOS header) → win32 +// +// Mach-O and Fat binaries are stored on disk in the host's native byte +// order. On x64/arm64 Darwin (every Apple Silicon + every Intel Mac that +// ships node-pty prebuilds) that is little-endian, so the on-disk magic is +// the CIGAM byte-swapped form, NOT the big-endian MH_MAGIC form. Checking +// only the BE constants misclassifies every real Darwin prebuild as unknown. // // Exported for unit testing. @@ -112,18 +121,30 @@ export function classifyNativeBinary(filePath) { if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) { return 'linux' } - // Mach-O 32-bit: feedface + // Mach-O 32-bit (big-endian / MH_MAGIC): feedface if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) { return 'darwin' } - // Mach-O 64-bit: feedfacf + // Mach-O 64-bit (big-endian / MH_MAGIC_64): feedfacf if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) { return 'darwin' } - // Fat/Universal binary: cafebabe + // Mach-O 32-bit (little-endian / MH_CIGAM): cefaedfe + if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) { + return 'darwin' + } + // Mach-O 64-bit (little-endian / MH_CIGAM_64): cffaedfe + if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) { + return 'darwin' + } + // Fat/Universal binary (big-endian / FAT_MAGIC): cafebabe if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) { return 'darwin' } + // Fat/Universal binary (little-endian / FAT_CIGAM): bebafeca + if (buf[0] === 0xbe && buf[1] === 0xba && buf[2] === 0xfe && buf[3] === 0xca) { + return 'darwin' + } // PE: MZ DOS header if (buf[0] === 0x4d && buf[1] === 0x5a) { return 'win32' diff --git a/apps/desktop/scripts/stage-native-deps.test.mjs b/apps/desktop/scripts/stage-native-deps.test.mjs index 7e0b82288..65e1bb5d3 100644 --- a/apps/desktop/scripts/stage-native-deps.test.mjs +++ b/apps/desktop/scripts/stage-native-deps.test.mjs @@ -21,7 +21,9 @@ const { join } = path function makeFakeNode(filePath, platform) { const headers = { linux: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]), // ELF - darwin: Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit + // On x64/arm64 Darwin, Mach-O binaries are stored little-endian on disk + // (MH_CIGAM_64 = cffaedfe). This is the form node-pty's prebuilds ship in. + darwin: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit LE (CIGAM_64) win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE) } fs.mkdirSync(path.dirname(filePath), { recursive: true }) @@ -54,7 +56,7 @@ test('classifyNativeBinary detects ELF as linux', () => { } }) -test('classifyNativeBinary detects Mach-O 64-bit as darwin', () => { +test('classifyNativeBinary detects Mach-O 64-bit BE as darwin', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try { const f = join(tmp, 'test.node') @@ -65,7 +67,18 @@ test('classifyNativeBinary detects Mach-O 64-bit as darwin', () => { } }) -test('classifyNativeBinary detects Mach-O 32-bit as darwin', () => { +test('classifyNativeBinary detects Mach-O 64-bit LE (CIGAM_64) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Mach-O 32-bit BE as darwin', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try { const f = join(tmp, 'test.node') @@ -76,6 +89,39 @@ test('classifyNativeBinary detects Mach-O 32-bit as darwin', () => { } }) +test('classifyNativeBinary detects Mach-O 32-bit LE (CIGAM) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Fat/Universal BE (cafebabe) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('classifyNativeBinary detects Fat/Universal LE (bebafeca / FAT_CIGAM) as darwin', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const f = join(tmp, 'test.node') + fs.writeFileSync(f, Buffer.from([0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00])) + assert.equal(classifyNativeBinary(f), 'darwin') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + test('classifyNativeBinary detects PE (MZ) as win32', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try {