fix: retain Electron file admission facts across adapters

This commit is contained in:
morluto
2026-10-24 03:08:30 +08:00
parent 57aa21af26
commit e94d5a5c74
7 changed files with 164 additions and 23 deletions
+8 -3
View File
@@ -29,15 +29,20 @@ files, including pages a packaged application serves from inside `app.asar`.
For those, the first `.asar` path component that is a regular file is the
archive, the remainder must name a regular file in its header, and the reported
path is the canonical archive path joined with that member. Remote hosts,
encoded path separators, ASAR directories or links, and nonexistent paths are
encoded separators in the pathname, ASAR directories or links, and nonexistent paths are
rejected. The endpoint exposes every eligible target and its local path
metadata.
metadata. Query strings and fragments do not participate in filesystem path
admission, including their encoded separator text. Listing and inspection
Evidence both retain the observed browser product/revision; host platform and
architecture remain explicitly unknown.
Cancellation stops further file authorization after an already-started filesystem
operation settles. ASAR descriptor-close failures remain typed incomplete cleanup
with an exact retryable owner; they are not treated as a missing or unauthorized
page. A later authorization and provider shutdown retry retained descriptors
before reporting their release.
before reporting their release. Failed listing retains its already-authorized
target prefix and explicit remaining-target uncertainty through cleanup errors
and exact-owner retries.
## Workflow
+7 -12
View File
@@ -30,20 +30,15 @@ export const createElectronEvidence = (
result,
confidence: "observed",
authority: "external-service",
environment:
"target" in result
? {
id: `${result.browser.product}@${result.browser.revision}`,
platform: "unknown",
architecture: "unknown",
isolation: "none",
}
: null,
environment: {
id: `${result.browser.product}@${result.browser.revision}`,
platform: "unknown",
architecture: "unknown",
isolation: "none",
},
limitations: [
...result.limitations,
...("target" in result
? ["Browser host platform and architecture were not observed."]
: []),
"Browser host platform and architecture were not observed.",
],
});
+92 -2
View File
@@ -1,4 +1,6 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -97,7 +99,9 @@ afterEach(async () => {
});
const asarPage = async (): Promise<string> => {
const root = await mkdtemp(join(tmpdir(), "rea-electron-file-scope-"));
const root = await realpath(
await mkdtemp(join(tmpdir(), "rea-electron-file-scope-")),
);
roots.push(root);
const archive = join(root, "app.asar");
const json = Buffer.from(
@@ -295,3 +299,89 @@ it("retries a failed ASAR descriptor lease during provider shutdown", async () =
expect(handleState.events).toEqual(["open", "close", "close"]);
});
it.each(["completed", "read-failed", "cancelled"] as const)(
"retains the completed listing prefix after ASAR finalization: %s",
async (mode) => {
const archiveUrl = await asarPage();
const page = join(roots.at(-1)!, "first.html");
await writeFile(page, "first");
const controller = new AbortController();
handleState.failCloseCount = 1;
if (mode === "read-failed")
handleState.readFailure = new Error("controlled ASAR read denial");
if (mode === "cancelled") {
handleState.closeController = controller;
handleState.closeReason = { request: "ASAR descriptor close" };
}
let endpoint = "";
const server = createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify(
request.url === "/json/version"
? {
Browser: "Electron/fixture",
"Protocol-Version": "1.3",
"WebKit-Version": "revision",
"User-Agent": "fixture",
"V8-Version": "V8",
webSocketDebuggerUrl:
endpoint.replace("http:", "ws:") +
"/devtools/browser/fixture",
}
: [pathToFileURL(page).href, archiveUrl].map((url, index) => ({
id: `page-${index}`,
type: "page",
title: `page ${index}`,
url,
attached: false,
webSocketDebuggerUrl:
endpoint.replace("http:", "ws:") +
`/devtools/page/page-${index}`,
})),
),
);
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Expected discovery port");
endpoint = `http://127.0.0.1:${address.port}`;
const provider = new CdpElectronProvider();
try {
const result = await provider.listTargets(
{ cdp_endpoint: endpoint },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected ASAR finalization failure");
const origin = result.error.cleanupPrimary ?? result.error;
expect(origin.diagnostics).toMatchObject({
completed_prefix: {
examined_targets: 2,
allowed_targets: [{ target_id: "page-0", file_path: page }],
coverage: expect.stringContaining("remaining targets are unknown"),
},
});
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
"completed_prefix",
);
expect(result.error.cleanupIncomplete).toBe(true);
expect(handleState.events).toEqual(["open", "close"]);
await provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(handleState.events).toEqual(["open", "close", "close"]);
expect(origin.diagnostics).toHaveProperty("completed_prefix");
} finally {
handleState.failCloseCount = 0;
await provider.close();
await new Promise<void>((resolve, reject) =>
server.close((cause) =>
cause === undefined ? resolve() : reject(cause),
),
);
}
},
);
+9 -4
View File
@@ -92,7 +92,6 @@ export const mapElectronFileAuthorizationFailure = (
const primary = hasCancellation
? new AnalysisCancelledError(operation, {
cause: cancellationReason,
...(diagnostics === undefined ? {} : { diagnostics }),
})
: physicalPrimary instanceof AnalysisError
? physicalPrimary
@@ -108,8 +107,14 @@ export const mapElectronFileAuthorizationFailure = (
{ operation, cause },
);
bindArtifactFileCleanup(cause, cleanup);
if (diagnostics !== undefined)
(primary ?? cleanup).retainDiagnostics(diagnostics);
if (primary === undefined) return cleanup;
const composed = analysisErrorWithCleanupFailure(primary, cleanup, operation);
const composed = analysisErrorWithCleanupFailure(
primary,
cleanup,
operation,
).bindCleanupDependencies([primary, cleanup]);
bindArtifactFileCleanup(cause, composed);
return composed;
};
@@ -120,7 +125,6 @@ const resolveAuthorizedElectronFile = async (
asarHeaderAdmission: AsarHeaderAdmission,
): Promise<string | undefined> => {
throwIfAborted(signal);
if (/%(?:2f|5c)/iu.test(value)) return undefined;
let url: URL;
try {
url = new URL(value);
@@ -133,7 +137,8 @@ const resolveAuthorizedElectronFile = async (
url.protocol !== "file:" ||
url.hostname !== "" ||
url.username !== "" ||
url.password !== ""
url.password !== "" ||
/%(?:2f|5c)/iu.test(url.pathname)
)
return undefined;
let path: string;
+11 -1
View File
@@ -43,7 +43,7 @@ it(
await writeFile(join(root, "index.html"), "<script src='app.js'></script>");
await writeFile(join(root, "app.js"), "export const app = true;");
const browser = await startFakeCdpBrowser({
electronFileUrl: pathToFileURL(join(root, "index.html")).href,
electronFileUrl: `${pathToFileURL(join(root, "index.html")).href}?next=%2Fsettings#%5Csettings`,
commandEvents: ({ method }) =>
method === "Debugger.enable"
? [
@@ -67,6 +67,15 @@ it(
);
expect(listed).toMatchObject({
operation: "list_electron_targets",
environment: {
id: expect.any(String),
platform: "unknown",
architecture: "unknown",
isolation: "none",
},
limitations: expect.arrayContaining([
"Browser host platform and architecture were not observed.",
]),
normalized_result: {
targets: [{ target_id: "electron-page" }],
},
@@ -84,6 +93,7 @@ it(
);
expect(inspected).toMatchObject({
operation: "inspect_electron_page",
environment: listed.environment,
provider: { id: "rea-cdp-electron" },
normalized_result: {
target: { file_path: join(root, "index.html") },
@@ -156,3 +156,26 @@ describe("Electron file scope", () => {
).toBeUndefined();
});
});
it("keeps encoded query and fragment separators outside filesystem admission", async () => {
const root = await createTestTempDirectory("rea-electron-url-components-");
const path = join(root, "index.html");
await writeFile(path, "allowed");
const base = pathToFileURL(path).href;
for (const suffix of [
"",
"?next=/settings",
"#/settings",
"?next=%2Fsettings",
"?next=%5Csettings",
"#%2Fsettings",
"#%5Csettings",
])
await expect(authorizedElectronFile(`${base}${suffix}`)).resolves.toBe(
path,
);
for (const encoded of ["%2F", "%5C"])
await expect(
authorizedElectronFile(`${pathToFileURL(root).href}${encoded}index.html`),
).resolves.toBeUndefined();
});
+14 -1
View File
@@ -286,7 +286,7 @@ it("exposes endpoint-scoped Electron discovery and inspection as Evidence", asyn
);
await writeFile(join(root, "worker.js"), "self.onmessage = () => {};\n");
const browser = await startFakeCdpBrowser({
electronFileUrl: pathToFileURL(join(root, "index.html")).href,
electronFileUrl: `${pathToFileURL(join(root, "index.html")).href}?next=%2Fsettings#%5Csettings`,
commandEvents: ({ method }) =>
method === "Debugger.enable"
? [
@@ -332,6 +332,15 @@ it("exposes endpoint-scoped Electron discovery and inspection as Evidence", asyn
});
expect(listed.isError).not.toBe(true);
expect(listed.structuredContent).toMatchObject({
environment: {
id: expect.any(String),
platform: "unknown",
architecture: "unknown",
isolation: "none",
},
limitations: expect.arrayContaining([
"Browser host platform and architecture were not observed.",
]),
normalized_result: {
targets: [{ target_id: "electron-page" }],
},
@@ -345,6 +354,10 @@ it("exposes endpoint-scoped Electron discovery and inspection as Evidence", asyn
include_script_sources: true,
},
});
expect(inspected.isError).not.toBe(true);
expect(parseEvidence(inspected.structuredContent).environment).toEqual(
parseEvidence(listed.structuredContent).environment,
);
const analyzed = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: root },