diff --git a/lib/mach-o.ts b/lib/mach-o.ts index 9993457b..50ee3136 100644 --- a/lib/mach-o.ts +++ b/lib/mach-o.ts @@ -72,14 +72,4 @@ function signMachOExecutable(executable: string) { } } -function removeMachOExecutableSignature(executable: string) { - execFileSync('codesign', ['--remove-signature', executable], { - stdio: 'inherit', - }); -} - -export { - patchMachOExecutable, - removeMachOExecutableSignature, - signMachOExecutable, -}; +export { patchMachOExecutable, signMachOExecutable }; diff --git a/lib/sea.ts b/lib/sea.ts index e2f9bd20..d695c9a0 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -18,16 +18,25 @@ import { homedir, tmpdir } from 'os'; import unzipper from 'unzipper'; import { extract as tarExtract } from 'tar'; import { log, wasReported } from './log'; -import { NodeTarget, Target, SeaEnhancedOptions } from './types'; import { - patchMachOExecutable, - removeMachOExecutableSignature, - signMachOExecutable, -} from './mach-o'; + NodeTarget, + Target, + SeaEnhancedOptions, + NodeVersion, + NodeRange, + NodeOs, + NodeArch, + NODE_OSES, + NODE_ARCHS, +} from './types'; +import { patchMachOExecutable, signMachOExecutable } from './mach-o'; import walk from './walker'; import refine from './refiner'; import { generateSeaAssets } from './sea-assets'; import { inject as postjectInject } from 'postject'; +import { system } from '@yao-pkg/pkg-fetch'; + +const { hostPlatform, hostArch } = system; const execFileAsync = util.promisify(cExecFile); @@ -90,7 +99,7 @@ async function downloadFile(url: string, filePath: string): Promise { } /** Extract node executable from the archive */ -async function extract(os: string, archivePath: string): Promise { +async function extract(os: NodeOs, archivePath: string): Promise { const nodeDir = basename(archivePath, os === 'win' ? '.zip' : '.tar.gz'); const archiveDir = dirname(archivePath); const nodePath = @@ -174,42 +183,39 @@ async function verifyChecksum( } /** Get the node os based on target platform */ -function getNodeOs(platform: string) { - const allowedOSs = ['darwin', 'linux', 'win']; +function getNodeOs(platform: string): NodeOs { const platformsMap: Record = { macos: 'darwin', }; const validatedPlatform = platformsMap[platform] || platform; - if (!allowedOSs.includes(validatedPlatform)) { + if (!(NODE_OSES as readonly string[]).includes(validatedPlatform)) { throw new Error(`Unsupported OS: ${platform}`); } - return validatedPlatform; + return validatedPlatform as NodeOs; } /** Get the node arch based on target arch */ -function getNodeArch(arch: string) { - const allowedArchs = [ - 'x64', - 'arm64', - 'armv7l', - 'ppc64', - 's390x', - 'riscv64', - 'loong64', - ]; - - if (!allowedArchs.includes(arch)) { +function getNodeArch(arch: string): NodeArch { + if (!(NODE_ARCHS as readonly string[]).includes(arch)) { throw new Error(`Unsupported architecture: ${arch}`); } - return arch; + return arch as NodeArch; } -/** Get latest node version based on the provided partial version */ -async function getNodeVersion(os: string, arch: string, nodeVersion: string) { +/** + * Get latest Node.js version covering a partial range. Accepts `22`, + * `22.22`, or `22.22.2`; returns the canonical v-prefixed triple the + * rest of the file expects. + */ +async function getNodeVersion( + os: NodeOs, + arch: NodeArch, + nodeVersion: string, +): Promise { // validate nodeVersion using regex. Allowed formats: 16, 16.0, 16.0.0 const regex = /^\d{1,2}(\.\d{1,2}){0,2}$/; if (!regex.test(nodeVersion)) { @@ -223,7 +229,7 @@ async function getNodeVersion(os: string, arch: string, nodeVersion: string) { } if (parts.length === 3) { - return nodeVersion; + return `v${nodeVersion}` as NodeVersion; } let url; @@ -243,29 +249,53 @@ async function getNodeVersion(os: string, arch: string, nodeVersion: string) { throw new Error('Failed to fetch node versions'); } - const versions = await response.json(); + const versions = (await response.json()) as { + version: string; + files: string[]; + }[]; const nodeOS = os === 'darwin' ? 'osx' : os; - const latestVersionAndFiles = versions - .map((v: { version: string; files: string[] }) => [v.version, v.files]) - .find( - ([v, files]: [string, string[]]) => - v.startsWith(`v${nodeVersion}`) && - files.find((f: string) => f.startsWith(`${nodeOS}-${arch}`)), - ); + const latest = versions.find( + (v) => + v.version.startsWith(`v${nodeVersion}`) && + v.files.some((f) => f.startsWith(`${nodeOS}-${arch}`)), + ); - if (!latestVersionAndFiles) { + if (!latest) { throw new Error(`Node version ${nodeVersion} not found`); } - return latestVersionAndFiles[0]; + return latest.version as NodeVersion; +} + +/** + * Resolve the concrete Node.js version (e.g. `v22.22.2`) pkg will use + * for `target` — mirrors the version selection done inside + * {@link getNodejsExecutable} without performing the download, so + * callers can reason about host/target version skew independently of + * the download itself. + */ +async function resolveTargetNodeVersion( + target: NodeTarget, + opts: GetNodejsExecutableOptions, +): Promise { + if (opts.useLocalNode) return process.version as NodeVersion; + if (opts.nodePath) { + // 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']); + return stdout.trim() as NodeVersion; + } + const os = getNodeOs(target.platform); + const arch = getNodeArch(target.arch); + return getNodeVersion(os, arch, target.nodeRange.replace('node', '')); } /** Fetch, validate and extract nodejs binary. Returns a path to it */ async function getNodejsExecutable( target: NodeTarget, opts: GetNodejsExecutableOptions, -) { +): Promise { if (opts.nodePath) { // check if the nodePath exists if (!(await exists(opts.nodePath))) { @@ -284,11 +314,7 @@ async function getNodejsExecutable( const os = getNodeOs(target.platform); const arch = getNodeArch(target.arch); - const nodeVersion = await getNodeVersion( - os, - arch, - target.nodeRange.replace('node', ''), - ); + const nodeVersion = await resolveTargetNodeVersion(target, opts); const fileName = `node-${nodeVersion}-${os}-${arch}.${os === 'win' ? 'zip' : 'tar.gz'}`; @@ -344,7 +370,7 @@ async function bake( nodePath: string, target: NodeTarget & Partial, blobData: Buffer, -) { +): Promise { const outPath = resolve(process.cwd(), target.output as string); log.info( @@ -363,13 +389,10 @@ async function bake( await copyFile(nodePath, outPath); log.info(`Injecting the blob into ${outPath}...`); - if (target.platform === 'macos') { - // codesign is only available on macOS — skip signature removal when - // cross-compiling from another platform - if (process.platform === 'darwin') { - removeMachOExecutableSignature(outPath); - } - } + // No pre-strip of the downloaded node binary's signature on macOS: + // the final `codesign -f --sign -` in signMacOSIfNeeded force-replaces + // any existing signature after postject injection, so a preliminary + // `codesign --remove-signature` is redundant. // Use postject JS API directly instead of spawning npx. // This avoids two CI issues: @@ -382,16 +405,35 @@ async function bake( }); } -/** Patch and sign macOS executable if needed */ +/** + * Patch mach-O __LINKEDIT (non-SEA only) and ad-hoc sign the binary. + * + * The __LINKEDIT patch exists for the classic pkg flow: pkg appends the + * VFS payload to the end of the binary, and codesign only hashes content + * covered by __LINKEDIT — so the segment must be extended to include the + * payload before signing. + * + * Pass `isSea: true` to skip the patch. For SEA binaries postject + * already creates a dedicated NODE_SEA `LC_SEGMENT_64` (per the + * [Node.js SEA docs](https://nodejs.org/api/single-executable-applications.html)) + * and __LINKEDIT already sits at the file tail with + * `filesize = file.length - fileoff`, so the patch is a no-op on the + * resulting Mach-O. The docs call for just `codesign --sign -` after + * postject, which is what `signMachOExecutable` does. + */ export async function signMacOSIfNeeded( output: string, target: NodeTarget & Partial, signature?: boolean, -) { + isSea?: boolean, +): Promise { if (!signature || target.platform !== 'macos') return; - const buf = patchMachOExecutable(await readFile(output)); - await writeFile(output, buf); + if (!isSea) { + const buf = patchMachOExecutable(await readFile(output)); + await writeFile(output, buf); + } + try { signMachOExecutable(output); } catch { @@ -445,7 +487,7 @@ async function withSeaTmpDir( * Host-only check — target Node majors are validated via * {@link resolveMinTargetMajor}. */ -function assertHostSeaNodeVersion() { +function assertHostSeaNodeVersion(): number { const nodeMajor = parseInt(process.version.slice(1).split('.')[0], 10); if (nodeMajor < 22) { throw new Error( @@ -498,36 +540,121 @@ function assertSingleTargetMajor( } } +/** + * Index into `targets` of the first entry whose platform+arch match + * `host`, or -1 when no target is runnable on the host. Exported for + * unit testing step 1 of the SEA blob-generator selection without + * spinning up a full pkg invocation. + */ +export function pickMatchingHostTargetIndex( + host: { platform: string; arch: string }, + targets: readonly { platform: string; arch: string }[], +): number { + return targets.findIndex( + (t) => t.platform === host.platform && t.arch === host.arch, + ); +} + /** * Pick the node binary used to generate the SEA prep blob. * - * The blob layout is node-version specific (e.g. Node 25.8 added an - * exec_argv_extension header field), so it must be generated by a node - * major that matches the target — otherwise the target cannot deserialize - * it. The blob itself is platform/arch-agnostic. + * The blob layout is node-version specific — not just major-version + * specific. Node occasionally changes the SEA header layout within a + * single major line (Node 22.19/22.20 added fields that break the 22.22 + * reader, Node 25.8 added `exec_argv_extension`, etc.), so using a host + * Node whose patch release differs from the downloaded target binary + * crashes `node::sea::FindSingleExecutableResource` at startup with + * `EXC_BAD_ACCESS` inside `BlobDeserializer::ReadArithmetic` — see + * discussion #236. * - * Rule: - * - host major === minTargetMajor → use process.execPath. Always - * executable regardless of target platform/arch, so this is the only - * path that works for cross-platform builds (e.g. Linux x64 host - * producing a Windows x64 SEA). - * - otherwise → use nodePaths[0], the downloaded - * target-platform binary. Matches the target major but requires host - * to be able to execute it (same platform/arch, or QEMU/Rosetta). A - * cross-major + cross-platform build will fail at spawn time — pkg - * has no way to produce a host-platform binary of the target major. + * Strategy (all paths guarantee the generator is the same version as the + * reader, eliminating patch-version skew): + * + * 1. Prefer a downloaded target binary whose platform & arch match the + * host — already downloaded, guaranteed version-matched. + * 2. Otherwise (pure cross-platform build, e.g. Linux host producing + * only a macos-arm64 binary), download a host-platform/arch node + * binary at the same node range as the targets and use it purely + * as the generator. + * 3. If the host-platform download fails (unsupported host such as + * alpine/musl, offline, checksum mismatch, …), fall back to + * `process.execPath` only when its version exactly matches the + * resolved target version. Otherwise hard-fail — silently running + * the generator with a skewed node would reintroduce the same + * EXC_BAD_ACCESS this function exists to prevent. * * All targets share a single node major (enforced by - * {@link assertSingleTargetMajor}), so inspecting only nodePaths[0] is - * sufficient. + * {@link assertSingleTargetMajor}). */ -function pickBlobGeneratorBinary( - minTargetMajor: number, +async function pickBlobGeneratorBinary( + targets: (NodeTarget & Partial)[], nodePaths: string[], -): string { - const hostMajor = parseInt(process.version.slice(1), 10); - if (hostMajor === minTargetMajor) return process.execPath; - return nodePaths[0]; + opts: GetNodejsExecutableOptions, +): Promise { + const matchIdx = pickMatchingHostTargetIndex( + { platform: hostPlatform, arch: hostArch }, + targets, + ); + if (matchIdx !== -1) { + log.debug( + `SEA blob generator: host matches ${targets[matchIdx].platform}-${targets[matchIdx].arch} target, reusing its downloaded binary (${nodePaths[matchIdx]}).`, + ); + return nodePaths[matchIdx]; + } + + // No target is runnable on the host. Resolve the target's concrete + // patch version first, then pin a host-platform download to that exact + // version so the blob generator and the SEA reader baked into each + // target share the same patch level — otherwise we regress into the + // discussion #236 crash on any host/target patch skew. Resolving + // against target's platform/arch (not host's) is what pins the + // version: host and target could otherwise land on different latest + // patches (unofficial builds, arch-specific availability). + const targetVersion = await resolveTargetNodeVersion(targets[0], opts); + + if (targetVersion === process.version) { + // Host already runs the exact target version; no download needed. + return process.execPath; + } + + log.info( + `No target matches host ${hostPlatform}-${hostArch}; downloading a ` + + `host-platform node ${targetVersion} to generate the SEA blob ` + + `(avoids SEA header version skew — see discussion #236).`, + ); + try { + // nodeRange must be `node` so + // getNodejsExecutable → getNodeVersion's `replace('node','')` + regex + // sees a clean `22.22.2` (v-prefix would fail the validator). + // `hostPlatform` from pkg-fetch is wider than NodeTarget.platform + // (e.g. 'alpine', 'linuxstatic'); getNodejsExecutable only reads + // platform/arch to route the download, so the assertion is safe. + const nodeRange: NodeRange = `node${targetVersion.slice(1)}`; + const hostGeneratorTarget = { + platform: hostPlatform, + arch: hostArch, + nodeRange, + } as NodeTarget; + // Drop user-supplied nodePath / useLocalNode: they'd short-circuit + // the download in getNodejsExecutable and reintroduce version skew. + const downloadOpts: GetNodejsExecutableOptions = { + ...opts, + nodePath: undefined, + useLocalNode: false, + }; + return await getNodejsExecutable(hostGeneratorTarget, downloadOpts); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw wasReported( + `Cannot generate SEA blob: host node ${process.version} differs ` + + `from target ${targetVersion} and the host-platform download ` + + `failed (${reason}). Running the generator with a skewed node ` + + `would crash the final binary at startup with EXC_BAD_ACCESS in ` + + `node::sea::FindSingleExecutableResource (see discussion #236). ` + + `Install node ${targetVersion} locally (e.g. via nvm) or pass ` + + `nodePath pointing to a host-runnable node binary of that version.`, + ); + } } /** @@ -541,7 +668,7 @@ function pickBlobGeneratorBinary( async function generateSeaBlob( seaConfigFilePath: string, generatorBinary: string, -) { +): Promise { log.info('Generating the blob...'); await execFileAsync(generatorBinary, [ '--experimental-sea-config', @@ -671,7 +798,7 @@ export async function seaEnhanced( await generateSeaBlob( seaConfigFilePath, - pickBlobGeneratorBinary(minTargetMajor, nodePaths), + await pickBlobGeneratorBinary(opts.targets, nodePaths, opts), ); // Read the blob once and share the buffer across all targets — avoids @@ -683,7 +810,7 @@ export async function seaEnhanced( nodePaths.map(async (nodePath, i) => { const target = opts.targets[i]; await bake(nodePath, target, blobData); - await signMacOSIfNeeded(target.output!, target, opts.signature); + await signMacOSIfNeeded(target.output!, target, opts.signature, true); }), ); }); @@ -722,7 +849,7 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { await generateSeaBlob( seaConfigFilePath, - pickBlobGeneratorBinary(resolveMinTargetMajor(opts.targets), nodePaths), + await pickBlobGeneratorBinary(opts.targets, nodePaths, opts), ); const blobData = await readFile(blobPath); @@ -731,7 +858,7 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { nodePaths.map(async (nodePath, i) => { const target = opts.targets[i]; await bake(nodePath, target, blobData); - await signMacOSIfNeeded(target.output!, target, opts.signature); + await signMacOSIfNeeded(target.output!, target, opts.signature, true); }), ); }); diff --git a/lib/types.ts b/lib/types.ts index 4a3a7310..13dcfb2a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -61,6 +61,37 @@ export const platform = { linux: 'linux', }; +/** + * Canonical Node.js version string as produced by nodejs.org/dist and + * `process.version`: `v..`. Always v-prefixed — + * downstream consumers rely on the prefix to build archive filenames + * (`node-v22.22.2-linux-x64.tar.gz`) and to compare against + * `process.version`. + */ +export type NodeVersion = `v${number}.${number}.${number}`; + +/** + * pkg's `nodeRange` format: `node` (e.g. `node22`, + * `node22.22.2`). Matches `NodeTarget.nodeRange` by convention. + */ +export type NodeRange = `node${string}`; + +/** OS segment used in nodejs.org archive filenames. */ +export const NODE_OSES = ['darwin', 'linux', 'win'] as const; +export type NodeOs = (typeof NODE_OSES)[number]; + +/** Arch segment used in nodejs.org archive filenames. */ +export const NODE_ARCHS = [ + 'x64', + 'arm64', + 'armv7l', + 'ppc64', + 's390x', + 'riscv64', + 'loong64', +] as const; +export type NodeArch = (typeof NODE_ARCHS)[number]; + export interface NodeTarget { nodeRange: string; arch: string; diff --git a/prelude/sea-vfs-setup.js b/prelude/sea-vfs-setup.js index 6f8cc2b1..94a34ea5 100644 --- a/prelude/sea-vfs-setup.js +++ b/prelude/sea-vfs-setup.js @@ -195,13 +195,12 @@ perf.end('manifest parse'); // match the non-slashed manifest keys. The root '/' is preserved as-is. // Mirrors removeTrailingSlashes() in lib/common.ts, which handles the same // case for the classic (non-SEA) bootstrap. +// +// Only '/' is checked: the Windows branch below normalizes '\' to '/' before +// calling this, so by the time we reach here every separator is a '/'. function _stripTrailingSeps(p) { var i = p.length; - while (i > 1) { - var c = p.charCodeAt(i - 1); - if (c !== 47 /* / */ && c !== 92 /* \\ */) break; - i--; - } + while (i > 1 && p.charCodeAt(i - 1) === 47 /* / */) i--; return i === p.length ? p : p.slice(0, i); } var toManifestKey = diff --git a/test/test-00-sea-picker/main.js b/test/test-00-sea-picker/main.js new file mode 100644 index 00000000..d557e5e6 --- /dev/null +++ b/test/test-00-sea-picker/main.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +'use strict'; + +// Unit test for the SEA blob-generator selection logic introduced to +// fix discussion #236: the generator node binary must be version-matched +// to every target binary pkg will inject into, otherwise the final SEA +// crashes at startup in node::sea::FindSingleExecutableResource. +// +// The full pipeline is covered by test-00-sea; this test isolates +// step 1 (host-matching) so regressions surface without a full build. + +const assert = require('assert'); +const { pickMatchingHostTargetIndex } = require('../../lib-es5/sea'); + +// Exact platform+arch match → return that target's index so its already +// downloaded binary (version-identical to the one being injected into) +// is reused as the generator. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, [ + { platform: 'linux', arch: 'x64' }, + { platform: 'macos', arch: 'arm64' }, + ]), + 0, +); + +// Host matches the second target, not the first. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, [ + { platform: 'macos', arch: 'arm64' }, + { platform: 'linux', arch: 'x64' }, + { platform: 'win', arch: 'x64' }, + ]), + 1, +); + +// Pure cross-platform build (Linux host, no Linux target in the list) — +// no platform match, forcing the host-platform download fallback in +// pickBlobGeneratorBinary. Multiple non-host targets included to make +// sure none of them accidentally match. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, [ + { platform: 'macos', arch: 'arm64' }, + { platform: 'win', arch: 'x64' }, + ]), + -1, +); + +// Same platform, different arch — NOT a match. Historically pkg would +// have used nodePaths[0] here (a cross-arch binary) and failed to spawn. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'macos', arch: 'arm64' }, [ + { platform: 'macos', arch: 'x64' }, + ]), + -1, +); + +// Alpine hosts report hostPlatform='alpine' (from @yao-pkg/pkg-fetch), +// which never equals any user-visible target platform ('linux', +// 'linuxstatic', 'macos', 'win'). This drives alpine builds through the +// fallback path, where pickBlobGeneratorBinary's version-safety check +// either accepts process.execPath (same version) or throws. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'alpine', arch: 'x64' }, [ + { platform: 'linux', arch: 'x64' }, + { platform: 'linuxstatic', arch: 'x64' }, + ]), + -1, +); + +// Empty targets → -1. Defensive; pkg enforces at least one target +// elsewhere, but the helper must not throw on an empty list. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, []), + -1, +); + +console.log('sea-picker: ok'); diff --git a/test/test.js b/test/test.js index d9b1abe0..d5605a29 100644 --- a/test/test.js +++ b/test/test.js @@ -76,6 +76,7 @@ const npmTests = [ // SEA tests — they ignore the target argument (always build for the host // Node version), so running them in both test:22 and test:24 is redundant. 'test-00-sea', + 'test-00-sea-picker', 'test-85-sea-enhanced', 'test-86-sea-assets', 'test-87-sea-esm',