Keep libnode pack manifest paths inside the bundle

This commit is contained in:
Chris Tate
2026-10-23 14:02:28 -05:00
parent c0e24cdb3f
commit 037b0b1dcc
7 changed files with 152 additions and 21 deletions
@@ -17,6 +17,7 @@
* thrown Error crosses as its nearest intrinsic class with its message and
* string `code`. */
import { realpathSync } from "node:fs";
import { isAbsolute } from "node:path";
import * as ts from "../ts7/adapter.js";
import { locOf } from "../program.js";
import type { NodeApiMember } from "../node-api-import.js";
@@ -108,6 +109,12 @@ export function lowerNodeApiCall(
throw new PoisonError();
}
const specifier = lowerer.lowerExprExpecting(specifierNode, STRING);
// An absolute specifier names a file of the build machine: the call names
// the shipped copy, relative to the caller inside the tree.
if (isAbsolute(specifierNode.text) && specifier.kind === "strLit") {
const target = lowerer.nodeIslandModules.resolvedPath(caller, specifierNode.text);
if (target !== null) lowerer.nodeIslandSpecifiers.push({ literal: specifier, caller, target });
}
const name = lowerer.lowerExprExpecting(argNodes[1]!, STRING);
const values = argNodes.slice(2).map((arg) => {
const value = lowerer.lowerExpr(arg);
@@ -3145,6 +3145,12 @@ export class Lowerer {
readonly nodeIslandModules = new NodeIslandModuleCollector();
/** Each call's caller literal, set to its tree path when lowering ends. */
readonly nodeIslandCallers: { literal: IrExpr & { kind: "strLit" }; caller: string }[] = [];
/** Absolute specifiers, rewritten to the shipped file relative to their caller. */
readonly nodeIslandSpecifiers: {
literal: IrExpr & { kind: "strLit" };
caller: string;
target: string;
}[] = [];
/** Per-directory answers of experimental-import.ts's declaration-file test. */
readonly experimentalFiles = new Map<string, boolean>();
/** Interned LifetimeRegion.run helpers, by callback type key. */
@@ -8656,6 +8662,8 @@ export class Lowerer {
const modules = this.nodeIslandModules.tree();
for (const { literal, caller } of this.nodeIslandCallers)
literal.value = NodeIslandModuleCollector.callerPath(modules, caller);
for (const { literal, caller, target } of this.nodeIslandSpecifiers)
literal.value = NodeIslandModuleCollector.relativeSpecifier(caller, target);
return { libnode: "", nodeVersion: "", ...(modules === null ? {} : { modules }) };
}
@@ -1,4 +1,12 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@@ -94,6 +102,39 @@ describe("the recorded libnode pack", () => {
expect(existsSync(join(out, "app.libnode"))).toBe(false);
});
test("beside refuses manifest file names outside the pack", () => {
for (const name of ["..", ".", "../escaped", "/etc/passwd", "a/b", "a\\b", "x\u0000y"]) {
const dir = pack("24.22.0");
writeFileSync(
join(dir, "manifest.json"),
JSON.stringify({
nodeVersion: "24.22.0",
bridgeAbi: NODE_BRIDGE_ABI,
files: { [name]: {} },
}),
);
const out = mkdtempSync(join(tmpdir(), "scriptc-libnode-out-"));
expect(libnodeRecord("beside", join(out, "app"), { SCRIPTC_LIBNODE: dir })).toBe(
`the libnode pack at ${dir} lists a file outside the pack ('${name}')`,
);
expect(existsSync(join(out, "app.libnode"))).toBe(false);
expect(existsSync(join(out, "escaped"))).toBe(false);
}
});
test("beside refuses pack files that are symbolic links", () => {
const dir = pack("24.22.0");
const elsewhere = join(mkdtempSync(join(tmpdir(), "scriptc-libnode-elsewhere-")), "secret");
writeFileSync(elsewhere, "secret");
rmSync(join(dir, "libnode.so.137"));
symlinkSync(elsewhere, join(dir, "libnode.so.137"));
const out = mkdtempSync(join(tmpdir(), "scriptc-libnode-out-"));
expect(libnodeRecord("beside", join(out, "app"), { SCRIPTC_LIBNODE: dir })).toBe(
`the libnode pack at ${dir} holds libnode.so.137 as a symbolic link; pack files must be regular files`,
);
expect(existsSync(join(out, "app.libnode"))).toBe(false);
});
test("a pack the deployment places beside the executable needs no manifest at build time", () => {
expect(libnodeRecord("$ORIGIN/node", "/out/app", {})).toEqual({
libnode: "$ORIGIN/node",
+25 -10
View File
@@ -14,13 +14,14 @@
* also the hook for bundling Node into the executable itself later. */
import { createHash } from "node:crypto";
import {
constants,
copyFileSync,
linkSync,
lstatSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
statSync,
} from "node:fs";
import { basename, isAbsolute, join, resolve, sep } from "node:path";
@@ -76,12 +77,15 @@ function readManifest(dir: string): PackManifest | string {
}
/** Copies the pack next to the executable (hard links where possible), or
* returns why it cannot: the target would replace the pack itself, or the
* manifest names a file outside the pack. */
* returns why it cannot: the target would replace the pack itself, the
* manifest names a file outside the pack, or a pack file is not a regular
* file (a symbolic link could point anywhere). Every destination is checked
* to resolve inside the target directory. */
function placeBeside(source: string, manifest: PackManifest, target: string): string | undefined {
const names = ["manifest.json", "LICENSE", ...Object.keys(manifest.files)];
const unsafe = names.find(
(name) => name === "" || name === "." || name === ".." || /[/\\]/.test(name),
(name) =>
name === "" || name === "." || name === ".." || /[/\\\0]/.test(name) || isAbsolute(name),
);
if (unsafe !== undefined)
return `the libnode pack at ${source} lists a file outside the pack ('${unsafe}')`;
@@ -89,19 +93,30 @@ function placeBeside(source: string, manifest: PackManifest, target: string): st
const to = realpathOr(target);
if (from === to || from.startsWith(to + sep) || to.startsWith(from + sep))
return `--libnode=beside would replace the libnode pack it copies (${source}); point SCRIPTC_LIBNODE at a pack outside ${target}`;
rmSync(target, { recursive: true, force: true });
mkdirSync(target, { recursive: true });
const present: string[] = [];
for (const name of names) {
const file = join(source, name);
let info;
try {
statSync(file);
info = lstatSync(join(source, name));
} catch {
continue;
}
if (!info.isFile())
return `the libnode pack at ${source} holds ${name} as a ${info.isSymbolicLink() ? "symbolic link" : "non-regular file"}; pack files must be regular files`;
present.push(name);
}
rmSync(target, { recursive: true, force: true });
mkdirSync(target, { recursive: true });
const inside = resolve(target) + sep;
for (const name of present) {
const destination = resolve(target, name);
if (!destination.startsWith(inside))
return `the libnode pack at ${source} lists a file outside the pack ('${name}')`;
const file = join(source, name);
try {
linkSync(file, join(target, name));
linkSync(file, destination);
} catch {
copyFileSync(file, join(target, name));
copyFileSync(file, destination, constants.COPYFILE_EXCL);
}
}
return undefined;
@@ -167,6 +167,33 @@ describe("the collected module tree", () => {
]);
});
test("a require through a linked directory keeps the link in the tree", () => {
const root = project({
"src/main.ts": "",
"src/entry.cjs": "module.exports = require('./lib/x.cjs');",
"shared/x.cjs": "exports.x = 1;",
});
symlinkSync("../shared", join(root, "src/lib"));
const collector = new NodeIslandModuleCollector();
expect(collector.add(join(root, "src/main.ts"), "./entry.cjs")).toBeNull();
const tree = collector.tree()!;
expect(tree.links).toEqual([{ path: "src/lib", target: "../shared" }]);
expect(tree.files.map((f) => f.path)).toEqual(["shared/x.cjs", "src/entry.cjs"]);
});
test("an absolute specifier names the shipped file relative to its caller", () => {
const root = project({ "src/main.ts": "", "lib/m.cjs": "" });
const collector = new NodeIslandModuleCollector();
const target = collector.resolvedPath(join(root, "src/main.ts"), join(root, "lib/m.cjs"))!;
expect(target).toBe(join(root, "lib/m.cjs"));
expect(NodeIslandModuleCollector.relativeSpecifier(join(root, "src/main.ts"), target)).toBe(
"../lib/m.cjs",
);
expect(
NodeIslandModuleCollector.relativeSpecifier(join(root, "main.ts"), join(root, "m.cjs")),
).toBe("./m.cjs");
});
test("built-in modules alone ship no tree", () => {
const root = project({ "main.ts": "" });
const collector = new NodeIslandModuleCollector();
+42 -10
View File
@@ -211,14 +211,34 @@ export class NodeIslandModuleCollector {
return null;
}
/** The real path of `path`, recording a link when `path` itself (not an
* ancestor) is a symbolic link. */
/** The real path of `path`, recording a link for each component of it
* that is a symbolic link (a linked directory on the way, or the file
* itself), so that the path resolves inside the tree as it does here. */
private placed(path: string): string {
const target = trackedRealpath(path) ?? path;
const parent = trackedRealpath(dirname(path)) ?? dirname(path);
const link = join(parent, basename(path));
if (link !== target) this.links.set(link, target);
return target;
let real: string = sep;
for (const part of resolve(path).split(sep)) {
if (part === "") continue;
const lexical = join(real, part);
const target = trackedRealpath(lexical) ?? lexical;
if (target !== lexical) this.links.set(lexical, target);
real = target;
}
return real;
}
/** The real path of the file an absolute or relative `specifier` names
* from `fromFile`, or null when there is none. */
resolvedPath(fromFile: string, specifier: string): string | null {
if (!isPathSpecifier(specifier)) return null;
const target = resolveFile(resolve(dirname(fromFile), specifier));
return target === null ? null : (trackedRealpath(target) ?? target);
}
/** A specifier that names `target` from `caller` inside the tree: the
* relative path between them, with `/` separators. */
static relativeSpecifier(caller: string, target: string): string {
const path = portable(relative(dirname(caller), target));
return path.startsWith("../") ? path : `./${path}`;
}
/** A module of the program: collected with the package.json that scopes
@@ -609,17 +629,29 @@ export function placeNodeIslandModules(
): string | null {
const data = readNodeIslandModules(modules);
if (typeof data === "string") return data;
// Every path, and every link's target, stays inside the tree.
const inside = resolve(target) + sep;
const placed = (relativePath: string): string | null => {
const path = resolve(target, ...relativePath.split("/"));
return path.startsWith(inside) ? path : null;
};
for (const entry of [...modules.files, ...modules.links]) {
const path = placed(entry.path);
if (path === null) return `the island module tree names a path outside it ('${entry.path}')`;
if ("target" in entry && !resolve(dirname(path), entry.target).startsWith(inside))
return `the island module tree links outside it ('${entry.path}' to '${entry.target}')`;
}
rmSync(target, { recursive: true, force: true });
mkdirSync(target, { recursive: true });
let offset = 0;
for (const file of modules.files) {
const path = join(target, ...file.path.split("/"));
const path = placed(file.path)!;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, data.subarray(offset, offset + file.size));
writeFileSync(path, data.subarray(offset, offset + file.size), { flag: "wx" });
offset += file.size;
}
for (const link of modules.links) {
const path = join(target, ...link.path.split("/"));
const path = placed(link.path)!;
mkdirSync(dirname(path), { recursive: true });
symlinkSync(link.target, path);
}
@@ -1,3 +1,4 @@
// @exit: 4
// @requires-libnode
// The program and the Node.js it loads end as one process: Node's
// 'beforeExit' and 'exit' run when the program ends, work that 'beforeExit'