diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index f2f8d1ef54..234121a512 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -1,5 +1,11 @@ import * as t from "@babel/types"; +import { + semanticStaticPropertyKey, + semanticWriteSiteTarget, + semanticWriteTargets, + unwrapJavaScriptExpression, +} from "./javascript/javascriptAstValues.js"; import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js"; import { semanticBindingId, @@ -49,6 +55,22 @@ export const unshadowedReferenceSourceRequireCalls = ( (reference.resolution === "unbound" || reference.bindingId === programRequireBinding), ); + const isLoaderReference = (identifier: t.Identifier): boolean => { + const start = identifier.loc?.start; + const reference = + start === undefined + ? undefined + : references.get(`${start.line}:${start.column}`); + return ( + (allowsUnboundRequire && reference?.resolution === "unbound") || + (reference?.resolution === "resolved" && + reference.bindingId === programRequireBinding) + ); + }; + const loaderMembersIntact = requireLoaderMembersIntact( + file.program, + isLoaderReference, + ); const calls = new Set(); traverseJavaScriptAst(file.program, { @@ -64,6 +86,9 @@ export const unshadowedReferenceSourceRequireCalls = ( ) ) return; + // `require.resolve` and `require.main` denote Node's members only + // while no visible write or escape can have replaced them. + if (!t.isIdentifier(node.callee) && !loaderMembersIntact) return; const reference = references.get(`${start.line}:${start.column}`); if ( (allowsUnboundRequire && @@ -80,6 +105,68 @@ export const unshadowedReferenceSourceRequireCalls = ( return calls; }; +/** + * Whether the loader's `resolve` and `main` members keep Node's identity. A + * write to either member or to a computed member, or `require` used as a + * value other than a call or member read (where a mutator or alias can + * replace members), leaves that identity unknown. + */ +const requireLoaderMembersIntact = ( + program: t.Program, + isLoaderReference: (identifier: t.Identifier) => boolean, +): boolean => { + let intact = true; + const isLoaderMemberTarget = (target: t.Node): boolean => { + if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target)) + return false; + const object = unwrapJavaScriptExpression(target.object).node; + if (!t.isIdentifier(object, { name: "require" })) return false; + if (!isLoaderReference(object)) return false; + const key = semanticStaticPropertyKey(target.property, target.computed); + return key === null || key === "resolve" || key === "main"; + }; + traverseJavaScriptAst(program, { + enter: (node, parent, readAncestors) => { + if (!intact) return; + const site = semanticWriteSiteTarget(node); + if (site !== null) { + for (const target of semanticWriteTargets(site)) + if (isLoaderMemberTarget(target)) intact = false; + return; + } + if ( + !t.isIdentifier(node, { name: "require" }) || + parent === null || + !isLoaderReference(node) + ) + return; + // Inert parentheses and TypeScript wrappers do not change how the + // loader value is used. + let used: t.Node = node; + let user: t.Node | undefined = parent; + const read = readAncestors(); + const ancestors = read.at(-1) === parent ? read : [...read, parent]; + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + user = ancestors[index]; + if ( + user === undefined || + unwrapJavaScriptExpression(user).node !== node + ) + break; + used = user; + } + const usedAsLoader = + ((t.isCallExpression(user) || t.isOptionalCallExpression(user)) && + user.callee === used) || + ((t.isMemberExpression(user) || t.isOptionalMemberExpression(user)) && + user.object === used) || + t.isUnaryExpression(user, { operator: "typeof" }); + if (!usedAsLoader) intact = false; + }, + }); + return intact; +}; + const contains = (container: t.Node, node: t.Node): boolean => typeof container.start === "number" && typeof container.end === "number" && diff --git a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts index 8c438e6473..0c88a38014 100644 --- a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts @@ -119,3 +119,67 @@ it("resolves require calls by lexical binding and source mode", async () => { ["top-level-var.cjs", "loaded.cjs"], ]); }); + +it("does not attribute overwritten require members to Node's resolver", async () => { + const root = await createTestTempDirectory("rea-reference-require-member-"); + const sources: Record = { + "assigned.cjs": + "require.resolve = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));", + "defined.cjs": + "Object.defineProperty(require, 'resolve', { value: () => 'custom' });\nconsole.log(require.resolve('./loaded.cjs'));", + "computed.cjs": + "const key = 'resolve';\nrequire[key] = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));", + "control.cjs": "console.log(typeof require.resolve('./loaded.cjs'));", + // A member overwrite does not change bare loader calls. + "bare-after-overwrite.cjs": + "require.resolve = () => 'custom';\nconsole.log(require('./loaded.cjs'));", + // A local loader's member write does not touch Node's loader. + "local-loader.cjs": + "function patch(require) { require.resolve = () => 'custom'; }\npatch({});\nconsole.log(typeof require.resolve('./loaded.cjs'));", + // Optional calls and inert wrappers do not let the loader escape. + "optional-call.cjs": + "require?.('./loaded.cjs');\nconsole.log(typeof (require).resolve('./loaded.cjs'));", + "loaded.cjs": "module.exports = 'real';\n", + }; + await Promise.all( + Object.entries(sources).map(([path, source]) => + writeFile(join(root, path), source), + ), + ); + const runNode = promisify(execFile); + for (const [path, stdout] of [ + ["assigned.cjs", "custom\n"], + ["defined.cjs", "custom\n"], + ["computed.cjs", "custom\n"], + ["control.cjs", "string\n"], + ["bare-after-overwrite.cjs", "real\n"], + ["local-loader.cjs", "string\n"], + ["optional-call.cjs", "string\n"], + ] as const) + expect( + ( + await runNode(process.execPath, [join(root, path)], { + cwd: root, + timeout: 5_000, + }) + ).stdout, + ).toBe(stdout); + + const result = await importReferenceSource({ + root, + caller: "reference-require-member-test", + policy: { secretPatterns: [] }, + }); + if (!result.ok) throw result.error; + expect( + result.value.relationships + .filter(({ kind }) => kind === "requires") + .map(({ from_path, to }) => [from_path, to]) + .sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")), + ).toEqual([ + ["bare-after-overwrite.cjs", "loaded.cjs"], + ["control.cjs", "loaded.cjs"], + ["local-loader.cjs", "loaded.cjs"], + ["optional-call.cjs", "loaded.cjs"], + ]); +});