mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user