fix(desktop): recognize little-endian Mach-O magic in native binary classifier

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.
This commit is contained in:
ethernet 2026-07-13 17:16:46 -04:00
parent 7577834206
commit 5d691374c3
2 changed files with 78 additions and 11 deletions

View file

@ -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'

View file

@ -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 {