mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
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:
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user