Merge pull request #710 from kaoru0822-kitauji/fix/electron-option-property-keys

fix(electron): respect computed option keys and overrides
This commit is contained in:
Kaoru0822 2026-10-06 02:44:14 -07:00 committed by GitHub
commit af48fe8791
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 340 additions and 29 deletions

View File

@ -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

View File

@ -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,
});
};

View File

@ -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"

View File

@ -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<ReturnType<typeof reconstructJavaScriptArtifact>>;
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<Reconstruction> => {
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 });
};