fix(javascript): preserve bound receivers for intrinsic invocation

This commit is contained in:
morluto committed 2026-10-12 00:39:01 +08:00
1 parent 1e1a611012
commit 3e9bce6249
6 files changed
+95 -70

No files matched your search

+2 -1
View File
@@ -344,7 +344,8 @@ reassignments, dynamic selections, and visibly overwritten namespace members
are unresolved.
Known Object/Reflect mutators remain recognizable through literal destructuring
of intrinsic objects and stable bound-function aliases. Calls retain bound
argument prefixes through `call`, `apply`, and `Reflect.apply`; unexamined
argument prefixes and the original bound receiver through `call`, `apply`, and
`Reflect.apply`, including generic `Function.prototype` invocation; unexamined
targets remain unresolved. Creating an unused binding does not itself mutate
an intrinsic. Variable redeclarations retain a single unconditional initializer
when they introduce no assignment or second initializer.
@@ -546,6 +546,12 @@ describe("Electron module interop bound mutators", () => {
'const change = Object.defineProperty.bind(null,...args); change(Object,"create",{value:customCreate});',
'const metadata = {}; Object.defineProperty.call(...args,metadata,"x",{value:1});',
'const metadata = {}; Object.defineProperty.apply(...args,[metadata,"x",{value:1}]);',
'const change = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});',
'const first = Function.prototype.call.bind(Object.defineProperty,null); const change = first.bind(customReceiver,Object,"create"); change({value:customCreate});',
'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([Object,"create",{value:customCreate}]);',
'Function.prototype.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});',
'Function.prototype.apply.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);',
'const uncurry = Function.prototype.call.bind(Function.prototype.call); uncurry(Object.defineProperty,null,Object,"create",{value:customCreate});',
'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);',
'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});',
'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);',
@@ -562,6 +568,8 @@ describe("Electron module interop bound mutators", () => {
'const change = Object.defineProperty.bind(null); change(metadata,"x",{value:1});',
'const change = Object.defineProperty.bind(null,metadata,"x"); change({value:1});',
'const first = Object.defineProperty.bind(null,metadata); const change = first.bind(null,"x"); change({value:1});',
'const change = Function.prototype.call.bind(Object.defineProperty,null); change(metadata,"x",{value:1});',
'const change = Function.prototype.apply.bind(Object.defineProperty,null); change([metadata,"x",{value:1}]);',
'const change = Object.defineProperty.bind(null,Object,"create");',
'Object.defineProperty.call(null,metadata,"x",{value:1});',
'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);',
@@ -43,6 +43,8 @@ export const electronMemberPath = (
/** A known intrinsic callable and its examined positional arguments. */
export interface JavaScriptIntrinsicInvocation {
/** The original bound receiver; rebinding cannot replace it. */
readonly receiver?: t.Node;
readonly path: readonly string[];
readonly arguments:
| readonly t.CallExpression["arguments"][number][]
@@ -5,11 +5,6 @@ import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
semanticStableInitializer,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
const arrayArguments = (array: t.Node | undefined) =>
t.isArrayExpression(array) &&
@@ -35,62 +30,79 @@ const invocationArguments = (
*/
export const resolveJavaScriptIntrinsicInvocation = (
node: t.CallExpression | t.OptionalCallExpression,
state: JavaScriptSemanticAnalysisState,
intrinsicPath: (node: t.Node) => readonly string[] | undefined,
boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined,
): JavaScriptIntrinsicInvocation | undefined => {
const visited = new Set<string>();
const visited = new Set<t.Node>();
let callee: t.Node = node.callee;
let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments;
let receiver: t.Node | undefined;
for (;;) {
callee = unwrapJavaScriptExpression(callee).node;
if (visited.has(callee)) return undefined;
visited.add(callee);
let path = intrinsicPath(callee);
// Static projections can capture call/apply themselves as aliases.
while (path !== undefined && path.length > 3) {
if (path === undefined) {
if (
t.isMemberExpression(callee) ||
t.isOptionalMemberExpression(callee)
) {
const method = semanticStaticPropertyKey(
callee.property,
callee.computed,
);
if (method === "call" || method === "apply") {
receiver = arguments_?.[0];
callee = callee.object;
arguments_ = invocationArguments(method, arguments_);
continue;
}
}
const bound = boundIntrinsic(callee);
if (bound === undefined) return undefined;
path = bound.path;
receiver = bound.receiver;
arguments_ =
bound.arguments === undefined
? undefined
: [...bound.arguments, ...(arguments_ ?? [])];
}
// A projected .call/.apply invokes the preceding callable. The actual
// Function.prototype methods instead invoke their receiver, retained by bind.
while (path.length > 3 && !genericInvocationMethod(path)) {
const method = path.at(-1);
if (method !== "call" && method !== "apply") break;
receiver = arguments_?.[0];
arguments_ = invocationArguments(method, arguments_);
path = path.slice(0, -1);
}
if (path?.join(".") === "intrinsic.Reflect.apply") {
const genericMethod = genericInvocationMethod(path);
if (genericMethod !== undefined) {
if (!t.isExpression(receiver)) return undefined;
callee = receiver;
receiver = arguments_?.[0];
arguments_ = invocationArguments(genericMethod, arguments_);
continue;
}
if (path.join(".") === "intrinsic.Reflect.apply") {
const callable = arguments_?.[0];
if (!t.isExpression(callable)) return undefined;
callee = callable;
receiver = arguments_?.[1];
arguments_ = arrayArguments(arguments_?.[2]);
continue;
}
if (path !== undefined) return { path, arguments: arguments_ };
if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) {
const method = semanticStaticPropertyKey(
callee.property,
callee.computed,
);
if (method === "call" || method === "apply") {
callee = callee.object;
arguments_ = invocationArguments(method, arguments_);
continue;
}
}
const bound = boundIntrinsic(callee);
if (bound !== undefined)
return {
path: bound.path,
arguments:
bound.arguments === undefined
? undefined
: [...bound.arguments, ...(arguments_ ?? [])],
};
if (!t.isIdentifier(callee)) return undefined;
const binding = resolveSemanticBindingState(state, callee, callee.name);
const initializer = semanticStableInitializer(binding, state);
if (
binding === undefined ||
initializer === undefined ||
initializer.projection.length !== 0 ||
visited.has(binding.bindingId)
)
return undefined;
visited.add(binding.bindingId);
callee = initializer.node;
return { path, arguments: arguments_ };
}
};
const genericInvocationMethod = (
path: readonly string[],
): "call" | "apply" | undefined =>
path.length === 4 &&
path[0] === "intrinsic" &&
path[1] === "Function" &&
path[2] === "prototype" &&
(path[3] === "call" || path[3] === "apply")
? path[3]
: undefined;
@@ -212,6 +212,7 @@ export const createSemanticModuleInterop = (
const argumentGroups: t.CallExpression["arguments"][] = [];
const bindReads: t.Node[] = [];
let opaqueArguments = false;
let receiver: t.Node | undefined;
let current = node;
for (;;) {
current = unwrapJavaScriptExpression(current).node;
@@ -243,11 +244,9 @@ export const createSemanticModuleInterop = (
// intervening members would require a separate proof of their identity.
if (!terminal && members.length !== 1) return undefined;
bindReads.push(captured?.capturedAt ?? current.callee);
const thisArgument = current.arguments[0];
receiver = current.arguments[0];
const ownArguments = current.arguments.slice(1);
opaqueArguments ||=
thisArgument !== undefined &&
!readExactJavaScriptLiteral(thisArgument).found;
opaqueArguments ||= t.isSpreadElement(receiver);
opaqueArguments ||= ownArguments.some((argument) =>
t.isSpreadElement(argument),
);
@@ -271,6 +270,7 @@ export const createSemanticModuleInterop = (
});
return {
path,
...(receiver === undefined ? {} : { receiver }),
// Rebinding appends arguments to the earlier bound argument list.
arguments: opaqueArguments
? undefined
@@ -549,12 +549,7 @@ export const createSemanticModuleInterop = (
callOrigin,
intrinsicPath,
intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) =>
resolveJavaScriptIntrinsicInvocation(
node,
state,
intrinsicPath,
boundIntrinsic,
),
resolveJavaScriptIntrinsicInvocation(node, intrinsicPath, boundIntrinsic),
boundIntrinsic,
staticString,
isFreshObject,
@@ -10,6 +10,26 @@ import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const INTRINSIC_MUTATIONS = [
["poisoned.cjs", "({Object} = replacements);"],
[
"projected.cjs",
'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});',
],
[
"decorated.cjs",
'@(() => Object) class metadata {}; Object.defineProperty(metadata,"create",{value:customCreate});',
],
[
"uncurried.cjs",
'const change = Function.prototype.call.bind(Object.defineProperty,null); change(Object,"create",{value:customCreate});',
],
[
"bound.cjs",
'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});',
],
] as const;
cliTest.for(["CLI", "stdio MCP"] as const)(
"retains converted Electron windows and preload evidence through %s",
async (surface, { cli }) => {
@@ -22,21 +42,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
join(root, "preload.js"),
'throw new Error("must stay inert");',
);
for (const [name, mutation] of [
["poisoned.cjs", "({Object} = replacements);"],
[
"projected.cjs",
'const {defineProperty:change} = Object; change(Object,"create",{value:customCreate});',
],
[
"decorated.cjs",
'@(() => Object) class metadata {}; Object.defineProperty(metadata,"create",{value:customCreate});',
],
[
"bound.cjs",
'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});',
],
] as const)
for (const [name, mutation] of INTRINSIC_MUTATIONS)
await writeFile(
join(root, name),
`${mutation}
@@ -86,6 +92,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
require("./projected.cjs");
require("./decorated.cjs");
require("./bound.cjs");
require("./uncurried.cjs");
throw new Error("must not execute main");
`;
await writeFile(join(root, "main.cjs"), source);
@@ -112,7 +119,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
preload_entrypoints: 1,
});
expect(result.statistics.parse_failures).toBe(0);
expect(result.statistics.parsed_javascript_files).toBe(6);
expect(result.statistics.parsed_javascript_files).toBe(7);
const windows = result.graph.nodes.filter(
({ kind }) => kind === "browser-window",
);