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:
commit
af48fe8791
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
Loading…
Reference in New Issue