From 408dfde5e8a69eb1b4a487de4c0700ed86070404 Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Wed, 10 Jun 2026 18:30:05 -0500 Subject: [PATCH 1/7] feat(sea): let users override the base Node binary SEA mode previously always downloaded a base Node binary from nodejs.org, so the resulting executable was pinned to an official build. That makes it impossible to ship a SEA on top of a custom runtime -- e.g. a Node built against an older glibc (to run on EL7 / older distros), or one configured differently. The internal SeaOptions / SeaEnhancedOptions already accepted `nodePath` / `useLocalNode`; this just exposes them: --sea-node-path embed a specific Node binary as the base --sea-use-local-node embed the Node running pkg (process.execPath) Both are also settable in the pkg config (seaNodePath / seaUseLocalNode) and via the programmatic exec() API. The two are mutually exclusive. The embedded binary's major version must match the target's (the existing version-skew checks still apply). This is also a prerequisite for adopting `node --build-sea` (Node >= 25.5): that flag is only meaningful once the base Node is no longer a fixed download. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/config.ts | 23 +++++++++++++++ lib/help.ts | 7 +++-- lib/index.ts | 14 +++++++++ lib/types.ts | 12 ++++++++ test/test-95-sea-local-node/index.js | 6 ++++ test/test-95-sea-local-node/main.js | 29 +++++++++++++++++++ test/test-95-sea-local-node/package.json | 6 ++++ test/test.js | 1 + test/unit/config-parse.test.ts | 37 ++++++++++++++++++++++++ 9 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 test/test-95-sea-local-node/index.js create mode 100644 test/test-95-sea-local-node/main.js create mode 100644 test/test-95-sea-local-node/package.json diff --git a/lib/config.ts b/lib/config.ts index 716febe0..2287adc1 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -138,6 +138,25 @@ const FLAG_SPECS: readonly FlagSpec[] = [ default: false, }, { cli: 'sea', cfg: 'sea', resolved: 'sea', kind: 'bool', default: false }, + { + // SEA mode: use a specific Node binary as the base for the executable + // instead of downloading one from nodejs.org. Lets you embed a custom + // build (e.g. one that runs on older glibc, or a differently-configured + // runtime). The binary's major version must match the target's. + cli: 'sea-node-path', + cfg: 'seaNodePath', + resolved: 'seaNodePath', + kind: 'string', + }, + { + // SEA mode: use the Node binary currently running pkg (process.execPath) + // as the base. Shorthand for `--sea-node-path "$(command -v node)"`. + cli: 'sea-use-local-node', + cfg: 'seaUseLocalNode', + resolved: 'seaUseLocalNode', + kind: 'bool', + default: false, + }, { cli: 'compress', cfg: 'compress', @@ -485,6 +504,10 @@ export interface ResolvedFlags { fallbackToSource: boolean; public: boolean; sea: boolean; + /** SEA mode: path to a base Node binary to embed (overrides the download). */ + seaNodePath: string | undefined; + /** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */ + seaUseLocalNode: boolean; publicPackages: string[] | undefined; noDictionary: string[] | undefined; bakeOptions: string[] | undefined; diff --git a/lib/help.ts b/lib/help.ts index 31f0e613..3886b7b1 100644 --- a/lib/help.ts +++ b/lib/help.ts @@ -26,10 +26,13 @@ export default function help() { --signature enable macOS binary signing (default; use to override signature:false in config) --no-signature skip macOS binary signing [default: sign] --sea (Experimental) compile given file using node's SEA feature. Requires node v20.0.0 or higher and only single file is supported + --sea-node-path SEA mode: path to a base Node binary to embed instead of downloading one (must match the target's major version) + --sea-use-local-node SEA mode: embed the Node binary running pkg (process.execPath) as the base All build-shaping flags above (compress, fallback-to-source, public, public-packages, - options, bytecode, native-build, no-dict, debug, signature, sea) can also be set in - the pkg config file (camelCase keys). CLI flags override config values. + options, bytecode, native-build, no-dict, debug, signature, sea, sea-node-path, + sea-use-local-node) can also be set in the pkg config file (camelCase keys). CLI flags + override config values. ${pc.dim('Examples:')} diff --git a/lib/index.ts b/lib/index.ts index 4b224def..e4287430 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -192,6 +192,18 @@ export async function exec( } if (flags.sea) { + // Base-node override (both forms are mutually exclusive). nodePath embeds a + // specific binary; useLocalNode embeds the Node running pkg. Either lets you + // ship a SEA built on a custom runtime (e.g. one linked against older glibc). + if (flags.seaNodePath && flags.seaUseLocalNode) { + throw wasReported( + "Specify either '--sea-node-path' or '--sea-use-local-node', not both", + ); + } + const seaBase = { + nodePath: flags.seaNodePath, + useLocalNode: flags.seaUseLocalNode, + }; if (inputJson || configJson) { // Enhanced SEA mode — use walker pipeline. // seaEnhanced validates the host Node version and minTargetMajor itself. @@ -202,6 +214,7 @@ export async function exec( params: { ...params, seaMode: true }, addition: isConfiguration(input) ? input : undefined, doCompress: flags.compress, + ...seaBase, }); } else { // Simple SEA mode — plain .js file without package.json. @@ -216,6 +229,7 @@ export async function exec( await sea(inputFin, { targets, signature: flags.signature, + ...seaBase, }); } return; diff --git a/lib/types.ts b/lib/types.ts index 8f1121f6..c1fcf798 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -66,6 +66,10 @@ export interface PkgOptions { debug?: boolean; signature?: boolean; sea?: boolean; + /** SEA mode: path to a base Node binary to embed (overrides the download). */ + seaNodePath?: string; + /** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */ + seaUseLocalNode?: boolean; } export interface PackageJson { @@ -200,4 +204,12 @@ export interface PkgExecOptions { noDictionary?: string[]; /** Sign macOS binaries when applicable. Default `true`. */ signature?: boolean; + /** + * SEA mode: path to a base Node binary to embed instead of downloading one + * from nodejs.org. Use to embed a custom build (e.g. one linked against an + * older glibc). Its major version must match the target's. + */ + seaNodePath?: string; + /** SEA mode: embed the Node binary running pkg (`process.execPath`) as the base. */ + seaUseLocalNode?: boolean; } diff --git a/test/test-95-sea-local-node/index.js b/test/test-95-sea-local-node/index.js new file mode 100644 index 00000000..d600887a --- /dev/null +++ b/test/test-95-sea-local-node/index.js @@ -0,0 +1,6 @@ +'use strict'; + +// Packaged into a SEA with `--sea-use-local-node`, so the embedded runtime is +// the same Node that ran pkg (rather than a downloaded one). Output is asserted +// by main.js. process.pkg confirms the enhanced SEA bootstrap is active. +console.log('local-node SEA OK:' + (process.pkg != null)); diff --git a/test/test-95-sea-local-node/main.js b/test/test-95-sea-local-node/main.js new file mode 100644 index 00000000..fee76f57 --- /dev/null +++ b/test/test-95-sea-local-node/main.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const utils = require('../utils.js'); + +// Enhanced SEA requires Node.js >= 22 +if (utils.getNodeMajorVersion() < 22) { + return; +} + +assert(__dirname === process.cwd()); + +const input = './package.json'; +const testName = 'test-95-sea-local-node'; + +const newcomers = utils.seaHostOutputs(testName); + +const before = utils.filesBefore(newcomers); + +// `--sea-use-local-node` embeds the Node binary running pkg as the SEA base +// instead of downloading one. Exercises the base-node override end to end (and, +// on Node >= 25.5 hosts, the in-core `--build-sea` injection path). +utils.runSeaHostOnly(input, testName, ['--sea-use-local-node']); + +utils.assertSeaOutput(testName, 'local-node SEA OK:true\n'); + +utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); diff --git a/test/test-95-sea-local-node/package.json b/test/test-95-sea-local-node/package.json new file mode 100644 index 00000000..4d8cec14 --- /dev/null +++ b/test/test-95-sea-local-node/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-95-sea-local-node", + "version": "1.0.0", + "main": "index.js", + "bin": "index.js" +} diff --git a/test/test.js b/test/test.js index 88c701af..95532c79 100644 --- a/test/test.js +++ b/test/test.js @@ -84,6 +84,7 @@ const npmTests = [ 'test-91-sea-esm-entry', 'test-92-sea-tla', 'test-94-sea-esm-import-meta', + 'test-95-sea-local-node', ]; if (testFilter) { diff --git a/test/unit/config-parse.test.ts b/test/unit/config-parse.test.ts index 49b02293..09e19b8c 100644 --- a/test/unit/config-parse.test.ts +++ b/test/unit/config-parse.test.ts @@ -165,6 +165,24 @@ describe('parseInput — CLI argv', () => { it('--options "" preserves the explicit empty signal', () => { assert.equal(parseInput(['--options', '', 'a.js']).flags.options, ''); }); + + it('string flag --sea-node-path (kebab key preserved)', () => { + assert.equal( + parseInput(['--sea-node-path', '/opt/node/bin/node', 'a.js']).flags[ + 'sea-node-path' + ], + '/opt/node/bin/node', + ); + }); + + it('bool flag --sea-use-local-node (kebab key preserved)', () => { + assert.equal( + parseInput(['--sea-use-local-node', 'a.js']).flags[ + 'sea-use-local-node' + ], + true, + ); + }); }); describe('negation', () => { @@ -410,6 +428,25 @@ describe('resolveFlags — CLI > config > default', () => { assert.equal(f.bytecode, false); }); + it('SEA base-node override — defaults, config, CLI precedence', () => { + const def = resolveFlags({}, {}); + assert.equal(def.seaNodePath, undefined); + assert.equal(def.seaUseLocalNode, false); + + // config keys (cfg name) resolve when CLI is absent + const cfg = resolveFlags({}, { seaNodePath: '/n', seaUseLocalNode: true }); + assert.equal(cfg.seaNodePath, '/n'); + assert.equal(cfg.seaUseLocalNode, true); + + // CLI (kebab cli name) overrides config + const cli = resolveFlags( + { 'sea-node-path': '/cli', 'sea-use-local-node': true }, + { seaNodePath: '/cfg' }, + ); + assert.equal(cli.seaNodePath, '/cli'); + assert.equal(cli.seaUseLocalNode, true); + }); + describe('list handling', () => { it('CLI "" clears the configured list (empty wins)', () => { const f = resolveFlags({ options: '' }, { options: ['expose-gc'] }); From 8dd9bc1ab57c45b03ce7ad04a1f7b904f4bb2785 Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Thu, 11 Jun 2026 18:58:54 -0500 Subject: [PATCH 2/7] fix(sea): rework base-node override around PKG_NODE_PATH (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #279 — extend the existing mechanism rather than add a SEA-only one: - Honour PKG_NODE_PATH in SEA: fold it into getNodejsExecutable's nodePath fallback (and the version resolver), matching standard mode. Precedence is CLI (--sea-node-path) > config (seaNodePath) > PKG_NODE_PATH. - Drop the --sea-use-local-node flag + seaUseLocalNode config key — it was just PKG_NODE_PATH="$(command -v node)". Keep --sea-node-path (for --help discoverability) and the seaNodePath config / exec() option. - Required guard: a custom base binary is restricted to a single target whose major matches the binary; otherwise pkg errors instead of silently baking the wrong binary into other targets' outputs. SEA has no per-platform fetch fallback like standard mode, and assertSingleTargetMajor only compares the targets to each other, never to the supplied binary. - Update docs-site/guide/custom-node.md (it previously said SEA does not honour PKG_NODE_PATH). - Tests: drop the seaUseLocalNode unit cases; rework the e2e (now test-95-sea-custom-node) to cover both --sea-node-path and PKG_NODE_PATH. - Fix pre-existing typo "Priovided" -> "Provided" in lib/sea.ts:364. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-site/guide/custom-node.md | 4 +- lib/config.ts | 17 +--- lib/help.ts | 8 +- lib/index.ts | 17 ++-- lib/sea.ts | 79 +++++++++++++++++-- lib/types.ts | 13 +-- test/test-95-sea-custom-node/index.js | 6 ++ test/test-95-sea-custom-node/main.js | 48 +++++++++++ .../package.json | 2 +- test/test-95-sea-local-node/index.js | 6 -- test/test-95-sea-local-node/main.js | 29 ------- test/test.js | 2 +- test/unit/config-parse.test.ts | 30 ++----- 13 files changed, 157 insertions(+), 104 deletions(-) create mode 100644 test/test-95-sea-custom-node/index.js create mode 100644 test/test-95-sea-custom-node/main.js rename test/{test-95-sea-local-node => test-95-sea-custom-node}/package.json (64%) delete mode 100644 test/test-95-sea-local-node/index.js delete mode 100644 test/test-95-sea-local-node/main.js diff --git a/docs-site/guide/custom-node.md b/docs-site/guide/custom-node.md index a79df4e2..926065e4 100644 --- a/docs-site/guide/custom-node.md +++ b/docs-site/guide/custom-node.md @@ -25,8 +25,8 @@ PKG_NODE_PATH=/path/to/node pkg app.js ## Caveats - The binary must be a **compatible** Node.js version — `pkg` still injects its bootstrap prelude and payload, and depends on specific symbol offsets. Use a version supported by `pkg-fetch` unless you know what you're doing. -- `PKG_NODE_PATH` affects a **single target**. Multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run. -- SEA mode uses stock Node.js by its own mechanism and does **not** honour `PKG_NODE_PATH` in the same way. For SEA, use `process.execPath` of the Node.js you want directly. +- `PKG_NODE_PATH` affects a **single target**. In standard mode, multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run. +- SEA mode honours `PKG_NODE_PATH` too. Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), a custom base binary there is restricted to a **single target** whose major version matches the binary — pkg errors out otherwise, rather than baking the wrong binary into the other targets. You can also point at the binary with the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both override `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). ## See also diff --git a/lib/config.ts b/lib/config.ts index 2287adc1..09e59971 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -142,21 +142,14 @@ const FLAG_SPECS: readonly FlagSpec[] = [ // SEA mode: use a specific Node binary as the base for the executable // instead of downloading one from nodejs.org. Lets you embed a custom // build (e.g. one that runs on older glibc, or a differently-configured - // runtime). The binary's major version must match the target's. + // runtime). Equivalent to the PKG_NODE_PATH env var (which this overrides); + // the binary's major version must match the target's, and it applies to a + // single target. Also settable as the `seaNodePath` pkg-config key. cli: 'sea-node-path', cfg: 'seaNodePath', resolved: 'seaNodePath', kind: 'string', }, - { - // SEA mode: use the Node binary currently running pkg (process.execPath) - // as the base. Shorthand for `--sea-node-path "$(command -v node)"`. - cli: 'sea-use-local-node', - cfg: 'seaUseLocalNode', - resolved: 'seaUseLocalNode', - kind: 'bool', - default: false, - }, { cli: 'compress', cfg: 'compress', @@ -504,10 +497,8 @@ export interface ResolvedFlags { fallbackToSource: boolean; public: boolean; sea: boolean; - /** SEA mode: path to a base Node binary to embed (overrides the download). */ + /** SEA mode: path to a base Node binary to embed (overrides the download / PKG_NODE_PATH). */ seaNodePath: string | undefined; - /** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */ - seaUseLocalNode: boolean; publicPackages: string[] | undefined; noDictionary: string[] | undefined; bakeOptions: string[] | undefined; diff --git a/lib/help.ts b/lib/help.ts index 3886b7b1..b5fc95f5 100644 --- a/lib/help.ts +++ b/lib/help.ts @@ -26,13 +26,11 @@ export default function help() { --signature enable macOS binary signing (default; use to override signature:false in config) --no-signature skip macOS binary signing [default: sign] --sea (Experimental) compile given file using node's SEA feature. Requires node v20.0.0 or higher and only single file is supported - --sea-node-path SEA mode: path to a base Node binary to embed instead of downloading one (must match the target's major version) - --sea-use-local-node SEA mode: embed the Node binary running pkg (process.execPath) as the base + --sea-node-path SEA mode: path to a base Node binary to embed instead of downloading one (overrides PKG_NODE_PATH; must match the target's major; single target only) All build-shaping flags above (compress, fallback-to-source, public, public-packages, - options, bytecode, native-build, no-dict, debug, signature, sea, sea-node-path, - sea-use-local-node) can also be set in the pkg config file (camelCase keys). CLI flags - override config values. + options, bytecode, native-build, no-dict, debug, signature, sea, sea-node-path) can also + be set in the pkg config file (camelCase keys). CLI flags override config values. ${pc.dim('Examples:')} diff --git a/lib/index.ts b/lib/index.ts index e4287430..5387ca1c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -192,18 +192,11 @@ export async function exec( } if (flags.sea) { - // Base-node override (both forms are mutually exclusive). nodePath embeds a - // specific binary; useLocalNode embeds the Node running pkg. Either lets you - // ship a SEA built on a custom runtime (e.g. one linked against older glibc). - if (flags.seaNodePath && flags.seaUseLocalNode) { - throw wasReported( - "Specify either '--sea-node-path' or '--sea-use-local-node', not both", - ); - } - const seaBase = { - nodePath: flags.seaNodePath, - useLocalNode: flags.seaUseLocalNode, - }; + // Base-node override: embed a custom Node binary instead of downloading one. + // From --sea-node-path / the seaNodePath config key, falling back to the + // PKG_NODE_PATH env var (resolved in lib/sea.ts). Lets you ship a SEA built + // on a custom runtime (e.g. one linked against an older glibc). + const seaBase = { nodePath: flags.seaNodePath }; if (inputJson || configJson) { // Enhanced SEA mode — use walker pipeline. // seaEnhanced validates the host Node version and minTargetMajor itself. diff --git a/lib/sea.ts b/lib/sea.ts index a2e976b2..ea2142b6 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -329,6 +329,67 @@ async function getNodeVersion( return latest.version as NodeVersion; } +/** + * The custom base Node binary to embed for SEA, or `undefined` to download one. + * + * Precedence matches standard mode: an explicit `opts.nodePath` (from the + * `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key) wins over the + * `PKG_NODE_PATH` environment variable. `PKG_NODE_PATH` is the same env var + * pkg-fetch honours for the traditional build path (`localPlace()`); folding it + * in here makes it work for SEA too, instead of a separate SEA-only mechanism. + */ +function resolveCustomBaseNode( + opts: GetNodejsExecutableOptions, +): string | undefined { + if (opts.nodePath) return opts.nodePath; + if (process.env.PKG_NODE_PATH) return resolve(process.env.PKG_NODE_PATH); + return undefined; +} + +/** + * Guard a custom base Node binary against multi-target / version-skew footguns. + * + * A supplied binary (via `--sea-node-path` / `seaNodePath` / `PKG_NODE_PATH`, or + * `useLocalNode`) is returned by {@link getNodejsExecutable} for *every* target, + * so a multi-target run would bake that one binary into outputs for other + * platforms/arches — silently producing broken artifacts. Require a single + * target, and verify the binary's major matches the requested `nodeRange` + * (`assertSingleTargetMajor` only compares the targets to each other, never to + * the supplied binary). The binary still has to match that target's platform and + * arch — pkg can't introspect an arbitrary binary's triple, so that's on the user. + */ +async function assertCustomBaseNodeTarget( + targets: (NodeTarget & Partial)[], + opts: GetNodejsExecutableOptions, +): Promise { + const customNode = resolveCustomBaseNode(opts); + if (!customNode && !opts.useLocalNode) return; + + if (targets.length !== 1) { + throw wasReported( + `A custom base Node binary (--sea-node-path / seaNodePath / PKG_NODE_PATH) ` + + `applies to a single target, but ${targets.length} targets were requested. ` + + `It would be baked into every output regardless of platform/arch. Run pkg ` + + `once per target with the matching binary.`, + ); + } + + const target = targets[0]; + const targetMajor = parseInt(target.nodeRange.replace('node', ''), 10); + if (Number.isNaN(targetMajor)) return; // 'latest' / unparseable: nothing to compare + const version = opts.useLocalNode + ? process.version + : (await execFileAsync(customNode!, ['--version'])).stdout.trim(); + const binMajor = parseInt(version.replace(/^v/, ''), 10); + if (binMajor !== targetMajor) { + throw wasReported( + `Custom base Node binary is ${version} (major ${binMajor}), but target ` + + `"${target.nodeRange}" requests Node ${targetMajor}. The binary's major ` + + `version must match the target.`, + ); + } +} + /** * Resolve the concrete Node.js version (e.g. `v22.22.2`) pkg will use * for `target` — mirrors the version selection done inside @@ -341,10 +402,11 @@ async function resolveTargetNodeVersion( opts: GetNodejsExecutableOptions, ): Promise { if (opts.useLocalNode) return process.version as NodeVersion; - if (opts.nodePath) { + const customNode = resolveCustomBaseNode(opts); + if (customNode) { // A user-supplied binary can be any version — don't assume it // matches the host. Ask it directly. - const { stdout } = await execFileAsync(opts.nodePath, ['--version']); + const { stdout } = await execFileAsync(customNode, ['--version']); return stdout.trim() as NodeVersion; } const os = getNodeOs(target.platform); @@ -357,15 +419,16 @@ async function getNodejsExecutable( target: NodeTarget, opts: GetNodejsExecutableOptions, ): Promise { - if (opts.nodePath) { - // check if the nodePath exists - if (!(await exists(opts.nodePath))) { + const customNode = resolveCustomBaseNode(opts); + if (customNode) { + // check if the custom base binary exists + if (!(await exists(customNode))) { throw new Error( - `Priovided node executable path "${opts.nodePath}" does not exist`, + `Provided node executable path "${customNode}" does not exist`, ); } - return opts.nodePath; + return customNode; } if (opts.useLocalNode) { @@ -765,6 +828,7 @@ export async function seaEnhanced( } assertSingleTargetMajor(opts.targets); + await assertCustomBaseNodeTarget(opts.targets, opts); const minTargetMajor = resolveMinTargetMajor(opts.targets); if (minTargetMajor < 22) { @@ -883,6 +947,7 @@ export async function seaEnhanced( export default async function sea(entryPoint: string, opts: SeaOptions) { assertHostSeaNodeVersion(); assertSingleTargetMajor(opts.targets); + await assertCustomBaseNodeTarget(opts.targets, opts); entryPoint = resolve(process.cwd(), entryPoint); diff --git a/lib/types.ts b/lib/types.ts index c1fcf798..54b33b5d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -66,10 +66,12 @@ export interface PkgOptions { debug?: boolean; signature?: boolean; sea?: boolean; - /** SEA mode: path to a base Node binary to embed (overrides the download). */ + /** + * SEA mode: path to a base Node binary to embed instead of downloading one + * (overrides the PKG_NODE_PATH env var). Its major version must match the + * target's, and it applies to a single target. + */ seaNodePath?: string; - /** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */ - seaUseLocalNode?: boolean; } export interface PackageJson { @@ -207,9 +209,8 @@ export interface PkgExecOptions { /** * SEA mode: path to a base Node binary to embed instead of downloading one * from nodejs.org. Use to embed a custom build (e.g. one linked against an - * older glibc). Its major version must match the target's. + * older glibc). Overrides the PKG_NODE_PATH env var. Its major version must + * match the target's, and it applies to a single target. */ seaNodePath?: string; - /** SEA mode: embed the Node binary running pkg (`process.execPath`) as the base. */ - seaUseLocalNode?: boolean; } diff --git a/test/test-95-sea-custom-node/index.js b/test/test-95-sea-custom-node/index.js new file mode 100644 index 00000000..0755ef03 --- /dev/null +++ b/test/test-95-sea-custom-node/index.js @@ -0,0 +1,6 @@ +'use strict'; + +// Packaged into a SEA on top of a custom base Node binary (the host's own node, +// supplied via --sea-node-path or PKG_NODE_PATH). Output is asserted by main.js; +// process.pkg confirms the enhanced SEA bootstrap is active. +console.log('custom-node SEA OK:' + (process.pkg != null)); diff --git a/test/test-95-sea-custom-node/main.js b/test/test-95-sea-custom-node/main.js new file mode 100644 index 00000000..6ac28f17 --- /dev/null +++ b/test/test-95-sea-custom-node/main.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const utils = require('../utils.js'); + +// Enhanced SEA requires Node.js >= 22 +if (utils.getNodeMajorVersion() < 22) { + return; +} + +assert(__dirname === process.cwd()); + +const input = './package.json'; +const expected = 'custom-node SEA OK:true\n'; + +// Both forms supply a custom base Node binary instead of downloading one. We +// use the host's own node (process.execPath) so the embedded runtime matches +// the single `host` target's platform/arch/major (required by the guard in +// lib/sea.ts). runSeaHostOnly builds for `host` only — a single target. + +// 1. Explicit --sea-node-path flag (exercises the exists() + `node --version` +// branch in getNodejsExecutable / the version guard). +{ + const testName = 'test-95-sea-custom-node'; + const newcomers = utils.seaHostOutputs(testName); + const before = utils.filesBefore(newcomers); + utils.runSeaHostOnly(input, testName, ['--sea-node-path', process.execPath]); + utils.assertSeaOutput(testName, expected); + utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); +} + +// 2. PKG_NODE_PATH env var (exercises the env fallback folded into SEA). pkg +// runs in a child process that inherits this env. +{ + const testName = 'test-95-sea-custom-node-env'; + const newcomers = utils.seaHostOutputs(testName); + const before = utils.filesBefore(newcomers); + process.env.PKG_NODE_PATH = process.execPath; + try { + utils.runSeaHostOnly(input, testName); + } finally { + delete process.env.PKG_NODE_PATH; + } + utils.assertSeaOutput(testName, expected); + utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); +} diff --git a/test/test-95-sea-local-node/package.json b/test/test-95-sea-custom-node/package.json similarity index 64% rename from test/test-95-sea-local-node/package.json rename to test/test-95-sea-custom-node/package.json index 4d8cec14..43bdfc94 100644 --- a/test/test-95-sea-local-node/package.json +++ b/test/test-95-sea-custom-node/package.json @@ -1,5 +1,5 @@ { - "name": "test-95-sea-local-node", + "name": "test-95-sea-custom-node", "version": "1.0.0", "main": "index.js", "bin": "index.js" diff --git a/test/test-95-sea-local-node/index.js b/test/test-95-sea-local-node/index.js deleted file mode 100644 index d600887a..00000000 --- a/test/test-95-sea-local-node/index.js +++ /dev/null @@ -1,6 +0,0 @@ -'use strict'; - -// Packaged into a SEA with `--sea-use-local-node`, so the embedded runtime is -// the same Node that ran pkg (rather than a downloaded one). Output is asserted -// by main.js. process.pkg confirms the enhanced SEA bootstrap is active. -console.log('local-node SEA OK:' + (process.pkg != null)); diff --git a/test/test-95-sea-local-node/main.js b/test/test-95-sea-local-node/main.js deleted file mode 100644 index fee76f57..00000000 --- a/test/test-95-sea-local-node/main.js +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const assert = require('assert'); -const utils = require('../utils.js'); - -// Enhanced SEA requires Node.js >= 22 -if (utils.getNodeMajorVersion() < 22) { - return; -} - -assert(__dirname === process.cwd()); - -const input = './package.json'; -const testName = 'test-95-sea-local-node'; - -const newcomers = utils.seaHostOutputs(testName); - -const before = utils.filesBefore(newcomers); - -// `--sea-use-local-node` embeds the Node binary running pkg as the SEA base -// instead of downloading one. Exercises the base-node override end to end (and, -// on Node >= 25.5 hosts, the in-core `--build-sea` injection path). -utils.runSeaHostOnly(input, testName, ['--sea-use-local-node']); - -utils.assertSeaOutput(testName, 'local-node SEA OK:true\n'); - -utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); diff --git a/test/test.js b/test/test.js index 95532c79..3fa8544b 100644 --- a/test/test.js +++ b/test/test.js @@ -84,7 +84,7 @@ const npmTests = [ 'test-91-sea-esm-entry', 'test-92-sea-tla', 'test-94-sea-esm-import-meta', - 'test-95-sea-local-node', + 'test-95-sea-custom-node', ]; if (testFilter) { diff --git a/test/unit/config-parse.test.ts b/test/unit/config-parse.test.ts index 09e19b8c..af7fdcb5 100644 --- a/test/unit/config-parse.test.ts +++ b/test/unit/config-parse.test.ts @@ -174,15 +174,6 @@ describe('parseInput — CLI argv', () => { '/opt/node/bin/node', ); }); - - it('bool flag --sea-use-local-node (kebab key preserved)', () => { - assert.equal( - parseInput(['--sea-use-local-node', 'a.js']).flags[ - 'sea-use-local-node' - ], - true, - ); - }); }); describe('negation', () => { @@ -428,23 +419,18 @@ describe('resolveFlags — CLI > config > default', () => { assert.equal(f.bytecode, false); }); - it('SEA base-node override — defaults, config, CLI precedence', () => { - const def = resolveFlags({}, {}); - assert.equal(def.seaNodePath, undefined); - assert.equal(def.seaUseLocalNode, false); + it('SEA base-node override (seaNodePath) — defaults, config, CLI precedence', () => { + assert.equal(resolveFlags({}, {}).seaNodePath, undefined); - // config keys (cfg name) resolve when CLI is absent - const cfg = resolveFlags({}, { seaNodePath: '/n', seaUseLocalNode: true }); - assert.equal(cfg.seaNodePath, '/n'); - assert.equal(cfg.seaUseLocalNode, true); + // config key (cfg name) resolves when CLI is absent + assert.equal(resolveFlags({}, { seaNodePath: '/n' }).seaNodePath, '/n'); // CLI (kebab cli name) overrides config - const cli = resolveFlags( - { 'sea-node-path': '/cli', 'sea-use-local-node': true }, - { seaNodePath: '/cfg' }, + assert.equal( + resolveFlags({ 'sea-node-path': '/cli' }, { seaNodePath: '/cfg' }) + .seaNodePath, + '/cli', ); - assert.equal(cli.seaNodePath, '/cli'); - assert.equal(cli.seaUseLocalNode, true); }); describe('list handling', () => { From 9d9d4d3033249267d05e8e0a4f4b8aef27689467 Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Thu, 11 Jun 2026 19:52:41 -0500 Subject: [PATCH 3/7] fix(sea): validate the custom base binary's platform/arch against the target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the #279 review. The earlier single-target guard stopped multi-target runs, but a SINGLE target with a mismatched binary still slipped through — e.g. `--targets node24-linux-x64 --sea-node-path ` silently produced a corrupt linux output. Per the reviewer's ask ("require a single target whose platform+arch match the supplied binary"): - Sniff the supplied binary's container format (ELF / Mach-O / PE) and CPU arch from its magic bytes (sniffBinaryTarget) and reject a mismatch against the target — a Mach-O binary for a `linux` target, or an x64 binary for `arm64`. - Reject targets that span more than one distinct platform+arch: one binary can't be several at once, including linux vs alpine vs linuxstatic (mutually exclusive glibc / musl / static — indistinguishable from the header, but the user clearly can't have meant them simultaneously). This replaces the blunt single-target-count check with a per-(platform,arch) one. - The ELF glibc/musl/static flavor isn't in the header, so matching that to linux / alpine / linuxstatic remains the user's responsibility (documented). Tests: a unit test for sniffBinaryTarget (crafted ELF/Mach-O/PE headers), and unhappy-path e2e cases in test-95-sea-custom-node (multi platform/arch, multi linux-flavor, wrong platform, wrong major — all host-independent, exit 2). Updates --help + docs-site/guide/custom-node.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-site/guide/custom-node.md | 2 +- lib/help.ts | 2 +- lib/sea.ts | 173 ++++++++++++++++++++++++--- test/test-95-sea-custom-node/main.js | 72 +++++++++++ test/unit/sea-sniff.test.ts | 122 +++++++++++++++++++ 5 files changed, 350 insertions(+), 21 deletions(-) create mode 100644 test/unit/sea-sniff.test.ts diff --git a/docs-site/guide/custom-node.md b/docs-site/guide/custom-node.md index 926065e4..6541db76 100644 --- a/docs-site/guide/custom-node.md +++ b/docs-site/guide/custom-node.md @@ -26,7 +26,7 @@ PKG_NODE_PATH=/path/to/node pkg app.js - The binary must be a **compatible** Node.js version — `pkg` still injects its bootstrap prelude and payload, and depends on specific symbol offsets. Use a version supported by `pkg-fetch` unless you know what you're doing. - `PKG_NODE_PATH` affects a **single target**. In standard mode, multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run. -- SEA mode honours `PKG_NODE_PATH` too. Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), a custom base binary there is restricted to a **single target** whose major version matches the binary — pkg errors out otherwise, rather than baking the wrong binary into the other targets. You can also point at the binary with the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both override `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). +- SEA mode honours `PKG_NODE_PATH` too. Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), a custom base binary there is restricted to a **single target**, and pkg validates the binary against it rather than baking a mismatched binary into the output: it sniffs the binary's format and architecture (a macOS binary for a `linux` target, or an x64 binary for an `arm64` target, is rejected) and checks its major version matches the target's. It also rejects targets that span more than one `platform`/`arch` (one binary can't be several at once — including `linux` vs `alpine` vs `linuxstatic`, which aren't distinguishable from the binary but clearly can't all be meant simultaneously). The glibc/musl/static flavor of an ELF binary isn't in the header, so matching that to `linux`/`alpine`/`linuxstatic` remains your responsibility. You can point at the binary with the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both override `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). ## See also diff --git a/lib/help.ts b/lib/help.ts index b5fc95f5..9f3c9410 100644 --- a/lib/help.ts +++ b/lib/help.ts @@ -26,7 +26,7 @@ export default function help() { --signature enable macOS binary signing (default; use to override signature:false in config) --no-signature skip macOS binary signing [default: sign] --sea (Experimental) compile given file using node's SEA feature. Requires node v20.0.0 or higher and only single file is supported - --sea-node-path SEA mode: path to a base Node binary to embed instead of downloading one (overrides PKG_NODE_PATH; must match the target's major; single target only) + --sea-node-path SEA mode: path to a base Node binary to embed instead of downloading one (overrides PKG_NODE_PATH; must match the target's platform/arch/major; single target only) All build-shaping flags above (compress, fallback-to-source, public, public-packages, options, bytecode, native-build, no-dict, debug, signature, sea, sea-node-path) can also diff --git a/lib/sea.ts b/lib/sea.ts index ea2142b6..fb670dea 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -9,6 +9,7 @@ import { mkdtemp, stat, readFile, + open, } from 'fs/promises'; import { createWriteStream } from 'fs'; import { pipeline } from 'stream/promises'; @@ -346,17 +347,121 @@ function resolveCustomBaseNode( return undefined; } +/** Executable container format sniffed from a binary's magic bytes. */ +type BinaryFormat = 'elf' | 'macho' | 'pe'; + +const ELF_MACHINE: Record = { + 0x3e: 'x64', // EM_X86_64 + 0xb7: 'arm64', // EM_AARCH64 +}; +const MACHO_CPU: Record = { + 0x01000007: 'x64', // CPU_TYPE_X86_64 + 0x0100000c: 'arm64', // CPU_TYPE_ARM64 +}; +const PE_MACHINE: Record = { + 0x8664: 'x64', // IMAGE_FILE_MACHINE_AMD64 + 0xaa64: 'arm64', // IMAGE_FILE_MACHINE_ARM64 +}; + +/** + * Expected container format for a pkg target platform (the raw suffix string). + * ELF covers the whole Linux/Alpine family (linux / alpine / linuxstatic / + * freebsd) — the glibc/musl/static flavor isn't in the header. + */ +const formatForPlatform = (platform: string): BinaryFormat => + platform === 'macos' || platform === 'darwin' + ? 'macho' + : platform === 'win' + ? 'pe' + : 'elf'; +const FORMAT_LABEL: Record = { + elf: 'ELF (Linux/Alpine)', + macho: 'Mach-O (macOS)', + pe: 'PE (Windows)', +}; + /** - * Guard a custom base Node binary against multi-target / version-skew footguns. + * Sniff a binary's container format and CPU arch from its magic bytes, so we can + * tell whether a supplied base Node binary actually matches the requested + * target. Reads only the header. Returns `{}` for an unrecognised file (caller + * skips the format/arch checks rather than guessing). Note: ELF can't reveal the + * glibc/musl/static *flavor*, so `elf` maps to the whole Linux/Alpine family. + */ +export async function sniffBinaryTarget( + file: string, +): Promise<{ format?: BinaryFormat; arch?: NodeArch }> { + let fh; + try { + fh = await open(file, 'r'); + } catch { + return {}; + } + try { + const buf = Buffer.alloc(4096); + const { bytesRead } = await fh.read(buf, 0, 4096, 0); + const b = buf.subarray(0, bytesRead); + if (b.length < 20) return {}; + + // ELF: 0x7f 'E' 'L' 'F'; EI_DATA at [5] (1=LE,2=BE); e_machine at [18..20]. + if (b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) { + const machine = b[5] === 2 ? b.readUInt16BE(18) : b.readUInt16LE(18); + return { format: 'elf', arch: ELF_MACHINE[machine] }; + } + + // Mach-O (thin): magic FEEDFACE/FEEDFACF; byte-swapped CEFAEDFE/CFFAEDFE are + // the little-endian on-disk forms. cputype is the next 4 bytes. + const be = b.readUInt32BE(0); + if ( + be === 0xfeedface || + be === 0xfeedfacf || + be === 0xcefaedfe || + be === 0xcffaedfe + ) { + const le = be === 0xcefaedfe || be === 0xcffaedfe; + const cpu = le ? b.readUInt32LE(4) : b.readUInt32BE(4); + return { format: 'macho', arch: MACHO_CPU[cpu] }; + } + + // PE: 'MZ', e_lfanew (uint32 @0x3C) -> 'PE\0\0', COFF machine 2 bytes after. + if (b[0] === 0x4d && b[1] === 0x5a && b.length >= 0x40) { + const lfanew = b.readUInt32LE(0x3c); + if ( + lfanew + 6 <= b.length && + b[lfanew] === 0x50 && + b[lfanew + 1] === 0x45 && + b[lfanew + 2] === 0 && + b[lfanew + 3] === 0 + ) { + return { format: 'pe', arch: PE_MACHINE[b.readUInt16LE(lfanew + 4)] }; + } + return { format: 'pe' }; + } + + return {}; + } finally { + await fh.close(); + } +} + +/** + * Guard a custom base Node binary against multi-target / wrong-platform / + * version-skew footguns. * * A supplied binary (via `--sea-node-path` / `seaNodePath` / `PKG_NODE_PATH`, or * `useLocalNode`) is returned by {@link getNodejsExecutable} for *every* target, * so a multi-target run would bake that one binary into outputs for other - * platforms/arches — silently producing broken artifacts. Require a single - * target, and verify the binary's major matches the requested `nodeRange` - * (`assertSingleTargetMajor` only compares the targets to each other, never to - * the supplied binary). The binary still has to match that target's platform and - * arch — pkg can't introspect an arbitrary binary's triple, so that's on the user. + * platforms/arches — silently producing broken artifacts. We: + * + * 1. Reject if the requested targets span more than one distinct + * `platform`+`arch` — one binary can't be several mutually-exclusive things + * at once (incl. linux vs alpine vs linuxstatic, which we can't tell apart + * from the binary but the user clearly can't have meant simultaneously). + * 2. Sniff the binary and reject a format/arch mismatch against that single + * target (e.g. a macOS binary for a `linux` target, or x64 for `arm64`). + * The glibc/musl/static flavor isn't in the header, so that sub-distinction + * stays the user's responsibility. + * 3. Verify the binary's major matches the requested `nodeRange` + * (`assertSingleTargetMajor` only compares the targets to each other). */ async function assertCustomBaseNodeTarget( targets: (NodeTarget & Partial)[], @@ -364,28 +469,58 @@ async function assertCustomBaseNodeTarget( ): Promise { const customNode = resolveCustomBaseNode(opts); if (!customNode && !opts.useLocalNode) return; - - if (targets.length !== 1) { + const binPath = customNode ?? process.execPath; + + // 1. A single binary maps to exactly one platform+arch. Key on the raw target + // suffix strings so linux / alpine / linuxstatic stay distinct (they collapse + // under getNodeOs, but they're mutually exclusive runtimes). + const combos = new Map(); + for (const t of targets) { + const platform = String(t.platform); + const arch = String(t.arch); + combos.set(`${platform}-${arch}`, { platform, arch }); + } + if (combos.size > 1) { throw wasReported( - `A custom base Node binary (--sea-node-path / seaNodePath / PKG_NODE_PATH) ` + - `applies to a single target, but ${targets.length} targets were requested. ` + - `It would be baked into every output regardless of platform/arch. Run pkg ` + - `once per target with the matching binary.`, + `A custom base Node binary applies to a single platform/arch, but the ` + + `requested targets span ${combos.size}: ${[...combos.keys()].join(', ')}. ` + + `One binary can't be all of them — run pkg once per target with a ` + + `matching binary.`, ); } + const { platform, arch } = [...combos.values()][0]; + + // 2. Format / arch match against that single target. + const sniff = await sniffBinaryTarget(binPath); + if (sniff.format) { + const expected = formatForPlatform(platform); + if (sniff.format !== expected) { + throw wasReported( + `Custom base Node binary is ${FORMAT_LABEL[sniff.format]}, but target ` + + `"${platform}" needs ${FORMAT_LABEL[expected]}.`, + ); + } + if (sniff.arch && sniff.arch !== arch) { + throw wasReported( + `Custom base Node binary is ${sniff.arch}, but target arch is "${arch}". ` + + `The binary must match the target's architecture.`, + ); + } + } - const target = targets[0]; - const targetMajor = parseInt(target.nodeRange.replace('node', ''), 10); + // 3. Major version match. + const targetMajor = parseInt(targets[0].nodeRange.replace('node', ''), 10); if (Number.isNaN(targetMajor)) return; // 'latest' / unparseable: nothing to compare - const version = opts.useLocalNode - ? process.version - : (await execFileAsync(customNode!, ['--version'])).stdout.trim(); + const version = + binPath === process.execPath + ? process.version + : (await execFileAsync(binPath, ['--version'])).stdout.trim(); const binMajor = parseInt(version.replace(/^v/, ''), 10); if (binMajor !== targetMajor) { throw wasReported( `Custom base Node binary is ${version} (major ${binMajor}), but target ` + - `"${target.nodeRange}" requests Node ${targetMajor}. The binary's major ` + - `version must match the target.`, + `"${targets[0].nodeRange}" requests Node ${targetMajor}. The binary's ` + + `major version must match the target.`, ); } } diff --git a/test/test-95-sea-custom-node/main.js b/test/test-95-sea-custom-node/main.js index 6ac28f17..40c80799 100644 --- a/test/test-95-sea-custom-node/main.js +++ b/test/test-95-sea-custom-node/main.js @@ -3,6 +3,8 @@ 'use strict'; const assert = require('assert'); +const os = require('os'); +const path = require('path'); const utils = require('../utils.js'); // Enhanced SEA requires Node.js >= 22 @@ -46,3 +48,73 @@ const expected = 'custom-node SEA OK:true\n'; utils.assertSeaOutput(testName, expected); utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); } + +// 3. Unhappy paths — the guard rejects a custom base binary that can't satisfy +// the requested target(s). These error before any build (no output produced), +// so they're fast and host-independent. We feed the host's own node and pick +// targets it provably can't be. +const M = utils.getNodeMajorVersion(); +const hostPlatform = + process.platform === 'darwin' + ? 'macos' + : process.platform === 'win32' + ? 'win' + : 'linux'; +const hostArch = process.arch; // 'x64' | 'arm64' +// macos / linux / win map to three DISTINCT binary formats (Mach-O / ELF / PE), +// so any cross-OS target gives the host binary a format it can't match. Pick a +// platform that isn't the host's — covers macOS, Linux, and Windows hosts. +// (Deliberately not alpine/linuxstatic: those share ELF with linux, so they +// wouldn't be a *format* mismatch for a Linux host.) +const otherPlatform = hostPlatform === 'macos' ? 'linux' : 'macos'; +const otherMajor = M === 22 ? 20 : 22; +const out = path.join(os.tmpdir(), 'pkg-sea-guard-out'); + +function expectGuardError(targets, re, label) { + const r = utils.pkg.sync( + [ + input, + '--sea', + '--sea-node-path', + process.execPath, + '--targets', + targets, + '--output', + out, + ], + { stdio: 'pipe', expect: 2 }, + ); + assert( + re.test(r.stdout + r.stderr), + `${label}: expected /${re.source}/, got:\n${r.stdout}\n${r.stderr}`, + ); +} + +// a) Multiple distinct platform/arch targets — one binary can't be all of them. +expectGuardError( + `node${M}-linux-x64,node${M}-macos-arm64`, + /single platform\/arch|span \d/i, + 'multi platform/arch', +); + +// b) Multiple mutually-exclusive Linux flavors (glibc vs musl) — same guard. +expectGuardError( + `node${M}-linux-x64,node${M}-alpine-x64`, + /single platform\/arch|span \d/i, + 'multi linux flavor', +); + +// c) Single target, wrong platform: the host binary's format can't match a +// different-OS target (e.g. a Mach-O binary for a linux target). +expectGuardError( + `node${M}-${otherPlatform}-${hostArch}`, + /needs (ELF|Mach-O|PE)/i, + 'platform mismatch', +); + +// d) Single target, right platform but wrong major. +expectGuardError( + `node${otherMajor}-${hostPlatform}-${hostArch}`, + /major version must match|requests Node/i, + 'major mismatch', +); diff --git a/test/unit/sea-sniff.test.ts b/test/unit/sea-sniff.test.ts new file mode 100644 index 00000000..e595dd37 --- /dev/null +++ b/test/unit/sea-sniff.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, describe, it } from 'node:test'; + +import { sniffBinaryTarget } from '../../lib/sea'; + +// sniffBinaryTarget reads a binary's magic bytes to decide whether a supplied +// custom base Node binary matches the requested SEA target's platform/arch. +// These fixtures are minimal but real headers (ELF / Mach-O / PE). + +function elf(machine: number, le = true): Buffer { + const b = Buffer.alloc(64); + b[0] = 0x7f; + b[1] = 0x45; + b[2] = 0x4c; + b[3] = 0x46; // 0x7f E L F + b[4] = 2; // ELFCLASS64 + b[5] = le ? 1 : 2; // EI_DATA + if (le) b.writeUInt16LE(machine, 18); + else b.writeUInt16BE(machine, 18); // e_machine + return b; +} + +function macho(cpu: number): Buffer { + const b = Buffer.alloc(32); + b.writeUInt32LE(0xfeedfacf, 0); // 64-bit little-endian on disk: CF FA ED FE + b.writeUInt32LE(cpu, 4); // cputype + return b; +} + +function pe(machine: number): Buffer { + const b = Buffer.alloc(0x100); + b[0] = 0x4d; + b[1] = 0x5a; // MZ + b.writeUInt32LE(0x80, 0x3c); // e_lfanew + b[0x80] = 0x50; + b[0x81] = 0x45; // 'PE' + b.writeUInt16LE(machine, 0x84); // COFF machine + return b; +} + +describe('sniffBinaryTarget', () => { + let dir: string; + const write = (name: string, buf: Buffer): string => { + const p = path.join(dir, name); + writeFileSync(p, buf); + return p; + }; + + before(() => { + dir = mkdtempSync(path.join(os.tmpdir(), 'pkg-sniff-')); + }); + after(() => { + /* tmpdir left for the OS to reap; fixtures are tiny */ + }); + + it('detects ELF x64 / arm64', async () => { + assert.deepEqual(await sniffBinaryTarget(write('elf-x64', elf(0x3e))), { + format: 'elf', + arch: 'x64', + }); + assert.deepEqual(await sniffBinaryTarget(write('elf-arm64', elf(0xb7))), { + format: 'elf', + arch: 'arm64', + }); + }); + + it('detects big-endian ELF too', async () => { + assert.equal( + (await sniffBinaryTarget(write('elf-be', elf(0xb7, false)))).arch, + 'arm64', + ); + }); + + it('detects Mach-O x64 / arm64', async () => { + assert.deepEqual( + await sniffBinaryTarget(write('macho-x64', macho(0x01000007))), + { format: 'macho', arch: 'x64' }, + ); + assert.deepEqual( + await sniffBinaryTarget(write('macho-arm64', macho(0x0100000c))), + { format: 'macho', arch: 'arm64' }, + ); + }); + + it('detects PE x64 / arm64', async () => { + assert.deepEqual(await sniffBinaryTarget(write('pe-x64', pe(0x8664))), { + format: 'pe', + arch: 'x64', + }); + assert.deepEqual(await sniffBinaryTarget(write('pe-arm64', pe(0xaa64))), { + format: 'pe', + arch: 'arm64', + }); + }); + + it('returns {} for an unrecognised / too-short file', async () => { + assert.deepEqual( + await sniffBinaryTarget(write('garbage', Buffer.alloc(64, 0x55))), + {}, + ); + assert.deepEqual( + await sniffBinaryTarget(write('tiny', Buffer.alloc(4))), + {}, + ); + }); + + it('returns {} for a nonexistent path (caller skips the check)', async () => { + assert.deepEqual( + await sniffBinaryTarget(path.join(dir, 'does-not-exist')), + {}, + ); + }); + + it('maps unknown machine types to undefined arch but keeps the format', async () => { + const res = await sniffBinaryTarget(write('elf-mips', elf(0x08))); + assert.equal(res.format, 'elf'); + assert.equal(res.arch, undefined); + }); +}); From cc6e6805d52d24140f51091815903dea436657d4 Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Wed, 17 Jun 2026 17:05:03 -0500 Subject: [PATCH 4/7] fix(sea): replace binary sniffing with a single probeNode (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit robertsLando pointed out that the ELF/Mach-O/PE header sniffing (sniffBinaryTarget + ELF_MACHINE/MACHO_CPU/PE_MACHINE tables, formatForPlatform, FORMAT_LABEL — ~90 lines) is redundant: the custom base binary is already exec'd for its version in assertCustomBaseNodeTarget and in resolveTargetNodeVersion, and sniffing's only theoretical edge is validating a base that can't run on the build host — but that base already fails the version exec, before sniffing helps. Replace it with one probe: node -p "process.version + ' ' + process.platform + ' ' + process.arch" - add probeNode(binPath) (short-circuits to process.* when binPath is the host node, so no exec for --sea-use-local-node / host targets) - assertCustomBaseNodeTarget: keep the multi-target guard (step 1), then compare the binary's reported process.platform/arch/major to the single target via probeNode (steps 2-3). Adds a PROCESS_PLATFORM map (macos->darwin, win->win32) - resolveTargetNodeVersion: reuse probeNode instead of a second `--version` exec - delete sniffBinaryTarget + its unit test; update custom-node.md to state the base must be runnable on the build host (already true in practice) This makes "the base binary must be runnable on the build host" an honest, documented constraint rather than a half-supported one. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-site/guide/custom-node.md | 2 +- lib/sea.ts | 176 ++++++++------------------- test/test-95-sea-custom-node/main.js | 17 +-- test/unit/sea-sniff.test.ts | 122 ------------------- 4 files changed, 63 insertions(+), 254 deletions(-) delete mode 100644 test/unit/sea-sniff.test.ts diff --git a/docs-site/guide/custom-node.md b/docs-site/guide/custom-node.md index 6541db76..e84be980 100644 --- a/docs-site/guide/custom-node.md +++ b/docs-site/guide/custom-node.md @@ -26,7 +26,7 @@ PKG_NODE_PATH=/path/to/node pkg app.js - The binary must be a **compatible** Node.js version — `pkg` still injects its bootstrap prelude and payload, and depends on specific symbol offsets. Use a version supported by `pkg-fetch` unless you know what you're doing. - `PKG_NODE_PATH` affects a **single target**. In standard mode, multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run. -- SEA mode honours `PKG_NODE_PATH` too. Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), a custom base binary there is restricted to a **single target**, and pkg validates the binary against it rather than baking a mismatched binary into the output: it sniffs the binary's format and architecture (a macOS binary for a `linux` target, or an x64 binary for an `arm64` target, is rejected) and checks its major version matches the target's. It also rejects targets that span more than one `platform`/`arch` (one binary can't be several at once — including `linux` vs `alpine` vs `linuxstatic`, which aren't distinguishable from the binary but clearly can't all be meant simultaneously). The glibc/musl/static flavor of an ELF binary isn't in the header, so matching that to `linux`/`alpine`/`linuxstatic` remains your responsibility. You can point at the binary with the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both override `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). +- SEA mode honours `PKG_NODE_PATH` too. Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), a custom base binary there is restricted to a **single target**, and pkg validates the binary against it rather than baking a mismatched binary into the output: it **runs the binary** and checks what it reports — `process.platform` and `process.arch` must match the target (a macOS binary for a `linux` target, or an x64 binary for an `arm64` target, is rejected), and its major version must match the target's. This means **the custom base binary must be runnable on the build host** (pkg already runs it to read its version). It also rejects targets that span more than one `platform`/`arch` (one binary can't be several at once — including `linux` vs `alpine` vs `linuxstatic`, which all report `process.platform` `linux` but clearly can't all be meant simultaneously). The glibc/musl/static flavor isn't reported by Node, so matching that to `linux`/`alpine`/`linuxstatic` remains your responsibility. You can point at the binary with the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both override `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). ## See also diff --git a/lib/sea.ts b/lib/sea.ts index fb670dea..6da339c9 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -9,7 +9,6 @@ import { mkdtemp, stat, readFile, - open, } from 'fs/promises'; import { createWriteStream } from 'fs'; import { pipeline } from 'stream/promises'; @@ -347,100 +346,32 @@ function resolveCustomBaseNode( return undefined; } -/** Executable container format sniffed from a binary's magic bytes. */ -type BinaryFormat = 'elf' | 'macho' | 'pe'; - -const ELF_MACHINE: Record = { - 0x3e: 'x64', // EM_X86_64 - 0xb7: 'arm64', // EM_AARCH64 -}; -const MACHO_CPU: Record = { - 0x01000007: 'x64', // CPU_TYPE_X86_64 - 0x0100000c: 'arm64', // CPU_TYPE_ARM64 -}; -const PE_MACHINE: Record = { - 0x8664: 'x64', // IMAGE_FILE_MACHINE_AMD64 - 0xaa64: 'arm64', // IMAGE_FILE_MACHINE_ARM64 -}; - -/** - * Expected container format for a pkg target platform (the raw suffix string). - * ELF covers the whole Linux/Alpine family (linux / alpine / linuxstatic / - * freebsd) — the glibc/musl/static flavor isn't in the header. - */ -const formatForPlatform = (platform: string): BinaryFormat => - platform === 'macos' || platform === 'darwin' - ? 'macho' - : platform === 'win' - ? 'pe' - : 'elf'; -const FORMAT_LABEL: Record = { - elf: 'ELF (Linux/Alpine)', - macho: 'Mach-O (macOS)', - pe: 'PE (Windows)', +/** Target-suffix -> the value Node reports as `process.platform`. */ +const PROCESS_PLATFORM: Record = { + macos: 'darwin', + win: 'win32', + // linux / alpine / linuxstatic / freebsd report their own name verbatim, so + // they need no mapping. (ELF can't reveal the glibc/musl/static flavor + // either, so that sub-distinction stays the user's responsibility.) }; -/** - * Sniff a binary's container format and CPU arch from its magic bytes, so we can - * tell whether a supplied base Node binary actually matches the requested - * target. Reads only the header. Returns `{}` for an unrecognised file (caller - * skips the format/arch checks rather than guessing). Note: ELF can't reveal the - * glibc/musl/static *flavor*, so `elf` maps to the whole Linux/Alpine family. - */ -export async function sniffBinaryTarget( - file: string, -): Promise<{ format?: BinaryFormat; arch?: NodeArch }> { - let fh; - try { - fh = await open(file, 'r'); - } catch { - return {}; - } - try { - const buf = Buffer.alloc(4096); - const { bytesRead } = await fh.read(buf, 0, 4096, 0); - const b = buf.subarray(0, bytesRead); - if (b.length < 20) return {}; - - // ELF: 0x7f 'E' 'L' 'F'; EI_DATA at [5] (1=LE,2=BE); e_machine at [18..20]. - if (b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) { - const machine = b[5] === 2 ? b.readUInt16BE(18) : b.readUInt16LE(18); - return { format: 'elf', arch: ELF_MACHINE[machine] }; - } - - // Mach-O (thin): magic FEEDFACE/FEEDFACF; byte-swapped CEFAEDFE/CFFAEDFE are - // the little-endian on-disk forms. cputype is the next 4 bytes. - const be = b.readUInt32BE(0); - if ( - be === 0xfeedface || - be === 0xfeedfacf || - be === 0xcefaedfe || - be === 0xcffaedfe - ) { - const le = be === 0xcefaedfe || be === 0xcffaedfe; - const cpu = le ? b.readUInt32LE(4) : b.readUInt32BE(4); - return { format: 'macho', arch: MACHO_CPU[cpu] }; - } - - // PE: 'MZ', e_lfanew (uint32 @0x3C) -> 'PE\0\0', COFF machine 2 bytes after. - if (b[0] === 0x4d && b[1] === 0x5a && b.length >= 0x40) { - const lfanew = b.readUInt32LE(0x3c); - if ( - lfanew + 6 <= b.length && - b[lfanew] === 0x50 && - b[lfanew + 1] === 0x45 && - b[lfanew + 2] === 0 && - b[lfanew + 3] === 0 - ) { - return { format: 'pe', arch: PE_MACHINE[b.readUInt16LE(lfanew + 4)] }; - } - return { format: 'pe' }; - } - - return {}; - } finally { - await fh.close(); +/** What a Node binary reports about itself when run. */ +async function probeNode( + binPath: string, +): Promise<{ version: string; platform: string; arch: string }> { + if (binPath === process.execPath) { + return { + version: process.version, + platform: process.platform, + arch: process.arch, + }; } + const { stdout } = await execFileAsync(binPath, [ + '-p', + "process.version + ' ' + process.platform + ' ' + process.arch", + ]); + const [version, platform, arch] = stdout.trim().split(' '); + return { version, platform, arch }; } /** @@ -456,10 +387,14 @@ export async function sniffBinaryTarget( * `platform`+`arch` — one binary can't be several mutually-exclusive things * at once (incl. linux vs alpine vs linuxstatic, which we can't tell apart * from the binary but the user clearly can't have meant simultaneously). - * 2. Sniff the binary and reject a format/arch mismatch against that single - * target (e.g. a macOS binary for a `linux` target, or x64 for `arm64`). - * The glibc/musl/static flavor isn't in the header, so that sub-distinction - * stays the user's responsibility. + * 2. Run the binary and reject if what it reports (`process.platform` / + * `process.arch`) disagrees with that single target (e.g. a macOS binary + * for a `linux` target, or x64 for `arm64`). We exec it for its version + * regardless (here and in {@link resolveTargetNodeVersion}), so asking it + * everything at once is simpler and stricter than sniffing its header — at + * the cost of requiring the base to be runnable on the build host, which is + * already true in practice. The glibc/musl/static flavor isn't reported, so + * that sub-distinction stays the user's responsibility. * 3. Verify the binary's major matches the requested `nodeRange` * (`assertSingleTargetMajor` only compares the targets to each other). */ @@ -490,37 +425,33 @@ async function assertCustomBaseNodeTarget( } const { platform, arch } = [...combos.values()][0]; - // 2. Format / arch match against that single target. - const sniff = await sniffBinaryTarget(binPath); - if (sniff.format) { - const expected = formatForPlatform(platform); - if (sniff.format !== expected) { - throw wasReported( - `Custom base Node binary is ${FORMAT_LABEL[sniff.format]}, but target ` + - `"${platform}" needs ${FORMAT_LABEL[expected]}.`, - ); - } - if (sniff.arch && sniff.arch !== arch) { - throw wasReported( - `Custom base Node binary is ${sniff.arch}, but target arch is "${arch}". ` + - `The binary must match the target's architecture.`, - ); - } + // 2. Ask the binary what it actually is and reject any disagreement. + const node = await probeNode(binPath); + + const expectedPlatform = PROCESS_PLATFORM[platform] ?? platform; + if (node.platform !== expectedPlatform) { + throw wasReported( + `Custom base Node binary is for "${node.platform}", but target ` + + `"${platform}" needs "${expectedPlatform}".`, + ); + } + if (node.arch !== arch) { + throw wasReported( + `Custom base Node binary is ${node.arch}, but target arch is "${arch}". ` + + `The binary must match the target's architecture.`, + ); } - // 3. Major version match. + // 3. Major version match (assertSingleTargetMajor only compares the targets + // to each other, never to the supplied binary). const targetMajor = parseInt(targets[0].nodeRange.replace('node', ''), 10); if (Number.isNaN(targetMajor)) return; // 'latest' / unparseable: nothing to compare - const version = - binPath === process.execPath - ? process.version - : (await execFileAsync(binPath, ['--version'])).stdout.trim(); - const binMajor = parseInt(version.replace(/^v/, ''), 10); + const binMajor = parseInt(node.version.replace(/^v/, ''), 10); if (binMajor !== targetMajor) { throw wasReported( - `Custom base Node binary is ${version} (major ${binMajor}), but target ` + - `"${targets[0].nodeRange}" requests Node ${targetMajor}. The binary's ` + - `major version must match the target.`, + `Custom base Node binary is ${node.version} (major ${binMajor}), but ` + + `target "${targets[0].nodeRange}" requests Node ${targetMajor}. The ` + + `binary's major version must match the target.`, ); } } @@ -541,8 +472,7 @@ async function resolveTargetNodeVersion( if (customNode) { // A user-supplied binary can be any version — don't assume it // matches the host. Ask it directly. - const { stdout } = await execFileAsync(customNode, ['--version']); - return stdout.trim() as NodeVersion; + return (await probeNode(customNode)).version as NodeVersion; } const os = getNodeOs(target.platform); const arch = getNodeArch(target.arch); diff --git a/test/test-95-sea-custom-node/main.js b/test/test-95-sea-custom-node/main.js index 40c80799..2007add4 100644 --- a/test/test-95-sea-custom-node/main.js +++ b/test/test-95-sea-custom-node/main.js @@ -61,11 +61,12 @@ const hostPlatform = ? 'win' : 'linux'; const hostArch = process.arch; // 'x64' | 'arm64' -// macos / linux / win map to three DISTINCT binary formats (Mach-O / ELF / PE), -// so any cross-OS target gives the host binary a format it can't match. Pick a -// platform that isn't the host's — covers macOS, Linux, and Windows hosts. -// (Deliberately not alpine/linuxstatic: those share ELF with linux, so they -// wouldn't be a *format* mismatch for a Linux host.) +// The guard runs the supplied node and compares its reported process.platform +// to the target. We feed the host's own node (process.execPath, which the guard +// reads without exec'ing it — see probeNode), so any non-host OS target +// mismatches. Pick a platform that isn't the host's — covers macOS, Linux, and +// Windows hosts. (Deliberately not alpine/linuxstatic: those report +// process.platform 'linux' like a Linux host, so they wouldn't be a mismatch.) const otherPlatform = hostPlatform === 'macos' ? 'linux' : 'macos'; const otherMajor = M === 22 ? 20 : 22; const out = path.join(os.tmpdir(), 'pkg-sea-guard-out'); @@ -104,11 +105,11 @@ expectGuardError( 'multi linux flavor', ); -// c) Single target, wrong platform: the host binary's format can't match a -// different-OS target (e.g. a Mach-O binary for a linux target). +// c) Single target, wrong platform: the binary reports a process.platform that +// can't match a different-OS target (e.g. a darwin node for a linux target). expectGuardError( `node${M}-${otherPlatform}-${hostArch}`, - /needs (ELF|Mach-O|PE)/i, + /is for "\w+", but target|needs "\w+"/i, 'platform mismatch', ); diff --git a/test/unit/sea-sniff.test.ts b/test/unit/sea-sniff.test.ts deleted file mode 100644 index e595dd37..00000000 --- a/test/unit/sea-sniff.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { after, before, describe, it } from 'node:test'; - -import { sniffBinaryTarget } from '../../lib/sea'; - -// sniffBinaryTarget reads a binary's magic bytes to decide whether a supplied -// custom base Node binary matches the requested SEA target's platform/arch. -// These fixtures are minimal but real headers (ELF / Mach-O / PE). - -function elf(machine: number, le = true): Buffer { - const b = Buffer.alloc(64); - b[0] = 0x7f; - b[1] = 0x45; - b[2] = 0x4c; - b[3] = 0x46; // 0x7f E L F - b[4] = 2; // ELFCLASS64 - b[5] = le ? 1 : 2; // EI_DATA - if (le) b.writeUInt16LE(machine, 18); - else b.writeUInt16BE(machine, 18); // e_machine - return b; -} - -function macho(cpu: number): Buffer { - const b = Buffer.alloc(32); - b.writeUInt32LE(0xfeedfacf, 0); // 64-bit little-endian on disk: CF FA ED FE - b.writeUInt32LE(cpu, 4); // cputype - return b; -} - -function pe(machine: number): Buffer { - const b = Buffer.alloc(0x100); - b[0] = 0x4d; - b[1] = 0x5a; // MZ - b.writeUInt32LE(0x80, 0x3c); // e_lfanew - b[0x80] = 0x50; - b[0x81] = 0x45; // 'PE' - b.writeUInt16LE(machine, 0x84); // COFF machine - return b; -} - -describe('sniffBinaryTarget', () => { - let dir: string; - const write = (name: string, buf: Buffer): string => { - const p = path.join(dir, name); - writeFileSync(p, buf); - return p; - }; - - before(() => { - dir = mkdtempSync(path.join(os.tmpdir(), 'pkg-sniff-')); - }); - after(() => { - /* tmpdir left for the OS to reap; fixtures are tiny */ - }); - - it('detects ELF x64 / arm64', async () => { - assert.deepEqual(await sniffBinaryTarget(write('elf-x64', elf(0x3e))), { - format: 'elf', - arch: 'x64', - }); - assert.deepEqual(await sniffBinaryTarget(write('elf-arm64', elf(0xb7))), { - format: 'elf', - arch: 'arm64', - }); - }); - - it('detects big-endian ELF too', async () => { - assert.equal( - (await sniffBinaryTarget(write('elf-be', elf(0xb7, false)))).arch, - 'arm64', - ); - }); - - it('detects Mach-O x64 / arm64', async () => { - assert.deepEqual( - await sniffBinaryTarget(write('macho-x64', macho(0x01000007))), - { format: 'macho', arch: 'x64' }, - ); - assert.deepEqual( - await sniffBinaryTarget(write('macho-arm64', macho(0x0100000c))), - { format: 'macho', arch: 'arm64' }, - ); - }); - - it('detects PE x64 / arm64', async () => { - assert.deepEqual(await sniffBinaryTarget(write('pe-x64', pe(0x8664))), { - format: 'pe', - arch: 'x64', - }); - assert.deepEqual(await sniffBinaryTarget(write('pe-arm64', pe(0xaa64))), { - format: 'pe', - arch: 'arm64', - }); - }); - - it('returns {} for an unrecognised / too-short file', async () => { - assert.deepEqual( - await sniffBinaryTarget(write('garbage', Buffer.alloc(64, 0x55))), - {}, - ); - assert.deepEqual( - await sniffBinaryTarget(write('tiny', Buffer.alloc(4))), - {}, - ); - }); - - it('returns {} for a nonexistent path (caller skips the check)', async () => { - assert.deepEqual( - await sniffBinaryTarget(path.join(dir, 'does-not-exist')), - {}, - ); - }); - - it('maps unknown machine types to undefined arch but keeps the format', async () => { - const res = await sniffBinaryTarget(write('elf-mips', elf(0x08))); - assert.equal(res.format, 'elf'); - assert.equal(res.arch, undefined); - }); -}); From f5a1d3af796200f5c69e9bde6ac6f1184cfd728d Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Thu, 18 Jun 2026 18:46:57 -0500 Subject: [PATCH 5/7] fix(sea): correct pkg->Node platform/arch translation in the custom-base guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit robertsLando's review caught that the guard compared pkg target identifiers directly against Node's process.platform/arch, which falsely rejects legitimate single targets: - alpine / linuxstatic report process.platform === 'linux' (they're pkg-fetch flavors, not Node platform values), so `expectedPlatform = 'alpine'` could never match — breaking the musl/old-glibc custom-runtime case this feature exists for. - armv7l has process.arch === 'arm', and arch had no mapping at all, so a single armv7l target with a matching custom node was rejected too. Fixes: - add total TARGET_PLATFORM_TO_PROCESS / TARGET_ARCH_TO_PROCESS maps and pure expectedProcessPlatform() / expectedProcessArch() helpers (alpine/linuxstatic ->linux, macos->darwin, win->win32, armv7/armv7l->arm, x86->ia32). They throw on an unmapped target instead of silently falling through to a wrong identity default. - unit-test the helpers over the full knownPlatforms/knownArchs sets (sea-target-identity.test.ts) — the e2e can't reach this because every case feeds the host's own node and trips probeNode's process.execPath short-circuit. - wrap probeNode's exec failure with a clear "must be runnable on the build host" message instead of a cryptic ENOEXEC. - memoize probeNode so the target guard and version resolver share one spawn. - resolve() opts.nodePath (the --sea-node-path flag) for parity with the PKG_NODE_PATH branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/sea.ts | 120 ++++++++++++++++++++++---- test/test-95-sea-custom-node/main.js | 2 +- test/unit/sea-target-identity.test.ts | 87 +++++++++++++++++++ 3 files changed, 189 insertions(+), 20 deletions(-) create mode 100644 test/unit/sea-target-identity.test.ts diff --git a/lib/sea.ts b/lib/sea.ts index 6da339c9..f9813a9f 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -341,21 +341,86 @@ async function getNodeVersion( function resolveCustomBaseNode( opts: GetNodejsExecutableOptions, ): string | undefined { - if (opts.nodePath) return opts.nodePath; + if (opts.nodePath) return resolve(opts.nodePath); if (process.env.PKG_NODE_PATH) return resolve(process.env.PKG_NODE_PATH); return undefined; } -/** Target-suffix -> the value Node reports as `process.platform`. */ -const PROCESS_PLATFORM: Record = { +/** + * pkg target platform/arch identifiers -> the values Node reports as + * `process.platform` / `process.arch`. + * + * A custom base binary is validated by *running* it (see {@link probeNode}) and + * comparing what it reports against the target, so pkg-fetch's identifiers must + * be translated into Node's: pkg's `macos`/`win` are Node's `darwin`/`win32`; + * `alpine`/`linuxstatic` are pkg-fetch *flavors* of Linux that Node still + * reports as `linux` (the musl/static distinction isn't in `process.platform`); + * pkg's `armv7`/`armv7l`/`x86` are Node's `arm`/`ia32`. + * + * Keyed over the full `knownPlatforms`/`knownArchs` sets (asserted complete by + * `sea-target-identity.test.ts`) so a newly-added target can't silently fall + * through to a wrong identity default — which would falsely reject a legitimate + * single target such as a musl-on-musl (`alpine`) or 32-bit-ARM (`armv7l`) + * custom runtime, the very case this feature exists for. + */ +const TARGET_PLATFORM_TO_PROCESS: Record = { + alpine: 'linux', + freebsd: 'freebsd', + linux: 'linux', + linuxstatic: 'linux', macos: 'darwin', win: 'win32', - // linux / alpine / linuxstatic / freebsd report their own name verbatim, so - // they need no mapping. (ELF can't reveal the glibc/musl/static flavor - // either, so that sub-distinction stays the user's responsibility.) }; -/** What a Node binary reports about itself when run. */ +const TARGET_ARCH_TO_PROCESS: Record = { + x64: 'x64', + x86: 'ia32', + armv7: 'arm', + armv7l: 'arm', + arm64: 'arm64', + ppc64: 'ppc64', + s390x: 's390x', + riscv64: 'riscv64', + loong64: 'loong64', +}; + +/** The `process.platform` a base Node binary for `targetPlatform` must report. */ +export function expectedProcessPlatform(targetPlatform: string): string { + const platform = TARGET_PLATFORM_TO_PROCESS[targetPlatform]; + if (platform === undefined) { + throw wasReported( + `No process.platform mapping for target platform "${targetPlatform}" — ` + + `please report this so pkg can learn it.`, + ); + } + return platform; +} + +/** The `process.arch` a base Node binary for `targetArch` must report. */ +export function expectedProcessArch(targetArch: string): string { + const arch = TARGET_ARCH_TO_PROCESS[targetArch]; + if (arch === undefined) { + throw wasReported( + `No process.arch mapping for target arch "${targetArch}" — ` + + `please report this so pkg can learn it.`, + ); + } + return arch; +} + +/** Memoized {@link probeNode} results, keyed by binary path. */ +const probeCache = new Map< + string, + { version: string; platform: string; arch: string } +>(); + +/** + * What a Node binary reports about itself when run (`process.version` / + * `process.platform` / `process.arch`). The custom base binary must therefore + * be runnable on the build host; a foreign, non-runnable binary fails here with + * a clear message instead of a cryptic ENOEXEC. The result is memoized so the + * two callers (the target guard and the version resolver) share a single spawn. + */ async function probeNode( binPath: string, ): Promise<{ version: string; platform: string; arch: string }> { @@ -366,12 +431,26 @@ async function probeNode( arch: process.arch, }; } - const { stdout } = await execFileAsync(binPath, [ - '-p', - "process.version + ' ' + process.platform + ' ' + process.arch", - ]); + const cached = probeCache.get(binPath); + if (cached) return cached; + let stdout: string; + try { + ({ stdout } = await execFileAsync(binPath, [ + '-p', + "process.version + ' ' + process.platform + ' ' + process.arch", + ])); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw wasReported( + `Couldn't run the custom base Node binary "${binPath}" on this host. ` + + `pkg runs it to read its version/platform/arch and validate it against ` + + `the target, so the binary must be runnable here (got: ${reason}).`, + ); + } const [version, platform, arch] = stdout.trim().split(' '); - return { version, platform, arch }; + const result = { version, platform, arch }; + probeCache.set(binPath, result); + return result; } /** @@ -425,20 +504,23 @@ async function assertCustomBaseNodeTarget( } const { platform, arch } = [...combos.values()][0]; - // 2. Ask the binary what it actually is and reject any disagreement. + // 2. Ask the binary what it actually is and reject any disagreement. pkg's + // target identifiers differ from Node's process.platform/arch (macos→darwin, + // armv7l→arm, alpine/linuxstatic→linux, …), so translate before comparing. const node = await probeNode(binPath); - const expectedPlatform = PROCESS_PLATFORM[platform] ?? platform; + const expectedPlatform = expectedProcessPlatform(platform); if (node.platform !== expectedPlatform) { throw wasReported( - `Custom base Node binary is for "${node.platform}", but target ` + - `"${platform}" needs "${expectedPlatform}".`, + `Custom base Node binary reports platform "${node.platform}", but target ` + + `"${platform}" needs a "${expectedPlatform}" binary.`, ); } - if (node.arch !== arch) { + const expectedArch = expectedProcessArch(arch); + if (node.arch !== expectedArch) { throw wasReported( - `Custom base Node binary is ${node.arch}, but target arch is "${arch}". ` + - `The binary must match the target's architecture.`, + `Custom base Node binary reports arch "${node.arch}", but target "${arch}" ` + + `needs a "${expectedArch}" binary. It must match the target's architecture.`, ); } diff --git a/test/test-95-sea-custom-node/main.js b/test/test-95-sea-custom-node/main.js index 2007add4..ebdc3fc5 100644 --- a/test/test-95-sea-custom-node/main.js +++ b/test/test-95-sea-custom-node/main.js @@ -109,7 +109,7 @@ expectGuardError( // can't match a different-OS target (e.g. a darwin node for a linux target). expectGuardError( `node${M}-${otherPlatform}-${hostArch}`, - /is for "\w+", but target|needs "\w+"/i, + /reports platform "\w+", but target/i, 'platform mismatch', ); diff --git a/test/unit/sea-target-identity.test.ts b/test/unit/sea-target-identity.test.ts new file mode 100644 index 00000000..a10fe052 --- /dev/null +++ b/test/unit/sea-target-identity.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { system } from '@yao-pkg/pkg-fetch'; + +import { expectedProcessPlatform, expectedProcessArch } from '../../lib/sea'; + +// The SEA custom-base guard validates a supplied node binary by *running* it +// and comparing its reported process.platform / process.arch to the target. +// pkg's target identifiers (alpine, linuxstatic, macos, win, armv7l, x86, …) +// are NOT the values Node reports, so they must be translated first. These maps +// are easy to get wrong in a way that only bites a non-host target — which the +// e2e can't reach (it feeds the host's own node, hitting probeNode's +// process.execPath short-circuit). So pin the translation here, over the full +// known* sets, so a wrong/missing mapping fails in CI rather than shipping. + +describe('expectedProcessPlatform', () => { + it('translates every known pkg platform to a Node process.platform', () => { + // Total over knownPlatforms: a newly-added target with no mapping throws + // here instead of silently falling through to a wrong identity default. + for (const platform of system.knownPlatforms) { + const reported = expectedProcessPlatform(platform); + assert.equal( + typeof reported, + 'string', + `${platform} should map to a process.platform string`, + ); + assert.ok( + reported.length > 0, + `${platform} should map to a non-empty value`, + ); + } + }); + + it('maps the pkg-specific aliases to their Node names', () => { + assert.equal(expectedProcessPlatform('macos'), 'darwin'); + assert.equal(expectedProcessPlatform('win'), 'win32'); + // alpine / linuxstatic are pkg-fetch flavors of Linux; Node reports 'linux' + // for all of them (the musl/static distinction isn't in process.platform). + assert.equal(expectedProcessPlatform('linux'), 'linux'); + assert.equal(expectedProcessPlatform('alpine'), 'linux'); + assert.equal(expectedProcessPlatform('linuxstatic'), 'linux'); + assert.equal(expectedProcessPlatform('freebsd'), 'freebsd'); + }); + + it('throws (rather than guessing) for an unknown platform', () => { + assert.throws( + () => expectedProcessPlatform('plan9'), + /no process\.platform mapping/i, + ); + }); +}); + +describe('expectedProcessArch', () => { + it('translates every known pkg arch to a Node process.arch', () => { + for (const arch of system.knownArchs) { + const reported = expectedProcessArch(arch); + assert.equal( + typeof reported, + 'string', + `${arch} should map to a process.arch string`, + ); + assert.ok(reported.length > 0, `${arch} should map to a non-empty value`); + } + }); + + it('maps the pkg-specific aliases to their Node names', () => { + // pkg uses armv7/armv7l and x86; Node reports 'arm' and 'ia32'. + assert.equal(expectedProcessArch('armv7'), 'arm'); + assert.equal(expectedProcessArch('armv7l'), 'arm'); + assert.equal(expectedProcessArch('x86'), 'ia32'); + // The rest pass through unchanged. + assert.equal(expectedProcessArch('x64'), 'x64'); + assert.equal(expectedProcessArch('arm64'), 'arm64'); + assert.equal(expectedProcessArch('ppc64'), 'ppc64'); + assert.equal(expectedProcessArch('s390x'), 's390x'); + assert.equal(expectedProcessArch('riscv64'), 'riscv64'); + assert.equal(expectedProcessArch('loong64'), 'loong64'); + }); + + it('throws (rather than guessing) for an unknown arch', () => { + assert.throws( + () => expectedProcessArch('sparc64'), + /no process\.arch mapping/i, + ); + }); +}); From f47221ba9ac32fe71b313d7778ba9c618f9d276d Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Thu, 18 Jun 2026 19:32:52 -0500 Subject: [PATCH 6/7] fix(sea): enforce the SEA Node floor for 'latest'-targeted custom bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second review round flagged that the custom-base major check returned early when the target nodeRange is unparseable ('latest'): targetMajor is NaN, so the guard skipped validation entirely. seaEnhanced's later minTargetMajor check resolves 'latest' to the host major (>= 22), so a too-old custom binary (e.g. `--sea-node-path ./node18 -t latest-...`) slipped through both checks and would break at injection instead of failing cleanly. - extract the major-compatibility logic into a pure, exported assertBaseMajorSatisfiesTarget(binVersion, nodeRange) and unit-test all branches (concrete match/mismatch, 'latest' with new/floor/too-old binaries) — the e2e can't reach the too-old path host-independently. - for a 'latest'/unparseable target, require the binary major >= the SEA floor instead of returning unchecked. - introduce MIN_SEA_TARGET_MAJOR (22) and reuse it in assertHostSeaNodeVersion and seaEnhanced's target check, so the floor lives in one place. - document that cross-compilation is unsupported with a custom SEA binary (the binary must be runnable on the build host to be probed). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-site/guide/custom-node.md | 11 ++++- lib/sea.ts | 66 ++++++++++++++++++++------- test/unit/sea-target-identity.test.ts | 45 +++++++++++++++++- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/docs-site/guide/custom-node.md b/docs-site/guide/custom-node.md index e84be980..d83ac125 100644 --- a/docs-site/guide/custom-node.md +++ b/docs-site/guide/custom-node.md @@ -26,7 +26,16 @@ PKG_NODE_PATH=/path/to/node pkg app.js - The binary must be a **compatible** Node.js version — `pkg` still injects its bootstrap prelude and payload, and depends on specific symbol offsets. Use a version supported by `pkg-fetch` unless you know what you're doing. - `PKG_NODE_PATH` affects a **single target**. In standard mode, multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run. -- SEA mode honours `PKG_NODE_PATH` too. Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), a custom base binary there is restricted to a **single target**, and pkg validates the binary against it rather than baking a mismatched binary into the output: it **runs the binary** and checks what it reports — `process.platform` and `process.arch` must match the target (a macOS binary for a `linux` target, or an x64 binary for an `arm64` target, is rejected), and its major version must match the target's. This means **the custom base binary must be runnable on the build host** (pkg already runs it to read its version). It also rejects targets that span more than one `platform`/`arch` (one binary can't be several at once — including `linux` vs `alpine` vs `linuxstatic`, which all report `process.platform` `linux` but clearly can't all be meant simultaneously). The glibc/musl/static flavor isn't reported by Node, so matching that to `linux`/`alpine`/`linuxstatic` remains your responsibility. You can point at the binary with the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both override `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). + +### SEA mode caveats + +SEA mode honours `PKG_NODE_PATH`, and also has its own overrides: the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both take precedence over `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). + +Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), supplying a custom base binary imposes strict limits to avoid silently baking a mismatched binary into the output: + +- **Single target only** — the custom binary applies to exactly one platform/arch, so pkg rejects a run whose targets span more than one (including `linux` vs `alpine` vs `linuxstatic`, which a single binary can't be all of at once). +- **No cross-compilation** — pkg validates the binary by **running it** and checking its `process.platform`, `process.arch`, and major version against the requested target. So the custom base binary must be **natively runnable on your build host** (e.g. you can't supply a Linux binary while building on macOS). Standard, non-SEA builds don't have this restriction and still cross-compile via `pkg-fetch`. +- **Flavor matching is yours** — `process.platform` reports `linux` for glibc, musl (`alpine`), and static (`linuxstatic`) alike, so matching the specific flavor of your binary to the target remains your responsibility. ## See also diff --git a/lib/sea.ts b/lib/sea.ts index f9813a9f..5a65fcdd 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -53,6 +53,13 @@ const execFileAsync = util.promisify(cExecFile); const SEA_SENTINEL_FUSE = 'NODE_SEA' + '_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; +/** + * Minimum Node.js major a SEA *target* (and thus a custom base binary) must be. + * node:sea exists from Node 20, but pkg's enhanced VFS bootstrap requires 22 + * (see {@link seaEnhanced} and engines.node / the @roberts_lando/vfs dep). + */ +const MIN_SEA_TARGET_MAJOR = 22; + /** Returns stat of path when exits, false otherwise */ const exists = async (path: string) => { try { @@ -408,6 +415,42 @@ export function expectedProcessArch(targetArch: string): string { return arch; } +/** + * Assert a custom base binary's major version is compatible with the requested + * target `nodeRange`. Pure (no process spawn) so it's unit-testable. + * + * - Concrete target major (e.g. `node24`): the binary's major must equal it. + * - `latest` / unparseable range: there's no specific major to match, but the + * binary must still be new enough for SEA ({@link MIN_SEA_TARGET_MAJOR}). + * seaEnhanced's later `minTargetMajor` check resolves `latest` to the host + * major (>= 22), so without this floor a too-old *custom* binary (e.g. Node + * 18 with `-t latest-...`) would slip through and break at injection instead + * of failing cleanly here. + */ +export function assertBaseMajorSatisfiesTarget( + binVersion: string, + nodeRange: string, +): void { + const binMajor = parseInt(binVersion.replace(/^v/, ''), 10); + const targetMajor = parseInt(nodeRange.replace('node', ''), 10); + if (Number.isNaN(targetMajor)) { + if (binMajor < MIN_SEA_TARGET_MAJOR) { + throw wasReported( + `Custom base Node binary is ${binVersion}, but SEA requires Node ` + + `${MIN_SEA_TARGET_MAJOR} or newer.`, + ); + } + return; + } + if (binMajor !== targetMajor) { + throw wasReported( + `Custom base Node binary is ${binVersion} (major ${binMajor}), but ` + + `target "${nodeRange}" requests Node ${targetMajor}. The binary's ` + + `major version must match the target.`, + ); + } +} + /** Memoized {@link probeNode} results, keyed by binary path. */ const probeCache = new Map< string, @@ -524,18 +567,9 @@ async function assertCustomBaseNodeTarget( ); } - // 3. Major version match (assertSingleTargetMajor only compares the targets - // to each other, never to the supplied binary). - const targetMajor = parseInt(targets[0].nodeRange.replace('node', ''), 10); - if (Number.isNaN(targetMajor)) return; // 'latest' / unparseable: nothing to compare - const binMajor = parseInt(node.version.replace(/^v/, ''), 10); - if (binMajor !== targetMajor) { - throw wasReported( - `Custom base Node binary is ${node.version} (major ${binMajor}), but ` + - `target "${targets[0].nodeRange}" requests Node ${targetMajor}. The ` + - `binary's major version must match the target.`, - ); - } + // 3. Major version compatibility (assertSingleTargetMajor only compares the + // targets to each other, never to the supplied binary). + assertBaseMajorSatisfiesTarget(node.version, targets[0].nodeRange); } /** @@ -762,9 +796,9 @@ async function withSeaTmpDir( */ function assertHostSeaNodeVersion(): number { const nodeMajor = parseInt(process.version.slice(1).split('.')[0], 10); - if (nodeMajor < 22) { + if (nodeMajor < MIN_SEA_TARGET_MAJOR) { throw new Error( - `SEA support requires at least node v22.0.0, actual node version is ${process.version}`, + `SEA support requires at least node v${MIN_SEA_TARGET_MAJOR}.0.0, actual node version is ${process.version}`, ); } return nodeMajor; @@ -978,9 +1012,9 @@ export async function seaEnhanced( await assertCustomBaseNodeTarget(opts.targets, opts); const minTargetMajor = resolveMinTargetMajor(opts.targets); - if (minTargetMajor < 22) { + if (minTargetMajor < MIN_SEA_TARGET_MAJOR) { throw wasReported( - `Enhanced SEA mode requires Node >= 22 targets. ` + + `Enhanced SEA mode requires Node >= ${MIN_SEA_TARGET_MAJOR} targets. ` + `Minimum target version resolved to Node ${minTargetMajor}.`, ); } diff --git a/test/unit/sea-target-identity.test.ts b/test/unit/sea-target-identity.test.ts index a10fe052..f1b06f96 100644 --- a/test/unit/sea-target-identity.test.ts +++ b/test/unit/sea-target-identity.test.ts @@ -3,7 +3,11 @@ import { describe, it } from 'node:test'; import { system } from '@yao-pkg/pkg-fetch'; -import { expectedProcessPlatform, expectedProcessArch } from '../../lib/sea'; +import { + expectedProcessPlatform, + expectedProcessArch, + assertBaseMajorSatisfiesTarget, +} from '../../lib/sea'; // The SEA custom-base guard validates a supplied node binary by *running* it // and comparing its reported process.platform / process.arch to the target. @@ -85,3 +89,42 @@ describe('expectedProcessArch', () => { ); }); }); + +describe('assertBaseMajorSatisfiesTarget', () => { + it('accepts a binary whose major matches a concrete target', () => { + assert.doesNotThrow(() => + assertBaseMajorSatisfiesTarget('v24.16.0', 'node24'), + ); + }); + + it('rejects a binary whose major differs from a concrete target', () => { + assert.throws( + () => assertBaseMajorSatisfiesTarget('v22.22.2', 'node24'), + /major version must match|requests Node 24/i, + ); + }); + + it('accepts a new-enough binary for a "latest" target', () => { + assert.doesNotThrow(() => + assertBaseMajorSatisfiesTarget('v24.16.0', 'latest'), + ); + // Exactly the floor is allowed. + assert.doesNotThrow(() => + assertBaseMajorSatisfiesTarget('v22.0.0', 'latest'), + ); + }); + + it('rejects a too-old binary for a "latest" target', () => { + // 'latest' has no concrete major, and seaEnhanced resolves it to the host + // major (>= 22), so this floor is the only thing catching an old custom + // binary. Node 18 / 21 must be rejected here. + assert.throws( + () => assertBaseMajorSatisfiesTarget('v18.20.4', 'latest'), + /SEA requires Node 22 or newer/i, + ); + assert.throws( + () => assertBaseMajorSatisfiesTarget('v21.7.3', 'latest'), + /SEA requires Node 22 or newer/i, + ); + }); +}); From 32b66d401b77d503c9e3a879a006c26875dedad0 Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Thu, 18 Jun 2026 20:03:16 -0500 Subject: [PATCH 7/7] refactor(sea): have probeNode emit JSON instead of space-joined fields probeNode ran `node -p "process.version + ' ' + process.platform + ' ' + process.arch"` and split the result on spaces. The fields can't contain spaces so it wasn't buggy, but JSON is clearer and more robust: emit `JSON.stringify({version, platform, arch})` and parse it, validating the three fields are strings. Unexpected output now fails with a clear parse error instead of silently yielding undefined fields that would later trip a confusing platform/arch mismatch. Also add e2e coverage for probeNode's real exec + parse path. Cases a-d all pass process.execPath, which short-circuits probeNode (no spawn), so the parse was never exercised in CI. Two POSIX-only mock "node" binaries close that gap: a shebang script reporting a mismatched version (valid JSON -> major-version guard error) and one emitting non-JSON (-> the new "unexpected output" error). Windows is skipped (execFile can't run a .bat/.cmd without a shell, and a real .exe isn't practical here); the parse logic is OS-agnostic JS. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/sea.ts | 37 ++++++++++++++++---- test/test-95-sea-custom-node/main.js | 51 ++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 5a65fcdd..f9b0e7f7 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -459,10 +459,13 @@ const probeCache = new Map< /** * What a Node binary reports about itself when run (`process.version` / - * `process.platform` / `process.arch`). The custom base binary must therefore - * be runnable on the build host; a foreign, non-runnable binary fails here with - * a clear message instead of a cryptic ENOEXEC. The result is memoized so the - * two callers (the target guard and the version resolver) share a single spawn. + * `process.platform` / `process.arch`), emitted as JSON so parsing can't be + * tripped by an unexpected delimiter. The custom base binary must therefore be + * runnable on the build host; a foreign, non-runnable binary fails here with a + * clear message instead of a cryptic ENOEXEC, and unexpected output fails with a + * clear parse error rather than silently yielding undefined fields. The result + * is memoized so the two callers (the target guard and the version resolver) + * share a single spawn. */ async function probeNode( binPath: string, @@ -480,7 +483,7 @@ async function probeNode( try { ({ stdout } = await execFileAsync(binPath, [ '-p', - "process.version + ' ' + process.platform + ' ' + process.arch", + 'JSON.stringify({ version: process.version, platform: process.platform, arch: process.arch })', ])); } catch (err) { const reason = err instanceof Error ? err.message : String(err); @@ -490,8 +493,28 @@ async function probeNode( `the target, so the binary must be runnable here (got: ${reason}).`, ); } - const [version, platform, arch] = stdout.trim().split(' '); - const result = { version, platform, arch }; + let result: { version: string; platform: string; arch: string }; + try { + const parsed = JSON.parse(stdout.trim()); + if ( + typeof parsed?.version !== 'string' || + typeof parsed?.platform !== 'string' || + typeof parsed?.arch !== 'string' + ) { + throw new Error('missing version/platform/arch'); + } + result = { + version: parsed.version, + platform: parsed.platform, + arch: parsed.arch, + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw wasReported( + `Custom base Node binary "${binPath}" produced unexpected output when ` + + `probed for its identity (${reason}): ${JSON.stringify(stdout.trim())}`, + ); + } probeCache.set(binPath, result); return result; } diff --git a/test/test-95-sea-custom-node/main.js b/test/test-95-sea-custom-node/main.js index ebdc3fc5..d34eb43c 100644 --- a/test/test-95-sea-custom-node/main.js +++ b/test/test-95-sea-custom-node/main.js @@ -3,6 +3,7 @@ 'use strict'; const assert = require('assert'); +const fs = require('fs'); const os = require('os'); const path = require('path'); const utils = require('../utils.js'); @@ -71,13 +72,13 @@ const otherPlatform = hostPlatform === 'macos' ? 'linux' : 'macos'; const otherMajor = M === 22 ? 20 : 22; const out = path.join(os.tmpdir(), 'pkg-sea-guard-out'); -function expectGuardError(targets, re, label) { +function expectGuardError(targets, re, label, nodePath = process.execPath) { const r = utils.pkg.sync( [ input, '--sea', '--sea-node-path', - process.execPath, + nodePath, '--targets', targets, '--output', @@ -119,3 +120,49 @@ expectGuardError( /major version must match|requests Node/i, 'major mismatch', ); + +// e+f) Drive probeNode's real exec + JSON-parse path with a *non-host* binary. +// Cases a-d all pass process.execPath, which short-circuits probeNode (no exec, +// no parse). Here we hand pkg a tiny mock "node" that prints a controlled +// identity, so the guard fails with a known error rather than letting pkg march +// on into postject against a non-Node file (which would blow up unpredictably). +// +// POSIX-only: a shebang script is directly execFile-able, but on Windows +// execFile can't run a .bat/.cmd without a shell and a real .exe isn't +// practical to author here. The parse/guard logic is OS-agnostic JS, so +// Linux/macOS coverage is sufficient. +if (process.platform !== 'win32') { + const mockDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkg-sea-mock-')); + + // e) Valid JSON, but a mismatched version. The mock reports the *real* host + // platform/arch (via node) so those checks pass and we reach the major check; + // it claims v22.0.0 while we request node24, so assertBaseMajorSatisfiesTarget + // throws. Exercises probeNode exec + JSON.parse + the major-version guard. + const versionMock = path.join(mockDir, 'node'); + fs.writeFileSync( + versionMock, + '#!/usr/bin/env node\n' + + "console.log(JSON.stringify({ version: 'v22.0.0', " + + 'platform: process.platform, arch: process.arch }));\n', + ); + fs.chmodSync(versionMock, 0o755); + expectGuardError( + `node24-${hostPlatform}-${hostArch}`, + /major version must match|requests Node 24/i, + 'mock binary: version mismatch', + versionMock, + ); + + // f) Non-JSON output. Exercises the parse-failure branch: probeNode runs the + // binary fine, but the output isn't the expected JSON, so it must fail with a + // clear "unexpected output" error rather than silently using undefined fields. + const garbageMock = path.join(mockDir, 'garbage-node'); + fs.writeFileSync(garbageMock, '#!/bin/sh\necho "this is not json"\n'); + fs.chmodSync(garbageMock, 0o755); + expectGuardError( + `node${M}-${hostPlatform}-${hostArch}`, + /produced unexpected output/i, + 'mock binary: non-JSON output', + garbageMock, + ); +}