fix(reference): stop attributing overwritten require members to Node (#3801)
* fix(reference): stop attributing overwritten require members to Node import-reference-source treated require.resolve(...) and require.main(...) as Node's resolver even after the file replaced that member, so a literal argument became a parsed internal dependency that the source never loads. Attribute member calls only while the loader's resolve and main members keep Node's identity: no write to either member or a computed member (assignment, update, delete, destructuring) and no use of require as a value where a mutator or alias could replace them. Bare require(...) calls are unaffected. Fixes #3393 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(reference): keep optional and wrapped loader uses intact An optional call require?.(...) or a TypeScript or parenthesis wrapper around require was read as require escaping as a value, which disabled every member-call link in the file. Look through inert wrappers and accept optional calls before deciding the loader escaped, and add controls for bare calls after a member overwrite, a local loader's member write, and optional calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af6a998066
commit
4692bb3330
2 files changed
+151
No files matched your search
@@ -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<t.CallExpression>();
|
||||
|
||||
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" &&
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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"],
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user