diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index 2b40efcb4..d9d2d1fae 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -188,7 +188,8 @@ const isRequireCallee = (callee: Node | null | undefined): boolean => { isMemberExpression(callee) && isIdentifier(callee.object) && callee.object.name === "require" && - (isIdentifier(callee.property) || isStringLiteral(callee.property)) + ((!callee.computed && isIdentifier(callee.property)) || + isStringLiteral(callee.property)) ) { const propertyName = isIdentifier(callee.property) ? callee.property.name diff --git a/src/domain/referenceSourceRequireMembers.test.ts b/src/domain/referenceSourceRequireMembers.test.ts new file mode 100644 index 000000000..1b01748aa --- /dev/null +++ b/src/domain/referenceSourceRequireMembers.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { parseReferenceSourceImports } from "./referenceSourceImportParsing.js"; + +const parseRequire = (source: string) => + parseReferenceSourceImports( + "main.cjs", + new TextEncoder().encode(source), + "JavaScript", + ); + +const knownCallees = [ + "require", + "require.resolve", + 'require["resolve"]', + "require.main", + 'require["main"]', +]; + +describe("historical source require member names", () => { + it.each([ + ...knownCallees, + 'require[("resolve")]', + 'require["res\\u006flve"]', + ])( + "retains a literal dependency for %s beside computed members", + (callee) => { + expect( + parseRequire( + `require[resolve]("./ignored.js"); ${callee}("./dep.js");`, + ), + ).toEqual({ + relationships: [ + { + from_path: "main.cjs", + to: "./dep.js", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }, + ], + parse_failures: [], + }); + }, + ); + + it.each([ + 'require[resolve]("./dep.js");', + 'require[main]("./dep.js");', + 'const resolve = "toString"; require[resolve]("./dep.js");', + 'const main = "toString"; require[main]("./dep.js");', + 'const resolve = "resolve"; require[resolve]("./dep.js");', + 'const main = "main"; require[main]("./dep.js");', + 'const method = "resolve"; require[method]("./dep.js");', + 'require[`resolve`]("./dep.js");', + 'require["re" + "solve"]("./dep.js");', + ])("does not infer computed member values in %s", (source) => { + expect(parseRequire(source)).toEqual({ + relationships: [], + parse_failures: [], + }); + }); + + it.each([ + "require?.", + "require.resolve?.", + 'require["resolve"]?.', + "require.main?.", + 'require["main"]?.', + "require?.resolve", + 'require?.["resolve"]', + "require?.[resolve]", + "require?.main", + 'require?.["main"]', + "require?.[main]", + "(require?.resolve)", + "require.resolve.call", + "require.main.require", + "module.require", + "other.resolve", + "other.require", + "require.toString", + 'require["toString"]', + ])("leaves the unrecognized callee %s unchanged", (callee) => { + expect(parseRequire(`${callee}("./dep.js");`)).toEqual({ + relationships: [], + parse_failures: [], + }); + }); + + it.each(knownCallees)("does not guess unknown arguments to %s", (callee) => { + expect( + parseRequire( + [ + 'const target = "./dep.js";', + `${callee}(target);`, + `${callee}(\`./dep.js\`);`, + `${callee}(getTarget());`, + `${callee}();`, + ].join("\n"), + ), + ).toEqual({ relationships: [], parse_failures: [] }); + }); +}); diff --git a/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts new file mode 100644 index 000000000..267e263fb --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts @@ -0,0 +1,92 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("distinguishes computed require member values from literal members in source evidence", async () => { + const root = await createTestTempDirectory("rea-reference-require-members-"); + const sources = { + "computed.cjs": [ + 'const resolve = "toString";', + 'const main = "toString";', + 'const value = require[resolve]("./dep.js");', + 'require[main]("./dep.js");', + "console.log(typeof value);", + ].join("\n"), + "literal.cjs": 'console.log(require["resolve"]("./dep.js"));\n', + "dep.js": 'throw new Error("The dependency must not execute");\n', + "unknown.cjs": [ + "function load(target) {", + " require(target);", + " require.resolve(target);", + ' require["resolve"](target);', + " return import(target);", + "}", + ].join("\n"), + }; + await Promise.all( + Object.entries(sources).map(([path, source]) => + writeFile(join(root, path), source), + ), + ); + + // These owned producers demonstrate the member's runtime meaning without + // loading the throwing dependency. The importer itself never executes them. + const runNode = promisify(execFile); + for (const [path, stdout] of [ + ["computed.cjs", "string\n"], + ["literal.cjs", `${join(root, "dep.js")}\n`], + ] as const) { + const executed = await runNode(process.execPath, [join(root, path)], { + cwd: root, + timeout: 5_000, + }); + expect(executed.stdout).toBe(stdout); + expect(executed.stderr).toBe(""); + } + + const result = await importReferenceSource({ + root, + caller: "reference-require-members-test", + policy: { secretPatterns: [] }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const graph = result.value; + expect(graph.parse_failures).toEqual([]); + expect(graph.relationships).toEqual([ + { + from_path: "literal.cjs", + to: "dep.js", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }, + { + from_path: "unknown.cjs", + to: "", + kind: "imports", + resolution: "unknown", + parse_state: "partial", + }, + ]); + expect(graph.entries).toHaveLength(Object.keys(sources).length); + for (const [path, source] of Object.entries(sources)) { + expect(graph.entries).toContainEqual({ + path, + kind: "file", + sha256: createHash("sha256").update(source).digest("hex"), + size: Buffer.byteLength(source), + language: "JavaScript", + classifications: ["source"], + content_state: "hashed", + limitations: [], + }); + } +});