mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(javascript): preserve execution and invocation provenance
Keep direct empty bridge objects observed, reject unexecuted initializers, invalidate modified bootstrap tables and Electron identities, and retain selection predicates through symbolic values and wrapper paths. Track lexical binding identity and completed activation boundaries without leaking analysis order into earlier reads. Separate wrapper invocation provenance from the underlying IPC source range so repeated registrations remain ambiguous through transfer and graph joins. Use the same offered transfer budget for module preflight and final writing. Index static literal properties once instead of rescanning every member. Keep the original 10,001-member capacity fixture; Flameox confirmed the repeated lookup cost and the optimized fixture completes in under one second locally. Prune the stale compact-schema tool inventory assertion and update the public call-graph registration expectation to its declared result envelope. Validation: build, check, docs:check, 83 focused tests, 10 Electron identity acceptance cases, 2 full-scale finding-capacity cases, and 7 compact-schema transport tests. No real Electron runtime execution is claimed.
This commit is contained in:
1 parent
edf46248f2
commit
b8fab74297
20 files changed
+1718
-342
No files matched your search
@@ -220,5 +220,9 @@ const resolvesToFile = (input: ResolveToFileInput): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const ipcRecordKey = (path: string, finding: ElectronIpcFinding): string =>
|
||||
`${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}\0${finding.channel ?? finding.channel_expression}\0${JSON.stringify(finding.handler_location)}`;
|
||||
const ipcRecordKey = (path: string, finding: ElectronIpcFinding): string => {
|
||||
const identity = `${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}\0${finding.channel ?? finding.channel_expression}\0${JSON.stringify(finding.handler_location)}`;
|
||||
return finding.invocation_location === undefined
|
||||
? identity
|
||||
: `${identity}\0${String(finding.invocation_location.start.line)}:${String(finding.invocation_location.start.column)}`;
|
||||
};
|
||||
@@ -169,11 +169,25 @@ export const addElectronCallPaths = (
|
||||
const matches = (
|
||||
projected: ProjectedPath,
|
||||
record: ElectronIpcRecord,
|
||||
): boolean =>
|
||||
projected.file.path === record.file.path &&
|
||||
projected.path.channel === record.finding.channel &&
|
||||
projected.path.steps.some(
|
||||
(step) =>
|
||||
electronRangeKey(step.location) ===
|
||||
electronRangeKey(record.finding.location),
|
||||
): boolean => {
|
||||
const { path } = projected;
|
||||
const { finding } = record;
|
||||
const invocation = finding.invocation_location;
|
||||
const sourceStepKind =
|
||||
finding.mode === "send" || finding.mode === "invoke" ? "terminal" : "ipc";
|
||||
return (
|
||||
projected.file.path === record.file.path &&
|
||||
path.channel === finding.channel &&
|
||||
path.steps.some(
|
||||
(step) =>
|
||||
step.kind === sourceStepKind &&
|
||||
electronRangeKey(step.location) === electronRangeKey(finding.location),
|
||||
) &&
|
||||
(invocation === undefined ||
|
||||
path.steps.some(
|
||||
(step) =>
|
||||
(step.kind === "call" || step.kind === "exposure") &&
|
||||
electronRangeKey(step.location) === electronRangeKey(invocation),
|
||||
))
|
||||
);
|
||||
};
|
||||
@@ -145,6 +145,9 @@ const addChannelNode = (
|
||||
channel: finding.channel,
|
||||
channel_expression: finding.channel_expression,
|
||||
resolution: finding.channel === null ? "dynamic" : "literal",
|
||||
...(finding.invocation_location === undefined
|
||||
? {}
|
||||
: { invocation_location: finding.invocation_location }),
|
||||
},
|
||||
evidence: (finding.resolution_basis === "wrapper-substitution"
|
||||
? staticInferenceEvidence
|
||||
@@ -192,6 +195,9 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
|
||||
channel_expression: finding.channel_expression,
|
||||
handler_kind: finding.handler_kind,
|
||||
handler_location: finding.handler_location,
|
||||
...(finding.invocation_location === undefined
|
||||
? {}
|
||||
: { invocation_location: finding.invocation_location }),
|
||||
},
|
||||
evidence: (finding.resolution_basis === "wrapper-substitution"
|
||||
? staticInferenceEvidence
|
||||
@@ -227,6 +233,9 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
|
||||
channel: finding.channel,
|
||||
channel_expression: finding.channel_expression,
|
||||
resolution: finding.channel === null ? "dynamic" : "literal",
|
||||
...(finding.invocation_location === undefined
|
||||
? {}
|
||||
: { invocation_location: finding.invocation_location }),
|
||||
},
|
||||
});
|
||||
return handler;
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
} from "../domain/javascript/javascriptSemanticAnalysis.js";
|
||||
import { failedJavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import { projectJavaScriptModuleSemantics } from "../domain/javascript/javascriptModuleSemanticIr.js";
|
||||
import { measureJavaScriptModuleTransfer } from "../javascript/analysis/JavaScriptAnalysisTransfer.js";
|
||||
import {
|
||||
javaScriptTransferByteBudget,
|
||||
measureJavaScriptModuleTransfer,
|
||||
} from "../javascript/analysis/JavaScriptAnalysisTransfer.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import { projectJavaScriptSemanticFileSteps } from "../application/javascript/JavaScriptSemanticGraphBuilder.js";
|
||||
import { completeJavaScriptAnalysisSteps } from "../application/javascript/JavaScriptAnalysisControl.js";
|
||||
@@ -54,7 +57,10 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async (
|
||||
);
|
||||
parsed = null;
|
||||
const module = projectJavaScriptModuleSemantics(ir);
|
||||
const measured = measureJavaScriptModuleTransfer(module);
|
||||
const measured = measureJavaScriptModuleTransfer(
|
||||
module,
|
||||
javaScriptTransferByteBudget(resultBudgetBytes),
|
||||
);
|
||||
if (measured.retentionBytes > resultBudgetBytes)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"analyze_javascript_application",
|
||||
|
||||
@@ -119,6 +119,8 @@ interface ElectronIpcFindingContext {
|
||||
/** Recovered factory runtime; absent in older or unbundled findings. */
|
||||
readonly module_runtime?: string;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
/** Outer syntax invocation when a wrapper produced this underlying IPC call. */
|
||||
readonly invocation_location?: JavaScriptSourceRange;
|
||||
}
|
||||
|
||||
/** One statically visible Electron IPC send, invocation, listener, or handler. */
|
||||
|
||||
@@ -105,148 +105,365 @@ describe("wrapped Electron control-flow alternatives", () => {
|
||||
...new Set(result.electron.call_paths?.map((path) => path.channel)),
|
||||
].sort(),
|
||||
).toEqual(["test:copy", "test:cut"]);
|
||||
const paths = result.electron.call_paths ?? [];
|
||||
expect(
|
||||
paths
|
||||
.find(({ channel }) => channel === "test:copy")
|
||||
?.conditions.map(({ expression }) => expression),
|
||||
).toContain("flag");
|
||||
expect(
|
||||
paths
|
||||
.filter(({ channel }) => channel === "test:cut")
|
||||
.some((path) =>
|
||||
path.conditions.some(({ expression }) => expression === "!(flag)"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapped Electron call paths", () => {
|
||||
it("connects exposed closures to IPC sends and text clipboard calls", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(`
|
||||
it("preserves an observed empty contextBridge API object", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const electron = require("electron");
|
||||
electron.contextBridge.exposeInMainWorld("desktop", {});
|
||||
`);
|
||||
expect(result.electron.context_bridge_apis).toContainEqual(
|
||||
expect.objectContaining({
|
||||
api_key: "desktop",
|
||||
api_status: "object-literal",
|
||||
members: [],
|
||||
unknown_members: 0,
|
||||
}),
|
||||
);
|
||||
expect(result.electron.context_bridge_apis?.[0]).not.toHaveProperty(
|
||||
"resolution_basis",
|
||||
);
|
||||
expect(result.parse_status).toBe("complete");
|
||||
});
|
||||
|
||||
it("connects exposed closures to IPC sends and text clipboard calls", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(`
|
||||
const text = value => navigator.clipboard.writeText(value);
|
||||
const api={copy:helper.sender("example:copy"),copyText:text};
|
||||
electron.contextBridge.exposeInMainWorld("desktop",api);
|
||||
`),
|
||||
);
|
||||
expect(result.electron.context_bridge_apis).toMatchObject([
|
||||
{ api_key: "desktop", members: ["copy", "copyText"] },
|
||||
]);
|
||||
expect(result.electron.ipc).toContainEqual(
|
||||
);
|
||||
expect(result.electron.context_bridge_apis).toMatchObject([
|
||||
{ api_key: "desktop", members: ["copy", "copyText"] },
|
||||
]);
|
||||
expect(result.electron.ipc).toContainEqual(
|
||||
expect.objectContaining({
|
||||
side: "renderer",
|
||||
channel: "example:copy",
|
||||
operation: "send",
|
||||
}),
|
||||
);
|
||||
expect(result.electron.call_paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
side: "renderer",
|
||||
entry: "desktop.copy",
|
||||
channel: "example:copy",
|
||||
operation: "send",
|
||||
terminal: "electron.ipcRenderer.send",
|
||||
}),
|
||||
);
|
||||
expect(result.electron.call_paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
entry: "desktop.copy",
|
||||
channel: "example:copy",
|
||||
terminal: "electron.ipcRenderer.send",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entry: "desktop.copyText",
|
||||
terminal: "navigator.clipboard.writeText",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("substitutes guarded listener callbacks and preserves guard evidence", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(
|
||||
`helper.events.addListener("example:copy",event=>event.sender.copy());`,
|
||||
),
|
||||
);
|
||||
expect(result.electron.ipc).toContainEqual(
|
||||
expect.objectContaining({
|
||||
side: "main",
|
||||
channel: "example:copy",
|
||||
operation: "on",
|
||||
entry: "desktop.copyText",
|
||||
terminal: "navigator.clipboard.writeText",
|
||||
}),
|
||||
);
|
||||
expect(result.electron.call_paths).toContainEqual(
|
||||
expect.objectContaining({
|
||||
entry_kind: "ipc-handler",
|
||||
channel: "example:copy",
|
||||
terminal: "ipc-event.sender.copy",
|
||||
conditions: [expect.objectContaining({ expression: "allowed(event)" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("does not identify overwritten Electron namespaces or shadowed require", () => {
|
||||
for (const source of [
|
||||
`const e=require("electron");e.ipcMain={on(){}};e.ipcMain.on("bad",event=>event.sender.copy());`,
|
||||
`function require(){return {ipcMain:{on(){}}}};const e=require("electron");e.ipcMain.on("bad",event=>event.sender.copy());`,
|
||||
])
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(source).electron.call_paths ?? [],
|
||||
).toEqual([]);
|
||||
});
|
||||
it("does not follow a statically false callback branch", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(
|
||||
`helper.events.addListener("example:copy",event=>{if(false)event.sender.copy()});`,
|
||||
),
|
||||
);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
});
|
||||
it("retains early rejection guards and excludes calls after definite returns", () => {
|
||||
const guarded = analyzeJavaScriptStaticSource(
|
||||
`const e=require("electron");e.ipcMain.on("guard",event=>{if(!allowed(event))return;event.sender.copy()});`,
|
||||
);
|
||||
expect(guarded.electron.call_paths).toContainEqual(
|
||||
expect.objectContaining({
|
||||
conditions: [
|
||||
expect.objectContaining({ expression: "!(!allowed(event))" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
for (const body of [
|
||||
"return;event.sender.copy()",
|
||||
"if(true)return;event.sender.copy()",
|
||||
"if(false)event.sender.copy()",
|
||||
])
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`const e=require("electron");e.ipcMain.on("dead",event=>{${body}});`,
|
||||
).electron.call_paths ?? [],
|
||||
).toEqual([]);
|
||||
]),
|
||||
);
|
||||
const ipcFinding = result.electron.ipc.find(
|
||||
({ channel }) => channel === "example:copy",
|
||||
);
|
||||
const ipcPath = result.electron.call_paths?.find(
|
||||
({ entry }) => entry === "desktop.copy",
|
||||
);
|
||||
expect(ipcFinding).toBeDefined();
|
||||
expect(ipcPath?.steps.at(-1)).toBeDefined();
|
||||
expect(ipcFinding?.location).toEqual(ipcPath?.steps.at(-1)?.location);
|
||||
});
|
||||
it("resolves a helper factory listed after its bundle consumer", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`(() => {
|
||||
const modules = {
|
||||
2(module) { module.exports = require("electron"); },
|
||||
9(module,exports,load) {
|
||||
const electron=load(2),helper=load(5);
|
||||
electron.contextBridge.exposeInMainWorld("desktop", {copy:helper.sender("after:copy")});
|
||||
},
|
||||
5(module,exports,load) {
|
||||
const electron=load(2);
|
||||
exports.sender=channel=>()=>electron.ipcRenderer.send(channel);
|
||||
}
|
||||
};
|
||||
function load(id) { const m={exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; }
|
||||
load(9);
|
||||
})();`);
|
||||
expect(result.electron.call_paths).toContainEqual(
|
||||
expect.objectContaining({
|
||||
entry: "desktop.copy",
|
||||
channel: "after:copy",
|
||||
terminal: "electron.ipcRenderer.send",
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("substitutes guarded listener callbacks and preserves guard evidence", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(
|
||||
`helper.events.addListener("example:copy",event=>event.sender.copy());`,
|
||||
),
|
||||
);
|
||||
expect(result.electron.ipc).toContainEqual(
|
||||
expect.objectContaining({
|
||||
side: "main",
|
||||
channel: "example:copy",
|
||||
operation: "on",
|
||||
}),
|
||||
);
|
||||
expect(result.electron.call_paths).toContainEqual(
|
||||
expect.objectContaining({
|
||||
entry_kind: "ipc-handler",
|
||||
channel: "example:copy",
|
||||
terminal: "ipc-event.sender.copy",
|
||||
conditions: [expect.objectContaining({ expression: "allowed(event)" })],
|
||||
}),
|
||||
);
|
||||
const ipcFinding = result.electron.ipc.find(
|
||||
({ channel, operation }) =>
|
||||
channel === "example:copy" && operation === "on",
|
||||
);
|
||||
const ipcStep = result.electron.call_paths
|
||||
?.find(({ entry_kind }) => entry_kind === "ipc-handler")
|
||||
?.steps.find(({ kind }) => kind === "ipc");
|
||||
expect(ipcFinding).toBeDefined();
|
||||
expect(ipcStep).toBeDefined();
|
||||
expect(ipcFinding?.location).toEqual(ipcStep?.location);
|
||||
});
|
||||
it("retains distinct wrapper invocation ranges for one IPC API source", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const e=require("electron");
|
||||
const copy=event=>event.sender.copy();
|
||||
function register(channel,handler){e.ipcMain.on(channel,handler);}
|
||||
register("same:channel",copy);
|
||||
register("same:channel",copy);
|
||||
`);
|
||||
const findings = result.electron.ipc.filter(
|
||||
({ resolution_basis, channel }) =>
|
||||
resolution_basis === "wrapper-substitution" && channel === "same:channel",
|
||||
);
|
||||
expect(findings).toHaveLength(2);
|
||||
expect(findings[0]?.location).toEqual(findings[1]?.location);
|
||||
expect(findings[0]?.invocation_location).toBeDefined();
|
||||
expect(findings[1]?.invocation_location).toBeDefined();
|
||||
expect(findings[0]?.invocation_location).not.toEqual(
|
||||
findings[1]?.invocation_location,
|
||||
);
|
||||
});
|
||||
it("does not identify overwritten Electron namespaces or shadowed require", () => {
|
||||
for (const source of [
|
||||
`const e=require("electron");e.ipcMain={on(){}};e.ipcMain.on("bad",event=>event.sender.copy());`,
|
||||
`function require(){return {ipcMain:{on(){}}}};const e=require("electron");e.ipcMain.on("bad",event=>event.sender.copy());`,
|
||||
])
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`const e=require("electron");if(false)e.ipcMain.on("dead",event=>event.sender.copy());`,
|
||||
).electron.call_paths ?? [],
|
||||
analyzeJavaScriptStaticSource(source).electron.call_paths ?? [],
|
||||
).toEqual([]);
|
||||
});
|
||||
it("rejects dynamic channels, computed overwrites, unknown spreads and reassigned callback parameters", () => {
|
||||
for (const entry of [
|
||||
`helper.events.addListener(runtimeChannel,event=>event.sender.copy());`,
|
||||
`electron[runtimeKey]={};electron.ipcMain.on("bad",event=>event.sender.copy());`,
|
||||
`const api={copy:helper.sender("bad"),...unknownApi};electron.contextBridge.exposeInMainWorld("desktop",api);`,
|
||||
`helper.events.addListener("bad",event=>{event=other;event.sender.copy()});`,
|
||||
])
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(bundle(entry)).electron.call_paths ?? [],
|
||||
).toEqual([]);
|
||||
});
|
||||
it("resolves renamed default and namespace import helpers from their syntax", () => {
|
||||
});
|
||||
it("preserves guarded alternatives in direct exposed object literals", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const e=require("electron");
|
||||
const copy=()=>e.ipcRenderer.send("copy");
|
||||
const cut=()=>e.ipcRenderer.send("cut");
|
||||
e.contextBridge.exposeInMainWorld("desktop", {action: flag ? copy : cut});
|
||||
`);
|
||||
expect(result.electron.call_paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
entry: "desktop.action",
|
||||
channel: "copy",
|
||||
conditions: [expect.objectContaining({ expression: "flag" })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entry: "desktop.action",
|
||||
channel: "cut",
|
||||
conditions: [expect.objectContaining({ expression: "!(flag)" })],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
it("preserves conditions when a wrapper dispatches through conditional external methods", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const e=require("electron");
|
||||
const send=flag ? e.ipcRenderer.send : e.ipcRenderer.invoke;
|
||||
e.contextBridge.exposeInMainWorld("desktop", {action:()=>send("channel")});
|
||||
`);
|
||||
expect(result.electron.call_paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
entry: "desktop.action",
|
||||
channel: "channel",
|
||||
terminal: "electron.ipcRenderer.send",
|
||||
conditions: [expect.objectContaining({ expression: "flag" })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entry: "desktop.action",
|
||||
channel: "channel",
|
||||
terminal: "electron.ipcRenderer.invoke",
|
||||
conditions: [expect.objectContaining({ expression: "!(flag)" })],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
result.electron.ipc.filter(
|
||||
({ resolution_basis, channel }) =>
|
||||
resolution_basis === "wrapper-substitution" && channel === "channel",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
it("keeps conditional IPC channels unknown instead of reporting them unconditionally", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const e=require("electron");
|
||||
const channel=flag ? "copy" : "cut";
|
||||
e.contextBridge.exposeInMainWorld("desktop", {action:()=>e.ipcRenderer.send(channel)});
|
||||
`);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
it("preserves the raw bridge observation for a guarded object value", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const e=require("electron");
|
||||
const api=flag ? {copy:()=>e.ipcRenderer.send("copy")} : {};
|
||||
e.contextBridge.exposeInMainWorld("desktop", api);
|
||||
`);
|
||||
expect(result.electron.context_bridge_apis).toContainEqual(
|
||||
expect.objectContaining({
|
||||
api_key: "desktop",
|
||||
api_status: "dynamic",
|
||||
}),
|
||||
);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
it("preserves the source expression for a guarded bridge world key", () => {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const e=require("electron");
|
||||
const world=flag ? "desktop" : "shell";
|
||||
e.contextBridge.exposeInMainWorld(world, {copy:()=>e.ipcRenderer.send("copy")});
|
||||
`);
|
||||
expect(result.electron.context_bridge_apis).toContainEqual(
|
||||
expect.objectContaining({ api_key: null, api_key_expression: "world" }),
|
||||
);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
it.each(["const", "var"])(
|
||||
"does not use a %s callback initializer before exposure",
|
||||
(declaration) => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(`
|
||||
const electron=load(2);
|
||||
electron.contextBridge.exposeInMainWorld("desktop", {copy: callback});
|
||||
${declaration} callback=()=>electron.ipcRenderer.send("late");
|
||||
`),
|
||||
);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
},
|
||||
);
|
||||
it("does not use an original Electron module factory after its slot is replaced", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(`
|
||||
modules[2] = function(module) {
|
||||
module.exports = {contextBridge:{exposeInMainWorld(){}},ipcRenderer:{send(){}}};
|
||||
};
|
||||
const electron=load(2);
|
||||
electron.contextBridge.exposeInMainWorld("desktop", {copy:()=>electron.ipcRenderer.send("fake")});
|
||||
`),
|
||||
);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
});
|
||||
it.each(["({})", "load(2)"])(
|
||||
"does not emit the impossible logical-RHS terminal after %s",
|
||||
(left) => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(`
|
||||
electron.ipcMain.on("guard", event => { ${left} || event.sender.copy(); });
|
||||
`),
|
||||
);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
},
|
||||
);
|
||||
it("does not follow a statically false callback branch", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(
|
||||
`helper.events.addListener("example:copy",event=>{if(false)event.sender.copy()});`,
|
||||
),
|
||||
);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
});
|
||||
it("retains early rejection guards and excludes calls after definite returns", () => {
|
||||
const guarded = analyzeJavaScriptStaticSource(
|
||||
`const e=require("electron");e.ipcMain.on("guard",event=>{if(!allowed(event))return;event.sender.copy()});`,
|
||||
);
|
||||
expect(guarded.electron.call_paths).toContainEqual(
|
||||
expect.objectContaining({
|
||||
conditions: [
|
||||
expect.objectContaining({ expression: "!(!allowed(event))" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
for (const body of [
|
||||
"return;event.sender.copy()",
|
||||
"if(true)return;event.sender.copy()",
|
||||
"if(false)event.sender.copy()",
|
||||
])
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`const e=require("electron");e.ipcMain.on("dead",event=>{${body}});`,
|
||||
).electron.call_paths ?? [],
|
||||
).toEqual([]);
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(
|
||||
`const e=require("electron");if(false)e.ipcMain.on("dead",event=>event.sender.copy());`,
|
||||
).electron.call_paths ?? [],
|
||||
).toEqual([]);
|
||||
});
|
||||
it("rejects dynamic channels, computed overwrites, unknown spreads and reassigned callback parameters", () => {
|
||||
for (const entry of [
|
||||
`helper.events.addListener(runtimeChannel,event=>event.sender.copy());`,
|
||||
`electron[runtimeKey]={};electron.ipcMain.on("bad",event=>event.sender.copy());`,
|
||||
`const api={copy:helper.sender("bad"),...unknownApi};electron.contextBridge.exposeInMainWorld("desktop",api);`,
|
||||
`helper.events.addListener("bad",event=>{event=other;event.sender.copy()});`,
|
||||
])
|
||||
expect(
|
||||
analyzeJavaScriptStaticSource(bundle(entry)).electron.call_paths ?? [],
|
||||
).toEqual([]);
|
||||
});
|
||||
it("resolves renamed default and namespace import helpers from their syntax", () => {
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
bundle(`
|
||||
const defaultImport=x=>x&&x.__esModule?x:{default:x};
|
||||
function namespaceImport(x){if(x&&x.__esModule)return x;return unknownWrap(x)}
|
||||
const E=defaultImport(load(2)),H=namespaceImport(load(5));
|
||||
E.default.contextBridge.exposeInMainWorld("renamed",{copy:(0,H.sender)("test:renamed")});
|
||||
`),
|
||||
);
|
||||
expect(result.electron.call_paths).toContainEqual(
|
||||
expect.objectContaining({
|
||||
entry: "renamed.copy",
|
||||
channel: "test:renamed",
|
||||
terminal: "electron.ipcRenderer.send",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.electron.call_paths
|
||||
?.flatMap((p) => p.steps)
|
||||
.every((step) => step.label.length > 0),
|
||||
).toBe(true);
|
||||
});
|
||||
it("does not trust helper names or follow returns after a definite throw", () => {
|
||||
for (const helper of [
|
||||
`function __toESM() {return {ipcRenderer:{send(){}}}}`,
|
||||
`function __toESM(value) {throw Error("inert");return value;}`,
|
||||
`function __toESM(value) {if(true){throw Error("inert")}return value;}`,
|
||||
]) {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
);
|
||||
expect(result.electron.call_paths).toContainEqual(
|
||||
expect.objectContaining({
|
||||
entry: "renamed.copy",
|
||||
channel: "test:renamed",
|
||||
terminal: "electron.ipcRenderer.send",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.electron.call_paths
|
||||
?.flatMap((p) => p.steps)
|
||||
.every((step) => step.label.length > 0),
|
||||
).toBe(true);
|
||||
});
|
||||
it("does not trust helper names or follow returns after a definite throw", () => {
|
||||
for (const helper of [
|
||||
`function __toESM() {return {ipcRenderer:{send(){}}}}`,
|
||||
`function __toESM(value) {throw Error("inert");return value;}`,
|
||||
`function __toESM(value) {if(true){throw Error("inert")}return value;}`,
|
||||
]) {
|
||||
const result = analyzeJavaScriptStaticSource(`
|
||||
const electron = require("electron");
|
||||
${helper}
|
||||
const wrapped = __toESM(electron);
|
||||
@@ -254,7 +471,6 @@ describe("wrapped Electron call paths", () => {
|
||||
copy:()=>wrapped.ipcRenderer.send("test:copy")
|
||||
});
|
||||
`);
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
}
|
||||
});
|
||||
expect(result.electron.call_paths ?? []).toEqual([]);
|
||||
}
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
const condition = (
|
||||
context: PathContext,
|
||||
node: t.Node,
|
||||
positive: boolean | "nullish" = true,
|
||||
positive: boolean | "nullish" | "not-nullish" = true,
|
||||
): PathContext => {
|
||||
const expression = source
|
||||
.slice(node.start ?? 0, node.end ?? 0)
|
||||
@@ -85,14 +85,33 @@ export const collectElectronWrappedCallPaths = (
|
||||
expression:
|
||||
positive === "nullish"
|
||||
? `(${expression}) === null || (${expression}) === undefined`
|
||||
: positive
|
||||
? expression
|
||||
: `!(${expression})`,
|
||||
: positive === "not-nullish"
|
||||
? `(${expression}) !== null && (${expression}) !== undefined`
|
||||
: positive
|
||||
? expression
|
||||
: `!(${expression})`,
|
||||
location: range(node),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
const metadataContext = (
|
||||
context: PathContext,
|
||||
value: SymbolicValue,
|
||||
): PathContext | null => {
|
||||
if (value.conditionUnresolved) {
|
||||
accumulator.unknownFindings += 1;
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
value.conditions?.reduce(
|
||||
(current, branch) => condition(current, branch.node, branch.positive),
|
||||
context,
|
||||
) ?? context
|
||||
);
|
||||
};
|
||||
const hasConditions = (value: SymbolicValue): boolean =>
|
||||
symbolicVariants(value).some((variant) => variant.conditions?.length);
|
||||
const emit = (
|
||||
context: PathContext,
|
||||
node: t.Node,
|
||||
@@ -118,14 +137,19 @@ export const collectElectronWrappedCallPaths = (
|
||||
fn: SymbolicFunction,
|
||||
args: readonly SymbolicValue[],
|
||||
context: PathContext,
|
||||
invocationNode: t.Node,
|
||||
): void => {
|
||||
if (context.active.has(fn.node)) return;
|
||||
if (fn.conditionUnresolved === true) {
|
||||
accumulator.unknownFindings += 1;
|
||||
return;
|
||||
}
|
||||
const name =
|
||||
(t.isFunctionExpression(fn.node) || t.isFunctionDeclaration(fn.node)) &&
|
||||
fn.node.id
|
||||
? fn.node.id.name
|
||||
: "closure";
|
||||
walk(fn.node.body, resolver.bind(fn, args), {
|
||||
walk(fn.node.body, resolver.bind(fn, args, invocationNode), {
|
||||
...context,
|
||||
steps: [
|
||||
...context.steps,
|
||||
@@ -147,14 +171,32 @@ export const collectElectronWrappedCallPaths = (
|
||||
args: readonly SymbolicValue[],
|
||||
context: PathContext,
|
||||
endpoint: string,
|
||||
guardedDispatch: boolean,
|
||||
): void => {
|
||||
const first = args[0];
|
||||
if (first?.kind !== "literal" || typeof first.value !== "string") return;
|
||||
if (first?.kind !== "literal" || typeof first.value !== "string") {
|
||||
accumulator.unknownFindings += 1;
|
||||
return;
|
||||
}
|
||||
const channelContext = metadataContext(context, first);
|
||||
if (!channelContext) return;
|
||||
const handlerValue = args[1] ?? symbolicUnknown("missing handler");
|
||||
const handlerContext = metadataContext(channelContext, handlerValue);
|
||||
if (!handlerContext) return;
|
||||
const channel = first.value;
|
||||
const location = range(context.entryNode);
|
||||
const handlers = symbolicVariants(
|
||||
args[1] ?? symbolicUnknown("missing handler"),
|
||||
);
|
||||
const location = range(node);
|
||||
const invocationLocation =
|
||||
JSON.stringify(range(context.entryNode)) === JSON.stringify(location)
|
||||
? undefined
|
||||
: range(context.entryNode);
|
||||
const findingContext = {
|
||||
module_key: null,
|
||||
location,
|
||||
...(invocationLocation === undefined
|
||||
? {}
|
||||
: { invocation_location: invocationLocation }),
|
||||
};
|
||||
const handlers = symbolicVariants(handlerValue);
|
||||
// A channel registration must not acquire one arbitrary handler from a union.
|
||||
// Keep the unresolved registration so it still competes with other handlers.
|
||||
const fn =
|
||||
@@ -170,8 +212,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
channel_expression: null,
|
||||
handler_kind: null,
|
||||
handler_location: null,
|
||||
module_key: null,
|
||||
location,
|
||||
...findingContext,
|
||||
}
|
||||
: fn
|
||||
? {
|
||||
@@ -180,8 +221,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
channel_expression: null,
|
||||
handler_kind: "inline-function",
|
||||
handler_location: range(fn.node),
|
||||
module_key: null,
|
||||
location,
|
||||
...findingContext,
|
||||
}
|
||||
: handlerNode
|
||||
? {
|
||||
@@ -190,8 +230,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
channel_expression: null,
|
||||
handler_kind: "dynamic-expression",
|
||||
handler_location: range(handlerNode),
|
||||
module_key: null,
|
||||
location,
|
||||
...findingContext,
|
||||
}
|
||||
: {
|
||||
...descriptor,
|
||||
@@ -199,28 +238,28 @@ export const collectElectronWrappedCallPaths = (
|
||||
channel_expression: null,
|
||||
handler_kind: "missing",
|
||||
handler_location: null,
|
||||
module_key: null,
|
||||
location,
|
||||
...findingContext,
|
||||
};
|
||||
const duplicate = accumulator.ipc.some(
|
||||
({ value }) =>
|
||||
value.side === finding.side &&
|
||||
value.operation === finding.operation &&
|
||||
value.channel === channel &&
|
||||
[JSON.stringify(location), JSON.stringify(range(node))].includes(
|
||||
JSON.stringify(value.location),
|
||||
),
|
||||
JSON.stringify(value.location) === JSON.stringify(location) &&
|
||||
JSON.stringify(value.invocation_location) ===
|
||||
JSON.stringify(invocationLocation),
|
||||
);
|
||||
if (!duplicate) {
|
||||
const findingGuarded = hasConditions(first) || guardedDispatch;
|
||||
if (!duplicate && !findingGuarded) {
|
||||
accumulator.ipc.push({
|
||||
offset: context.entryNode.start ?? -1,
|
||||
offset: node.start ?? -1,
|
||||
value: { ...finding, resolution_basis: "wrapper-substitution" },
|
||||
});
|
||||
if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn)
|
||||
accumulator.unknownFindings += 1;
|
||||
}
|
||||
} else if (findingGuarded) accumulator.unknownFindings += 1;
|
||||
if (descriptor.mode === "send" || descriptor.mode === "invoke") {
|
||||
emit(context, node, endpoint, channel);
|
||||
emit(channelContext, node, endpoint, channel);
|
||||
return;
|
||||
}
|
||||
if (descriptor.side === "main" && fn)
|
||||
@@ -231,23 +270,29 @@ export const collectElectronWrappedCallPaths = (
|
||||
symbolicUnknown("IPC payload"),
|
||||
],
|
||||
{
|
||||
...context,
|
||||
...handlerContext,
|
||||
entryKind: "ipc-handler",
|
||||
entry: channel,
|
||||
channel,
|
||||
steps: [...context.steps, step(node, "ipc", endpoint)],
|
||||
},
|
||||
node,
|
||||
);
|
||||
};
|
||||
const bridge = (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
args: readonly SymbolicValue[],
|
||||
context: PathContext,
|
||||
guardedDispatch: boolean,
|
||||
): void => {
|
||||
const key = args[0];
|
||||
const object = args[1];
|
||||
if (key?.kind !== "literal" || typeof key.value !== "string" || !object)
|
||||
if (key?.kind !== "literal" || typeof key.value !== "string" || !object) {
|
||||
accumulator.unknownFindings += 1;
|
||||
return;
|
||||
}
|
||||
const keyContext = metadataContext(context, key);
|
||||
if (!keyContext || !metadataContext(keyContext, object)) return;
|
||||
const members = [...resolver.members(object)].sort();
|
||||
const location = range(node);
|
||||
const existing = accumulator.contextBridgeApis.find(
|
||||
@@ -266,14 +311,23 @@ export const collectElectronWrappedCallPaths = (
|
||||
module_key: null,
|
||||
location,
|
||||
};
|
||||
if (existing)
|
||||
existing.value.members.length === 0 &&
|
||||
// This report shape has no predicate field, so a guarded world or object
|
||||
// cannot be recorded as an unconditional exposure fact.
|
||||
const guardedKey = hasConditions(key);
|
||||
const guardedObject = hasConditions(object);
|
||||
const guarded = guardedKey || guardedObject || guardedDispatch;
|
||||
if (guarded) accumulator.unknownFindings += 1;
|
||||
// Existing entries are raw syntax observations and remain valid even when
|
||||
// symbolic substitution can only describe this call under a predicate.
|
||||
if (!guarded && existing)
|
||||
(existing.value.api_status !== "object-literal" ||
|
||||
existing.value.unknown_members > 0) &&
|
||||
accumulator.contextBridgeApis.splice(
|
||||
accumulator.contextBridgeApis.indexOf(existing),
|
||||
1,
|
||||
{ offset: node.start ?? -1, value: finding },
|
||||
);
|
||||
else
|
||||
else if (!guarded)
|
||||
accumulator.contextBridgeApis.push({
|
||||
offset: node.start ?? -1,
|
||||
value: finding,
|
||||
@@ -282,11 +336,13 @@ export const collectElectronWrappedCallPaths = (
|
||||
const value = resolver.property(object, name);
|
||||
for (const fn of symbolicVariants(value))
|
||||
if (fn.kind === "function") {
|
||||
const memberContext = metadataContext(keyContext, fn);
|
||||
if (!memberContext) continue;
|
||||
functionBody(
|
||||
fn,
|
||||
fn.node.params.map(() => symbolicUnknown("public API argument")),
|
||||
{
|
||||
...context,
|
||||
...memberContext,
|
||||
entryNode: node,
|
||||
entryKind: "bridge-member",
|
||||
entry: `${key.value}.${name}`,
|
||||
@@ -294,6 +350,7 @@ export const collectElectronWrappedCallPaths = (
|
||||
steps: [step(node, "exposure", `${key.value}.${name}`)],
|
||||
active: new Set(),
|
||||
},
|
||||
node,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -306,27 +363,45 @@ export const collectElectronWrappedCallPaths = (
|
||||
const value = resolver.evaluate(node.callee, env);
|
||||
const args = resolver.arguments(node, env);
|
||||
for (const callee of symbolicVariants(value)) {
|
||||
const branchContext = metadataContext(context, callee);
|
||||
if (!branchContext) continue;
|
||||
if (callee.kind === "function") {
|
||||
functionBody(callee, args, {
|
||||
...context,
|
||||
steps: [...context.steps, step(node, "call", callLabel(node.callee))],
|
||||
});
|
||||
functionBody(
|
||||
callee,
|
||||
args,
|
||||
{
|
||||
...branchContext,
|
||||
steps: [
|
||||
...branchContext.steps,
|
||||
step(node, "call", callLabel(node.callee)),
|
||||
],
|
||||
},
|
||||
node,
|
||||
);
|
||||
} else if (callee.kind === "external") {
|
||||
const path = callee.path;
|
||||
const endpoint = path.join(".");
|
||||
if (/^electron(?:\/(?:main|renderer|common))?$/u.test(path[0] ?? "")) {
|
||||
const descriptor = ipcDescriptor(path.slice(1));
|
||||
if (descriptor) ipc(node, descriptor, args, context, endpoint);
|
||||
if (descriptor)
|
||||
ipc(
|
||||
node,
|
||||
descriptor,
|
||||
args,
|
||||
branchContext,
|
||||
endpoint,
|
||||
hasConditions(callee),
|
||||
);
|
||||
else if (
|
||||
path.slice(1).join(".") === "contextBridge.exposeInMainWorld"
|
||||
)
|
||||
bridge(node, args, context);
|
||||
else if (path[1] === "clipboard") emit(context, node, endpoint);
|
||||
bridge(node, args, branchContext, hasConditions(callee));
|
||||
else if (path[1] === "clipboard") emit(branchContext, node, endpoint);
|
||||
} else if (
|
||||
(path[0] === "navigator" && path[1] === "clipboard") ||
|
||||
(path[0] === "ipc-event" && path[1] === "sender")
|
||||
)
|
||||
emit(context, node, endpoint);
|
||||
emit(branchContext, node, endpoint);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -457,5 +532,6 @@ export const collectElectronWrappedCallPaths = (
|
||||
accumulator.unknownFindings += 1;
|
||||
accumulator.electronCallPathLimitReached = true;
|
||||
}
|
||||
if (resolver.unresolved) accumulator.unknownFindings += 1;
|
||||
return paths;
|
||||
};
|
||||
@@ -3,17 +3,25 @@ import * as t from "@babel/types";
|
||||
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
|
||||
import { moduleFactory } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
|
||||
/** A literal module table dispatched by a standalone CommonJS-style runtime. */
|
||||
interface JavaScriptBootstrap {
|
||||
readonly body: t.BlockStatement;
|
||||
readonly table: t.ObjectExpression;
|
||||
readonly loaderName: string;
|
||||
readonly loaderBindingId: string;
|
||||
readonly tableBindingId: string;
|
||||
}
|
||||
|
||||
/** Recognize syntax, never invoke a bootstrap or infer it from variable names. */
|
||||
export const javascriptBootstrapAt = (
|
||||
call: t.CallExpression,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptBootstrap | undefined => {
|
||||
if (
|
||||
(!t.isFunctionExpression(call.callee) &&
|
||||
@@ -35,8 +43,18 @@ export const javascriptBootstrapAt = (
|
||||
declaration.init.properties.every(
|
||||
(property) => moduleFactory(property) !== undefined,
|
||||
)
|
||||
)
|
||||
tables.set(declaration.id.name, declaration.init);
|
||||
) {
|
||||
const binding = resolveSemanticBindingState(
|
||||
state,
|
||||
declaration.id,
|
||||
declaration.id.name,
|
||||
);
|
||||
if (
|
||||
binding !== undefined &&
|
||||
semanticStableInitializer(binding, state)?.node === declaration.init
|
||||
)
|
||||
tables.set(binding.bindingId, declaration.init);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tables.size === 0) return undefined;
|
||||
@@ -51,7 +69,11 @@ export const javascriptBootstrapAt = (
|
||||
const parameter = loader.params[0];
|
||||
if (!t.isIdentifier(parameter)) continue;
|
||||
const loaderName = loader.id.name;
|
||||
let tableName: string | undefined;
|
||||
const loaderBindingId = bindingId(state, loader.id);
|
||||
const parameterBindingId = bindingId(state, parameter);
|
||||
if (loaderBindingId === undefined || parameterBindingId === undefined)
|
||||
continue;
|
||||
let tableBindingId: string | undefined;
|
||||
traverseJavaScriptAst(loader.body, {
|
||||
enter: (node, _parent, ancestors) => {
|
||||
if (
|
||||
@@ -59,22 +81,29 @@ export const javascriptBootstrapAt = (
|
||||
ancestors().some((node) => t.isFunction(node))
|
||||
)
|
||||
return;
|
||||
const candidate = dispatchedTableName(node, loaderName, parameter.name);
|
||||
const candidate = dispatchedTableBinding(
|
||||
node,
|
||||
loaderBindingId,
|
||||
parameterBindingId,
|
||||
state,
|
||||
);
|
||||
if (candidate !== undefined && tables.has(candidate))
|
||||
tableName = candidate;
|
||||
tableBindingId = candidate;
|
||||
},
|
||||
});
|
||||
const table = tableName === undefined ? undefined : tables.get(tableName);
|
||||
if (table !== undefined && tableName !== undefined)
|
||||
return { body, table, loaderName };
|
||||
const table =
|
||||
tableBindingId === undefined ? undefined : tables.get(tableBindingId);
|
||||
if (table !== undefined && tableBindingId !== undefined)
|
||||
return { body, table, loaderName, loaderBindingId, tableBindingId };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const dispatchedTableName = (
|
||||
const dispatchedTableBinding = (
|
||||
call: t.CallExpression,
|
||||
loaderName: string,
|
||||
keyName: string,
|
||||
loaderBindingId: string,
|
||||
keyBindingId: string,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): string | undefined => {
|
||||
const member = call.callee;
|
||||
const viaCall =
|
||||
@@ -86,7 +115,8 @@ const dispatchedTableName = (
|
||||
!t.isMemberExpression(dispatch) ||
|
||||
!dispatch.computed ||
|
||||
!t.isIdentifier(dispatch.object) ||
|
||||
!t.isIdentifier(dispatch.property, { name: keyName })
|
||||
!t.isIdentifier(dispatch.property) ||
|
||||
bindingId(state, dispatch.property) !== keyBindingId
|
||||
)
|
||||
return undefined;
|
||||
const args = viaCall ? call.arguments.slice(1) : call.arguments;
|
||||
@@ -94,11 +124,14 @@ const dispatchedTableName = (
|
||||
if (
|
||||
args.length !== 3 ||
|
||||
!t.isIdentifier(module) ||
|
||||
bindingId(state, module) === undefined ||
|
||||
!t.isMemberExpression(exports) ||
|
||||
!t.isIdentifier(exports.object, { name: module.name }) ||
|
||||
!t.isIdentifier(exports.object) ||
|
||||
bindingId(state, exports.object) !== bindingId(state, module) ||
|
||||
semanticStaticPropertyKey(exports.property, exports.computed) !==
|
||||
"exports" ||
|
||||
!t.isIdentifier(require, { name: loaderName })
|
||||
!t.isIdentifier(require) ||
|
||||
bindingId(state, require) !== loaderBindingId
|
||||
)
|
||||
return undefined;
|
||||
if (
|
||||
@@ -107,5 +140,11 @@ const dispatchedTableName = (
|
||||
!t.isNodesEquivalent(call.arguments[0], exports))
|
||||
)
|
||||
return undefined;
|
||||
return dispatch.object.name;
|
||||
return bindingId(state, dispatch.object);
|
||||
};
|
||||
|
||||
const bindingId = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
identifier: t.Identifier,
|
||||
): string | undefined =>
|
||||
resolveSemanticBindingState(state, identifier, identifier.name)?.bindingId;
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type JavaScriptFindingContext,
|
||||
} from "./javascriptStaticAnalysisState.js";
|
||||
import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
|
||||
import {
|
||||
classifyParsedJavaScriptElectronBindingsSteps,
|
||||
indexJavaScriptBindingsSteps,
|
||||
@@ -86,14 +87,18 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
const electronBindings =
|
||||
yield* classifyParsedJavaScriptElectronBindingsSteps(file);
|
||||
const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file);
|
||||
yield* traverseStaticSourceSteps(file, {
|
||||
source,
|
||||
accumulator,
|
||||
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
|
||||
electronBindings,
|
||||
rpcScan: createJavaScriptRpcScan(source, clientRoots),
|
||||
});
|
||||
const bindings = yield* indexJavaScriptBindingsSteps(file.program);
|
||||
yield* traverseStaticSourceSteps(
|
||||
file,
|
||||
{
|
||||
source,
|
||||
accumulator,
|
||||
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
|
||||
electronBindings,
|
||||
rpcScan: createJavaScriptRpcScan(source, clientRoots),
|
||||
},
|
||||
bindings,
|
||||
);
|
||||
accumulator.electronCallPaths = collectElectronWrappedCallPaths(
|
||||
file.program,
|
||||
bindings,
|
||||
@@ -107,11 +112,12 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
||||
function* traverseStaticSourceSteps(
|
||||
file: ParsedJavaScriptSource,
|
||||
findings: JavaScriptFindingContext,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
): Generator<void, void> {
|
||||
yield* traverseJavaScriptAstSteps(file, {
|
||||
enter: (node) => {
|
||||
findings.accumulator.visitedNodes += 1;
|
||||
inspectNode(node, findings);
|
||||
inspectNode(node, findings, bindings);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
@@ -217,11 +223,12 @@ const finalizeStaticAnalysis = (
|
||||
const inspectNode = (
|
||||
node: t.Node,
|
||||
findings: JavaScriptFindingContext,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
const { source, accumulator } = findings;
|
||||
inspectElectronStaticNode(node, findings);
|
||||
if (t.isCallExpression(node)) {
|
||||
inspectBundlerBootstrap(source, node, accumulator);
|
||||
inspectBundlerBootstrap(source, node, accumulator, bindings);
|
||||
inspectBundlerRegistration(source, node, accumulator);
|
||||
inspectEsbuildWrapper(source, node, accumulator);
|
||||
inspectCall(source, node, findings);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as t from "@babel/types";
|
||||
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
|
||||
@@ -83,8 +87,9 @@ export const inspectBundlerBootstrap = (
|
||||
source: string,
|
||||
call: t.CallExpression,
|
||||
accumulator: AnalysisAccumulator,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
const bootstrap = javascriptBootstrapAt(call);
|
||||
const bootstrap = javascriptBootstrapAt(call, bindings);
|
||||
if (bootstrap === undefined) return;
|
||||
const runtime = `webpack-bootstrap@${String(call.start ?? 0)}`;
|
||||
const recovered = recoverBundlerModules(
|
||||
@@ -100,7 +105,9 @@ export const inspectBundlerBootstrap = (
|
||||
if (
|
||||
!t.isCallExpression(node) ||
|
||||
ancestors().some((node) => t.isFunction(node)) ||
|
||||
!t.isIdentifier(node.callee, { name: bootstrap.loaderName })
|
||||
!t.isIdentifier(node.callee) ||
|
||||
resolveSemanticBindingState(bindings, node.callee, node.callee.name)
|
||||
?.bindingId !== bootstrap.loaderBindingId
|
||||
)
|
||||
return;
|
||||
const value = argumentValue(node.arguments[0]);
|
||||
|
||||
@@ -50,6 +50,51 @@ it("does not infer a bootstrap from a function table without a matching loader",
|
||||
expect(analysis.bundler_registrations).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not confuse a shadowed dispatch table with the outer module table", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`(() => {
|
||||
const modules = { 17(module) { module.exports = require("electron"); } };
|
||||
function load(id) {
|
||||
const modules = userInput;
|
||||
const module = { exports: {} };
|
||||
modules[id](module, module.exports, load);
|
||||
return module.exports;
|
||||
}
|
||||
load(17);
|
||||
})();`);
|
||||
expect(analysis.bundler_registrations).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains a stable var module table without treating declaration mutability as a write", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`(() => {
|
||||
var modules = { 17(module) { module.exports = require("electron"); } };
|
||||
function load(id) {
|
||||
const module = { exports: {} };
|
||||
modules[id](module, module.exports, load);
|
||||
return module.exports;
|
||||
}
|
||||
load(17);
|
||||
})();`);
|
||||
expect(analysis.bundler_registrations).toMatchObject([
|
||||
{ entry_module_keys: ["17"], modules: [{ module_key: "17" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("only attributes bootstrap entries to the recovered loader binding", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`(() => {
|
||||
const modules = { 17(module) { module.exports = require("electron"); } };
|
||||
function load(id) {
|
||||
const module = { exports: {} };
|
||||
modules[id](module, module.exports, load);
|
||||
return module.exports;
|
||||
}
|
||||
{ const load = userInput; load(99); }
|
||||
load(17);
|
||||
})();`);
|
||||
expect(analysis.bundler_registrations).toMatchObject([
|
||||
{ entry_module_keys: ["17"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves first insertion precedence and inclusive interval bounds", () => {
|
||||
const nested = new JavaScriptModuleRangeIndex(100);
|
||||
expect(nested.find(0)).toBeUndefined();
|
||||
|
||||
@@ -7,10 +7,60 @@ import {
|
||||
traverseJavaScriptAst,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
|
||||
import { symbolicVariants } from "./javascriptSymbolicValues.js";
|
||||
import {
|
||||
symbolicTruth,
|
||||
symbolicVariants,
|
||||
type SymbolicValue,
|
||||
} from "./javascriptSymbolicValues.js";
|
||||
|
||||
const resolverFor = (source: string) => {
|
||||
const parsed = parseJavaScriptSource(source);
|
||||
if (parsed === null) throw new Error("symbolic source fixture did not parse");
|
||||
const state = completeSemanticSteps(
|
||||
indexJavaScriptBindingsSteps(parsed.program),
|
||||
);
|
||||
return {
|
||||
parsed,
|
||||
resolver: new JavaScriptSymbolicBindings(state, parsed.program),
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse a fixture and collect the first argument of each inspect call. */
|
||||
const inspectFixture = (
|
||||
source: string,
|
||||
evaluate: (
|
||||
resolver: JavaScriptSymbolicBindings,
|
||||
node: t.CallExpression,
|
||||
) => SymbolicValue = (resolver, node) => resolver.evaluate(node.arguments[0]),
|
||||
) => {
|
||||
const { parsed, resolver } = resolverFor(source);
|
||||
const values: SymbolicValue[] = [];
|
||||
traverseJavaScriptAst(parsed.program, {
|
||||
enter: (node) => {
|
||||
if (
|
||||
t.isCallExpression(node) &&
|
||||
t.isIdentifier(node.callee, { name: "inspect" })
|
||||
) {
|
||||
values.push(evaluate(resolver, node));
|
||||
}
|
||||
},
|
||||
});
|
||||
return { parsed, resolver, values };
|
||||
};
|
||||
|
||||
const inspectedValues = (source: string): SymbolicValue[] =>
|
||||
inspectFixture(source).values;
|
||||
|
||||
const inspectOne = (source: string): SymbolicValue => {
|
||||
const values = inspectedValues(source);
|
||||
expect(values).toHaveLength(1);
|
||||
const value = values[0];
|
||||
if (value === undefined) throw new Error("inspect call result missing");
|
||||
return value;
|
||||
};
|
||||
|
||||
it("substitutes numeric modules, named exports and captured wrapper arguments", () => {
|
||||
const parsed = parseJavaScriptSource(`(() => {
|
||||
const { resolver, values } = inspectFixture(`(() => {
|
||||
const modules = {
|
||||
4(module) { module.exports = require("electron"); },
|
||||
8(module, exports, load) {
|
||||
@@ -21,12 +71,29 @@ it("substitutes numeric modules, named exports and captured wrapper arguments",
|
||||
};
|
||||
function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; }
|
||||
load(12);
|
||||
})();`)!;
|
||||
const state = completeSemanticSteps(
|
||||
indexJavaScriptBindingsSteps(parsed.program),
|
||||
);
|
||||
const resolver = new JavaScriptSymbolicBindings(state, parsed.program);
|
||||
let found = false;
|
||||
})();`);
|
||||
expect(values).toHaveLength(1);
|
||||
const value = values[0];
|
||||
if (value === undefined) throw new Error("inspect call result missing");
|
||||
const fn = symbolicVariants(value).find((v) => v.kind === "function");
|
||||
expect(fn?.kind).toBe("function");
|
||||
if (fn?.kind !== "function" || !t.isCallExpression(fn.node.body))
|
||||
throw new Error("closure missing");
|
||||
const env = resolver.bind(fn, []);
|
||||
expect(resolver.evaluate(fn.node.body.callee, env)).toEqual({
|
||||
kind: "external",
|
||||
path: ["electron", "ipcRenderer", "send"],
|
||||
});
|
||||
expect(resolver.arguments(fn.node.body, env)[0]).toEqual({
|
||||
kind: "literal",
|
||||
value: "test:copy",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks block lexical initializers against their containing function call", () => {
|
||||
const { parsed, resolver } = resolverFor(`run();
|
||||
function run() { { const value = require("electron"); inspect(value); } }`);
|
||||
let resolved: SymbolicValue | undefined;
|
||||
traverseJavaScriptAst(parsed.program, {
|
||||
enter: (node) => {
|
||||
if (
|
||||
@@ -34,22 +101,340 @@ it("substitutes numeric modules, named exports and captured wrapper arguments",
|
||||
!t.isIdentifier(node.callee, { name: "inspect" })
|
||||
)
|
||||
return;
|
||||
const value = resolver.evaluate(node.arguments[0]);
|
||||
const fn = symbolicVariants(value).find((v) => v.kind === "function");
|
||||
expect(fn?.kind).toBe("function");
|
||||
if (fn?.kind !== "function" || !t.isCallExpression(fn.node.body))
|
||||
throw new Error("closure missing");
|
||||
const env = resolver.bind(fn, []);
|
||||
expect(resolver.evaluate(fn.node.body.callee, env)).toEqual({
|
||||
kind: "external",
|
||||
path: ["electron", "ipcRenderer", "send"],
|
||||
});
|
||||
expect(resolver.arguments(fn.node.body, env)[0]).toEqual({
|
||||
kind: "literal",
|
||||
value: "test:copy",
|
||||
});
|
||||
found = true;
|
||||
const fn = parsed.program.body.find(
|
||||
(statement): statement is t.FunctionDeclaration =>
|
||||
t.isFunctionDeclaration(statement) && statement.id?.name === "run",
|
||||
);
|
||||
if (!fn) throw new Error("run declaration missing");
|
||||
const call = parsed.program.body[0];
|
||||
if (
|
||||
!t.isExpressionStatement(call) ||
|
||||
!t.isCallExpression(call.expression)
|
||||
)
|
||||
throw new Error("run invocation missing");
|
||||
resolved = resolver.evaluate(
|
||||
node.arguments[0],
|
||||
resolver.bind(
|
||||
{
|
||||
kind: "function",
|
||||
node: fn,
|
||||
environment: { values: new Map() },
|
||||
origins: [],
|
||||
},
|
||||
[],
|
||||
call.expression,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
expect(found).toBe(true);
|
||||
expect(resolved).toEqual({ kind: "external", path: ["electron"] });
|
||||
});
|
||||
|
||||
it("keeps same-factory reads before initialization unresolved", () => {
|
||||
const { values, resolver } = inspectFixture(
|
||||
`(() => {
|
||||
const modules = { 9(module) { inspect(value); const value = require("electron"); } };
|
||||
function load(id) { const m={exports:{}}; modules[id](m); return m.exports; }
|
||||
load(9);
|
||||
})();`,
|
||||
(bindings, node) =>
|
||||
bindings.evaluate(node.arguments[0], bindings.environmentAt(node)),
|
||||
);
|
||||
expect(values).toHaveLength(1);
|
||||
const resolved = values[0];
|
||||
if (resolved === undefined) throw new Error("inspect call result missing");
|
||||
expect(resolved).toEqual({
|
||||
kind: "unknown",
|
||||
reason: "initializer not available at this read",
|
||||
});
|
||||
expect(resolver.unresolved).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates a bootstrap module slot mutated through an alias", () => {
|
||||
const values = inspectedValues(`(() => {
|
||||
const modules = {
|
||||
1(module) { module.exports = require("electron"); },
|
||||
2(module, exports, load) { inspect(load(1)); inspect(load(3)); },
|
||||
3(module) { module.exports = require("electron"); }
|
||||
};
|
||||
const table = modules;
|
||||
function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; }
|
||||
table[1] = function(module) { module.exports = require("other"); };
|
||||
delete table[3];
|
||||
load(2);
|
||||
})();`);
|
||||
expect(values).toHaveLength(2);
|
||||
expect(values).toEqual([
|
||||
expect.objectContaining({ kind: "unknown" }),
|
||||
expect.objectContaining({ kind: "unknown" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`const electron=require("electron"); const ipc=electron.ipcMain; ipc.handle=()=>null; inspect(electron.ipcMain.handle);`,
|
||||
`require("electron").ipcMain.handle=()=>null; inspect(require("electron").ipcMain.handle);`,
|
||||
`const electron=require("electron"); ({ipcMain:electron.ipcMain}=replacement); inspect(electron.ipcMain);`,
|
||||
`const electron=require("electron"); for(electron.ipcMain of replacements){} inspect(electron.ipcMain);`,
|
||||
])("invalidates visibly overwritten Electron members: %s", (source) => {
|
||||
const values = inspectedValues(source);
|
||||
expect(values).toHaveLength(1);
|
||||
expect(values[0]?.kind).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does not substitute a lexical or var initializer before its source write", () => {
|
||||
for (const source of [
|
||||
`inspect(value); const value = require("electron");`,
|
||||
`function run() { inspect(value); var value = require("electron"); }`,
|
||||
]) {
|
||||
const values = inspectedValues(source);
|
||||
expect(values).toHaveLength(1);
|
||||
const value = values[0];
|
||||
if (value === undefined) throw new Error("inspect call result missing");
|
||||
expect(value).toMatchObject({ kind: "unknown" });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps function declarations hoisted and checks closures at invocation", () => {
|
||||
const inspected = inspectedValues(`
|
||||
inspect(read);
|
||||
function read() { return value; }
|
||||
function localRead() {
|
||||
const local = require("electron");
|
||||
return local;
|
||||
}
|
||||
const closure = () => value;
|
||||
const value = require("electron");
|
||||
inspect(closure());
|
||||
inspect(localRead());
|
||||
`);
|
||||
expect(inspected[0]).toMatchObject({ kind: "function" });
|
||||
expect(inspected[1]).toEqual({ kind: "external", path: ["electron"] });
|
||||
expect(inspected[2]).toEqual({ kind: "external", path: ["electron"] });
|
||||
});
|
||||
|
||||
it("keeps a synchronous cross-function callback before initialization unresolved", () => {
|
||||
const { parsed, resolver } = resolverFor(`function outer() {
|
||||
const value = require("electron");
|
||||
return use(() => value);
|
||||
}
|
||||
function use(callback) { return callback(); }
|
||||
inspect(outer());`);
|
||||
const outer = parsed.program.body.find(
|
||||
(statement): statement is t.FunctionDeclaration =>
|
||||
t.isFunctionDeclaration(statement) && statement.id?.name === "outer",
|
||||
);
|
||||
const outerCall = parsed.program.body.find(
|
||||
(statement): statement is t.ExpressionStatement =>
|
||||
t.isExpressionStatement(statement) &&
|
||||
t.isCallExpression(statement.expression) &&
|
||||
t.isIdentifier(statement.expression.callee, { name: "inspect" }),
|
||||
);
|
||||
if (!outer || !outerCall || !t.isCallExpression(outerCall.expression))
|
||||
throw new Error("outer fixture missing");
|
||||
const invocation = outer.body.body.find(
|
||||
(statement): statement is t.ReturnStatement =>
|
||||
t.isReturnStatement(statement),
|
||||
);
|
||||
if (!invocation || !t.isCallExpression(invocation.argument))
|
||||
throw new Error("synchronous callback call missing");
|
||||
const result = resolver.evaluate(
|
||||
invocation.argument,
|
||||
resolver.bind(
|
||||
{
|
||||
kind: "function",
|
||||
node: outer,
|
||||
environment: { values: new Map() },
|
||||
origins: [],
|
||||
},
|
||||
[],
|
||||
outerCall.expression,
|
||||
),
|
||||
);
|
||||
expect(result).toMatchObject({ kind: "unknown" });
|
||||
});
|
||||
|
||||
it("uses completed activation provenance for a returned closure", () => {
|
||||
const result = inspectOne(`inspect(use(outer()));
|
||||
function use(callback) { return callback(); }
|
||||
function outer() {
|
||||
const value = require("electron");
|
||||
return () => value;
|
||||
}`);
|
||||
expect(result).toEqual({ kind: "external", path: ["electron"] });
|
||||
});
|
||||
|
||||
it("does not treat statements after a returned closure as initialized", () => {
|
||||
const result = inspectOne(`inspect(outer()());
|
||||
function outer() {
|
||||
return () => value;
|
||||
const value = require("electron");
|
||||
}`);
|
||||
expect(result).toMatchObject({ kind: "unknown" });
|
||||
});
|
||||
|
||||
it("does not treat statements after a returned module factory as initialized", () => {
|
||||
const result = inspectOne(`(() => {
|
||||
const modules = {
|
||||
5(module, exports) {
|
||||
exports.read = () => electron;
|
||||
return;
|
||||
const electron = require("electron");
|
||||
},
|
||||
9(module, exports, load) { const helper = load(5); inspect(helper.read()); }
|
||||
};
|
||||
function load(id) { const m={exports:{}}; modules[id](m,m.exports,load); return m.exports; }
|
||||
load(9);
|
||||
})();`);
|
||||
expect(result).toMatchObject({ kind: "unknown" });
|
||||
});
|
||||
|
||||
it("treats known symbolic records and loaders as truthy", () => {
|
||||
expect(symbolicTruth({ kind: "record", values: new Map() })).toBe(true);
|
||||
expect(symbolicTruth({ kind: "loader", modules: new Map() })).toBe(true);
|
||||
expect(
|
||||
symbolicTruth({
|
||||
kind: "union",
|
||||
values: [
|
||||
{ kind: "record", values: new Map() },
|
||||
{ kind: "loader", modules: new Map() },
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains branch predicates on conditionally selected wrapper functions", () => {
|
||||
const value = inspectOne(`
|
||||
const selected = enabled
|
||||
? () => navigator.clipboard.writeText("yes")
|
||||
: () => navigator.clipboard.writeText("no");
|
||||
inspect(selected);
|
||||
`);
|
||||
const functions = symbolicVariants(value).filter(
|
||||
(candidate) => candidate.kind === "function",
|
||||
);
|
||||
expect(functions).toHaveLength(2);
|
||||
expect(functions.map((fn) => fn.conditions?.[0]?.positive)).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains fallthrough return guards after a one-sided early return", () => {
|
||||
const value = inspectOne(`
|
||||
function choose() {
|
||||
if (enabled) return () => navigator.clipboard.writeText("early");
|
||||
return () => navigator.clipboard.writeText("late");
|
||||
}
|
||||
inspect(choose());
|
||||
`);
|
||||
const functions = symbolicVariants(value).filter(
|
||||
(candidate) => candidate.kind === "function",
|
||||
);
|
||||
expect(functions).toHaveLength(2);
|
||||
expect(functions[0]).toMatchObject({ conditions: [{ positive: true }] });
|
||||
expect(functions[1]).toMatchObject({ conditions: [{ positive: false }] });
|
||||
});
|
||||
|
||||
it("composes fallthrough guards across successive conditional returns", () => {
|
||||
const value = inspectOne(`
|
||||
function choose() {
|
||||
if (first) return () => navigator.clipboard.writeText("first");
|
||||
if (second) return () => navigator.clipboard.writeText("second");
|
||||
return () => navigator.clipboard.writeText("last");
|
||||
}
|
||||
inspect(choose());
|
||||
`);
|
||||
const functions = symbolicVariants(value).filter(
|
||||
(candidate) => candidate.kind === "function",
|
||||
);
|
||||
expect(functions).toHaveLength(3);
|
||||
expect(functions[0]?.conditions?.map(({ positive }) => positive)).toEqual([
|
||||
true,
|
||||
]);
|
||||
expect(functions[1]?.conditions?.map(({ positive }) => positive)).toEqual([
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
expect(functions[2]?.conditions?.map(({ positive }) => positive)).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks return guards unresolved when a nested join cannot be represented", () => {
|
||||
const value = inspectOne(`
|
||||
function choose() {
|
||||
if (enabled) {
|
||||
if (other) return () => navigator.clipboard.writeText("early");
|
||||
}
|
||||
return () => navigator.clipboard.writeText("late");
|
||||
}
|
||||
inspect(choose());
|
||||
`);
|
||||
const functions = symbolicVariants(value).filter(
|
||||
(candidate) => candidate.kind === "function",
|
||||
);
|
||||
expect(functions).toHaveLength(2);
|
||||
expect(functions[1]).toMatchObject({ conditionUnresolved: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"duplicate literal keys use the last value",
|
||||
`const object = { value: "first", value: "last" }; inspect(object.value);`,
|
||||
{ kind: "literal", value: "last" },
|
||||
],
|
||||
[
|
||||
"a final accessor is unknown",
|
||||
`const object = { value: "first", get value() { return "accessor"; } }; inspect(object.value);`,
|
||||
{ kind: "unknown", reason: "accessor property" },
|
||||
],
|
||||
[
|
||||
"a later literal overwrites an accessor",
|
||||
`const object = { get value() { return "accessor"; }, value: "last" }; inspect(object.value);`,
|
||||
{ kind: "literal", value: "last" },
|
||||
],
|
||||
[
|
||||
"a later unknown computed key can overwrite an earlier value",
|
||||
`const object = { value: "known", [dynamic]: "other" }; inspect(object.value);`,
|
||||
{ kind: "unknown", reason: "dynamic property overwrite" },
|
||||
],
|
||||
[
|
||||
"a later static key overrides an earlier unknown computed key",
|
||||
`const object = { [dynamic]: "other", value: "last" }; inspect(object.value);`,
|
||||
{ kind: "literal", value: "last" },
|
||||
],
|
||||
[
|
||||
"a known spread can override an earlier static value",
|
||||
`const replacement = { value: "spread" }; const object = { value: "first", ...replacement }; inspect(object.value);`,
|
||||
{ kind: "literal", value: "spread" },
|
||||
],
|
||||
] as const)("resolves object properties when %s", (_case, source, expected) => {
|
||||
expect(inspectOne(source)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("propagates branch guards through object properties and function calls", () => {
|
||||
const inspected = inspectedValues(`
|
||||
const direct = flag
|
||||
? { action: () => navigator.clipboard.writeText("direct-yes") }
|
||||
: { action: () => navigator.clipboard.writeText("direct-no") };
|
||||
function choose() {
|
||||
return flag
|
||||
? () => navigator.clipboard.writeText("call-yes")
|
||||
: () => navigator.clipboard.writeText("call-no");
|
||||
}
|
||||
const called = { action: choose() };
|
||||
inspect(direct.action);
|
||||
inspect(called.action);
|
||||
`);
|
||||
for (const value of inspected) {
|
||||
const functions = symbolicVariants(value).filter(
|
||||
(candidate) => candidate.kind === "function",
|
||||
);
|
||||
expect(functions).toHaveLength(2);
|
||||
expect(functions.map((fn) => fn.conditions?.[0]?.positive)).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -4,22 +4,38 @@ import {
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
|
||||
import {
|
||||
collectJavaScriptIdentityWritesSteps,
|
||||
ELECTRON_MODULE,
|
||||
electronMemberPath,
|
||||
electronExportPath,
|
||||
} from "./javascriptElectronMemberWrites.js";
|
||||
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
|
||||
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { symbolicLogicalRight } from "./javascriptSymbolicControlFlow.js";
|
||||
import {
|
||||
moduleFactory,
|
||||
modulePropertyName,
|
||||
} from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
childNodes,
|
||||
completeSemanticSteps,
|
||||
traverseJavaScriptAst,
|
||||
} from "./javascriptSemanticTraversal.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
semanticResolutionBlocked,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
symbolicCondition,
|
||||
symbolicUnion,
|
||||
symbolicUnknown,
|
||||
symbolicVariants,
|
||||
symbolicConditionsUnresolved,
|
||||
symbolicTruth,
|
||||
type SymbolicCondition,
|
||||
type SymbolicValue,
|
||||
type SymbolicEnvironment,
|
||||
type SymbolicFunction,
|
||||
@@ -50,17 +66,30 @@ export class JavaScriptSymbolicBindings {
|
||||
readonly #moduleBindings = new Set<string>();
|
||||
readonly #replacements = new Map<string, t.Node>();
|
||||
readonly #propertyActive = new WeakMap<object, Set<string>>();
|
||||
readonly #literalPropertyIndex = new WeakMap<
|
||||
t.ObjectExpression,
|
||||
ReadonlyMap<string, t.ObjectMethod | t.ObjectProperty> | null
|
||||
>();
|
||||
readonly #mutated = new Set<string>();
|
||||
readonly #electronWrites = new SemanticPropertyPathCoverage();
|
||||
readonly #namespaceOwners = new Map<string, t.Function>();
|
||||
readonly #unsafeExports = new Set<string>();
|
||||
readonly #aliasesByBinding = new Map<string, string[]>();
|
||||
readonly #bootstrapTables: Array<{
|
||||
readonly tableAliases: ReadonlySet<string>;
|
||||
readonly modules: Map<string, SymbolicValue>;
|
||||
}> = [];
|
||||
#remainingEvaluations = 1_000_000;
|
||||
#depth = 0;
|
||||
truncated = false;
|
||||
unresolved = false;
|
||||
|
||||
constructor(
|
||||
readonly state: JavaScriptSemanticAnalysisState,
|
||||
program: t.Program,
|
||||
) {
|
||||
this.#indexElectronWrites(program);
|
||||
this.#indexBindingAliases();
|
||||
traverseJavaScriptAst(program, {
|
||||
enter: (node) => {
|
||||
if (t.isCallExpression(node)) this.#indexBootstrap(node);
|
||||
@@ -70,6 +99,10 @@ export class JavaScriptSymbolicBindings {
|
||||
enter: (node) => {
|
||||
if (t.isAssignmentExpression(node))
|
||||
this.#indexWrite(node.left, node.right, node);
|
||||
if (t.isUpdateExpression(node))
|
||||
this.#indexWrite(node.argument, node, node);
|
||||
if (t.isUnaryExpression(node, { operator: "delete" }))
|
||||
this.#indexWrite(node.argument, node, node);
|
||||
if (
|
||||
t.isCallExpression(node) &&
|
||||
t.isMemberExpression(node.callee) &&
|
||||
@@ -97,6 +130,8 @@ export class JavaScriptSymbolicBindings {
|
||||
}
|
||||
},
|
||||
});
|
||||
for (const table of this.#bootstrapTables)
|
||||
this.#invalidateBootstrapTable(table.tableAliases, table.modules);
|
||||
}
|
||||
|
||||
environmentAt(node: t.Node): SymbolicEnvironment {
|
||||
@@ -138,22 +173,42 @@ export class JavaScriptSymbolicBindings {
|
||||
}
|
||||
|
||||
property(value: SymbolicValue, key: string): SymbolicValue {
|
||||
let result: SymbolicValue;
|
||||
if (value.kind === "union")
|
||||
result = symbolicUnion(
|
||||
value.values.map((variant) => this.property(variant, key)),
|
||||
);
|
||||
else result = this.#property(value, key);
|
||||
for (const condition of value.conditions ?? [])
|
||||
result = symbolicCondition(result, condition);
|
||||
if (value.conditionUnresolved)
|
||||
result = symbolicConditionsUnresolved(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
#property(value: SymbolicValue, key: string): SymbolicValue {
|
||||
const active = this.#propertyActive.get(value) ?? new Set<string>();
|
||||
if (active.has(key)) return symbolicUnknown("cyclic property");
|
||||
active.add(key);
|
||||
this.#propertyActive.set(value, active);
|
||||
try {
|
||||
return this.#property(value, key);
|
||||
return this.#propertyValue(value, key);
|
||||
} finally {
|
||||
active.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
#property(value: SymbolicValue, key: string): SymbolicValue {
|
||||
if (value.kind === "union")
|
||||
return symbolicUnion(value.values.map((v) => this.property(v, key)));
|
||||
if (value.kind === "external")
|
||||
return { kind: "external", path: [...value.path, key] };
|
||||
#propertyValue(value: SymbolicValue, key: string): SymbolicValue {
|
||||
if (value.kind === "external") {
|
||||
const path = [...value.path, key];
|
||||
if (
|
||||
path[0] === "electron" &&
|
||||
this.#electronWrites.covers(path.slice(1))
|
||||
) {
|
||||
return symbolicUnknown("overwritten Electron API member");
|
||||
}
|
||||
return { kind: "external", path };
|
||||
}
|
||||
if (value.kind === "record")
|
||||
return value.values.get(key) ?? symbolicUnknown("missing property");
|
||||
if (value.kind === "namespace") {
|
||||
@@ -177,6 +232,28 @@ export class JavaScriptSymbolicBindings {
|
||||
: symbolicUnknown("unresolved module export");
|
||||
}
|
||||
if (value.kind === "object") {
|
||||
let index = this.#literalPropertyIndex.get(value.node);
|
||||
if (index === undefined) {
|
||||
const properties = new Map<string, t.ObjectMethod | t.ObjectProperty>();
|
||||
index = properties;
|
||||
for (const property of value.node.properties) {
|
||||
const name = t.isSpreadElement(property)
|
||||
? null
|
||||
: semanticStaticPropertyKey(property.key, property.computed);
|
||||
if (name === null || t.isSpreadElement(property)) {
|
||||
index = null;
|
||||
break;
|
||||
}
|
||||
properties.set(name, property);
|
||||
}
|
||||
this.#literalPropertyIndex.set(value.node, index);
|
||||
}
|
||||
if (index !== null) {
|
||||
const property = index.get(key);
|
||||
return property === undefined
|
||||
? symbolicUnknown("missing property")
|
||||
: this.#objectPropertyValue(property, value.environment);
|
||||
}
|
||||
for (const p of [...value.node.properties].reverse()) {
|
||||
if (t.isSpreadElement(p)) {
|
||||
const spread = this.evaluate(p.argument, value.environment);
|
||||
@@ -188,12 +265,7 @@ export class JavaScriptSymbolicBindings {
|
||||
)
|
||||
return found;
|
||||
} else if (semanticStaticPropertyKey(p.key, p.computed) === key) {
|
||||
return t.isObjectMethod(p) && p.kind !== "method"
|
||||
? symbolicUnknown("accessor property")
|
||||
: this.evaluate(
|
||||
t.isObjectProperty(p) ? p.value : p,
|
||||
value.environment,
|
||||
);
|
||||
return this.#objectPropertyValue(p, value.environment);
|
||||
} else if (semanticStaticPropertyKey(p.key, p.computed) === null)
|
||||
return symbolicUnknown("dynamic property overwrite");
|
||||
}
|
||||
@@ -203,6 +275,18 @@ export class JavaScriptSymbolicBindings {
|
||||
return symbolicUnknown("unresolved property");
|
||||
}
|
||||
|
||||
#objectPropertyValue(
|
||||
property: t.ObjectMethod | t.ObjectProperty,
|
||||
environment: SymbolicEnvironment,
|
||||
): SymbolicValue {
|
||||
return t.isObjectMethod(property) && property.kind !== "method"
|
||||
? symbolicUnknown("accessor property")
|
||||
: this.evaluate(
|
||||
t.isObjectProperty(property) ? property.value : property,
|
||||
environment,
|
||||
);
|
||||
}
|
||||
|
||||
members(
|
||||
value: SymbolicValue,
|
||||
active = new Set<SymbolicValue>(),
|
||||
@@ -277,6 +361,7 @@ export class JavaScriptSymbolicBindings {
|
||||
bind(
|
||||
fn: SymbolicFunction,
|
||||
args: readonly SymbolicValue[],
|
||||
invocationNode?: t.Node,
|
||||
): SymbolicEnvironment {
|
||||
const values = new Map<string, SymbolicValue>();
|
||||
fn.node.params.forEach((param, index) => {
|
||||
@@ -295,7 +380,16 @@ export class JavaScriptSymbolicBindings {
|
||||
: (args[index] ?? symbolicUnknown("runtime parameter")),
|
||||
);
|
||||
});
|
||||
return { values, parent: fn.environment };
|
||||
const scopeId =
|
||||
this.state.scopeByNode.get(fn.node)?.scopeId ??
|
||||
this.state.scopeByNode.get(fn.node.body)?.scopeId;
|
||||
return {
|
||||
values,
|
||||
parent: fn.environment,
|
||||
point: invocationNode ?? fn.node,
|
||||
scopeNode: fn.node,
|
||||
...(scopeId === undefined ? {} : { scopeId }),
|
||||
};
|
||||
}
|
||||
|
||||
arguments(
|
||||
@@ -346,8 +440,14 @@ export class JavaScriptSymbolicBindings {
|
||||
const truth = symbolicTruth(this.evaluate(node.test, env));
|
||||
return truth === undefined
|
||||
? symbolicUnion([
|
||||
this.evaluate(node.consequent, env),
|
||||
this.evaluate(node.alternate, env),
|
||||
symbolicCondition(this.evaluate(node.consequent, env), {
|
||||
node: node.test,
|
||||
positive: true,
|
||||
}),
|
||||
symbolicCondition(this.evaluate(node.alternate, env), {
|
||||
node: node.test,
|
||||
positive: false,
|
||||
}),
|
||||
])
|
||||
: this.evaluate(truth ? node.consequent : node.alternate, env);
|
||||
}
|
||||
@@ -356,7 +456,28 @@ export class JavaScriptSymbolicBindings {
|
||||
const right = symbolicLogicalRight(node.operator, left);
|
||||
if (right !== undefined)
|
||||
return right ? this.evaluate(node.right, env) : left;
|
||||
return symbolicUnion([left, this.evaluate(node.right, env)]);
|
||||
const leftCondition: SymbolicCondition = {
|
||||
node: node.left,
|
||||
positive:
|
||||
node.operator === "&&"
|
||||
? false
|
||||
: node.operator === "||"
|
||||
? true
|
||||
: "not-nullish",
|
||||
};
|
||||
const rightCondition: SymbolicCondition = {
|
||||
node: node.left,
|
||||
positive:
|
||||
node.operator === "&&"
|
||||
? true
|
||||
: node.operator === "||"
|
||||
? false
|
||||
: "nullish",
|
||||
};
|
||||
return symbolicUnion([
|
||||
symbolicCondition(left, leftCondition),
|
||||
symbolicCondition(this.evaluate(node.right, env), rightCondition),
|
||||
]);
|
||||
}
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
|
||||
return this.#call(node, env);
|
||||
@@ -390,6 +511,17 @@ export class JavaScriptSymbolicBindings {
|
||||
binding.initializers.length === 1 ? binding.initializers[0] : undefined;
|
||||
if (!initializer)
|
||||
return symbolicUnknown("missing or ambiguous initializer");
|
||||
const point = this.#initializerReadPoint(binding.scopeId, env, node);
|
||||
if (
|
||||
!initializer.entryBody &&
|
||||
!binding.definitions.every(({ kind }) => kind === "function") &&
|
||||
(initializer.node.end == null ||
|
||||
point == null ||
|
||||
initializer.node.end > point)
|
||||
) {
|
||||
this.unresolved = true;
|
||||
return symbolicUnknown("initializer not available at this read");
|
||||
}
|
||||
if (this.state.conditionalInitializers.has(initializer.node))
|
||||
return symbolicUnknown("conditional initializer");
|
||||
let value = this.evaluate(initializer.node, env);
|
||||
@@ -400,6 +532,100 @@ export class JavaScriptSymbolicBindings {
|
||||
return value;
|
||||
}
|
||||
|
||||
#initializerReadPoint(
|
||||
bindingScopeId: string,
|
||||
environment: SymbolicEnvironment,
|
||||
readNode: t.Node,
|
||||
): number | undefined {
|
||||
let current: SymbolicEnvironment | undefined = environment;
|
||||
let child: SymbolicEnvironment | undefined;
|
||||
while (current) {
|
||||
if (this.#scopeContains(bindingScopeId, current.scopeId)) {
|
||||
const point = child?.point ?? readNode;
|
||||
if (current.completedAt !== undefined)
|
||||
return current.completedAt.end ?? undefined;
|
||||
if (current.scopeNode && !this.#nodeWithin(point, current.scopeNode))
|
||||
return undefined;
|
||||
return point.start ?? undefined;
|
||||
}
|
||||
child = current;
|
||||
current = current.parent;
|
||||
}
|
||||
let outermost = environment;
|
||||
while (outermost.parent) outermost = outermost.parent;
|
||||
const point = outermost.point ?? environment.point ?? readNode;
|
||||
return point.start ?? undefined;
|
||||
}
|
||||
|
||||
#scopeContains(bindingScopeId: string, environmentScopeId?: string): boolean {
|
||||
let scopeId: string | null = bindingScopeId;
|
||||
while (scopeId !== null) {
|
||||
if (scopeId === environmentScopeId) return true;
|
||||
scopeId = this.state.scopesById.get(scopeId)?.parentScopeId ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#nodeWithin(node: t.Node, ancestor?: t.Node): boolean {
|
||||
if (!ancestor) return false;
|
||||
let current: t.Node | undefined = node;
|
||||
while (current) {
|
||||
if (current === ancestor) return true;
|
||||
current = this.state.parentsByNode.get(current);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#completedEnvironment(
|
||||
environment: SymbolicEnvironment,
|
||||
activation: SymbolicEnvironment,
|
||||
returnPoint: t.Node,
|
||||
): SymbolicEnvironment | undefined {
|
||||
if (environment === activation)
|
||||
return { ...environment, completedAt: returnPoint };
|
||||
if (environment.parent === undefined) return undefined;
|
||||
const parent = this.#completedEnvironment(
|
||||
environment.parent,
|
||||
activation,
|
||||
returnPoint,
|
||||
);
|
||||
return parent === undefined ? undefined : { ...environment, parent };
|
||||
}
|
||||
|
||||
#completeReturnedFunction(
|
||||
value: SymbolicFunction,
|
||||
activation: SymbolicEnvironment,
|
||||
returnPoint: t.Node,
|
||||
): SymbolicFunction {
|
||||
const environment = this.#completedEnvironment(
|
||||
value.environment,
|
||||
activation,
|
||||
returnPoint,
|
||||
);
|
||||
if (environment === undefined) return value;
|
||||
const { returnPoint: _returnPoint, ...returned } = value;
|
||||
return { ...returned, environment };
|
||||
}
|
||||
|
||||
#moduleCompletionPoint(factory: t.Function): t.Node {
|
||||
let earliestReturn: t.ReturnStatement | undefined;
|
||||
const pending: t.Node[] = [factory.body];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
if (!node) continue;
|
||||
if (node !== factory.body && (t.isFunction(node) || t.isClass(node)))
|
||||
continue;
|
||||
if (
|
||||
t.isReturnStatement(node) &&
|
||||
(earliestReturn === undefined ||
|
||||
(node.start ?? Infinity) < (earliestReturn.start ?? Infinity))
|
||||
)
|
||||
earliestReturn = node;
|
||||
pending.push(...childNodes(node));
|
||||
}
|
||||
return earliestReturn ?? factory.body;
|
||||
}
|
||||
|
||||
#call(
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
env: SymbolicEnvironment,
|
||||
@@ -408,37 +634,71 @@ export class JavaScriptSymbolicBindings {
|
||||
const args = this.arguments(node, env);
|
||||
return symbolicUnion(
|
||||
symbolicVariants(callee).map((value) => {
|
||||
let result: SymbolicValue;
|
||||
if (value.kind === "loader") {
|
||||
const key = args[0];
|
||||
return key?.kind === "literal"
|
||||
? (value.modules.get(String(key.value)) ??
|
||||
const loaded =
|
||||
key?.kind === "literal"
|
||||
? (value.modules.get(String(key.value)) ??
|
||||
symbolicUnknown("missing module"))
|
||||
: symbolicUnknown("dynamic module");
|
||||
}
|
||||
if (value.kind === "external" && value.path.join(".") === "require") {
|
||||
: symbolicUnknown("dynamic module");
|
||||
if (loaded.kind === "namespace") {
|
||||
const factory = this.#namespaceOwners.get(loaded.bindingId);
|
||||
result =
|
||||
factory === undefined
|
||||
? loaded
|
||||
: {
|
||||
...loaded,
|
||||
environment: {
|
||||
...loaded.environment,
|
||||
completedAt: this.#moduleCompletionPoint(factory),
|
||||
},
|
||||
};
|
||||
} else result = loaded;
|
||||
} else if (
|
||||
value.kind === "external" &&
|
||||
value.path.join(".") === "require"
|
||||
) {
|
||||
const spec = args[0];
|
||||
return spec?.kind === "literal" && typeof spec.value === "string"
|
||||
? { kind: "external", path: [spec.value] }
|
||||
: symbolicUnknown("dynamic require");
|
||||
}
|
||||
if (value.kind !== "function" || this.#activeFunctions.has(value.node))
|
||||
return symbolicUnknown("unresolved or recursive call");
|
||||
this.#activeFunctions.add(value.node);
|
||||
try {
|
||||
const bound = this.bind(value, args);
|
||||
const result = t.isBlockStatement(value.node.body)
|
||||
? this.#returns(value.node.body.body, bound)
|
||||
: this.evaluate(value.node.body, bound);
|
||||
return symbolicUnion(
|
||||
symbolicVariants(result).map((v) =>
|
||||
v.kind === "function"
|
||||
? { ...v, origins: [...value.origins, node, ...v.origins] }
|
||||
: v,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
this.#activeFunctions.delete(value.node);
|
||||
result =
|
||||
spec?.kind === "literal" && typeof spec.value === "string"
|
||||
? { kind: "external", path: [spec.value] }
|
||||
: symbolicUnknown("dynamic require");
|
||||
} else if (
|
||||
value.kind !== "function" ||
|
||||
this.#activeFunctions.has(value.node)
|
||||
) {
|
||||
result = symbolicUnknown("unresolved or recursive call");
|
||||
} else {
|
||||
this.#activeFunctions.add(value.node);
|
||||
try {
|
||||
const bound = this.bind(value, args, node);
|
||||
const returned = t.isBlockStatement(value.node.body)
|
||||
? this.#returns(value.node.body.body, bound)
|
||||
: this.evaluate(value.node.body, bound);
|
||||
result = symbolicUnion(
|
||||
symbolicVariants(returned).map((v) =>
|
||||
v.kind === "function"
|
||||
? {
|
||||
...this.#completeReturnedFunction(
|
||||
v,
|
||||
bound,
|
||||
v.returnPoint ?? value.node.body,
|
||||
),
|
||||
origins: [...value.origins, node, ...v.origins],
|
||||
}
|
||||
: v,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
this.#activeFunctions.delete(value.node);
|
||||
}
|
||||
}
|
||||
for (const condition of value.conditions ?? [])
|
||||
result = symbolicCondition(result, condition);
|
||||
if (value.conditionUnresolved)
|
||||
result = symbolicConditionsUnresolved(result);
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -462,6 +722,16 @@ export class JavaScriptSymbolicBindings {
|
||||
depth = 0,
|
||||
): SymbolicReturnFlow {
|
||||
const possible: SymbolicValue[] = [];
|
||||
let unresolvedFallthrough = false;
|
||||
const continuationConditions: SymbolicCondition[] = [];
|
||||
const guardCurrentPath = (value: SymbolicValue): SymbolicValue => {
|
||||
let guarded = value;
|
||||
for (const condition of continuationConditions)
|
||||
guarded = symbolicCondition(guarded, condition);
|
||||
return unresolvedFallthrough
|
||||
? symbolicConditionsUnresolved(guarded)
|
||||
: guarded;
|
||||
};
|
||||
for (const statement of statements) {
|
||||
if (--this.#remainingEvaluations < 0 || depth >= 128) {
|
||||
this.truncated = true;
|
||||
@@ -475,14 +745,23 @@ export class JavaScriptSymbolicBindings {
|
||||
values: [...possible, symbolicUnknown("wrapper exits by throwing")],
|
||||
fallsThrough: false,
|
||||
};
|
||||
if (t.isReturnStatement(statement))
|
||||
if (t.isReturnStatement(statement)) {
|
||||
const value = this.evaluate(statement.argument, env);
|
||||
const returned = symbolicUnion(
|
||||
symbolicVariants(value).map((variant) =>
|
||||
variant.kind === "function"
|
||||
? { ...variant, returnPoint: statement }
|
||||
: variant,
|
||||
),
|
||||
);
|
||||
return {
|
||||
values: [...possible, this.evaluate(statement.argument, env)],
|
||||
values: [...possible, guardCurrentPath(returned)],
|
||||
fallsThrough: false,
|
||||
};
|
||||
}
|
||||
if (t.isBlockStatement(statement)) {
|
||||
const flow = this.#returnFlow(statement.body, env, depth + 1);
|
||||
possible.push(...flow.values);
|
||||
possible.push(...flow.values.map(guardCurrentPath));
|
||||
if (!flow.fallsThrough)
|
||||
return { values: possible, fallsThrough: false };
|
||||
}
|
||||
@@ -495,23 +774,72 @@ export class JavaScriptSymbolicBindings {
|
||||
const flows = branches.map((branch) =>
|
||||
this.#returnFlow(branch ? [branch] : [], env, depth + 1),
|
||||
);
|
||||
possible.push(...flows.flatMap((flow) => flow.values));
|
||||
possible.push(
|
||||
...flows.flatMap((flow, index) =>
|
||||
flow.values.map((value) => {
|
||||
const guarded = guardCurrentPath(value);
|
||||
return truth === undefined
|
||||
? symbolicCondition(guarded, {
|
||||
node: statement.test,
|
||||
positive: index === 0,
|
||||
})
|
||||
: guarded;
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (flows.every((flow) => !flow.fallsThrough))
|
||||
return { values: possible, fallsThrough: false };
|
||||
if (truth === undefined) {
|
||||
const fallthrough = flows.flatMap((flow, index) =>
|
||||
flow.fallsThrough ? [{ flow, index }] : [],
|
||||
);
|
||||
if (
|
||||
fallthrough.length === 1 &&
|
||||
fallthrough[0]?.flow.values.length === 0
|
||||
)
|
||||
continuationConditions.push({
|
||||
node: statement.test,
|
||||
positive: fallthrough[0].index === 0,
|
||||
});
|
||||
else if (
|
||||
fallthrough.length > 0 &&
|
||||
(fallthrough.length < flows.length ||
|
||||
fallthrough.some(({ flow }) => flow.values.length > 0))
|
||||
)
|
||||
unresolvedFallthrough = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { values: possible, fallsThrough: true };
|
||||
}
|
||||
|
||||
#indexBootstrap(call: t.CallExpression): void {
|
||||
const bootstrap = javascriptBootstrapAt(call);
|
||||
const bootstrap = javascriptBootstrapAt(call, this.state);
|
||||
if (!bootstrap) return;
|
||||
const modules = new Map<string, SymbolicValue>();
|
||||
const tableAliases = new Set([bootstrap.tableBindingId]);
|
||||
const pendingAliases = [bootstrap.tableBindingId];
|
||||
while (pendingAliases.length > 0) {
|
||||
const source = pendingAliases.pop()!;
|
||||
for (const alias of this.#aliasesByBinding.get(source) ?? [])
|
||||
if (!tableAliases.has(alias)) {
|
||||
tableAliases.add(alias);
|
||||
pendingAliases.push(alias);
|
||||
}
|
||||
}
|
||||
for (const property of bootstrap.table.properties) {
|
||||
const factory = moduleFactory(property);
|
||||
if (!factory) continue;
|
||||
const moduleKey = modulePropertyName(property);
|
||||
const values = new Map<string, SymbolicValue>();
|
||||
const environment = { values };
|
||||
const environment: SymbolicEnvironment = {
|
||||
values,
|
||||
point: factory.body,
|
||||
scopeNode: factory,
|
||||
...(this.state.scopeByNode.get(factory)?.scopeId === undefined
|
||||
? {}
|
||||
: { scopeId: this.state.scopeByNode.get(factory)!.scopeId }),
|
||||
};
|
||||
const first = factory.params[0];
|
||||
const second = factory.params[1];
|
||||
const third = factory.params[2];
|
||||
@@ -543,8 +871,153 @@ export class JavaScriptSymbolicBindings {
|
||||
}
|
||||
if (loaderId) values.set(loaderId, { kind: "loader", modules });
|
||||
this.#environments.set(factory, environment);
|
||||
modules.set(modulePropertyName(property), namespace);
|
||||
modules.set(moduleKey, namespace);
|
||||
}
|
||||
this.#bootstrapTables.push({ tableAliases, modules });
|
||||
}
|
||||
|
||||
#indexBindingAliases(): void {
|
||||
for (const binding of this.state.bindingsById.values()) {
|
||||
const initializer = binding.initializers[0];
|
||||
if (
|
||||
!initializer ||
|
||||
initializer.entryBody !== undefined ||
|
||||
initializer.projection.length !== 0 ||
|
||||
!t.isIdentifier(initializer.node)
|
||||
)
|
||||
continue;
|
||||
const source = resolveSemanticBindingState(
|
||||
this.state,
|
||||
initializer.node,
|
||||
initializer.node.name,
|
||||
);
|
||||
if (!source) continue;
|
||||
const aliases = this.#aliasesByBinding.get(source.bindingId) ?? [];
|
||||
aliases.push(binding.bindingId);
|
||||
this.#aliasesByBinding.set(source.bindingId, aliases);
|
||||
}
|
||||
}
|
||||
|
||||
#indexElectronWrites(program: t.Program): void {
|
||||
const interop = createSemanticModuleInterop(this.state);
|
||||
const pathsByBinding = new Map<string, readonly string[] | null>();
|
||||
const retainPath = (
|
||||
bindingId: string,
|
||||
path: readonly string[] | undefined,
|
||||
): readonly string[] | undefined => {
|
||||
pathsByBinding.set(bindingId, path ?? null);
|
||||
return path;
|
||||
};
|
||||
const bindingPath = (
|
||||
binding: ReturnType<typeof resolveSemanticBindingState>,
|
||||
visited = new Set<string>(),
|
||||
): readonly string[] | undefined => {
|
||||
if (!binding || visited.has(binding.bindingId)) return undefined;
|
||||
const cached = pathsByBinding.get(binding.bindingId);
|
||||
if (cached !== undefined) return cached ?? undefined;
|
||||
visited.add(binding.bindingId);
|
||||
const converted = interop.origin(binding)?.origin;
|
||||
const direct =
|
||||
converted ??
|
||||
(binding.directOrigins.length === 1
|
||||
? binding.directOrigins[0]
|
||||
: undefined);
|
||||
if (direct && ELECTRON_MODULE.test(direct.specifier))
|
||||
return retainPath(binding.bindingId, [
|
||||
"electron",
|
||||
...electronExportPath(direct.importedPath),
|
||||
]);
|
||||
const initializer =
|
||||
binding.initializers.length === 1 ? binding.initializers[0] : undefined;
|
||||
if (
|
||||
!initializer ||
|
||||
!initializer.projection.every(
|
||||
(segment): segment is string => typeof segment === "string",
|
||||
)
|
||||
)
|
||||
return retainPath(binding.bindingId, undefined);
|
||||
const required = semanticRequireOrigin(
|
||||
initializer.node,
|
||||
this.state,
|
||||
)?.origin;
|
||||
if (required && ELECTRON_MODULE.test(required.specifier))
|
||||
return retainPath(binding.bindingId, [
|
||||
"electron",
|
||||
...electronExportPath([
|
||||
...required.importedPath,
|
||||
...initializer.projection.map(String),
|
||||
]),
|
||||
]);
|
||||
const member = electronMemberPath(initializer.node);
|
||||
if (
|
||||
!t.isIdentifier(member.root) ||
|
||||
!member.members.every(
|
||||
(segment): segment is string => typeof segment === "string",
|
||||
)
|
||||
)
|
||||
return retainPath(binding.bindingId, undefined);
|
||||
const source = resolveSemanticBindingState(
|
||||
this.state,
|
||||
member.root,
|
||||
member.root.name,
|
||||
);
|
||||
const sourcePath = bindingPath(source, visited);
|
||||
return retainPath(
|
||||
binding.bindingId,
|
||||
sourcePath === undefined
|
||||
? undefined
|
||||
: [
|
||||
...sourcePath,
|
||||
...member.members,
|
||||
...initializer.projection.map(String),
|
||||
],
|
||||
);
|
||||
};
|
||||
const rootPath = (root: t.Node): readonly string[] | undefined => {
|
||||
const direct = semanticRequireOrigin(root, this.state)?.origin;
|
||||
if (direct && ELECTRON_MODULE.test(direct.specifier))
|
||||
return ["electron", ...electronExportPath(direct.importedPath)];
|
||||
if (!t.isIdentifier(root)) return undefined;
|
||||
const binding = resolveSemanticBindingState(this.state, root, root.name);
|
||||
return bindingPath(binding);
|
||||
};
|
||||
completeSemanticSteps(
|
||||
collectJavaScriptIdentityWritesSteps(
|
||||
program,
|
||||
(root, kind) => {
|
||||
if (kind === "opaque-call") return interop.opaqueMutationPath(root);
|
||||
return rootPath(root) ?? interop.intrinsicPath(root);
|
||||
},
|
||||
interop.intrinsicInvocation,
|
||||
interop.staticString,
|
||||
interop.isFreshObject,
|
||||
(_node, path) => {
|
||||
if (path[0] === "electron") {
|
||||
this.#electronWrites.retain(path.slice(1));
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#invalidateBootstrapTable(
|
||||
tableAliases: ReadonlySet<string>,
|
||||
modules: Map<string, SymbolicValue>,
|
||||
): void {
|
||||
let invalidateAll = false;
|
||||
const invalidSlots = new Set<string>();
|
||||
for (const bindingId of tableAliases) {
|
||||
if (this.#mutated.has(bindingId)) invalidateAll = true;
|
||||
for (const definition of this.state.bindingsById.get(bindingId)
|
||||
?.definitions ?? [])
|
||||
if (definition.kind === "assignment") invalidateAll = true;
|
||||
for (const write of this.#writes.get(bindingId) ?? []) {
|
||||
if (write.path.length === 0) invalidateAll = true;
|
||||
else invalidSlots.add(write.path[0]!);
|
||||
}
|
||||
}
|
||||
if (invalidateAll) modules.clear();
|
||||
else for (const key of invalidSlots) modules.delete(key);
|
||||
}
|
||||
|
||||
#indexWrite(target: t.Node, value: t.Node, site: t.Node): void {
|
||||
|
||||
@@ -4,16 +4,38 @@ import type * as t from "@babel/types";
|
||||
export interface SymbolicEnvironment {
|
||||
readonly values: ReadonlyMap<string, SymbolicValue>;
|
||||
readonly parent?: SymbolicEnvironment;
|
||||
/** Source point at which the current function is being symbolically executed. */
|
||||
readonly point?: t.Node;
|
||||
/** Lexical function scope for point-sensitive local initializer checks. */
|
||||
readonly scopeId?: string;
|
||||
/** Function activation captured by this environment, when one exists. */
|
||||
readonly scopeNode?: t.Function;
|
||||
/** Completion boundary proven for this captured activation. */
|
||||
readonly completedAt?: t.Node;
|
||||
}
|
||||
|
||||
export interface SymbolicFunction {
|
||||
export interface SymbolicCondition {
|
||||
readonly node: t.Node;
|
||||
readonly positive: boolean | "nullish" | "not-nullish";
|
||||
}
|
||||
|
||||
export interface SymbolicMetadata {
|
||||
/** Predicates required for this value to be selected. */
|
||||
readonly conditions?: readonly SymbolicCondition[];
|
||||
/** A control-flow join prevented a sound predicate from being retained. */
|
||||
readonly conditionUnresolved?: true;
|
||||
}
|
||||
|
||||
export interface SymbolicFunction extends SymbolicMetadata {
|
||||
readonly kind: "function";
|
||||
readonly node: t.Function;
|
||||
readonly environment: SymbolicEnvironment;
|
||||
readonly origins: readonly t.Node[];
|
||||
/** Return statement completing the activation that produced this value. */
|
||||
readonly returnPoint?: t.Node;
|
||||
}
|
||||
|
||||
export type SymbolicValue =
|
||||
export type SymbolicValue = (
|
||||
| { readonly kind: "unknown"; readonly reason: string }
|
||||
| {
|
||||
readonly kind: "literal";
|
||||
@@ -40,7 +62,9 @@ export type SymbolicValue =
|
||||
readonly values: ReadonlyMap<string, SymbolicValue>;
|
||||
}
|
||||
| { readonly kind: "union"; readonly values: readonly SymbolicValue[] }
|
||||
| SymbolicFunction;
|
||||
| SymbolicFunction
|
||||
) &
|
||||
SymbolicMetadata;
|
||||
|
||||
export const symbolicUnknown = (reason: string): SymbolicValue => ({
|
||||
kind: "unknown",
|
||||
@@ -56,9 +80,39 @@ export const symbolicUnion = (
|
||||
const unique = [...new Set(values.flatMap(symbolicVariants))];
|
||||
return unique.length === 1 ? unique[0]! : { kind: "union", values: unique };
|
||||
};
|
||||
export const symbolicTruth = (value: SymbolicValue): boolean | undefined =>
|
||||
value.kind === "literal"
|
||||
export const symbolicTruth = (value: SymbolicValue): boolean | undefined => {
|
||||
if (value.kind === "union") {
|
||||
const choices = value.values.map(symbolicTruth);
|
||||
return choices.length > 0 &&
|
||||
choices.every((choice) => choice === choices[0])
|
||||
? choices[0]
|
||||
: undefined;
|
||||
}
|
||||
return value.kind === "literal"
|
||||
? Boolean(value.value)
|
||||
: ["function", "object", "namespace", "array"].includes(value.kind)
|
||||
: ["function", "object", "namespace", "array", "record", "loader"].includes(
|
||||
value.kind,
|
||||
)
|
||||
? true
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Attach selection predicates to values while preserving their representation. */
|
||||
export const symbolicCondition = (
|
||||
value: SymbolicValue,
|
||||
condition: SymbolicCondition,
|
||||
): SymbolicValue => {
|
||||
if (value.kind === "union")
|
||||
return symbolicUnion(
|
||||
value.values.map((variant) => symbolicCondition(variant, condition)),
|
||||
);
|
||||
return { ...value, conditions: [...(value.conditions ?? []), condition] };
|
||||
};
|
||||
|
||||
export const symbolicConditionsUnresolved = (
|
||||
value: SymbolicValue,
|
||||
): SymbolicValue => {
|
||||
if (value.kind === "union")
|
||||
return symbolicUnion(value.values.map(symbolicConditionsUnresolved));
|
||||
return { ...value, conditionUnresolved: true };
|
||||
};
|
||||
@@ -62,6 +62,17 @@ export const javaScriptTransferByteBudget = (
|
||||
Math.max(TRANSFER_BYTES, Math.floor(resultBudgetBytes / 8)),
|
||||
);
|
||||
|
||||
const assertTransferByteBudget = (transferBytes: number): void => {
|
||||
if (
|
||||
!Number.isSafeInteger(transferBytes) ||
|
||||
transferBytes < 1 ||
|
||||
transferBytes > MAX_TRANSFER_BYTES
|
||||
)
|
||||
throw new RangeError(
|
||||
"JavaScript transfer requires a valid complete-file byte budget",
|
||||
);
|
||||
};
|
||||
|
||||
const encodeTransferRecord = (
|
||||
input: unknown,
|
||||
previousBytes: number,
|
||||
@@ -103,11 +114,13 @@ function* moduleTransferRecords(
|
||||
/** Measure actual validated module rows before allocating an unadmittable graph. */
|
||||
export const measureJavaScriptModuleTransfer = (
|
||||
module: JavaScriptModuleSemanticIr,
|
||||
transferBytes = TRANSFER_BYTES,
|
||||
) => {
|
||||
assertTransferByteBudget(transferBytes);
|
||||
let bytes = 0;
|
||||
let records = 0;
|
||||
for (const record of moduleTransferRecords(module)) {
|
||||
bytes += encodeTransferRecord(record, bytes).size;
|
||||
bytes += encodeTransferRecord(record, bytes, transferBytes).size;
|
||||
records += 1;
|
||||
}
|
||||
return {
|
||||
@@ -124,14 +137,7 @@ export const writeJavaScriptAnalysisTransfer = async (
|
||||
projection: JavaScriptSemanticFileProjection,
|
||||
transferBytes = TRANSFER_BYTES,
|
||||
): Promise<Descriptor> => {
|
||||
if (
|
||||
!Number.isSafeInteger(transferBytes) ||
|
||||
transferBytes < 1 ||
|
||||
transferBytes > MAX_TRANSFER_BYTES
|
||||
)
|
||||
throw new RangeError(
|
||||
"JavaScript transfer requires a valid complete-file byte budget",
|
||||
);
|
||||
assertTransferByteBudget(transferBytes);
|
||||
const output = await open(path, "wx", 0o600);
|
||||
const digest = createHash("sha256");
|
||||
let bytes = 0;
|
||||
|
||||
@@ -68,6 +68,7 @@ const ipcChannel = <Shape extends z.ZodRawShape>(shape: Shape) =>
|
||||
z.union([
|
||||
z.strictObject({
|
||||
...findingContext,
|
||||
invocation_location: range.optional(),
|
||||
resolution_basis: z.literal("wrapper-substitution").optional(),
|
||||
...shape,
|
||||
channel: z.string(),
|
||||
@@ -75,6 +76,7 @@ const ipcChannel = <Shape extends z.ZodRawShape>(shape: Shape) =>
|
||||
}),
|
||||
z.strictObject({
|
||||
...findingContext,
|
||||
invocation_location: range.optional(),
|
||||
resolution_basis: z.literal("wrapper-substitution").optional(),
|
||||
...shape,
|
||||
channel: z.null(),
|
||||
@@ -121,11 +123,13 @@ const ipc = z
|
||||
}),
|
||||
])
|
||||
.transform((value) => {
|
||||
const { module_runtime, resolution_basis, ...rest } = value;
|
||||
const { module_runtime, resolution_basis, invocation_location, ...rest } =
|
||||
value;
|
||||
return {
|
||||
...rest,
|
||||
...(module_runtime === undefined ? {} : { module_runtime }),
|
||||
...(resolution_basis === undefined ? {} : { resolution_basis }),
|
||||
...(invocation_location === undefined ? {} : { invocation_location }),
|
||||
};
|
||||
});
|
||||
const windowShape = {
|
||||
|
||||
@@ -129,6 +129,7 @@ for (const surface of ["CLI", "stdio MCP"] as const) {
|
||||
const root = await createTestTempDirectory("rea-electron-branch-paths-");
|
||||
await writeFixtureFiles(root, {
|
||||
"preload.js": `const e=require("electron");
|
||||
e.contextBridge.exposeInMainWorld("empty", {});
|
||||
function choose(){
|
||||
if(flag)return ()=>e.ipcRenderer.send("test:copy");
|
||||
else return ()=>e.ipcRenderer.send("test:cut");
|
||||
@@ -150,6 +151,19 @@ for (const surface of ["CLI", "stdio MCP"] as const) {
|
||||
register("test:fallback",event=>{false ?? event.sender.copy()});`,
|
||||
});
|
||||
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
|
||||
const empty = graph.nodes
|
||||
.filter((node) => node.kind === "context-bridge-api")
|
||||
.flatMap((node) => node.observations)
|
||||
.find((observation) => observation.properties.api_key === "empty");
|
||||
expect(empty).toMatchObject({
|
||||
properties: {
|
||||
api_status: "object-literal",
|
||||
members: [],
|
||||
unknown_members: 0,
|
||||
resolution_basis: "direct-syntax",
|
||||
},
|
||||
evidence: { state: "observed" },
|
||||
});
|
||||
const joins = graph.edges.filter(
|
||||
(edge) =>
|
||||
edge.evidence.extractor.operation === "join-electron-wrapper-paths",
|
||||
@@ -172,6 +186,17 @@ for (const surface of ["CLI", "stdio MCP"] as const) {
|
||||
),
|
||||
].sort(),
|
||||
).toEqual(["test:copy", "test:cut"]);
|
||||
for (const [channel, expression] of [
|
||||
["test:copy", "flag"],
|
||||
["test:cut", "!(flag)"],
|
||||
])
|
||||
expect(
|
||||
steps.find(
|
||||
(step) =>
|
||||
step.properties.entry === "desktop.choose" &&
|
||||
step.properties.channel === channel,
|
||||
)?.properties.conditions,
|
||||
).toEqual([expect.objectContaining({ expression })]);
|
||||
expect(
|
||||
steps.some(
|
||||
(step) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { completeJavaScriptAnalysisSteps } from "../../../src/application/javasc
|
||||
import { projectJavaScriptSemanticFileSteps } from "../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js";
|
||||
import {
|
||||
javaScriptTransferByteBudget,
|
||||
measureJavaScriptModuleTransfer,
|
||||
writeJavaScriptAnalysisTransfer,
|
||||
readJavaScriptAnalysisTransfer,
|
||||
} from "../../../src/javascript/analysis/JavaScriptAnalysisTransfer.js";
|
||||
@@ -44,6 +45,27 @@ it("enforces the offered complete-file byte budget while preserving authenticate
|
||||
module,
|
||||
projection,
|
||||
);
|
||||
const measured = measureJavaScriptModuleTransfer(module);
|
||||
expect(measured).toMatchObject({
|
||||
bytes: observed.module_bytes,
|
||||
records: observed.module_records,
|
||||
});
|
||||
expect(measureJavaScriptModuleTransfer(module, measured.bytes)).toEqual(
|
||||
measured,
|
||||
);
|
||||
expect(() =>
|
||||
measureJavaScriptModuleTransfer(module, measured.bytes - 1),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
resource: "transport",
|
||||
reportedLimits: expect.objectContaining({
|
||||
transfer_file_bytes: measured.bytes - 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(() =>
|
||||
measureJavaScriptModuleTransfer(module, 2 ** 31 + 1),
|
||||
).toThrowError(RangeError);
|
||||
const exactPath = join(root, "exact");
|
||||
const exact = await writeJavaScriptAnalysisTransfer(
|
||||
exactPath,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||
import { compactAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js";
|
||||
import {
|
||||
COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
|
||||
parseMcpInputSchemaProfile,
|
||||
@@ -121,29 +120,6 @@ describe("MCP compact input schema profile", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reduces at most the structurally oversized tools", async () => {
|
||||
const full = fullServer();
|
||||
registerCatalog(full);
|
||||
const tools = await listToolsThroughTransport(full);
|
||||
const reduced: string[] = [];
|
||||
for (const tool of tools) {
|
||||
const { presentation } = compactAdvertisedInputSchema(
|
||||
tool.inputSchema,
|
||||
COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
|
||||
);
|
||||
if (presentation === "reduced") reduced.push(tool.name);
|
||||
}
|
||||
expect(reduced.sort()).toEqual([
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"capture_browser_scenario",
|
||||
"compare_application_versions",
|
||||
"compare_web_captures",
|
||||
"evaluate_reconstruction_coverage",
|
||||
"project_managed_application_graph",
|
||||
]);
|
||||
expect(tools.length).toBe(TOOL_CONTRACTS.length);
|
||||
});
|
||||
|
||||
it("retains parameter types and avoids client-side reference expansion", async () => {
|
||||
const server = compactServer();
|
||||
registerCatalog(server);
|
||||
|
||||
@@ -288,37 +288,43 @@ describe("enhanced MCP tools", () => {
|
||||
failed: 0,
|
||||
});
|
||||
expect(results[3]).toEqual({
|
||||
"0": [
|
||||
{
|
||||
address: "0x1",
|
||||
status: "ok",
|
||||
calls: ["0x2", "0x3"],
|
||||
relationship_coverage: {
|
||||
status: "unknown",
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
graph: {
|
||||
"0": [
|
||||
{
|
||||
address: "0x1",
|
||||
status: "ok",
|
||||
calls: ["0x2", "0x3"],
|
||||
limitations: ["Provider did not report relationship coverage."],
|
||||
relationship_coverage: {
|
||||
status: "unknown",
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"1": [
|
||||
{
|
||||
address: "0x2",
|
||||
status: "ok",
|
||||
calls: ["0x1"],
|
||||
relationship_coverage: {
|
||||
status: "unknown",
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
],
|
||||
"1": [
|
||||
{
|
||||
address: "0x2",
|
||||
status: "ok",
|
||||
calls: ["0x1"],
|
||||
limitations: ["Provider did not report relationship coverage."],
|
||||
relationship_coverage: {
|
||||
status: "unknown",
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x3",
|
||||
status: "ok",
|
||||
calls: [],
|
||||
relationship_coverage: {
|
||||
status: "unknown",
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
{
|
||||
address: "0x3",
|
||||
status: "ok",
|
||||
calls: [],
|
||||
limitations: ["Provider did not report relationship coverage."],
|
||||
relationship_coverage: {
|
||||
status: "unknown",
|
||||
reason: "Provider did not report relationship coverage.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
expect(results[4]).toMatchObject({
|
||||
total: 1,
|
||||
|
||||
Reference in new issue
Block a user