mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
fix(javascript): keep strict module checks unproven when alias walks exhaust their budget (#9387)
* fix(javascript): keep strict module checks unproven when alias walks exhaust their budget Fixes #8201. * fix(javascript): examine queued module sources after a strict walk exhausts its budget A strict check that exhausts the alias budget stopped the whole walk, so a module branch queued beside the growing alias was never examined. Stop expanding instead, and drain the queued sources. * fix(javascript): drain exhausted strict walks by selected keys and finite expressions After exhaustion the limiter widens every key, so draining by normalized paths could match process.foo as mainModule. Keep each queued source's selected keys, expand finite expressions such as sequences and conditionals, and stop only at bindings and calls. * fix(javascript): follow bindings and calls breadth first in exhausted strict walks An exhausted strict walk skipped every binding and call, so (flag ? m : r).require with const m = module lost the module branch when r was explored first. Drain the queue breadth first after exhaustion and follow bindings and calls under a second alias selection budget, so a growing alias cannot starve a sibling. * fix(javascript): bound every state a strict walk creates after exhaustion After the alias budget is exhausted, paths are no longer normalized, so a cycle through containers or getters can keep growing its selected path without crossing a binding or call. Count each new state against the drain budget instead of only binding and call follow-ups. * fix(javascript): retain uncertainty at alias cutoff --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
2 files changed
+233
-51
No files matched your search
@@ -118,7 +118,7 @@ const commonJsDelegateChangedAt = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean =>
|
||||
writesModuleRequire(state).some(
|
||||
(write) => semanticEffectTiming(node, write, state) !== "after",
|
||||
(write) => semanticEffectTiming(node, write.node, state) !== "after",
|
||||
);
|
||||
|
||||
const commonJsLoaderAt = (
|
||||
@@ -1003,9 +1003,14 @@ const globalProcessReplacements = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
readonly (t.Node | undefined)[]
|
||||
>();
|
||||
type ModuleSourceMatch = "possible" | "no-match" | "incomplete";
|
||||
type ModuleRequireWrite = {
|
||||
readonly node: t.Node;
|
||||
readonly resolution: "possible" | "incomplete";
|
||||
};
|
||||
const moduleRequireWrites = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
readonly t.Node[]
|
||||
readonly ModuleRequireWrite[]
|
||||
>();
|
||||
const moduleReturnedReferences = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
@@ -1144,7 +1149,10 @@ const isCommonJsModuleObject = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
includeUnknown = true,
|
||||
): boolean => hasCommonJsModuleSource(node, state, includeUnknown, "module");
|
||||
): boolean => {
|
||||
const result = hasCommonJsModuleSource(node, state, includeUnknown, "module");
|
||||
return result === "possible" || (includeUnknown && result === "incomplete");
|
||||
};
|
||||
|
||||
/** A captured delegate uses the same receiver and assignment ownership facts. */
|
||||
export const isCommonJsRequireDelegate = (
|
||||
@@ -1153,7 +1161,8 @@ export const isCommonJsRequireDelegate = (
|
||||
includeUnknown = false,
|
||||
readAt?: t.Node,
|
||||
): boolean =>
|
||||
hasCommonJsModuleSource(node, state, includeUnknown, "delegate", readAt);
|
||||
hasCommonJsModuleSource(node, state, includeUnknown, "delegate", readAt) ===
|
||||
"possible";
|
||||
|
||||
const hasCommonJsModuleSource = (
|
||||
node: t.Node,
|
||||
@@ -1161,7 +1170,7 @@ const hasCommonJsModuleSource = (
|
||||
includeUnknown: boolean,
|
||||
target: "module" | "delegate",
|
||||
readAt?: t.Node,
|
||||
): boolean => {
|
||||
): ModuleSourceMatch => {
|
||||
type Work = ModuleObjectSource & {
|
||||
/** Await occurs after this many requested outer property selections. */
|
||||
readonly awaitDepths: readonly number[];
|
||||
@@ -1182,7 +1191,18 @@ const hasCommonJsModuleSource = (
|
||||
>();
|
||||
const examined = new WeakSet<object>();
|
||||
let additionalStates = 0;
|
||||
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
||||
// A strict check that exhausts the budget examines the queued sources by
|
||||
// their own selected keys, breadth first, so a growing alias cannot starve a
|
||||
// sibling. One may still be a retained module identity, possibly behind a
|
||||
// binding, call, container or getter, so a second budget bounds every state
|
||||
// created after exhaustion. Unnormalized paths can grow without a binding.
|
||||
let exhausted = false;
|
||||
let drained = 0;
|
||||
for (
|
||||
let next = pending.pop();
|
||||
next !== undefined;
|
||||
next = exhausted ? pending.shift() : pending.pop()
|
||||
) {
|
||||
const value = unwrapJavaScriptExpression(next.node).node;
|
||||
const { awaitDepths } = next;
|
||||
const byMode =
|
||||
@@ -1195,19 +1215,27 @@ const hasCommonJsModuleSource = (
|
||||
byMode.set(modeKey, context);
|
||||
contexts.set(value, byMode);
|
||||
}
|
||||
const path = selections.normalize(context, next.path);
|
||||
if (selections.limited) return true;
|
||||
let path = exhausted ? next.path : selections.normalize(context, next.path);
|
||||
if (selections.limited && !exhausted) {
|
||||
if (includeUnknown) return "possible";
|
||||
exhausted = true;
|
||||
// The limiter widens every key after exhaustion. Keep the selected keys.
|
||||
path = next.path;
|
||||
}
|
||||
const pathKey = JSON.stringify(path);
|
||||
let identity = context.identities.get(pathKey);
|
||||
if (identity === undefined) {
|
||||
// Consumption contexts must not provide an unbounded second path to
|
||||
// expansion beyond the shared selector budget.
|
||||
if (
|
||||
if (exhausted) {
|
||||
if (++drained > SEMANTIC_ALIAS_SELECTION_LIMIT) return "incomplete";
|
||||
} else if (
|
||||
(byMode.size > 1 || context.identities.size > 0) &&
|
||||
++additionalStates > SEMANTIC_ALIAS_SELECTION_LIMIT
|
||||
) {
|
||||
state.valueResourceLimits.add("alias-selections");
|
||||
return true;
|
||||
if (includeUnknown) return "possible";
|
||||
exhausted = true;
|
||||
}
|
||||
identity = {};
|
||||
context.identities.set(pathKey, identity);
|
||||
@@ -1221,10 +1249,18 @@ const hasCommonJsModuleSource = (
|
||||
...sources: ModuleObjectSource[]
|
||||
) => {
|
||||
pending.push(
|
||||
...inheritLoaderSourceContext(next, sources).map((source) => ({
|
||||
...source,
|
||||
awaitDepths: depths,
|
||||
})),
|
||||
...inheritLoaderSourceContext(next, sources)
|
||||
// These leaves cannot contribute a module source and the walker
|
||||
// would discard them without expanding their selected paths.
|
||||
.filter((source) => {
|
||||
const value = unwrapJavaScriptExpression(source.node).node;
|
||||
return (
|
||||
!t.isNumericLiteral(value) &&
|
||||
!t.isBooleanLiteral(value) &&
|
||||
!t.isNullLiteral(value)
|
||||
);
|
||||
})
|
||||
.map((source) => ({ ...source, awaitDepths: depths })),
|
||||
);
|
||||
};
|
||||
const enqueue = (...sources: ModuleObjectSource[]) => {
|
||||
@@ -1292,7 +1328,7 @@ const hasCommonJsModuleSource = (
|
||||
value.callee,
|
||||
t.isNewExpression(value) ? undefined : value,
|
||||
);
|
||||
if (returnedResult.unresolvedInvocation) return true;
|
||||
if (returnedResult.unresolvedInvocation) return "possible";
|
||||
enqueue(
|
||||
...returnedResult.references.map((source) => ({
|
||||
node: source.node,
|
||||
@@ -1319,7 +1355,7 @@ const hasCommonJsModuleSource = (
|
||||
(key) =>
|
||||
key === null || semanticPropertyPathKeyMatches(key, "require"),
|
||||
));
|
||||
if (processMainModulePath) return true;
|
||||
if (processMainModulePath) return "possible";
|
||||
const mainModulePath =
|
||||
value.name === "require" &&
|
||||
state.program.sourceType === "script" &&
|
||||
@@ -1339,7 +1375,7 @@ const hasCommonJsModuleSource = (
|
||||
binding === undefined &&
|
||||
!semanticResolutionBlocked(state, value, "require")
|
||||
)
|
||||
return true;
|
||||
return "possible";
|
||||
const copyAdmitsModuleDelegate =
|
||||
value.name !== "module" ||
|
||||
target !== "delegate" ||
|
||||
@@ -1362,7 +1398,7 @@ const hasCommonJsModuleSource = (
|
||||
)) &&
|
||||
!semanticResolutionBlocked(state, value, "module")
|
||||
)
|
||||
return true;
|
||||
return "possible";
|
||||
continue;
|
||||
}
|
||||
if (binding === undefined) continue;
|
||||
@@ -1383,7 +1419,7 @@ const hasCommonJsModuleSource = (
|
||||
binding.declarationKinds.has("var") &&
|
||||
!read.hasDefiniteInitializer
|
||||
)
|
||||
return true;
|
||||
return "possible";
|
||||
if (
|
||||
value.name === "module" &&
|
||||
copyAdmitsModuleDelegate &&
|
||||
@@ -1397,7 +1433,7 @@ const hasCommonJsModuleSource = (
|
||||
commonJsWrapperBindingDeclaration(value, "module", state) !== undefined &&
|
||||
!read.hasDefiniteInitializer
|
||||
)
|
||||
return true;
|
||||
return "possible";
|
||||
let eligibility = next.eligibility;
|
||||
const facts = eligibility
|
||||
? undefined
|
||||
@@ -1410,7 +1446,8 @@ const hasCommonJsModuleSource = (
|
||||
next.readAt ?? value,
|
||||
next.capturedSelections,
|
||||
);
|
||||
if (retainsMainModule && (eligibility || facts?.retainsOrigin)) return true;
|
||||
if (retainsMainModule && (eligibility || facts?.retainsOrigin))
|
||||
return "possible";
|
||||
let sources = eligibility
|
||||
? semanticProjectedSourceEligibilityAtRead(
|
||||
binding,
|
||||
@@ -1422,7 +1459,7 @@ const hasCommonJsModuleSource = (
|
||||
)
|
||||
: facts?.sources;
|
||||
if (sources === undefined) {
|
||||
if (target !== "delegate") return true;
|
||||
if (target !== "delegate") return "possible";
|
||||
// An opaque member effect can obscure a selected native delegate, but
|
||||
// cannot make every escaped callable a possible Node loader. Keep its
|
||||
// receiver's retained sources as the eligibility boundary for that gap.
|
||||
@@ -1434,7 +1471,7 @@ const hasCommonJsModuleSource = (
|
||||
next.readAt ?? value,
|
||||
next.capturedSelections,
|
||||
);
|
||||
if (sources === undefined) return true;
|
||||
if (sources === undefined) return "possible";
|
||||
eligibility = true;
|
||||
}
|
||||
enqueue(
|
||||
@@ -1443,7 +1480,7 @@ const hasCommonJsModuleSource = (
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
return "no-match";
|
||||
};
|
||||
|
||||
/** Select an unchecked member-loader receiver before its identity is examined. */
|
||||
@@ -1504,18 +1541,20 @@ export const semanticModuleRequireCallAt = (
|
||||
|
||||
const writesModuleRequire = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly t.Node[] => {
|
||||
): readonly ModuleRequireWrite[] => {
|
||||
const cached = moduleRequireWrites.get(state);
|
||||
if (cached !== undefined) return cached;
|
||||
// Interop invocation resolution may ask for require provenance while this
|
||||
// index is being built. Publish a temporary empty result to break that cycle.
|
||||
moduleRequireWrites.set(state, []);
|
||||
const writes: t.Node[] = [];
|
||||
const writes: ModuleRequireWrite[] = [];
|
||||
const interop = createSemanticModuleInterop(state);
|
||||
const isModuleObject = (node: t.Node): boolean =>
|
||||
// Only a retained module identity can revoke the host delegate. Escaping
|
||||
// an unrelated local table is not evidence that it became the host module.
|
||||
isCommonJsModuleObject(node, state, false);
|
||||
const isModuleObject = (node: t.Node): ModuleSourceMatch =>
|
||||
hasCommonJsModuleSource(node, state, false, "module");
|
||||
const record = (node: t.Node, receiver: t.Node): void => {
|
||||
const resolution = isModuleObject(receiver);
|
||||
if (resolution !== "no-match") writes.push({ node, resolution });
|
||||
};
|
||||
traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, {
|
||||
enter: (node) => {
|
||||
const target = t.isAssignmentExpression(node)
|
||||
@@ -1529,11 +1568,8 @@ const writesModuleRequire = (
|
||||
t.isOptionalMemberExpression(target)
|
||||
) {
|
||||
const key = semanticStaticPropertyKey(target.property, target.computed);
|
||||
if (
|
||||
(key === "require" || (key === null && target.computed)) &&
|
||||
isModuleObject(target.object)
|
||||
)
|
||||
writes.push(node);
|
||||
if (key === "require" || (key === null && target.computed))
|
||||
record(node, target.object);
|
||||
}
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||
const invocation = interop.intrinsicInvocation(node);
|
||||
@@ -1543,16 +1579,12 @@ const writesModuleRequire = (
|
||||
const args = invocation?.arguments;
|
||||
if (
|
||||
(owner === "Object" || owner === "Reflect") &&
|
||||
method === "defineProperty" &&
|
||||
isModuleObject(args?.[0] ?? t.nullLiteral())
|
||||
method === "defineProperty"
|
||||
) {
|
||||
const key = interop.staticString(args?.[1]);
|
||||
if (key === "require" || key === undefined) writes.push(node);
|
||||
} else if (
|
||||
owner === "Object" &&
|
||||
method === "defineProperties" &&
|
||||
isModuleObject(args?.[0] ?? t.nullLiteral())
|
||||
) {
|
||||
if (key === "require" || key === undefined)
|
||||
record(node, args?.[0] ?? t.nullLiteral());
|
||||
} else if (owner === "Object" && method === "defineProperties") {
|
||||
const descriptors = args?.[1];
|
||||
if (
|
||||
!t.isObjectExpression(descriptors) ||
|
||||
@@ -1564,14 +1596,10 @@ const writesModuleRequire = (
|
||||
"require"),
|
||||
)
|
||||
)
|
||||
writes.push(node);
|
||||
} else if (
|
||||
owner === "Object" &&
|
||||
method === "assign" &&
|
||||
args?.some((arg) => isModuleObject(arg))
|
||||
) {
|
||||
record(node, args?.[0] ?? t.nullLiteral());
|
||||
} else if (owner === "Object" && method === "assign") {
|
||||
const target = args?.[0];
|
||||
if (target !== undefined && isModuleObject(target)) {
|
||||
if (target !== undefined) {
|
||||
const sourceHasRequire = args
|
||||
?.slice(1)
|
||||
.some(
|
||||
@@ -1590,7 +1618,7 @@ const writesModuleRequire = (
|
||||
sourceHasRequire ||
|
||||
args?.slice(1).some((arg) => !t.isObjectExpression(arg))
|
||||
)
|
||||
writes.push(node);
|
||||
record(node, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,3 +156,157 @@ it("revokes the host delegate after a destructured module receiver write", () =>
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("revokes the host delegate after a dynamic defineProperty on module", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
`Object.defineProperty(module,key,{value:x});require('./shared.cjs');`,
|
||||
);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
// Zod's issue formatter (`r = s` after `Object.defineProperty(r, k, …)`)
|
||||
// exhausts the alias-selection budget. A strict module check stays unproven.
|
||||
const loopAliasedPropertyWalk = (defineProperty: boolean): string => {
|
||||
const step = defineProperty
|
||||
? "Object.prototype.hasOwnProperty.call(r,k)||Object.defineProperty(r,k,{value:{_errors:[]}});"
|
||||
: "Object.prototype.hasOwnProperty.call(r,k);";
|
||||
return `let h=require("electron");function f(n,e){let r=n,i=0;for(;i<e.length;){let k=e[i];${step}let s=r[k];r=s,i++}return r}new h.BrowserWindow({webPreferences:{sandbox:!0}});h.ipcMain.handle(\`x:y\`,()=>1);`;
|
||||
};
|
||||
|
||||
const expectElectronProbe = (
|
||||
source: string,
|
||||
): ReturnType<typeof analyzeJavaScriptStaticSource> => {
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(1);
|
||||
expect(analysis.electron.ipc).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ channel: "x:y", side: "main" }),
|
||||
]),
|
||||
);
|
||||
expect(analysis.references).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "require", specifier: "electron" }),
|
||||
]),
|
||||
);
|
||||
return analysis;
|
||||
};
|
||||
|
||||
it("keeps a possible defineProperty write unknown when alias selections are exhausted", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(loopAliasedPropertyWalk(true));
|
||||
expect(analysis.electron.browser_windows).toHaveLength(0);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
expect(analysis.analysis_coverage).toMatchObject({
|
||||
examination: {
|
||||
semantic_resource_limits: expect.arrayContaining(["alias-selections"]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps electron facts for the same path walk without defineProperty", () => {
|
||||
expectElectronProbe(loopAliasedPropertyWalk(false));
|
||||
});
|
||||
|
||||
// A strict check that exhausts the budget still examines queued sources by
|
||||
// their selected keys, so the result does not depend on branch order.
|
||||
const exhaustedWalkWrite = (receiver: string): string =>
|
||||
`const m=module,g=()=>module;function f(n,e,flag){let r=n,i=0;for(;i<e.length;){let k=e[i];Object.prototype.hasOwnProperty.call(r,k)||Object.defineProperty(r,k,{value:{_errors:[]}});let s=r[k];r=s,i++}${receiver}.require=replacement;return r}let h=require("electron");new h.BrowserWindow({});`;
|
||||
|
||||
it.each([
|
||||
"(flag?module:r)",
|
||||
"(flag?r:module)",
|
||||
"(flag?(0,module):r)",
|
||||
"(flag?r:(0,module))",
|
||||
"(flag?process.mainModule:r)",
|
||||
"(flag?r:process.mainModule)",
|
||||
"(flag?m:r)",
|
||||
"(flag?r:m)",
|
||||
"(flag?g():r)",
|
||||
"(flag?r:g())",
|
||||
])(
|
||||
"revokes the host delegate through %s after alias selections are exhausted",
|
||||
(receiver) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
exhaustedWalkWrite(receiver),
|
||||
);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(0);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
"does not certify a loader when the final drain leaves a module source pending (reversed: %s)",
|
||||
(reversed) => {
|
||||
const array = `[${Array.from({ length: 520 }, () => "0").join(",")},module][key]`;
|
||||
const receiver = reversed ? `(flag?r:${array})` : `(flag?${array}:r)`;
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
exhaustedWalkWrite(receiver),
|
||||
);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(0);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
"keeps the loader unknown when identifier alternatives exhaust the final drain (reversed: %s)",
|
||||
(reversed) => {
|
||||
const array = `[${Array.from({ length: 520 }, () => "zero").join(",")},module][key]`;
|
||||
const receiver = reversed ? `(flag?r:${array})` : `(flag?${array}:r)`;
|
||||
const source = `const zero=0;${exhaustedWalkWrite(receiver)}`;
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
expect(
|
||||
analysis.analysis_coverage?.examination.semantic_resource_limits,
|
||||
).toContain("alias-selections");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["defineProperty", "assign"])(
|
||||
"keeps a possible Object.%s write unknown when receiver analysis is incomplete",
|
||||
(operation) => {
|
||||
const array = `[${Array.from({ length: 520 }, () => "zero").join(",")},module][key]`;
|
||||
const receiver = `(flag?${array}:r)`;
|
||||
const write =
|
||||
operation === "defineProperty"
|
||||
? `Object.defineProperty(${receiver},"require",{value:replacement})`
|
||||
: `Object.assign(${receiver},{require:replacement})`;
|
||||
const source = `const zero=0;${exhaustedWalkWrite(receiver).replace(`${receiver}.require=replacement`, write)}`;
|
||||
const analysis = analyzeJavaScriptStaticSource(source);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
expect(
|
||||
analysis.analysis_coverage?.examination.semantic_resource_limits,
|
||||
).toContain("alias-selections");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["(flag?process.foo:r)", "(flag?r:process.foo)"])(
|
||||
"keeps the delegate unknown through %s after alias selections are exhausted",
|
||||
(receiver) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(
|
||||
exhaustedWalkWrite(receiver),
|
||||
);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.electron.browser_windows).toHaveLength(0);
|
||||
expect(analysis.references).toEqual([
|
||||
expect.objectContaining({ kind: "require", specifier: null }),
|
||||
]);
|
||||
expect(
|
||||
analysis.analysis_coverage?.examination.semantic_resource_limits,
|
||||
).toContain("alias-selections");
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user