diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 097d93458d..c704d460c0 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -1,11 +1,10 @@ import { - hasElectronRendererEvidence, + electronRendererEvidenceSteps, mayRunUnderElectronScriptLoader, } from "../domain/javascript/electronScriptLoaderEvidence.js"; import { moduleResolutionModeForJavaScriptReference } from "../domain/javascript/javascriptModuleResolutionMode.js"; import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js"; import { analyzeParsedJavaScriptStaticSourceSteps } from "../domain/javascript/javascriptStaticAnalysis.js"; -import type { JavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisTypes.js"; import { analyzeParsedJavaScriptSemanticsSteps } from "../domain/javascript/javascriptSemanticAnalysis.js"; import { failedJavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisHelpers.js"; import { projectJavaScriptModuleSemantics } from "../domain/javascript/javascriptModuleSemanticIr.js"; @@ -36,23 +35,20 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( return null; } await callbacks.phase("static"); - let facts: JavaScriptStaticAnalysis | undefined; if (mayRunUnderElectronScriptLoader(parsed.program)) { // Renderer-side evidence is read outside the package scope, where an // unbound require keeps its Electron identity; with that evidence the // whole source keeps that context, so its static and semantic facts agree. const script = parseJavaScriptSource(file.text.value, file.path); - if (script !== null) { - const scriptFacts = await completeJavaScriptAnalysisSteps( - analyzeParsedJavaScriptStaticSourceSteps(file.text.value, script), - ); - if (hasElectronRendererEvidence(scriptFacts.electron)) { - parsed = script; - facts = scriptFacts; - } - } + if ( + script !== null && + (await completeJavaScriptAnalysisSteps( + electronRendererEvidenceSteps(script.program), + )) + ) + parsed = script; } - facts ??= await completeJavaScriptAnalysisSteps( + const facts = await completeJavaScriptAnalysisSteps( analyzeParsedJavaScriptStaticSourceSteps(file.text.value, parsed), ); const resultBudgetBytes = await callbacks.checkpoint(facts); diff --git a/src/domain/javascript/electronScriptLoaderEvidence.test.ts b/src/domain/javascript/electronScriptLoaderEvidence.test.ts index 02680254e0..fe290cdeeb 100644 --- a/src/domain/javascript/electronScriptLoaderEvidence.test.ts +++ b/src/domain/javascript/electronScriptLoaderEvidence.test.ts @@ -1,9 +1,10 @@ import { expect, it } from "vitest"; import { - hasElectronRendererEvidence, + electronRendererEvidenceSteps, mayRunUnderElectronScriptLoader, } from "./electronScriptLoaderEvidence.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; @@ -20,14 +21,37 @@ it.each([ "module", true, ], - ['require("electron/renderer");', "preload.ts", "module", true], - ['void (() => require("electron"));', "preload.js", "module", true], - ['const dependency = require("./dep.cjs");', "main.js", "module", false], - ['const store = require("electron-store");', "main.js", "module", false], - ['import "./setup.js"; require("electron");', "main.js", "module", false], - ['require("electron");', "preload.mjs", "module", false], - ['require("electron");', "preload.js", "commonjs", false], - ['require("electron");', "preload.js", undefined, false], + ['require("electron/renderer").contextBridge;', "preload.ts", "module", true], + [ + 'void (() => require("electron")["ipcRenderer"]);', + "preload.js", + "module", + true, + ], + ["require(`electron`)[`ipcRenderer`];", "preload.js", "module", true], + ['require("electron");', "main.js", "module", false], + [ + 'const { app, BrowserWindow } = require("electron");', + "main.js", + "module", + false, + ], + ['const ipcRenderer = require("./dep.cjs");', "main.js", "module", false], + [ + 'const store = require("electron-store"); store.ipcRenderer;', + "main.js", + "module", + false, + ], + [ + 'import "./setup.js"; require("electron").ipcRenderer;', + "main.js", + "module", + false, + ], + ['require("electron").ipcRenderer;', "preload.mjs", "module", false], + ['require("electron").ipcRenderer;', "preload.js", "commonjs", false], + ['require("electron").ipcRenderer;', "preload.js", undefined, false], ] as const)( "selects package-module scripts that require Electron: %s (%s, %s)", (source, path, packageType, expected) => { @@ -48,6 +72,11 @@ it.each([ 'require("electron").ipcRenderer.send("ready");', true, ], + [ + "a template-literal specifier", + 'require(`electron`).ipcRenderer.send("ready");', + true, + ], [ "a renderer IPC listener", 'const { ipcRenderer } = require("electron");\nipcRenderer.on("update", () => {});', @@ -68,7 +97,19 @@ it.each([ "reads renderer-side Electron evidence from %s", (_name, source, expected) => { const parsed = parse(source, "preload.js"); + expect( + completeSemanticSteps(electronRendererEvidenceSteps(parsed.program)), + ).toBe(expected); + // The focused check agrees with the full static pass's direct findings. const { electron } = analyzeParsedJavaScriptStaticSource(source, parsed); - expect(hasElectronRendererEvidence(electron)).toBe(expected); + expect( + electron.context_bridge_apis.some( + ({ resolution_basis }) => resolution_basis === undefined, + ) || + electron.ipc.some( + ({ side, resolution_basis }) => + side === "renderer" && resolution_basis === undefined, + ), + ).toBe(expected); }, ); diff --git a/src/domain/javascript/electronScriptLoaderEvidence.ts b/src/domain/javascript/electronScriptLoaderEvidence.ts index c579ca68ab..4f0a508fa9 100644 --- a/src/domain/javascript/electronScriptLoaderEvidence.ts +++ b/src/domain/javascript/electronScriptLoaderEvidence.ts @@ -1,9 +1,20 @@ import * as t from "@babel/types"; -import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js"; +import { ipcDescriptor } from "./electronStaticAnalysisIpc.js"; +import { electronCalleePath } from "./electronStaticAnalysisValues.js"; +import { contextBridgeExposureArguments } from "./javascriptContextBridgeExposure.js"; import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js"; -import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { + indexJavaScriptBindingsWithReceiversSteps, + indexJavaScriptElectronIdentitySteps, +} from "./javascriptSemanticAnalysis.js"; +import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js"; +import { + traverseJavaScriptAst, + traverseJavaScriptValueAstSteps, +} from "./javascriptSemanticTraversal.js"; import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; +import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; /** * Whether a script owes its module context only to a `"type": "module"` @@ -12,7 +23,10 @@ import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; * need the .mjs suffix ("ES Modules (ESM) in Electron"). The call alone is not * evidence of that loader: Node's ESM loader, which the main process uses, * has no `require`, so the same call there throws. Module syntax would make - * the source a module under every loader, so only scripts qualify. + * the source a module under every loader, so only scripts qualify. The + * source must also name `contextBridge` or `ipcRenderer`: Electron identity + * reaches a member only through an identifier, string, or template key, so a + * source naming neither cannot yield {@link electronRendererEvidenceSteps}. */ export const mayRunUnderElectronScriptLoader = ( program: t.Program, @@ -22,33 +36,68 @@ export const mayRunUnderElectronScriptLoader = ( program.sourceType !== "script" ) return false; - let found = false; + let requiresElectron = false; + let namesRendererModule = false; traverseJavaScriptAst(program, { - skip: () => found, + skip: () => requiresElectron && namesRendererModule, enter: (node) => { - found ||= + requiresElectron ||= t.isCallExpression(node) && t.isIdentifier(node.callee, { name: "require" }) && - t.isStringLiteral(node.arguments[0]) && - ELECTRON_MODULE.test(node.arguments[0].value); + ELECTRON_MODULE.test(stringValue(node.arguments[0]) ?? ""); + namesRendererModule ||= + (t.isIdentifier(node) && rendererModules.has(node.name)) || + (t.isStringLiteral(node) && rendererModules.has(node.value)) || + (t.isTemplateElement(node) && + rendererModules.has(node.value.cooked ?? "")); }, }); - return found; + return requiresElectron && namesRendererModule; }; +const rendererModules: ReadonlySet = new Set([ + "contextBridge", + "ipcRenderer", +]); + /** - * `contextBridge` and `ipcRenderer` are renderer-process Electron modules; the - * main process cannot obtain them. A directly observed exposure or renderer - * IPC operation therefore places its source under a renderer-side loader. - * Wrapper substitutions are inferred, so they do not count. + * Whether a source directly calls a `contextBridge` exposure or a renderer IPC + * operation. Both are renderer-process Electron modules that the main process + * cannot obtain, so such a call places the source under a renderer-side + * loader. The check runs only the static pass's binding and Electron identity + * preamble and its exposure and IPC descriptors, without the rest of that + * pass; wrapper substitutions are inferred, so they are not expanded. */ -export const hasElectronRendererEvidence = ( - electron: ElectronStaticFindings, -): boolean => - electron.context_bridge_apis.some( - ({ resolution_basis }) => resolution_basis === undefined, - ) || - electron.ipc.some( - ({ side, resolution_basis }) => - side === "renderer" && resolution_basis === undefined, +export function* electronRendererEvidenceSteps( + program: t.Program, +): Generator { + const index = yield* indexJavaScriptBindingsWithReceiversSteps(program); + yield* index.indexAmbientWritesSteps(); + if (hasPossibleCommonJsDelegateWrite(index.bindings)) + yield* index.indexMemberMutationFactsSteps(); + const identity = yield* indexJavaScriptElectronIdentitySteps( + program, + index.bindings, ); + const context = { electronBindings: identity.bindings }; + let found = false; + yield* traverseJavaScriptValueAstSteps( + program, + index.bindings.erasedSyntaxNodes, + { + skip: () => found, + enter: (node) => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return; + const path = electronCalleePath(node.callee, context); + found ||= + (path?.length === 2 && + path[0] === "contextBridge" && + contextBridgeExposureArguments(path[1] ?? "", node.arguments) !== + undefined) || + ipcDescriptor(path)?.side === "renderer"; + }, + }, + ); + return found; +} diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 2b232e8f1e..d859be95d1 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -13,7 +13,7 @@ import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.j /** Resolve a callee only when its root has a proven Electron module identity. */ export const electronCalleePath = ( callee: t.Node, - context: JavaScriptFindingContext, + context: Pick, ): readonly string[] | undefined => { const { root, members } = electronMemberPath(callee); if (!members.every((member): member is string => member !== null))