Refuse to evaluate program source in the island deferral probe

The build-time probe loads island package modules to decide their
deferral. A package can require a file outside node_modules (a program
module it shares, or any project file), and that file evaluated on the
build machine. The probe's resolve hook now throws for a file outside
every node_modules directory before it evaluates, so the module that
asked for it stays eager. This also covers a program module passed to the
probe directly.
This commit is contained in:
Chris Tate
2026-10-30 01:18:35 -05:00
parent 6874c3b562
commit e4bc919aec
2 changed files with 86 additions and 2 deletions
@@ -0,0 +1,74 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { PROBE_RECORDER } from "./probe-recorder.js";
const made: string[] = [];
afterEach(() => {
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function project(files: Record<string, string>): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-probe-recorder-")));
made.push(root);
for (const [path, text] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), text);
}
return root;
}
interface ProbeResult {
path: string;
throws?: string;
root?: number;
}
/** Runs the recorder in a Node process of its own, as the probe host runs
* it in libnode, over `modules`. */
function probe(root: string, modules: string[]): ProbeResult[] {
writeFileSync(join(root, "recorder.cjs"), PROBE_RECORDER);
writeFileSync(
join(root, "run.cjs"),
`const out = require("./recorder.cjs").probe({ modules: ${JSON.stringify(modules)} });\n` +
`process.stdout.write(JSON.stringify(out.results));\n`,
);
const run = spawnSync(process.execPath, [join(root, "run.cjs")], { cwd: root, encoding: "utf8" });
expect(run.status, run.stderr).toBe(0);
return JSON.parse(run.stdout) as ProbeResult[];
}
// A source file that, when it evaluates, writes `evaluated-<name>` beside itself.
const marker = (name: string): string =>
`require("fs").writeFileSync(require("path").join(__dirname, "evaluated-${name}"), "");\nmodule.exports = { n: 1 };\n`;
test("the probe evaluates an installed package", () => {
const root = project({
"node_modules/pkg/package.json": '{ "name": "pkg" }',
"node_modules/pkg/index.js": "module.exports = { answer: 42 };\n",
});
const [result] = probe(root, [join(root, "node_modules/pkg/index.js")]);
expect(result!.throws).toBeUndefined();
expect(result!.root).toBeDefined();
});
test("the probe never evaluates the program's own source", () => {
const root = project({
"src/program.js": marker("program"),
"src/shared.js": marker("shared"),
"node_modules/reaches/package.json": '{ "name": "reaches" }',
"node_modules/reaches/index.js": 'module.exports = require("../../src/shared.js");\n',
});
const results = probe(root, [
join(root, "src/program.js"),
join(root, "node_modules/reaches/index.js"),
]);
expect(results.map((r) => r.throws)).toEqual([
`the build does not evaluate ${join(root, "src/program.js")}, a file of the program`,
`the build does not evaluate ${join(root, "src/shared.js")}, a file of the program`,
]);
expect(existsSync(join(root, "src/evaluated-program"))).toBe(false);
expect(existsSync(join(root, "src/evaluated-shared"))).toBe(false);
});
@@ -20,6 +20,8 @@
* (island-local, since native code has its own builtins, but
* observable by other island modules evaluated out of order);
* - every file the load required (the module's closure), and a throw;
* a load that resolves a file outside every node_modules directory (the
* program's own source) throws before that file evaluates;
*
* and takes the interface snapshot of the module's value (snapshot.ts).
* Calls made by Node's own internals (the module loader reading files,
@@ -429,11 +431,19 @@ exports.probe = function probe(input) {
undo.push(() => hook.disable());
// The closure: a resolve hook (no frame of ours stays on the stack while
// modules evaluate, so Node's node_modules checks see the real callers).
// The probe evaluates installed packages only: a file outside every
// node_modules directory is the program's own source (a program module,
// or a project file a package requires), and its load throws before the
// file evaluates, so the module that asked for it stays eager.
const hooks = Module.registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context);
if (recording && typeof result.url === "string" && result.url.startsWith("file:"))
files.add(require("node:url").fileURLToPath(result.url));
if (recording && typeof result.url === "string" && result.url.startsWith("file:")) {
const file = require("node:url").fileURLToPath(result.url);
if (!/[\\/]node_modules[\\/]/.test(file))
throw new Error("the build does not evaluate " + file + ", a file of the program");
files.add(file);
}
return result;
},
});