test(linux): reject a wrong-architecture native binary at packaging time

Cross-building the arm64 slice on an x64 host silently packed an x86-64
`pty.node` -- the rebuild logged "Forcing native rebuild for linux-arm64" and
shipped the host's binary anyway. Every gate here inspects symbol versions,
which are perfectly valid on the wrong architecture, so nothing noticed.

Observed on a Raspberry Pi 5: the packaged app loaded, then failed with
"Failed to load native module: pty.node", and the launch contract reported
3 of 8 cases crashed rather than naming the cause. Swapping in the aarch64
`pty.node` took the same build to 8/8.

Compare ELF `e_machine` against the slice being packaged and fail with the
offending path. Checked before the glibc pass, because a wrong-architecture
binary's symbol versions are valid but meaningless and would send the reader
down the wrong path.

Release CI builds arm64 on a native runner, so this guards local and future
cross-builds rather than a shipped artifact.
This commit is contained in:
Neil
2026-09-01 22:40:12 -07:00
parent a0bacaeedf
commit 264e69e7ce
3 changed files with 137 additions and 1 deletions
+5 -1
View File
@@ -242,7 +242,11 @@ module.exports = {
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
// Fail packaging if any bundled native binary exceeds the supported floor.
if (context.electronPlatformName === 'linux') {
verifyLinuxGlibcFloor(context.appOutDir)
// Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary,
// so a cross-built slice could ship the host's pty.node and only fail at runtime.
verifyLinuxGlibcFloor(context.appOutDir, {
targetArch: { 1: 'x64', 3: 'arm64' }[context.arch]
})
}
const resourcesDir =
context.electronPlatformName === 'darwin'
@@ -164,6 +164,53 @@ function findMissingProviderDeps(importedSymbols, neededLibraries) {
return missing
}
// ELF e_machine values for the Linux slices we package. Names match electron-builder's Arch enum.
const ELF_MACHINE_BY_ARCH = Object.freeze({ x64: 0x3e, arm64: 0xb7 })
const ARCH_BY_ELF_MACHINE = Object.freeze({ 0x3e: 'x64', 0xb7: 'arm64' })
/**
* ELF `e_machine`, or null when the file is not a readable little-endian ELF.
*
* Why this is checked at all: cross-building an arm64 package on an x64 host can silently pack an
* x86-64 `pty.node` into the arm64 slice — the rebuild logs a forced arm64 rebuild and still ships
* the host's binary. Every other gate here inspects symbol versions, which are perfectly valid on
* the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the app loaded, then
* failed with "Failed to load native module: pty.node".
*/
function readElfMachine(filePath) {
let fd
try {
fd = openSync(filePath, 'r')
const header = Buffer.alloc(20)
if (readSync(fd, header, 0, 20, 0) !== 20) {
return null
}
// EI_DATA (offset 5) must be ELFDATA2LSB for a little-endian e_machine read.
if (header[5] !== 1) {
return null
}
return header.readUInt16LE(18)
} catch {
return null
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
}
function findArchViolation(filePath, targetArch) {
const expected = ELF_MACHINE_BY_ARCH[targetArch]
if (expected === undefined) {
return null
}
const machine = readElfMachine(filePath)
if (machine === null || machine === expected) {
return null
}
return { machine, actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}` }
}
function isElfFile(filePath) {
let fd
try {
@@ -312,6 +359,7 @@ function readImportedSymbols(filePath, objdumpPath) {
*/
function verifyLinuxGlibcFloor(rootDir, options = {}) {
const binaries = collectNativeBinaries(rootDir)
const targetArch = options.targetArch
if (binaries.length === 0) {
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
return
@@ -327,6 +375,27 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
)
}
// Why before the glibc pass: a wrong-architecture binary's symbol versions are valid but
// meaningless, so reporting a floor violation for it would send the reader down the wrong path.
const archOffenders = binaries
.map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch) }))
.filter(({ violation }) => violation !== null)
if (archOffenders.length > 0) {
const detail = archOffenders
.map(
({ filePath, violation }) =>
` ${relative(rootDir, filePath) || filePath} is ${violation.actual}`
)
.join('\n')
throw new Error(
`[verify-linux-glibc-floor] ${archOffenders.length} bundled native binar` +
`${archOffenders.length === 1 ? 'y is' : 'ies are'} built for the wrong architecture ` +
`(target ${targetArch}), so the app will fail to load them at runtime:\n${detail}\n` +
'Cross-building a Linux slice can pack the host architecture despite a forced rebuild; ' +
'build this slice on a native runner.'
)
}
const offenders = []
for (const filePath of binaries) {
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
@@ -375,6 +444,9 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
module.exports = {
MIN_GLIBC,
ELF_MACHINE_BY_ARCH,
readElfMachine,
findArchViolation,
VERSION_FLOORS,
FLOOR_LABEL,
RELOCATED_SYMBOL_PROVIDERS,
@@ -6,6 +6,9 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
readElfMachine,
findArchViolation,
ELF_MACHINE_BY_ARCH,
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
@@ -321,3 +324,60 @@ describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => {
}
})
})
/** Minimal little-endian 64-bit ELF header with the given e_machine. */
function elfHeader(machine) {
const header = Buffer.alloc(64)
header.write('\x7fELF', 0, 'latin1')
header[4] = 2 // ELFCLASS64
header[5] = 1 // ELFDATA2LSB
header[6] = 1 // EV_CURRENT
header.writeUInt16LE(3, 16) // ET_DYN
header.writeUInt16LE(machine, 18)
return header
}
describe('bundled native binary architecture', () => {
it('reads e_machine from a little-endian ELF', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64))
expect(readElfMachine(file)).toBe(ELF_MACHINE_BY_ARCH.arm64)
await rm(dir, { recursive: true, force: true })
})
// The observed failure: cross-building arm64 on an x64 host packed an x86-64 pty.node, whose
// symbol versions are valid, so every other gate here passed it.
it('flags an x86-64 binary in an arm64 slice', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64' })
await rm(dir, { recursive: true, force: true })
})
it('accepts a matching architecture', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
expect(findArchViolation(file, 'x64')).toBeNull()
await rm(dir, { recursive: true, force: true })
})
it('stays silent when no target architecture is supplied', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
expect(findArchViolation(file, undefined)).toBeNull()
await rm(dir, { recursive: true, force: true })
})
it('ignores a file that is not a readable little-endian ELF', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'not-elf.node')
await writeFile(file, Buffer.from('not an elf at all'))
expect(readElfMachine(file)).toBeNull()
expect(findArchViolation(file, 'arm64')).toBeNull()
await rm(dir, { recursive: true, force: true })
})
})