From eed8b33f69deb9992bf2182992d715bd7a5e3ffd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:55:16 -0700 Subject: [PATCH] test(linux): judge per-arch vendored binaries against their own path The first CI run of the architecture gate failed the x64 package job on `@parcel/watcher-linux-arm64-glibc/watcher.node`. That binary is arm64 on purpose: the package ships every architecture and its loader picks the match, so its presence in an x64 build is correct. Judge a binary against the architecture its own path names, falling back to the slice when the path names none. That keeps the case this gate exists for -- `bin/linux-arm64-*/node-pty.node` holding an x86-64 binary, which is what shipped to a Raspberry Pi 5 -- while letting multi-arch dependencies through. Dry-run over the real dependency tree flags nothing for either target arch. --- config/scripts/verify-linux-glibc-floor.cjs | 33 +++++++++++++++++-- .../scripts/verify-linux-glibc-floor.test.mjs | 27 +++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index 8c77b1037a1..3a138ed894b 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -199,8 +199,28 @@ function readElfMachine(filePath) { } } +// Arch tokens that appear in vendored per-architecture package/directory names. +const ARCH_TOKEN_PATTERN = /(?:^|[^a-z0-9])(arm64|aarch64|x64|x86_64)(?:[^a-z0-9]|$)/i +const ARCH_BY_TOKEN = Object.freeze({ arm64: 'arm64', aarch64: 'arm64', x64: 'x64', x86_64: 'x64' }) + +/** + * The architecture a path advertises, or null when it advertises none. + * + * Why this matters: some dependencies ship every architecture and let their loader pick + * (`@parcel/watcher-linux-arm64-glibc/watcher.node` is arm64 on purpose inside an x64 build). Those + * must be judged against the arch their own path declares, not against the slice. + */ +function declaredArchFromPath(filePath) { + const match = ARCH_TOKEN_PATTERN.exec(filePath) + return match ? ARCH_BY_TOKEN[match[1].toLowerCase()] : null +} + function findArchViolation(filePath, targetArch) { - const expected = ELF_MACHINE_BY_ARCH[targetArch] + // A path that names an architecture is judged against that name, so a per-arch vendored package + // is fine while `bin/linux-arm64-.../node-pty.node` holding an x86-64 binary is still caught. + const declared = declaredArchFromPath(filePath) + const expectedArch = declared ?? targetArch + const expected = ELF_MACHINE_BY_ARCH[expectedArch] if (expected === undefined) { return null } @@ -208,7 +228,12 @@ function findArchViolation(filePath, targetArch) { if (machine === null || machine === expected) { return null } - return { machine, actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}` } + return { + machine, + actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}`, + expectedArch, + declared: declared !== null + } } function isElfFile(filePath) { @@ -384,7 +409,8 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { const detail = archOffenders .map( ({ filePath, violation }) => - ` ${relative(rootDir, filePath) || filePath} is ${violation.actual}` + ` ${relative(rootDir, filePath) || filePath} is ${violation.actual}, expected ` + + `${violation.expectedArch}${violation.declared ? ' (from its own path)' : ''}` ) .join('\n') throw new Error( @@ -446,6 +472,7 @@ module.exports = { MIN_GLIBC, ELF_MACHINE_BY_ARCH, readElfMachine, + declaredArchFromPath, findArchViolation, VERSION_FLOORS, FLOOR_LABEL, diff --git a/config/scripts/verify-linux-glibc-floor.test.mjs b/config/scripts/verify-linux-glibc-floor.test.mjs index c6837bbbe92..d8d82165053 100644 --- a/config/scripts/verify-linux-glibc-floor.test.mjs +++ b/config/scripts/verify-linux-glibc-floor.test.mjs @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) const { readElfMachine, + declaredArchFromPath, findArchViolation, ELF_MACHINE_BY_ARCH, parseGlibcVersion, @@ -348,6 +349,32 @@ describe('bundled native binary architecture', () => { // 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. + // Real CI hit: @parcel/watcher ships every architecture and its loader picks the match, so the + // arm64 copy is present in an x64 build on purpose. + it('accepts a per-arch vendored package that matches its own path', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const pkg = join(dir, '@parcel', 'watcher-linux-arm64-glibc') + await mkdir(pkg, { recursive: true }) + const file = join(pkg, 'watcher.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64)) + expect(declaredArchFromPath(file)).toBe('arm64') + expect(findArchViolation(file, 'x64')).toBeNull() + await rm(dir, { recursive: true, force: true }) + }) + + // But a path that names an arch must actually hold it — this is the Pi 5 failure. + it('flags a binary that contradicts the architecture its own path names', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-')) + const nested = join(dir, 'bin', 'linux-arm64-148') + await mkdir(nested, { recursive: true }) + const file = join(nested, 'node-pty.node') + await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64)) + expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' }) + // Still caught even when the slice being built is x64. + expect(findArchViolation(file, 'x64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' }) + await rm(dir, { recursive: true, force: true }) + }) + 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')