mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Keep libnode pack manifest paths inside the bundle
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user