Files
rea/tests/boundary/filesystem/electronFileScope.test.ts
T
曾亮andmorluto 4d8d76b253 fix(electron): support packaged app.asar pages and rewritten unpacked companions (#1400)
* fix(electron): resolve pages served from inside app.asar

Packaged Electron applications load their renderer pages from file URLs
inside app.asar. The host filesystem cannot stat those paths, so
list_electron_targets excluded every such page as unsupported_url and
inspect_electron_page could not select it.

Resolve these URLs the way Electron's file protocol does: the first .asar
path component that is a regular file is the archive, and the remainder
must name a regular file in its header. Directories, links, and missing
members stay rejected. Parsed headers are cached by archive identity.

* fix(artifacts): extract and explain rewritten ASAR unpacked companions

Packaging tools commonly sign or strip .asar.unpacked native binaries after
writing the archive header, so their declared hashes no longer match.
analyze_javascript_application already accepts integrity_policy, but
extract_artifact did not, and inspect_artifact and extract_artifact still
told callers to "get a fresh copy", which cannot help for a shipped app.

- Accept integrity_policy on extract_artifact (MCP and CLI). The default
  still fails closed; record-and-continue writes the observed bytes and
  returns the extracted files' integrity_contradictions records.
- Name the unpacked entry and the post-packaging cause in integrity
  failures, and point every operation that accepts integrity_policy at
  record-and-continue.
- Report an active unpacked entry whose companion file is absent as
  unavailable with its logical path instead of a format failure.
- Share one integrity_policy schema across the inventory, extraction, and
  JavaScript application inputs.

* fix(electron): bound ASAR headers before allocation

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:01:54 +08:00

123 lines
4.8 KiB
TypeScript

import { mkdir, realpath, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { createPackageWithOptions } from "@electron/asar";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { authorizedElectronFile } from "../../../src/browser/ElectronFileScope.js";
describe("Electron file scope", () => {
it("accepts dot-prefixed child names beneath a canonical root", async () => {
const root = await createTestTempDirectory("rea-electron-dot-child-");
const directory = join(root, "..cache");
await mkdir(directory);
const path = join(directory, "index.html");
await writeFile(path, "allowed");
expect(await authorizedElectronFile(pathToFileURL(path).href)).toBe(path);
});
it("accepts explicit canonical local files and rejects remote or malformed URLs", async () => {
const base = await createTestTempDirectory("rea-electron-scope-");
const root = join(base, "root");
const outside = join(base, "outside");
await mkdir(root);
await mkdir(outside);
const allowed = join(root, "index.html");
const denied = join(outside, "secret.html");
await writeFile(allowed, "allowed");
await writeFile(denied, "denied");
await symlink(denied, join(root, "escape.html"));
expect(await authorizedElectronFile(pathToFileURL(allowed).href)).toBe(
allowed,
);
expect(await authorizedElectronFile(pathToFileURL(denied).href)).toBe(
denied,
);
expect(
await authorizedElectronFile(
pathToFileURL(join(root, "escape.html")).href,
),
).toBe(denied);
expect(
await authorizedElectronFile("file://server/share/index.html"),
).toBeUndefined();
expect(
await authorizedElectronFile("file:///tmp/root%2Findex.html"),
).toBeUndefined();
});
it("resolves regular files Electron serves from inside an ASAR archive", async () => {
const root = await createTestTempDirectory("rea-electron-asar-scope-");
const source = join(root, "source");
await mkdir(join(source, "build", "static"), { recursive: true });
await mkdir(join(source, "native"));
await writeFile(join(source, "build", "index.html"), "<p>packed</p>");
await writeFile(join(source, "build", "static", "app.js"), "app();");
await writeFile(join(source, "native", "addon.node"), "native");
await writeFile(join(source, "notes.txt"), "plain");
const archive = join(root, "app.asar");
await createPackageWithOptions(source, archive, { unpack: "*.node" });
const canonical = await realpath(archive);
const member = (...parts: string[]) =>
pathToFileURL(join(archive, ...parts)).href;
expect(await authorizedElectronFile(member("build", "index.html"))).toBe(
join(canonical, "build", "index.html"),
);
expect(
await authorizedElectronFile(
`${member("build", "static", "app.js")}?isBookDoubleClicked=0#/library`,
),
).toBe(join(canonical, "build", "static", "app.js"));
expect(await authorizedElectronFile(member("native", "addon.node"))).toBe(
join(canonical, "native", "addon.node"),
);
expect(await authorizedElectronFile(member("build"))).toBeUndefined();
expect(
await authorizedElectronFile(member("build", "missing.html")),
).toBeUndefined();
expect(
await authorizedElectronFile(
pathToFileURL(join(source, "notes.txt", "index.html")).href,
),
).toBeUndefined();
});
it("accepts file URLs with long query text", async () => {
const root = await createTestTempDirectory("rea-electron-long-url-");
const path = join(root, "index.html");
await writeFile(path, "allowed");
const value = `${pathToFileURL(path).href}?${"x".repeat(70_000)}`;
await expect(authorizedElectronFile(value)).resolves.toBe(path);
});
it("resolves an archive beneath an ASAR-named directory and rejects oversized declared headers", async () => {
const root = await createTestTempDirectory("rea-electron-header-");
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "index.html"), "page");
const parent = join(root, "parent.asar");
await mkdir(parent);
const archive = join(parent, "app.asar");
await createPackageWithOptions(source, archive, {});
expect(
await authorizedElectronFile(
pathToFileURL(join(archive, "index.html")).href,
),
).toBe(join(await realpath(archive), "index.html"));
const malformed = join(root, "bad.asar");
const header = Buffer.alloc(16);
header.writeUInt32LE(4, 0);
header.writeUInt32LE(0x40000000, 4);
await writeFile(malformed, header);
expect(
await authorizedElectronFile(
pathToFileURL(join(malformed, "index.html")).href,
),
).toBeUndefined();
});
});