From 264e69e7cec125d2ca7bdafc5d4bf43c532d5891 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:40:12 -0700 Subject: [PATCH] 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. --- config/electron-builder.config.cjs | 6 +- config/scripts/verify-linux-glibc-floor.cjs | 72 +++++++++++++++++++ .../scripts/verify-linux-glibc-floor.test.mjs | 60 ++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e06db8e6854..196a0fbd39f 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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' diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index 55ec8ca7724..8c77b1037a1 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -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, diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs index 603e4e85c00..c6837bbbe92 100644 --- a/config/scripts/verify-linux-glibc-floor.test.mjs +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -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 }) + }) +})