diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 68c7ad521c..79d624224d 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -249,7 +249,8 @@ utility-process runtime paths retain the runtime filename selected by the caller Selected bounded text is then parsed as inert data to recover: - `package.json` metadata and declared main or renderer entrypoints; -- Electron preload paths and renderer files visible in static syntax; +- Electron preload paths owned by proven BrowserWindow options and renderer + files loaded through proven BrowserWindow instances; - local HTML script entrypoints; - Webpack and Rspack chunk registrations and module factories represented as AST literals, plus esbuild `__commonJS` module tables; recovered esbuild @@ -366,6 +367,18 @@ prefix is never presented as a complete stable fingerprint. ## Authority and unknowns +API identity is a derivation: a familiar method or property name does not +establish a browser or Electron API. The shared lexical, ambient-write, and +member-mutation owners must establish the receiver before static API facts become +roles or storage observations. Path `join`, `resolve`, and `fileURLToPath` use +proven Node imports; source-relative URL conversion additionally requires an +unmodified global URL constructor and `import.meta.url`. Other calls preserve +their expressions. A property named `preload` outside a proven BrowserWindow +configuration does not establish a script entrypoint. Renderer recovery currently +covers direct BrowserWindow instances, stable local aliases, and their +`webContents`; other receiver-producing APIs remain unmodeled. Complete AST +traversal does not imply complete API-model coverage. + Artifact bytes and AST syntax are observations. Entrypoint resolution, imports, loads, calls, and persistence relationships are static inferences and explicitly say that syntax does not prove runtime execution. A malformed or unavailable diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 22adee7d58..8b59d80144 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -2937,7 +2937,7 @@ describe("contextual JavaScript module identity", () => { new BrowserWindow({ webPreferences: { preload: new URL("./remote-base.js", location.href) }}); - window.loadURL("file:///renderer/index.html"); + new BrowserWindow().loadURL("file:///renderer/index.html"); `, ); diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index 93afe2902b..cc24eff53d 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -135,7 +135,7 @@ const inspectBrowserWindow = ( context.source, lookup.status, resolvedPreferences, - context.nodePathResolve, + context.pathIntrinsics, ); context.accumulator.unknownFindings += collected.unknown; const finding: ElectronBrowserWindowFinding = { @@ -154,6 +154,23 @@ const inspectBrowserWindow = ( module_key: null, location: range(node), }; + if ( + finding.preload_path !== null && + finding.preload_resolution_context !== null + ) + addLocatedFinding(context, { + collection: context.accumulator.roles, + key: `role\0preload\0${finding.preload_path}`, + node, + value: { + role: "preload", + path: finding.preload_path, + resolution_context: finding.preload_resolution_context, + mechanism: "BrowserWindow:webPreferences.preload", + module_key: null, + location: range(node), + }, + }); addLocatedFinding(context, { collection: context.accumulator.browserWindows, key: "electron-window\0BrowserWindow", @@ -166,7 +183,7 @@ const collectWindowOptions = ( source: string, lookupStatus: "explicit" | "missing" | "dynamic", preferences: t.Node | undefined, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): { readonly status: ElectronBrowserWindowFinding["web_preferences_status"]; readonly preferences: readonly ElectronWebPreference[]; @@ -180,13 +197,13 @@ const collectWindowOptions = ( preload: { preload_path: null, preload_resolution_context: null }, unknown: lookupStatus === "missing" ? 0 : 1, }; - return collectWebPreferences(source, preferences, nodePathResolve); + return collectWebPreferences(source, preferences, pathIntrinsics); }; const collectWebPreferences = ( source: string, object: t.ObjectExpression, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): { readonly status: "object-literal"; readonly preferences: readonly ElectronWebPreference[]; @@ -198,7 +215,7 @@ const collectWebPreferences = ( const effectivePreload = staticObjectProperty(object, "preload"); const preloadPath = effectivePreload.status === "explicit" - ? staticPath(effectivePreload.property.value, nodePathResolve) + ? staticPath(effectivePreload.property.value, pathIntrinsics) : undefined; const preload: ElectronBrowserWindowPreload = effectivePreload.status === "explicit" && preloadPath !== undefined @@ -206,7 +223,7 @@ const collectWebPreferences = ( preload_path: preloadPath, preload_resolution_context: staticPathResolutionContext( effectivePreload.property.value, - nodePathResolve, + pathIntrinsics, ), } : { preload_path: null, preload_resolution_context: null }; @@ -238,7 +255,7 @@ const collectWebPreferences = ( } const path = name === "preload" - ? staticPath(property.value, nodePathResolve) + ? staticPath(property.value, pathIntrinsics) : undefined; const value: ElectronStaticValue = path === undefined @@ -316,7 +333,7 @@ const inspectUtilityProcess = ( const modulePath = moduleNode === undefined ? undefined - : staticPath(moduleNode, context.nodePathResolve); + : staticPath(moduleNode, context.pathIntrinsics); // Electron treats a non-array object in the argv slot as the options. const shapes = context.expressionShapes; const options = optionsAfterOptionalArgv(node.arguments, shapes); @@ -333,7 +350,7 @@ const inspectUtilityProcess = ( const serviceName = declaredName === undefined ? undefined - : staticPath(declaredName, context.nodePathResolve); + : staticPath(declaredName, context.pathIntrinsics); if ( serviceNameProperty.status === "dynamic" || (declaredName !== undefined && serviceName === undefined) @@ -351,7 +368,7 @@ const inspectUtilityProcess = ( module_path: modulePath, module_resolution_context: staticPathResolutionContext( moduleNode, - context.nodePathResolve, + context.pathIntrinsics, ), module_expression: null, }; diff --git a/src/domain/javascript/electronStaticRenderer.ts b/src/domain/javascript/electronStaticRenderer.ts new file mode 100644 index 0000000000..86c4128327 --- /dev/null +++ b/src/domain/javascript/electronStaticRenderer.ts @@ -0,0 +1,67 @@ +import * as t from "@babel/types"; + +import { electronCalleePath } from "./electronStaticAnalysisValues.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticStableInitializer, + type JavaScriptBindingIndex, +} from "./javascriptSemanticState.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; + +/** A renderer load belongs to an intact, source-proven BrowserWindow instance. */ +export const isElectronRendererLoad = ( + callee: t.Node, + site: t.Node, + context: JavaScriptFindingContext, + index: JavaScriptBindingIndex, +): boolean => { + callee = unwrapJavaScriptExpression(callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const method = semanticStaticPropertyKey(callee.property, callee.computed); + if (method !== "loadFile" && method !== "loadURL") return false; + let receiver = unwrapJavaScriptExpression(callee.object).node; + const path: string[] = [method]; + if ( + t.isMemberExpression(receiver) && + semanticStaticPropertyKey(receiver.property, receiver.computed) === + "webContents" + ) { + receiver = unwrapJavaScriptExpression(receiver.object).node; + path.unshift("webContents"); + } + const seen = new Set(); + while (t.isIdentifier(receiver)) { + const binding = index.bindingForReferenceAt(receiver); + if (binding === undefined || seen.has(binding.bindingId)) return false; + seen.add(binding.bindingId); + const initializer = semanticStableInitializer(binding, index.bindings); + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !semanticBindingAtRead(binding, receiver, index.bindings.parentsByNode) + .hasDefiniteInitializer + ) + return false; + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + if (index.referenceMayHaveChangedAt(site, receiver, path)) return false; + receiver = unwrapJavaScriptExpression(initializer.node).node; + } + if (!t.isNewExpression(receiver)) return false; + const constructor = electronCalleePath(receiver.callee, context); + return ( + constructor?.length === 1 && + constructor[0] === "BrowserWindow" && + context.electronExportIntact?.([ + "electron", + "BrowserWindow", + "prototype", + ...path, + ]) === true + ); +}; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 0aa7ccee2e..7b45553f16 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -14,10 +14,11 @@ import { traverseJavaScriptAstSteps, } from "./javascriptSemanticTraversal.js"; +import { isElectronRendererLoad } from "./electronStaticRenderer.js"; import { inspectElectronStaticNode } from "./electronStaticAnalysis.js"; import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js"; import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js"; -import { createStaticNodePathResolveQuery } from "./javascriptStaticPathIntrinsics.js"; +import { createStaticPathIntrinsicQuery } from "./javascriptStaticPathIntrinsics.js"; import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; import { finishElectronBrowserWindowsSteps } from "./electronStaticAnalysisBrowser.js"; import { @@ -34,7 +35,6 @@ import { finishModuleRequireCalls, finishSqliteStorageCalls, inspectCall, - inspectRoleProperty, inspectRpcNode, finishRpcScanSteps, indexVitePreloadDependencyTable, @@ -103,12 +103,15 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( bindings, ); const findings: JavaScriptFindingContext = { - nodePathResolve: createStaticNodePathResolveQuery(index), + pathIntrinsics: createStaticPathIntrinsicQuery(index), + rendererLoad: (callee, site) => + isElectronRendererLoad(callee, site, findings, index), source, accumulator, vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program), openReceiverFacts: receivers.openReceivers, electronBindings: electronIdentity.bindings, + electronExportIntact: electronIdentity.intactExport, electronInvocationPath: electronIdentity.invocationPath, electronAppPathChangePossible: electronIdentity.mayChangeAppPath, rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index), @@ -330,8 +333,5 @@ const inspectNode = ( kind: "dynamic-import", specifier: stringValue(node.source), }); - if (t.isObjectProperty(node)) { - inspectRoleProperty(node, findings); - } inspectRpcNode(node, findings); }; diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index 38c1859043..282a7fa934 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -433,7 +433,12 @@ it("preserves preload and utility paths through exact computed join members", () ), ) .toEqual([ - ["preload", "preload.js", "filesystem-expression", "property:preload"], + [ + "preload", + "preload.js", + "filesystem-expression", + "BrowserWindow:webPreferences.preload", + ], ]); expect .soft( @@ -541,11 +546,14 @@ it("keeps dynamic, interpolated, and invalid template keys unresolved", () => { describe("static callee boundary matching", () => { it("matches complete callee members without suffix collisions", () => { const analysis = analyzeJavaScriptStaticSource(` + const {BrowserWindow} = require("electron"); + const win = new BrowserWindow(); win.loadFile("./window.html"); - BrowserWindow.loadURL("https://app.example.test"); + new BrowserWindow().loadURL("https://app.example.test"); navigator.serviceWorker.register("./worker.js"); loadFile("./bare.html"); - win?.loadURL("https://optional.example.test"); + const optional = new BrowserWindow(); + optional?.loadURL("https://optional.example.test"); uploadFile("./upload.html"); preloadFile("./preload.html"); @@ -559,9 +567,8 @@ describe("static callee boundary matching", () => { analysis.role_paths.map(({ path, mechanism }) => [path, mechanism]), ).toEqual([ ["./window.html", "call:win.loadFile"], - ["https://app.example.test", "call:BrowserWindow.loadURL"], - ["./bare.html", "call:loadFile"], - ["https://optional.example.test", "call:win.loadURL"], + ["https://app.example.test", "call:loadURL"], + ["https://optional.example.test", "call:optional.loadURL"], ]); // The document base URL is unknown, so the script path stays unresolved. expect( diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 5bfa12e247..225419bec0 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -17,9 +17,6 @@ import { range, rangeForOffsets, sourceSlice, - staticPath, - staticPathResolutionContext, - storageKind, stringValue, } from "./javascriptStaticAnalysisHelpers.js"; import { @@ -147,11 +144,10 @@ export const inspectCall = ( specifier: moduleUrlSpecifier(node, bindings), }); inspectEndpointCall(node, name, first, context, bindings); - inspectStorageCall(node, name, first, context); - if (first === "audio" || first === "video") - noteMediaElementPreloads(node, accumulator); + inspectStorageCall(node, name, first, context, bindings); if ( (name === "loadFile" || name.endsWith(".loadFile")) && + context.rendererLoad?.(node.callee, node) === true && first !== undefined ) addLocatedFinding(context, { @@ -167,7 +163,11 @@ export const inspectCall = ( location: range(node), }, }); - if ((name === "loadURL" || name.endsWith(".loadURL")) && first !== undefined) + if ( + (name === "loadURL" || name.endsWith(".loadURL")) && + context.rendererLoad?.(node.callee, node) === true && + first !== undefined + ) addLocatedFinding(context, { collection: accumulator.roles, key: `role\0renderer\0${first}`, @@ -246,23 +246,6 @@ const workerConstructorKind = ( : undefined; }; -// Element factories such as jsx("audio", props) and createElement("video", -// props) carry the HTML media `preload` hint ("none", "metadata", "auto"), -// which never names an Electron preload script. -const noteMediaElementPreloads = ( - node: JavaScriptCallLike, - accumulator: AnalysisAccumulator, -): void => { - const props = node.arguments[1]; - if (!t.isObjectExpression(props)) return; - for (const property of props.properties) - if ( - t.isObjectProperty(property) && - semanticStaticPropertyName(property.key, property.computed) === "preload" - ) - accumulator.mediaElementPreloads.add(property); -}; - const requireCallSpecifier = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, @@ -824,37 +807,12 @@ export function* finishRpcScanSteps( } } -export const inspectRoleProperty = ( - node: t.ObjectProperty, - context: FindingContext, -): void => { - if (semanticStaticPropertyName(node.key, node.computed) !== "preload") return; - if (context.accumulator.mediaElementPreloads.has(node)) return; - const path = staticPath(node.value, context.nodePathResolve); - if (path === undefined) return; - addLocatedFinding(context, { - collection: context.accumulator.roles, - key: `role\0preload\0${path}`, - node, - value: { - role: "preload", - path, - resolution_context: staticPathResolutionContext( - node.value, - context.nodePathResolve, - ), - mechanism: "property:preload", - module_key: null, - location: range(node), - }, - }); -}; - const inspectStorageCall = ( node: JavaScriptCallLike, name: string, first: string | undefined, context: FindingContext, + bindings: JavaScriptSemanticAnalysisState, ): void => { if (name === "Database" || name.endsWith(".Database")) { context.accumulator.pendingSqliteCalls.push({ node, name }); @@ -870,7 +828,7 @@ const inspectStorageCall = ( ? "indexed-db" : receiverFact === "cache-storage" ? "cache-storage" - : storageKind(name); + : browserStorageKind(node.callee, bindings); if (storage === undefined) return; addLocatedFinding(context, { collection: context.accumulator.storage, @@ -886,6 +844,35 @@ const inspectStorageCall = ( }); }; +const browserStorageKind = ( + callee: t.Node, + bindings: JavaScriptSemanticAnalysisState, +): + | "local-storage" + | "session-storage" + | "indexed-db" + | "cache-storage" + | undefined => { + callee = unwrapJavaScriptExpression(callee).node; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return undefined; + const method = semanticStaticPropertyKey(callee.property, callee.computed); + if (method === null) return undefined; + if (["getItem", "setItem", "removeItem", "clear", "key"].includes(method)) { + if (isUnmodifiedGlobalReference(callee, bindings, ["localStorage", method])) + return "local-storage"; + if ( + isUnmodifiedGlobalReference(callee, bindings, ["sessionStorage", method]) + ) + return "session-storage"; + } + if (isUnmodifiedGlobalReference(callee, bindings, ["indexedDB", "open"])) + return "indexed-db"; + if (isUnmodifiedGlobalReference(callee, bindings, ["caches", "open"])) + return "cache-storage"; + return undefined; +}; + /** Constructor spelling selects a candidate; source provenance owns certainty. */ export const finishSqliteStorageCalls = ( context: FindingContext, diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index f8347c3d68..deccdde911 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -173,29 +173,34 @@ export const staticArrayValues = ( /** Classify whether inert path syntax is a module specifier or file expression. */ export type JavaScriptStaticPathIntrinsicQuery = ( node: t.Node, - fact?: "source-directory", + fact?: + | "resolve" + | "join" + | "file-url-to-path" + | "source-url" + | "source-directory", ) => boolean; export const staticPathResolutionContext = ( node: t.Node, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): JavaScriptStaticPathContext => - isFilesystemPathExpression(node, nodePathResolve) + isFilesystemPathExpression(node, pathIntrinsics) ? "filesystem-expression" : "module-specifier"; /** Resolve a path string composed only from inert literal syntax. */ export const staticPath = ( node: t.Node, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { node = unwrapJavaScriptExpression(node).node; const value = stringValue(node); if (value !== undefined) return value; if (t.isBinaryExpression(node, { operator: "+" })) - return staticConcatenation(node, nodePathResolve); + return staticConcatenation(node, pathIntrinsics); if (t.isCallExpression(node) || t.isNewExpression(node)) - return staticCallPath(node, nodePathResolve); + return staticCallPath(node, pathIntrinsics); return undefined; }; @@ -206,7 +211,7 @@ export const staticPath = ( */ const staticConcatenation = ( node: t.BinaryExpression, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { const operands: t.Node[] = []; let current: t.Node = node; @@ -214,11 +219,11 @@ const staticConcatenation = ( operands.push(current.right); current = unwrapJavaScriptExpression(current.left).node; } - const anchored = isDirectoryPath(current, nodePathResolve); + const anchored = isDirectoryPath(current, pathIntrinsics); if (!anchored) operands.push(current); let text = ""; for (const operand of operands.reverse()) { - const value = staticPath(operand, nodePathResolve); + const value = staticPath(operand, pathIntrinsics); if (value === undefined) return undefined; text += value; } @@ -228,26 +233,27 @@ const staticConcatenation = ( const staticCallPath = ( node: t.CallExpression | t.NewExpression, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { - if (t.isCallExpression(node) && nodePathResolve?.(node.callee)) - return staticResolvedPath(node, nodePathResolve); - const name = calleeName(node.callee); - if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) { - return staticFileUrlPath(node, nodePathResolve); - } - if (name.endsWith(".resolve")) return undefined; - if (!name.endsWith(".join") && name !== "join") return undefined; + if (t.isCallExpression(node) && pathIntrinsics?.(node.callee)) + return staticResolvedPath(node, pathIntrinsics); + if ( + t.isCallExpression(node) && + pathIntrinsics?.(node.callee, "file-url-to-path") + ) + return staticFileUrlPath(node, pathIntrinsics); + if (!t.isCallExpression(node) || !pathIntrinsics?.(node.callee, "join")) + return undefined; const parts: string[] = []; for (const argument of node.arguments) { - if (isDirectoryPath(argumentNode(argument), nodePathResolve)) { + if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) { // join never resets at an absolute segment, so `/preload.js` after the // source directory stays inside it. parts.push("."); continue; } const value = t.isNode(argument) - ? staticPath(argument, nodePathResolve) + ? staticPath(argument, pathIntrinsics) : undefined; if (value === undefined) return undefined; parts.push(value); @@ -270,15 +276,15 @@ export const staticSourceRelativeUrlPath = ( /** Convert an exact static file URL expression to a POSIX path. */ export const staticFileUrlPath = ( node: t.CallExpression | t.NewExpression, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { if (node.arguments.length !== 1) return undefined; const rawArgument = argumentNode(node.arguments[0]); if (rawArgument === undefined) return undefined; const argument = unwrapJavaScriptExpression(rawArgument).node; - if (isSourceFileUrl(argument)) + if (isSourceFileUrl(argument) && pathIntrinsics?.(argument, "source-url")) return sourceRelativeFileUrlPath(argument)?.path; - const value = staticPath(argument, nodePathResolve); + const value = staticPath(argument, pathIntrinsics); const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; if ( value === undefined || @@ -354,19 +360,19 @@ const isSourceFileUrl = ( const staticResolvedPath = ( node: t.CallExpression | t.NewExpression, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { const parts: string[] = []; let anchored = false; // Node walks right-to-left and never inspects arguments before its anchor. for (let index = node.arguments.length - 1; index >= 0; index -= 1) { const argument = node.arguments[index]; - if (isDirectoryPath(argumentNode(argument), nodePathResolve)) { + if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) { anchored = true; break; } const value = t.isNode(argument) - ? staticPath(argument, nodePathResolve) + ? staticPath(argument, pathIntrinsics) : undefined; if (value === undefined) return undefined; parts.push(value); @@ -383,45 +389,27 @@ const staticResolvedPath = ( const isFilesystemPathExpression = ( node: t.Node, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): boolean => { node = unwrapJavaScriptExpression(node).node; const value = stringValue(node); if (value !== undefined) return value.startsWith("/"); - if (isFileIdentity(node) || isDirectoryPath(node, nodePathResolve)) + if (isFileIdentity(node) || isDirectoryPath(node, pathIntrinsics)) return true; if (t.isBinaryExpression(node, { operator: "+" })) return ( - isFilesystemPathExpression(node.left, nodePathResolve) || - isFilesystemPathExpression(node.right, nodePathResolve) + isFilesystemPathExpression(node.left, pathIntrinsics) || + isFilesystemPathExpression(node.right, pathIntrinsics) ); if (t.isCallExpression(node) || t.isNewExpression(node)) { - const name = calleeName(node.callee); - if ( - nodePathResolve?.(node.callee) && - staticCallPath(node, nodePathResolve) !== undefined - ) - return true; - // A URL object is not a path string until fileURLToPath converts it. - if (name === "URL" || name.endsWith(".URL")) return false; - if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) - return ( - staticFileUrlPath(node, nodePathResolve) !== undefined || - node.arguments.some((argument) => { - const value = argumentNode(argument); - return ( - value !== undefined && - isFilesystemPathExpression(value, nodePathResolve) - ); - }) - ); - return node.arguments.some((argument) => { - const value = argumentNode(argument); - return ( - value !== undefined && - isFilesystemPathExpression(value, nodePathResolve) - ); - }); + if (!t.isCallExpression(node)) return false; + const native = + pathIntrinsics?.(node.callee) || + pathIntrinsics?.(node.callee, "join") || + pathIntrinsics?.(node.callee, "file-url-to-path"); + return ( + native === true && staticCallPath(node, pathIntrinsics) !== undefined + ); } return false; }; @@ -429,11 +417,11 @@ const isFilesystemPathExpression = ( /** `__dirname` or the dirname of the source file, as a string. */ const isDirectoryPath = ( node: t.Node | undefined, - nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, ): boolean => { if (node !== undefined) node = unwrapJavaScriptExpression(node).node; return ( - node !== undefined && nodePathResolve?.(node, "source-directory") === true + node !== undefined && pathIntrinsics?.(node, "source-directory") === true ); }; @@ -704,8 +692,8 @@ export const endpointArgument = ( return undefined; }; -/** Classify a recognized storage API call name. */ -export const storageKind = ( +/** Spelling-only candidate exclusion for overloaded open calls; not API identity. */ +const storageKind = ( name: string, ): JavaScriptStaticStorage["kind"] | undefined => { if (name.includes("localStorage.")) return "local-storage"; diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index dc42744ef0..e3c2ab4196 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -80,8 +80,6 @@ export interface JavaScriptAnalysisAccumulator { readonly moduleRangeIndex: JavaScriptModuleRangeIndex; /** Binding identities proven as third-parameter loaders of recovered factories. */ readonly factoryRequireBindingIds: Set; - /** `preload` attributes of audio and video elements, which are not scripts. */ - readonly mediaElementPreloads: WeakSet; readonly seen: Set; visitedNodes: number; unknownFindings: number; @@ -89,8 +87,10 @@ export interface JavaScriptAnalysisAccumulator { /** Shared source and accumulator for helper inspections. */ export interface JavaScriptFindingContext { - /** Node path.resolve identity established by shared callee and mutation owners. */ - readonly nodePathResolve?: JavaScriptStaticPathIntrinsicQuery; + /** Native path/URL identities established by shared lexical and mutation owners. */ + readonly pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery; + readonly electronExportIntact?: (path: readonly string[]) => boolean; + readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean; readonly rpcScan: JavaScriptRpcScan; /** Shared bounded argv/nullish argument shapes for optional-slot signatures. */ readonly expressionShapes: JavaScriptExpressionShapeResolver; @@ -159,7 +159,6 @@ export const createJavaScriptAnalysisAccumulator = ( nativeAddonBindings: [], moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength), factoryRequireBindingIds: new Set(), - mediaElementPreloads: new WeakSet(), seen: new Set(), visitedNodes: 0, unknownFindings: 0, diff --git a/src/domain/javascript/javascriptStaticApiIdentity.test.ts b/src/domain/javascript/javascriptStaticApiIdentity.test.ts new file mode 100644 index 0000000000..0d4c5a5d46 --- /dev/null +++ b/src/domain/javascript/javascriptStaticApiIdentity.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const windowPrefix = + "import {BrowserWindow} from 'electron'; import {fileURLToPath} from 'node:url';"; + +it.each([ + 'class URL {constructor(input, base) {return new globalThis.URL("./actual.js", base);}}', + "globalThis.URL = class {};", + 'import.meta.url = "file:///another/main.js";', +])( + "retains a dynamic preload when source URL identity is unavailable: %s", + (prefix) => { + const analysis = analyzeJavaScriptStaticSource(`${windowPrefix}${prefix} + new BrowserWindow({webPreferences:{preload:fileURLToPath(new URL("./decoy.js", import.meta.url))}});`); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null }, + ]); + expect(analysis.role_paths).toEqual([]); + }, +); + +it.each([ + '["a", "b"].join("/")', + 'settings.join("/app", "preload.js")', + 'settings.fileURLToPath("file:///decoy.js")', +])("does not assign native path semantics to %s", (expression) => { + const analysis = + analyzeJavaScriptStaticSource(`import {BrowserWindow} from 'electron'; + const settings = {join() {}, fileURLToPath() {}}; + new BrowserWindow({webPreferences:{preload:${expression}}});`); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null }, + ]); + expect(analysis.role_paths).toEqual([]); +}); + +it("recovers native aliases and emits preload roles only for their Electron owner", () => { + const analysis = analyzeJavaScriptStaticSource(`${windowPrefix} + import {join as append} from 'node:path'; + const unrelated = {preload: "./unrelated.js"}; + new BrowserWindow({webPreferences:{preload:append(__dirname, "preload.js")}}); + new BrowserWindow({webPreferences:{preload:fileURLToPath(new URL("./native.js", import.meta.url))}});`); + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["preload.js", "./native.js"]); + expect(analysis.role_paths.map(({ path }) => path)).toEqual([ + "preload.js", + "./native.js", + ]); +}); + +it.each(["localStorage", "sessionStorage"])( + "requires browser identity for %s", + (name) => { + const local = analyzeJavaScriptStaticSource( + `const ${name} = {setItem(key, value) {return value;}}; ${name}.setItem("ghost", "value");`, + ); + expect(local.storage).toEqual([]); + const global = analyzeJavaScriptStaticSource( + `globalThis.${name}.setItem("real", "value");`, + ); + expect(global.storage).toMatchObject([{ name: "real" }]); + const overwritten = analyzeJavaScriptStaticSource( + `globalThis.${name}.setItem = () => {}; ${name}.setItem("ghost", "value");`, + ); + expect(overwritten.storage).toEqual([]); + }, +); + +it("requires an intact Electron instance for renderer roles", () => { + const local = analyzeJavaScriptStaticSource( + `const cache = {loadFile(value) {return value;}};cache.loadFile("data.json");`, + ); + expect(local.role_paths).toEqual([]); + const native = analyzeJavaScriptStaticSource( + `import {BrowserWindow as Win} from 'electron';const window = new Win(); window.loadFile("index.html");`, + ); + expect(native.role_paths).toMatchObject([ + { role: "renderer", path: "index.html" }, + ]); + const replaced = analyzeJavaScriptStaticSource( + `import {BrowserWindow as Win} from 'electron';const window = new Win(); window.loadFile = () => {};window.loadFile("data.json");`, + ); + expect(replaced.role_paths).toEqual([]); +}); diff --git a/src/domain/javascript/javascriptStaticPathIntrinsics.ts b/src/domain/javascript/javascriptStaticPathIntrinsics.ts index 701ca98e7e..899cf5af74 100644 --- a/src/domain/javascript/javascriptStaticPathIntrinsics.ts +++ b/src/domain/javascript/javascriptStaticPathIntrinsics.ts @@ -11,11 +11,11 @@ import { import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js"; -/** Prove Node's path resolver before applying its argument-selection semantics. */ -export const createStaticNodePathResolveQuery = ( +/** Prove path and URL intrinsic identity before applying native semantics. */ +export const createStaticPathIntrinsicQuery = ( index: JavaScriptBindingIndex, ): JavaScriptStaticPathIntrinsicQuery => { - const facts = new WeakMap(); + const facts = new WeakMap>(); const nativeMethod = ( callee: t.Node, method: string, @@ -64,10 +64,24 @@ export const createStaticNodePathResolveQuery = ( nativeMethod(callee.callee, "dirname", ["path", "path/posix"]) && sourceFile(callee.arguments[0]); } - const cached = facts.get(callee); + const kind = fact ?? "resolve"; + const cached = facts.get(callee)?.get(kind); if (cached !== undefined) return cached; - const proven = nativeMethod(callee, "resolve", ["path", "path/posix"]); - facts.set(callee, proven); + const proven = + kind === "source-url" + ? t.isNewExpression(callee) && + callee.arguments.length === 2 && + isUnmodifiedGlobalReference(callee.callee, index.bindings, "URL") && + isUnmodifiedImportMetaUrl(callee.arguments[1], index.bindings) + : kind === "file-url-to-path" + ? nativeMethod(callee, "fileURLToPath", ["url"]) + : nativeMethod(callee, kind, ["path", "path/posix"]); + let entry = facts.get(callee); + if (entry === undefined) { + entry = new Map(); + facts.set(callee, entry); + } + entry.set(kind, proven); return proven; }; }; diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 56b44c7c4c..7256e389bb 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -417,9 +417,14 @@ const assertPathProjection = ( ) => { const node = parsePathExpression(expression); // This syntax matrix supplies identity explicitly; full-source cases prove it. - const nodePathResolve = ( + const pathIntrinsics = ( callee: import("@babel/types").Node, - fact?: "source-directory", + fact?: + | "resolve" + | "join" + | "file-url-to-path" + | "source-url" + | "source-directory", ) => { if (fact === "source-directory") { const name = calleeName( @@ -429,36 +434,27 @@ const assertPathProjection = ( name === "__dirname" || name === "dirname" || name.endsWith(".dirname") ); } + if (fact === "source-url") return true; const name = calleeName(callee); - return name.endsWith(".resolve") && !name.includes("win32"); + if (fact === "file-url-to-path") return name.endsWith("fileURLToPath"); + return name.endsWith(`.${fact ?? "resolve"}`) && !name.includes("win32"); }; - expect(staticPath(node, nodePathResolve)).toBe(path); - expect(staticPathResolutionContext(node, nodePathResolve)).toBe(context); - const { - analysis, - property, - genericPrefix, - windowPrefix, - windowExpression, - utilityExpression, - } = analyzePathConsumers(expression); + expect(staticPath(node, pathIntrinsics)).toBe(path); + expect(staticPathResolutionContext(node, pathIntrinsics)).toBe(context); + const { analysis, windowExpression, utilityExpression } = + analyzePathConsumers(expression); expect(analysis.parse_status).toBe("complete"); expect(analysis.parse_error_count).toBe(0); - expect(analysis.role_paths).toEqual( - ( - [ - [1, genericPrefix.length], - [2, windowPrefix.length], - ] as const - ).map(([line, column]) => ({ + expect(analysis.role_paths).toEqual([ + { role: "preload", path, resolution_context: context, - mechanism: "property:preload", + mechanism: "BrowserWindow:webPreferences.preload", module_key: null, - location: lineRange(line, column, column + property.length), - })), - ); + location: lineRange(2, 0, windowExpression.length), + }, + ]); expect(analysis.electron.browser_windows).toEqual([ { options_status: "object-literal", diff --git a/tests/acceptance/applications/javascriptStaticApiIdentity.test.ts b/tests/acceptance/applications/javascriptStaticApiIdentity.test.ts new file mode 100644 index 0000000000..f20018fcb1 --- /dev/null +++ b/tests/acceptance/applications/javascriptStaticApiIdentity.test.ts @@ -0,0 +1,60 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves static API identity through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-static-api-identity-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.mjs", type: "module" }), + "main.mjs": ` + import {BrowserWindow as Win} from 'electron'; + import {fileURLToPath} from 'node:url'; + class URL {constructor(input, base) {return new globalThis.URL('./actual.js', base);}} + const cache = {loadFile(value) {return value;}}; + cache.loadFile('data.json'); + const localStorage = {setItem(key, value) {return value;}}; + localStorage.setItem('ghost', 'value'); + globalThis.localStorage.setItem('real', 'value'); + const unrelated = {preload: './decoy.js'}; + new Win({webPreferences:{preload:fileURLToPath(new URL('./decoy.js', import.meta.url))}}); + new Win({webPreferences:{preload:'./preload.js'}}).loadFile('index.html'); + `, + "actual.js": "export {};", + "decoy.js": "export {};", + "preload.js": "export {};", + "data.json": "{}", + "index.html": "Fixture", + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const roles = result.graph.nodes.filter( + ({ kind }) => kind === "electron-preload" || kind === "electron-renderer", + ); + expect( + roles.flatMap(({ observations }) => + observations.map(({ properties }) => properties.resolved_path), + ), + ).toEqual(expect.arrayContaining(["preload.js", "index.html"])); + expect( + roles.flatMap(({ observations }) => + observations.map(({ properties }) => properties.resolved_path), + ), + ).not.toEqual(expect.arrayContaining(["decoy.js"])); + expect( + roles.flatMap(({ observations }) => + observations.map(({ properties }) => properties.resolved_path), + ), + ).not.toEqual(expect.arrayContaining(["data.json"])); + const storage = result.graph.nodes.filter(({ kind }) => kind === "storage"); + expect( + storage.flatMap(({ observations }) => + observations.map(({ properties }) => properties.name), + ), + ).toEqual(["real"]); + }, +); diff --git a/tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts b/tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts index fb85848f99..2ab8bfb735 100644 --- a/tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts +++ b/tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts @@ -55,8 +55,7 @@ cliTest.for( const { graph } = result; const windows = graph.nodes.filter(({ kind }) => kind === "browser-window"); expect(windows).toHaveLength(expectedRoles); - // Generic `property:preload` discovery is separate from a proven - // BrowserWindow identity; only the latter may create this association. + // Preload roles share the BrowserWindow identity owner. expect( graph.nodes.filter( ({ kind, observations }) => @@ -67,6 +66,9 @@ cliTest.for( ), ), ).toHaveLength(expectedRoles); + expect( + graph.nodes.filter(({ kind }) => kind === "electron-preload"), + ).toHaveLength(expectedRoles); const main = findSourceModule(graph, "main.cjs"); expect(main).toBeDefined(); const imports = graph.edges.filter( diff --git a/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts b/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts index c979355381..790ae6424f 100644 --- a/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts +++ b/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts @@ -45,8 +45,10 @@ describe("Electron option keys in reconstructed application graphs", () => { expect(result.electron_summary.preload_entrypoints).toBe(0); expect(result.electron_summary.explicit_web_preferences).toBe(0); - // The independent property:preload heuristic can still discover the nested - // literal. It must not carry a BrowserWindow-specific observation or edge. + // A nested literal cannot bypass the unresolved options owner. + expect( + result.graph.nodes.filter(({ kind }) => kind === "electron-preload"), + ).toEqual([]); expect(windowPreloadNodes(result)).toEqual([]); expect( result.graph.edges.filter(