fix(desktop): prevent staging wrong-platform node-pty binary for cross targets

stageNodePty received electron-builder's { platform, arch } but unconditionally
copied host build/Release, staging a host binary (e.g. macOS Mach-O) for a
foreign target (e.g. linux-arm64). The fallback rebuild also didn't pass the
target arch to electron-rebuild.

Now:
- build/Release is only staged when target platform+arch match the host
- cross-platform targets with no matching prebuild fail closed
- same-platform different-arch rebuild passes --arch to electron-rebuild
- post-staging validation reads .node magic bytes (ELF/Mach-O/PE) and rejects
  any binary whose platform doesn't match the target

Adds stageNodePtyInto() (testable core) and classifyNativeBinary() (pure),
plus 11 regression tests covering the cross-target scenarios.
This commit is contained in:
ethernet 2026-07-13 14:19:01 -04:00
parent 47d56b802a
commit 7a44a8fdec
2 changed files with 396 additions and 20 deletions

View file

@ -18,6 +18,7 @@ import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync
} from 'node:fs'
import { spawnSync } from 'node:child_process'
@ -79,9 +80,115 @@ function copyBuildRelease(srcDir, destDir) {
}
}
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
const srcRoot = resolveNodePtyRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
// ─── binary classification ───────────────────────────────────────────
//
// .node files are shared libraries in the target platform's native binary
// 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
//
// Exported for unit testing.
/**
* Classify a native binary's target platform from its magic bytes.
* Returns `'linux'`, `'darwin'`, `'win32'`, or `null` if unrecognized
* or the file cannot be read.
*/
export function classifyNativeBinary(filePath) {
let buf
try {
buf = readFileSync(filePath, { start: 0, end: 63 }) // first 64 bytes
} catch {
return null
}
if (buf.length < 4) return null
// ELF: \x7f E L F
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
return 'linux'
}
// Mach-O 32-bit: feedface
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) {
return 'darwin'
}
// Mach-O 64-bit: feedfacf
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) {
return 'darwin'
}
// Fat/Universal binary: cafebabe
if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) {
return 'darwin'
}
// PE: MZ DOS header
if (buf[0] === 0x4d && buf[1] === 0x5a) {
return 'win32'
}
return null
}
/**
* Scan the staged destination tree for .node files and verify each one's
* binary platform matches the requested target. Throws on any mismatch.
*
* This is the fail-closed safety net: even if a prebuild or build/Release
* somehow slipped through with the wrong platform, this catches it before
* the package ships a broken native binary to users.
*/
function validateStagedBinaries(destRoot, targetPlatform) {
const mismatches = []
function scan(dir, relPrefix) {
if (!existsSync(dir)) return
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
scan(join(dir, entry.name), `${relPrefix}${entry.name}/`)
continue
}
if (!entry.name.endsWith('.node')) continue
const fullPath = join(dir, entry.name)
const classified = classifyNativeBinary(fullPath)
if (classified !== targetPlatform) {
mismatches.push({ file: `${relPrefix}${entry.name}`, classified, expected: targetPlatform })
}
}
}
scan(join(destRoot, 'prebuilds'), 'prebuilds/')
scan(join(destRoot, 'build', 'Release'), 'build/Release/')
if (mismatches.length > 0) {
throw new Error(
`[stage-native-deps] native binary platform mismatch (target=${targetPlatform}):\n` +
mismatches
.map((m) => ` ${m.file}: expected ${m.expected}, got ${m.classified ?? 'unknown'}`)
.join('\n') +
`\nRefusing to stage a binary compiled for the wrong platform.`
)
}
}
/**
* Stage node-pty's native runtime dependencies into `destRoot`.
*
* Exported separately from `stageNodePty` so tests can supply a fake
* node-pty source tree without going through real module resolution.
*
* Strategy (fail-closed):
*
* 1. Copy the matching prebuild (`prebuilds/<platform>-<arch>/`) if present.
* 2. Copy `build/Release/` **only when the target matches the host**
* build/Release contains a binary compiled for the host's platform/arch,
* so staging it for a different target ships a broken app.
* 3. If no native binary was staged:
* - Same platform as host, different arch run `electron-rebuild --arch`.
* - Different platform from host throw (cannot cross-compile native
* modules; build on the target platform or provide a prebuild).
* 4. Validate every staged `.node` file's binary platform matches the target.
*/
export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platform, arch = process.arch } = {}) {
const hostMatch = platform === process.platform && arch === process.arch
rmSync(destRoot, { recursive: true, force: true })
mkdirSync(destRoot, { recursive: true })
@ -119,47 +226,77 @@ export function stageNodePty({ platform = process.platform, arch = process.arch
// build/Release/* — present when node-pty was compiled locally
// (e.g. no prebuild available for this Electron ABI/platform combo).
// Some installs won't have this at all if prebuild-install succeeded.
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
// Only stage this when the target matches the host, because
// build/Release contains a binary compiled for the *host's* platform
// and architecture. Staging a host binary for a different target (e.g.
// a macOS Mach-O .node staged for a linux-arm64 target) ships a broken
// app that crashes the first time a terminal is spawned.
if (hostMatch) {
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
}
// If neither a prebuild nor build/Release produced a .node binary for this
// target, run electron-rebuild to compile one from source. This happens on
// CI (npm ci --ignore-scripts skips postinstall) and on platforms where
// node-pty doesn't publish prebuilds (e.g. linux-x64).
// Check whether a native binary for this target was staged.
const stagedDirs = [
join(destRoot, 'prebuilds', `${platform}-${arch}`),
join(destRoot, 'build/Release')
]
const hasNativeBinary = stagedDirs.some(dir => {
const hasNativeBinary = stagedDirs.some((dir) => {
if (!existsSync(dir)) return false
return readdirSync(dir, { recursive: true }).some(name => String(name).endsWith('.node'))
return readdirSync(dir, { recursive: true }).some((name) => String(name).endsWith('.node'))
})
if (!hasNativeBinary) {
if (platform !== process.platform) {
throw new Error(
`[stage-native-deps] no prebuilt binary for ${platform}-${arch} and ` +
`cannot cross-compile native modules from ${process.platform}-${process.arch}. ` +
`Build on the target platform or provide a prebuild.`
)
}
// Same platform, possibly different arch — rebuild from source with
// the target architecture so electron-rebuild produces the correct
// binary rather than defaulting to the host's arch.
console.log(
`[stage-native-deps] no prebuilt or compiled native binary for ${platform}-${arch}; ` +
`running electron-rebuild to compile from source...`
)
const result = spawnSync(
process.execPath,
['../../node_modules/.bin/electron-rebuild', '-f', '-w', 'node-pty'],
{ cwd: projectRoot, stdio: 'inherit' }
`[stage-native-deps] no native binary for ${platform}-${arch}; ` +
`running electron-rebuild (target arch: ${arch})...`
)
const rebuildArgs = [
'../../node_modules/.bin/electron-rebuild',
'-f',
'-w',
'node-pty',
'--arch',
arch
]
const result = spawnSync(process.execPath, rebuildArgs, {
cwd: projectRoot,
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(
`electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` +
`Cannot stage node-pty without a native binary.`
`Cannot stage node-pty without a native binary.`
)
}
// Re-copy build/Release after electron-rebuild populated it.
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
}
// Validate every staged .node binary matches the target platform.
validateStagedBinaries(destRoot, platform)
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
return destRoot
}
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
const srcRoot = resolveNodePtyRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
return stageNodePtyInto(srcRoot, destRoot, { platform, arch })
}
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
if (isMain(import.meta.url)) {
const [platform, arch] = process.argv.slice(2)

View file

@ -0,0 +1,239 @@
import assert from 'node:assert/strict'
import fs, { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
stageNodePtyInto,
classifyNativeBinary
} from '../scripts/stage-native-deps.mjs'
const { join } = path
// ─── fixtures ──────────────────────────────────────────────────────
//
// Create minimal fake .node files with correct magic bytes so the
// binary classifier and the staging validator exercise real code paths
// without needing actual native modules.
/** Write a fake .node file with the given platform's magic bytes. */
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
win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE)
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, headers[platform] ?? headers.linux)
}
/** Create a minimal fake node-pty source tree in a temp dir. */
function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) {
fs.mkdirSync(srcRoot, { recursive: true })
fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'lib/index.js' }))
fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true })
fs.writeFileSync(join(srcRoot, 'lib', 'index.js'), 'module.exports = {};')
if (prebuildPlatform && prebuildArch) {
const prebuildDir = join(srcRoot, 'prebuilds', `${prebuildPlatform}-${prebuildArch}`)
makeFakeNode(join(prebuildDir, 'pty.node'), prebuildPlatform)
}
}
// ─── classifyNativeBinary tests ─────────────────────────────────────
test('classifyNativeBinary detects ELF as linux', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'linux')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 64-bit as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 32-bit as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xce, 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 {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'win32')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary returns null for unrecognized magic', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), null)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary returns null for a missing file', () => {
assert.equal(classifyNativeBinary('/nonexistent/path/to/thing.node'), null)
})
// ─── cross-target regression tests ──────────────────────────────────
//
// The core bug: stageNodePty receives { platform, arch } from
// electron-builder but unconditionally copies host build/Release, staging
// a host binary for a foreign target. These tests prove the fix:
//
// 1. A host build/Release must NOT be staged for a foreign platform.
// 2. A matching prebuild IS staged for a foreign target.
// 3. A foreign target with no prebuild throws (fail closed).
// 4. A host build/Release IS staged for a matching target.
// 5. Validation rejects a binary whose magic bytes don't match the target.
test('cross-target: host build/Release is NOT staged for a foreign platform', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a node-pty tree with ONLY a host build/Release (no prebuild).
makeFakeNodePty(srcRoot)
const buildReleaseDir = join(srcRoot, 'build', 'Release')
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
// Request a foreign platform (different from the host).
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
/cannot cross-compile/i
)
// build/Release must NOT have been copied to the dest tree.
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
false,
'host build/Release .node must not be staged for a foreign target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('cross-target: matching prebuild IS staged for a foreign target', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Host is (say) darwin. Request linux-x64, which has a prebuild.
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
makeFakeNodePty(srcRoot, { prebuildPlatform: foreignPlatform, prebuildArch: 'x64' })
// Also create a host build/Release that should NOT be staged.
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' })
// The foreign prebuild must be staged.
const stagedPrebuild = join(destRoot, 'prebuilds', `${foreignPlatform}-x64`, 'pty.node')
assert.equal(existsSync(stagedPrebuild), true, 'foreign prebuild must be staged')
// The host build/Release must NOT be staged.
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
false,
'host build/Release must not be staged for a foreign target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('cross-target: foreign target with no prebuild throws (fail closed)', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a tree with a host build/Release but no foreign prebuild.
makeFakeNodePty(srcRoot)
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
/cannot cross-compile/i
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('host-target: host build/Release IS staged for a matching target', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
makeFakeNodePty(srcRoot)
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
true,
'host build/Release must be staged for a matching target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('validation rejects a staged binary with the wrong platform magic', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a prebuild dir that claims to be linux-x64 but contains
// a darwin (Mach-O) binary. This simulates the original bug where
// a host binary ends up in a foreign target's prebuild slot.
makeFakeNodePty(srcRoot, { prebuildPlatform: 'linux', prebuildArch: 'x64' })
// Overwrite the prebuild .node with the WRONG platform magic.
makeFakeNode(join(srcRoot, 'prebuilds', 'linux-x64', 'pty.node'), 'darwin')
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: 'linux', arch: 'x64' }),
/platform mismatch/i
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})