From 9d8e42bdd6f1bf8a984da1e06e1d76a1dbb9a7ed Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:22:26 +0800 Subject: [PATCH] fix(javascript): follow actual receivers in nested intrinsic calls (#1912) Consume arguments per generic function invocation rather than property-chain depth. Preserve the callable receiver through call/apply, Reflect.apply and bound invocation. Cover nested mutations and fresh targets through source analysis and inert production CLI/MCP fixtures. Fixes #1909. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/javascript-artifact-reconstruction.md | 12 +++-- .../javascript/electronModuleInterop.test.ts | 54 +++++++++++++++++-- .../javascriptIntrinsicInvocation.ts | 40 ++++++-------- .../electronModuleInteropCli.test.ts | 38 +++++++++---- 4 files changed, 99 insertions(+), 45 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index fbafe042e4..1ac08f88cc 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -346,12 +346,14 @@ Known Object/Reflect mutators remain recognizable through literal destructuring of intrinsic objects and stable bound-function aliases. Calls retain bound argument prefixes and the original bound receiver through `call`, `apply`, and `Reflect.apply`, including generic `Function.prototype` invocation and binding -creation. Visibly replaced invocation intrinsics produce unknown effects instead +creation. Arguments follow each actual callable invocation rather than property +chain depth; rebinding retains the original bound receiver. +Visibly replaced invocation intrinsics produce unknown effects instead of using their former call semantics; captured function aliases retain their -earlier identity. Unexamined -targets remain unresolved. `Reflect.set` examines its explicit receiver as well -as its target, retaining unknown receiver effects. Creating an unused binding does not itself mutate -an intrinsic. Variable redeclarations retain a single unconditional initializer +earlier identity. Unexamined targets remain unresolved. `Reflect.set` examines +its explicit receiver as well as its target, retaining unknown receiver effects. +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. Stable identifier chains to fresh object, array, function, undecorated class or RegExp syntax keep those targets separate from intrinsic diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index bdaf113bf8..cca3a20c89 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -534,6 +534,50 @@ describe("Electron module interop capture and scope", () => { }); }); +describe("Electron module interop generic invocations", () => { + it.each([ + 'const change = Function.prototype.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const call = Function.prototype.call; const change = call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});', + 'Function.prototype.call.apply(Object.defineProperty,[null,Object,"create",{value:customCreate}]);', + 'Reflect.apply(Function.prototype.call,Object.defineProperty,[null,Object,"create",{value:customCreate}]);', + 'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[Object,"create",{value:customCreate}]);', + 'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + 'const first = Function.prototype.call.bind(Object.defineProperty,null,Object,"create"); const change = first.bind(fake); change({value:customCreate});', + "const change = Function.prototype.call.bind(Object.defineProperty); change(null,...args);", + "const change = Function.prototype.apply.bind(Object.defineProperty); change(null,args);", + 'const change = Function.prototype.call.bind(Object.defineProperty,null,...args); change(metadata,"x",{value:1});', + 'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Object.defineProperty.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'Function.prototype.apply.apply(Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + 'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,Object,"create",{value:customCreate});', + 'const change = Function.prototype.call.call.bind(Object.defineProperty,null,Object,"create"); change({value:customCreate});', + ])("follows the callable receiver of a generic invocation: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toEqual([]); + }); + + it.each([ + 'const change = Function.prototype.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});', + 'const change = Function.prototype.call.bind(Object.defineProperty,null,metadata,"x"); change({value:1});', + 'Function.prototype.call.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'const change = Function.prototype.apply.bind(Object.defineProperty); change(null,[metadata,"x",{value:1}]);', + 'Reflect.apply(Function.prototype.apply,Object.defineProperty,[null,[metadata,"x",{value:1}]]);', + 'Function.prototype.call.call.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'Function.prototype.apply.apply(Object.defineProperty,[null,[metadata,"x",{value:1}]]);', + 'const change = Object.defineProperty.call.bind(Object.defineProperty); change(null,metadata,"x",{value:1});', + ])("preserves fresh targets through a generic invocation: %s", (write) => { + expect( + analyzeJavaScriptStaticSource( + `const metadata = {}; ${write}\n${ROLLDOWN_MODULE_INTEROP}\n${IMPORT} ${WRAP} ${WINDOW}`, + ).electron.browser_windows, + ).toHaveLength(1); + }); +}); + describe("Electron module interop bound mutators", () => { it.each([ 'const change = Object.defineProperty.bind(null); change(Object,"create",{value:customCreate});', @@ -560,6 +604,9 @@ describe("Electron module interop bound mutators", () => { 'Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', 'const apply = Reflect.apply; Reflect.apply = Object.defineProperty; Reflect.apply(Object,"create",{value:customCreate});', 'const change = Reflect.set.bind(null,Object,"create"); change(customCreate);', + 'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + 'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[Object,"create",{value:customCreate}]);', + 'const change = Reflect.apply.call; change.call(Reflect.apply,null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', 'Reflect.set({},"create",customCreate,Object);', 'const metadata = {}; Reflect.set(metadata,"create",customCreate,Object);', 'const change = Reflect.set.bind(null,{},"create",customCreate); change(Object);', @@ -568,9 +615,6 @@ describe("Electron module interop bound mutators", () => { 'Reflect.set({},"create",...valuesAndReceivers);', "Reflect.set({},...keysValuesAndReceivers);", 'Reflect.set({},"create",customCreate,receiver);', - 'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});', - 'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);', - 'const change = Reflect.apply.call; change(null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', ])("rejects intrinsic writes through a bound mutator: %s", (write) => { expect( analyzeJavaScriptStaticSource( @@ -593,8 +637,8 @@ describe("Electron module interop bound mutators", () => { 'const change = Object.defineProperty.bind(null,Object,"create");', 'Object.defineProperty.call(null,metadata,"x",{value:1});', 'Object.defineProperty.apply(null,[metadata,"x",{value:1}]);', - 'const change = Object.defineProperty.call; change(null,metadata,"x",{value:1});', - 'const {apply:change} = Object.defineProperty; change(null,[metadata,"x",{value:1}]);', + 'const change = Object.defineProperty.call; change.call(Object.defineProperty,null,metadata,"x",{value:1});', + 'const {apply:change} = Object.defineProperty; change.call(Object.defineProperty,null,[metadata,"x",{value:1}]);', 'Reflect.set(metadata,"x",1,{});', 'const receiver = {}; Reflect.set({},"x",1,receiver);', ])("keeps a fresh bound target or unused binding separate: %s", (write) => { diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index e34efe04a6..20885069fd 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -38,6 +38,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( let callee: t.Node = node.callee; let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments; let receiver: t.Node | undefined; + let explicitReceiver = false; for (;;) { callee = unwrapJavaScriptExpression(callee).node; if (visited.has(callee)) return undefined; @@ -62,6 +63,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( ) return { kind: "opaque", path: bound.path, arguments: undefined }; receiver = arguments_?.[0]; + explicitReceiver = true; callee = callee.object; arguments_ = invocationArguments(method, arguments_); continue; @@ -72,26 +74,26 @@ export const resolveJavaScriptIntrinsicInvocation = ( if (bound.kind === "opaque") return bound; path = bound.path; receiver = bound.receiver; + explicitReceiver = true; 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; + const method = path.length > 3 ? path.at(-1) : undefined; + if (method === "call" || method === "apply") { + // These properties hold generic function values. Their receiver selects + // the callable; property-chain depth does not consume more arguments. + const callable: t.Node | undefined = explicitReceiver + ? receiver + : t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) + ? callee.object + : undefined; + if (!t.isExpression(callable)) return undefined; + callee = callable; receiver = arguments_?.[0]; + explicitReceiver = true; arguments_ = invocationArguments(method, arguments_); - path = path.slice(0, -1); - } - 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") { @@ -99,6 +101,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( if (!t.isExpression(callable)) return undefined; callee = callable; receiver = arguments_?.[1]; + explicitReceiver = true; arguments_ = arrayArguments(arguments_?.[2]); continue; } @@ -110,14 +113,3 @@ export const resolveJavaScriptIntrinsicInvocation = ( }; } }; - -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; diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index badad8696f..d4191a2131 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -37,8 +37,29 @@ const INTRINSIC_MUTATIONS = [ 'const change = Object.defineProperty.bind(null,Object,"create"); change({value:customCreate});', ], ["receiver.cjs", 'Reflect.set({},"create",customCreate,Object);'], + [ + "deep-call.cjs", + 'Function.prototype.call.call.call(Object.defineProperty,null,Object,"create",{value:customCreate});', + ], + [ + "deep-apply.cjs", + 'Function.prototype.apply.apply(Object.defineProperty,[null,[Object,"create",{value:customCreate}]]);', + ], ] as const; +const writePoisonedModules = async (root: string): Promise => { + for (const [name, mutation] of INTRINSIC_MUTATIONS) + await writeFile( + join(root, name), + `${mutation} + ${ROLLDOWN_MODULE_INTEROP} + let poisoned = require("electron"); + poisoned = __toESM(poisoned, 1); + new poisoned.BrowserWindow({}); + throw new Error("must not execute poisoned source");`, + ); +}; + cliTest.for(["CLI", "stdio MCP"] as const)( "retains converted Electron windows and preload evidence through %s", async (surface, { cli }) => { @@ -51,20 +72,13 @@ cliTest.for(["CLI", "stdio MCP"] as const)( join(root, "preload.js"), 'throw new Error("must stay inert");', ); - for (const [name, mutation] of INTRINSIC_MUTATIONS) - await writeFile( - join(root, name), - `${mutation} - ${ROLLDOWN_MODULE_INTEROP} - let poisoned = require("electron"); - poisoned = __toESM(poisoned, 1); - new poisoned.BrowserWindow({}); - throw new Error("must not execute poisoned source");`, - ); + await writePoisonedModules(root); const source = `const metadata = {}; Object.defineProperty(metadata,"x",{value:1}); const decorateMetadata = Object.defineProperty.bind(null,metadata,"bound"); decorateMetadata({value:1}); + const decorateViaCall = Function.prototype.call.bind(Object.defineProperty); + decorateViaCall(null,metadata,"uncurried",{value:1}); const localFunction = () => {}; Object.defineProperty(localFunction,"x",{value:1}); const localClass = class {}; @@ -105,6 +119,8 @@ cliTest.for(["CLI", "stdio MCP"] as const)( require("./generic-bind.cjs"); require("./replaced-apply.cjs"); require("./receiver.cjs"); + require("./deep-call.cjs"); + require("./deep-apply.cjs"); throw new Error("must not execute main"); `; await writeFile(join(root, "main.cjs"), source); @@ -131,7 +147,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(10); + expect(result.statistics.parsed_javascript_files).toBe(12); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", );