diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index bab5f15a1..fae8aa219 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -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); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 4b6b0acdf..6594d8175 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -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(); /** 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 }) }; } diff --git a/packages/compiler/src/node-islands/libnode.test.ts b/packages/compiler/src/node-islands/libnode.test.ts index cd310e0e1..94add4f1c 100644 --- a/packages/compiler/src/node-islands/libnode.test.ts +++ b/packages/compiler/src/node-islands/libnode.test.ts @@ -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", diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index a0ae29d2f..dc6498924 100644 --- a/packages/compiler/src/node-islands/libnode.ts +++ b/packages/compiler/src/node-islands/libnode.ts @@ -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; diff --git a/packages/compiler/src/node-islands/modules.test.ts b/packages/compiler/src/node-islands/modules.test.ts index 6badf376a..63201e1e0 100644 --- a/packages/compiler/src/node-islands/modules.test.ts +++ b/packages/compiler/src/node-islands/modules.test.ts @@ -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(); diff --git a/packages/compiler/src/node-islands/modules.ts b/packages/compiler/src/node-islands/modules.ts index 0d63dc333..e1551c070 100644 --- a/packages/compiler/src/node-islands/modules.ts +++ b/packages/compiler/src/node-islands/modules.ts @@ -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); } diff --git a/tests/corpus/7662-node-call-exit-events/main.ts b/tests/corpus/7662-node-call-exit-events/main.ts index 909550588..15bb6ad5d 100644 --- a/tests/corpus/7662-node-call-exit-events/main.ts +++ b/tests/corpus/7662-node-call-exit-events/main.ts @@ -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'