diff --git a/src/domain/electronStaticAnalysisBrowser.ts b/src/domain/electronStaticAnalysisBrowser.ts index 7693d89ab..3287455cf 100644 --- a/src/domain/electronStaticAnalysisBrowser.ts +++ b/src/domain/electronStaticAnalysisBrowser.ts @@ -77,29 +77,18 @@ const collectWindowOptions = ( readonly preload: ElectronBrowserWindowPreload; readonly unknown: number; } => { - if (!t.isObjectExpression(options)) + const lookup = objectProperty(options, "webPreferences"); + if ( + lookup.status !== "explicit" || + !t.isObjectExpression(lookup.property.value) + ) return { - status: options === undefined ? "missing" : "dynamic", + status: lookup.status === "missing" ? "missing" : "dynamic", preferences: [], preload: { preload_path: null, preload_resolution_context: null }, - unknown: options === undefined ? 0 : 1, + unknown: lookup.status === "missing" ? 0 : 1, }; - const property = objectProperty(options, "webPreferences"); - if (property === undefined) - return { - status: "missing", - preferences: [], - preload: { preload_path: null, preload_resolution_context: null }, - unknown: 0, - }; - if (!t.isObjectExpression(property.value)) - return { - status: "dynamic", - preferences: [], - preload: { preload_path: null, preload_resolution_context: null }, - unknown: 1, - }; - return collectWebPreferences(source, property.value); + return collectWebPreferences(source, lookup.property.value); }; const collectWebPreferences = ( @@ -224,11 +213,13 @@ const inspectUtilityProcess = ( const modulePath = moduleNode === undefined ? undefined : staticPath(moduleNode); const options = argumentNode(node.arguments[2]); + const serviceNameProperty = objectProperty(options, "serviceName"); const serviceName = - t.isObjectExpression(options) && - objectProperty(options, "serviceName") !== undefined - ? staticPath(objectProperty(options, "serviceName")?.value ?? options) + serviceNameProperty.status === "explicit" + ? staticPath(serviceNameProperty.property.value) : undefined; + if (serviceNameProperty.status !== "missing" && serviceName === undefined) + context.accumulator.unknownFindings += 1; if (modulePath === undefined) context.accumulator.unknownFindings += 1; const moduleReference = modulePath === undefined || moduleNode === undefined diff --git a/src/domain/electronStaticAnalysisOptions.test.ts b/src/domain/electronStaticAnalysisOptions.test.ts new file mode 100644 index 000000000..16569d46f --- /dev/null +++ b/src/domain/electronStaticAnalysisOptions.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +describe.each(["webPreferences", "serviceName"] as const)( + "Electron %s option lookup", + (name) => { + const value = + name === "webPreferences" + ? '{ preload: "./kept.js", sandbox: true }' + : '"kept"'; + const explicit = `${name}: ${value}`; + const method = `${name}() { return ${value}; }`; + const getter = `get ${name}() { return ${value}; }`; + const setter = `set ${name}(value) {}`; + + const explicitCases = [ + ["plain key", explicit], + ["quoted key", `"${name}": ${value}`], + ["computed string key", `["${name}"]: ${value}`], + ["duplicate key", `${name}: null, ${explicit}`], + ["computed duplicate key", `${name}: null, ["${name}"]: ${value}`], + ["earlier spread", `...options, ${explicit}`], + ["earlier computed identifier", `[key]: null, ${explicit}`], + ["earlier computed expression", `[getKey()]: null, ${explicit}`], + ["earlier getter", `${getter}, ${explicit}`], + ["earlier method", `${method}, ${explicit}`], + ["unrelated later key", `${explicit}, title: "title"`], + ["unrelated computed literal", `${explicit}, ["title"]: null`], + ["unrelated numeric key", `${explicit}, [1]: null`], + ["unrelated empty key", `${explicit}, [""]: null`], + ["unrelated getter", `${explicit}, get title() { return "title"; }`], + ]; + + it.each(explicitCases)( + "retains the explicit value with %s", + (_, members) => { + const analysis = analyzeOption(name, `{ ${members} }`); + expect(analysis.parse_status).toBe("complete"); + if (name === "webPreferences") { + expect(analysis.electron.browser_windows).toEqual([ + expect.objectContaining({ + options_status: "object-literal", + web_preferences_status: "object-literal", + preload_path: "./kept.js", + preload_resolution_context: "module-specifier", + web_preferences: [ + { name: "preload", value: literal("./kept.js") }, + { name: "sandbox", value: literal(true) }, + ], + }), + ]); + } else { + expect(analysis.electron.utility_processes).toEqual([ + expect.objectContaining({ service_name: "kept" }), + ]); + } + }, + ); + + const dynamicCases = [ + ["same-spelled computed binding", `[${name}]: ${value}`], + ["different computed binding", `[key]: ${value}`], + ["computed expression", `[getKey()]: ${value}`], + ["unevaluated template key", `[\`${name}\`]: ${value}`], + ["spread only", "...options"], + ["later spread", `${explicit}, ...options`], + ["later computed binding", `${explicit}, [key]: null`], + ["later computed expression", `${explicit}, [getKey()]: null`], + ["exact getter", getter], + ["exact setter", setter], + ["exact method", method], + ["computed string method", `["${name}"]() { return ${value}; }`], + ["overriding getter", `${explicit}, ${getter}`], + ["overriding setter", `${explicit}, ${setter}`], + ["overriding method", `${explicit}, ${method}`], + ["dynamic explicit value", `${name}: dynamicValue`], + ["null explicit value", `${name}: null`], + ["duplicate null value", `${explicit}, ${name}: null`], + ]; + + it.each(dynamicCases)("preserves uncertainty for %s", (_, members) => { + expectUnknownOption(name, `{ ${members} }`, "object-literal"); + }); + + it.each(["null", "options"])( + "preserves nonliteral options %s", + (options) => { + expectUnknownOption(name, options, "dynamic"); + }, + ); + + it.each(["", "{}", '{ title: "title", ["other"]: null }'])( + "reports an omitted option in %s without inventing a default", + (options) => { + const analysis = analyzeOption(name, options); + expect(analysis.parse_status).toBe("complete"); + if (name === "webPreferences") + expect(analysis.electron.browser_windows[0]).toMatchObject({ + options_status: options === "" ? "missing" : "object-literal", + web_preferences_status: "missing", + web_preferences: [], + preload_path: null, + preload_resolution_context: null, + }); + else + expect( + analysis.electron.utility_processes[0]?.service_name, + ).toBeNull(); + }, + ); + }, +); + +type OptionName = "webPreferences" | "serviceName"; + +const analyzeOption = (name: OptionName, options: string) => + analyzeJavaScriptStaticSource(` + const webPreferences = "title"; + const serviceName = "env"; + ${ + name === "webPreferences" + ? `new BrowserWindow(${options});` + : `utilityProcess.fork("./worker.js", []${options === "" ? "" : `, ${options}`});` + } + `); + +const literal = (value: string | boolean) => ({ + status: "literal", + value, + expression: null, +}); + +const expectUnknownOption = ( + name: OptionName, + options: string, + optionsStatus: "object-literal" | "dynamic", +): void => { + const analysis = analyzeOption(name, options); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.limitations.join(" ")).toContain("remain unknown"); + if (name === "webPreferences") + expect(analysis.electron.browser_windows[0]).toMatchObject({ + options_status: optionsStatus, + web_preferences_status: "dynamic", + web_preferences: [], + preload_path: null, + preload_resolution_context: null, + }); + else + expect(analysis.electron.utility_processes[0]).toMatchObject({ + module_path: "./worker.js", + module_expression: null, + service_name: null, + }); +}; diff --git a/src/domain/electronStaticAnalysisValues.ts b/src/domain/electronStaticAnalysisValues.ts index 549b9e815..be15bca35 100644 --- a/src/domain/electronStaticAnalysisValues.ts +++ b/src/domain/electronStaticAnalysisValues.ts @@ -2,6 +2,7 @@ import * as t from "@babel/types"; import { compareCodePoints } from "./canonicalOrdering.js"; import type { ElectronStaticValue } from "./electronStaticAnalysisTypes.js"; +import { semanticStaticPropertyName } from "./javascriptAstValues.js"; import { propertyName, sourceSlice, @@ -32,15 +33,27 @@ export const boundedExpression = ( return expression === "" ? `[${node.type}]` : expression; }; -/** Read one named object property without following spreads or bindings. */ +/** Find the effective explicit property, preserving unresolved overrides. */ export const objectProperty = ( - object: t.ObjectExpression, + object: t.Node | undefined, name: string, -): t.ObjectProperty | undefined => - object.properties.find( - (property): property is t.ObjectProperty => - t.isObjectProperty(property) && propertyName(property.key) === name, - ); +): + | { readonly status: "missing" | "dynamic" } + | { readonly status: "explicit"; readonly property: t.ObjectProperty } => { + if (object === undefined) return { status: "missing" }; + if (!t.isObjectExpression(object)) return { status: "dynamic" }; + for (const property of object.properties.toReversed()) { + if (t.isSpreadElement(property)) return { status: "dynamic" }; + const key = semanticStaticPropertyName(property.key, property.computed); + if (key === "" && !t.isStringLiteral(property.key)) + return { status: "dynamic" }; + if (key !== name) continue; + return t.isObjectProperty(property) + ? { status: "explicit", property } + : { status: "dynamic" }; + } + return { status: "missing" }; +}; type PresentHandlerKind = | "inline-function" diff --git a/tests/composition/javascript-applications/electronOptionKeys.test.ts b/tests/composition/javascript-applications/electronOptionKeys.test.ts new file mode 100644 index 000000000..404f374aa --- /dev/null +++ b/tests/composition/javascript-applications/electronOptionKeys.test.ts @@ -0,0 +1,150 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const PRELOAD = '{ preload: "./preload.js", sandbox: true }'; +const EXPLICIT = `webPreferences: ${PRELOAD}`; + +describe("Electron option keys in reconstructed application graphs", () => { + it.each([ + ["computed binding", `[webPreferences]: ${PRELOAD}`], + ["later spread", `${EXPLICIT}, ...options`], + ["later computed key", `${EXPLICIT}, [getKey()]: null`], + ["later accessor", `${EXPLICIT}, get webPreferences() { return null; }`], + ["later duplicate", `${EXPLICIT}, webPreferences: null`], + ])( + "does not associate a BrowserWindow preload from %s", + async (_, options) => { + const result = await reconstructSource(` + const webPreferences = "title"; + new BrowserWindow({ ${options} }); + `); + const windows = result.graph.nodes.filter( + ({ kind }) => kind === "browser-window", + ); + expect(windows).toHaveLength(1); + const observation = windows[0]?.observations[0]; + expect(observation).toMatchObject({ + properties: { + options_status: "object-literal", + web_preferences_status: "dynamic", + web_preferences: [], + preload_path: null, + preload_resolution_context: null, + }, + evidence: { + state: "observed", + confidence: "exact", + coverage: { status: "partial" }, + }, + }); + 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. + expect(windowPreloadNodes(result)).toEqual([]); + expect( + result.graph.edges.filter( + (edge) => + edge.source_node_id === windows[0]?.node_id && + edge.relation === "loads", + ), + ).toEqual([]); + expect(result.graph.edges).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "maps_to", + evidence: expect.objectContaining({ + location: observation?.evidence.location, + }), + }), + ]), + ); + }, + ); + + it.each([ + ["plain key", EXPLICIT], + ["quoted key", `"webPreferences": ${PRELOAD}`], + ["computed literal", `["webPreferences"]: ${PRELOAD}`], + ["later explicit key", `...options, [getKey()]: null, ${EXPLICIT}`], + ["last duplicate key", `webPreferences: null, ${EXPLICIT}`], + ])( + "retains the BrowserWindow preload association for %s", + async (_, options) => { + const result = await reconstructSource( + `new BrowserWindow({ ${options} });`, + ); + const preloads = windowPreloadNodes(result); + expect(preloads).toHaveLength(1); + expect(result.electron_summary.preload_entrypoints).toBe(1); + expect(result.electron_summary.explicit_web_preferences).toBe(2); + expect(result.graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "loads", + target_node_id: preloads[0]?.node_id, + evidence: expect.objectContaining({ + state: "inferred", + confidence: "high", + extractor: expect.objectContaining({ + operation: "associate-browser-window-preload", + }), + coverage: expect.objectContaining({ status: "complete" }), + }), + }), + expect.objectContaining({ + relation: "maps_to", + source_node_id: preloads[0]?.node_id, + properties: expect.objectContaining({ + resolved_path: "preload.js", + }), + }), + ]), + ); + }, + ); + + it("keeps a computed serviceName unknown in the utility-process observation", async () => { + const result = await reconstructSource(` + const serviceName = "env"; + utilityProcess.fork("./worker.js", [], { [serviceName]: "wrong-name" }); + `); + const utilities = result.graph.nodes.filter( + ({ kind }) => kind === "electron-utility", + ); + expect(utilities).toHaveLength(1); + expect(utilities[0]?.observations[0]).toMatchObject({ + label: "./worker.js", + properties: { service_name: null, module_path: "./worker.js" }, + evidence: { coverage: { status: "partial" } }, + }); + }); +}); + +type Reconstruction = Awaited>; + +const windowPreloadNodes = (result: Reconstruction) => + result.graph.nodes.filter( + (node) => + node.kind === "electron-preload" && + node.observations.some( + ({ properties }) => + properties.mechanism === "BrowserWindow:webPreferences.preload", + ), + ); + +const reconstructSource = async (source: string): Promise => { + const root = await createTestTempDirectory("rea-electron-option-keys-"); + await Promise.all([ + writeFile(join(root, "package.json"), JSON.stringify({ main: "main.js" })), + writeFile(join(root, "main.js"), source), + writeFile(join(root, "preload.js"), "void 0;"), + writeFile(join(root, "worker.js"), "void 0;"), + ]); + return reconstructJavaScriptArtifact({ input_path: root }); +};