diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index c7f6222d28..390f967a4d 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -316,6 +316,27 @@ describe("Webpack loader alias bounds", () => { }); }); +it("reports the alias budget when a destructured external alias chain exceeds it", () => { + // 520 const hops exceed the default 512 alias selections; the origin is not + // claimed, and the walk reports its limit instead of a silent absence. + const hops = Array.from( + { length: 520 }, + (_, index) => `const a${index + 1}=a${index};`, + ).join(""); + const analysis = analyzeJavaScriptStaticSource( + bootstrap( + EXTERNAL, + `const a0=n(45840);${hops}const{BrowserWindow}=a520;new BrowserWindow({});`, + ), + ); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.analysis_coverage).toMatchObject({ + examination: { + semantic_resource_limits: expect.arrayContaining(["alias-selections"]), + }, + }); +}); + describe("Webpack externals with a tampered runtime", () => { it.each([ [ @@ -632,3 +653,74 @@ describe("Webpack identity through erased TypeScript syntax", () => { }, ); }); + +describe("Electron members destructured from a Webpack external binding", () => { + const WINDOW = 'webPreferences:{preload:"./p.js",nodeIntegration:true}'; + const directIpc = (source: string) => + electronOf(source).ipc.filter( + ({ resolution_basis: basis }) => basis !== "wrapper-substitution", + ); + + it.each([ + ["a shorthand pattern", "const{BrowserWindow}=s;new BrowserWindow"], + ["a renamed pattern", "const{BrowserWindow:W}=s;new W"], + ["a two-hop alias", "const w=s;const{BrowserWindow}=w;new BrowserWindow"], + ["a plain alias", "const w=s;new w.BrowserWindow"], + ])("follows the window class through %s", (_name, consumer) => { + expect( + electronOf( + bootstrap(EXTERNAL, `const s=n(45840);${consumer}({${WINDOW}});`), + ).browser_windows, + ).toMatchObject([{ preload_path: "./p.js" }]); + }); + + it.each([ + [ + "a top-level registration", + 'const s=n(45840);const{app,ipcMain}=s;ipcMain.handle("chan",async()=>[]);', + ], + [ + "a guarded registration in a nested function", + 'const s=n(45840);const{app,ipcMain}=s;const init=()=>{if(!ipcMain||!app)return;ipcMain.on("chan",()=>{})};init();', + ], + ])("follows destructured IPC through %s", (_name, consumer) => { + expect(directIpc(bootstrap(EXTERNAL, consumer))).toMatchObject([ + { channel: "chan", side: "main" }, + ]); + }); + + it.each([ + [ + "a reassigned external binding", + "let s=n(45840);s=other;const{BrowserWindow}=s;", + ], + [ + "an overwritten class member", + "const s=n(45840);s.BrowserWindow=class{};const{BrowserWindow}=s;", + ], + [ + "an overwritten member through an alias", + "const s=n(45840);const w=s;w.BrowserWindow=class{};const{BrowserWindow}=s;", + ], + ["a cyclic alias", "var a=b,b=a;const{BrowserWindow}=a;"], + ["a different table key", "const s=n(45841);const{BrowserWindow}=s;"], + [ + "a rebindable binding exposed to direct eval", + "let s=n(45840);eval(code);const{BrowserWindow}=s;", + ], + ])("keeps %s unresolved", (_name, prefix) => { + expect( + electronOf( + bootstrap(EXTERNAL, `${prefix}new BrowserWindow({${WINDOW}});`), + ).browser_windows, + ).toEqual([]); + }); + + it("keeps a require alias exposed to direct eval unresolved", () => { + expect( + electronOf( + `let s=require("electron");eval(code);const{BrowserWindow}=s;new BrowserWindow({${WINDOW}});`, + ).browser_windows, + ).toEqual([]); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index a671268f08..182272200e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -9,6 +9,7 @@ import { semanticOpaqueBindingEffects, semanticBindingCanBeRebound, semanticOpaqueBindingReadMayHaveChanged, + semanticBindingMayHaveOpaqueEffect, } from "./javascriptSemanticOpaqueEffects.js"; import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticReceiverFacts.js"; import * as t from "@babel/types"; @@ -1138,9 +1139,16 @@ const electronRequireOrigin = ( semanticRequireOrigin(node, state) ?? semanticBundledRequireOrigin(node, state); +/** + * General provenance only knows literal `require`, so a bundled external held + * in one binding and destructured from another (`const s=n(K);const{X}=s`) + * is followed here through stable lexical aliases. Like provenance, a hop + * stops at a rebindable binding that direct eval may have changed. + */ const bindingOrigin = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, + visited: ReadonlySet = new Set(), ): JavaScriptModuleOrigin | undefined => { if (binding.directOrigins.length > 0) return binding.directOrigins.length === 1 @@ -1148,7 +1156,29 @@ const bindingOrigin = ( : undefined; const initializer = semanticStableInitializer(binding, state); if (initializer === undefined) return undefined; - const origin = electronRequireOrigin(initializer.node, state)?.origin; + const source = unwrapJavaScriptExpression(initializer.node).node; + const alias = + t.isIdentifier(source) && + !semanticResolutionBlocked(state, source, source.name) + ? resolveSemanticBindingState(state, source, source.name) + : undefined; + // The alias walk shares the caller-selectable alias budget; running out is a + // reported resource limit, not a silent absence of the origin. + const exhausted = + alias !== undefined && visited.size >= state.precision.alias_selections; + if (exhausted) state.valueResourceLimits.add("alias-selections"); + const origin = + alias === undefined + ? electronRequireOrigin(initializer.node, state)?.origin + : visited.has(alias.bindingId) || + exhausted || + [binding, alias].some( + (hop) => + semanticBindingCanBeRebound(hop) && + semanticBindingMayHaveOpaqueEffect(hop, state), + ) + ? undefined + : bindingOrigin(alias, state, new Set([...visited, binding.bindingId])); const projection = initializer.projection; return origin === undefined || !projection.every((key): key is string => typeof key === "string") diff --git a/tests/acceptance/applications/webpackLoaderReturnsCli.test.ts b/tests/acceptance/applications/webpackLoaderReturnsCli.test.ts index 8a12905090..3d71f43ec8 100644 --- a/tests/acceptance/applications/webpackLoaderReturnsCli.test.ts +++ b/tests/acceptance/applications/webpackLoaderReturnsCli.test.ts @@ -30,6 +30,14 @@ cliTest.for([ ], ["a loader that returns only cached exports", loaderSource(CACHE_HIT, ""), 0], ["a loader without returns", loaderSource("", ""), 0], + [ + "a binding destructured from the canonical loader's result", + loaderSource(CACHE_HIT, "\n return module.exports;").replace( + "new (n(45840).BrowserWindow)({});", + "var electron = n(45840); var { BrowserWindow } = electron; new BrowserWindow({});", + ), + 1, + ], ] as const)( "attributes the BrowserWindow to Electron only through %s", async ([, source, windows], { cli }) => {