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 <[email protected]>
This commit is contained in:
曾亮
2026-10-10 23:01:54 +08:00
committed by GitHub
co-authored by morluto
parent 162f9bf7ff
commit 4d8d76b253
17 changed files with 480 additions and 41 deletions
+7 -1
View File
@@ -15,7 +15,11 @@ when both Evidence sets already exist.
The request supplies a literal-port loopback endpoint and, for inspection, a
target ID. REA accepts local hostless `file://` URLs that resolve to regular
files; remote hosts, encoded path separators, and nonexistent paths are
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
rejected. The endpoint exposes every eligible target and its local path
metadata.
@@ -120,3 +124,5 @@ projection is intentionally target-only and partial: it binds the selected
application path and capture outcome to the static graph, while frames, scripts,
workers, and execution claims remain unavailable until a separate passive runtime
capture provides them.
ASAR observation reads the header through one open file handle, validates its declared lengths against the archive size, and limits encoded JSON headers to 16 MiB before parsing. The header cache also retains at most 16 MiB of encoded headers across eight archives. Oversized headers produce a scope refusal.
+12
View File
@@ -449,3 +449,15 @@ and reconstruction cannot treat it as unchanged.
canonical `integrity_contradictions` records and marks application graph
coverage partial when any mismatch is continued. Contradicted nested ASARs
remain opaque.
`extract_artifact` accepts the same policy in MCP and as `--integrity-policy`
on `rea extract-artifact`. With `record-and-continue` it writes the observed
bytes and returns the `integrity_contradictions` records for the extracted
files. An active ASAR unpacked entry whose companion file is absent cannot be
materialized; extraction fails as `unavailable` with that logical path.
Packaging tools commonly sign or strip `.asar.unpacked` native binaries after
writing the archive header, so their declared hashes no longer match. Electron
does not check unpacked companions against those hashes at runtime. Integrity
failures for such entries name this cause and the operation to rerun with
`record-and-continue`; REA does not exempt unpacked entries silently.
@@ -1,10 +1,11 @@
import { z } from "zod";
import { artifactIntegrityPolicySchema } from "../../domain/artifactIntegrityPolicy.js";
/** Local ASAR/directory reconstruction request. */
export const javascriptArtifactReconstructionInputSchema = z.strictObject({
input_path: z.string().min(1),
format: z.enum(["auto", "asar", "directory"]).default("auto"),
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
integrity_policy: artifactIntegrityPolicySchema,
});
/** Parsed local reconstruction request. */
+1
View File
@@ -183,6 +183,7 @@ class ArtifactClient implements AnalysisClient {
inputFormat: this.target.format,
outputRoot: parsed.output_root,
environment: this.environment,
integrityPolicy: parsed.integrity_policy,
},
options?.signal,
);
@@ -23,11 +23,13 @@ import {
type ArtifactGraphManifest,
type ArtifactNode,
type ArtifactOccurrence,
type IntegrityContradiction,
} from "../../domain/artifactGraph.js";
import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { scanArtifactInventory } from "../inventory/ArtifactInventory.js";
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
/** Local extraction input with the output root chosen by the adapter. */
export interface ArtifactExtractionInput {
@@ -35,6 +37,7 @@ export interface ArtifactExtractionInput {
readonly inputFormat: BinaryTarget["format"];
readonly outputRoot: string;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly integrityPolicy: ArtifactIntegrityPolicyName;
}
/** Extract every regular inventory occurrence into an exclusively owned absent root. */
@@ -49,6 +52,7 @@ export const extractArtifact = async (
const snapshot = await scanArtifactInventory(sourcePath, {
signal,
environment: input.environment,
integrity: { mode: input.integrityPolicy },
});
return materializeArtifactInventory(input, sourcePath, snapshot, signal);
};
@@ -100,6 +104,9 @@ export const materializeArtifactInventory = async (
manifest: snapshot.manifest,
occurrences,
nodes,
integrityContradictions: snapshot.integrity_contradictions.filter(
({ occurrence_id: id }) => selectedIds.has(id),
),
};
const selected = activeOccurrences.map((occurrence) => {
// REA never decrypts archive entries, so an encrypted entry makes the
@@ -110,6 +117,24 @@ export const materializeArtifactInventory = async (
input.inputPath,
`Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`,
);
// Inventory keeps an ASAR unpacked entry whose companion file is absent,
// but extraction cannot materialize bytes it never observed.
if (
occurrence.entry_kind === "file" &&
occurrence.artifact_id === null &&
occurrence.hash_status === "unavailable"
)
throw new ArtifactReaderFailure(
"unavailable",
`ASAR unpacked companion bytes are unavailable for ${occurrence.logical_path}`,
undefined,
{
logicalPath: occurrence.logical_path,
declaredSha256: null,
calculatedSha256: null,
unpacked: true,
},
);
if (
(occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
occurrence.artifact_id === null ||
@@ -293,8 +318,14 @@ const createExtractionResult = (
containment_verified: true,
cleanup: { attempted: false, verified: true, residual_paths: [] },
provenance: [],
integrity_contradictions: inventory.integrityContradictions,
limitations: [
"All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file.",
...(inventory.integrityContradictions.length === 0
? []
: [
`${String(inventory.integrityContradictions.length)} extracted file(s) contradict declared integrity metadata; their bytes are observed-untrusted.`,
]),
],
});
};
@@ -303,6 +334,7 @@ interface LoadedInventory {
readonly manifest: ArtifactGraphManifest;
readonly occurrences: ReadonlyMap<string, ArtifactOccurrence>;
readonly nodes: ReadonlyMap<string, ArtifactNode>;
readonly integrityContradictions: readonly IntegrityContradiction[];
}
const collectOccurrences = (
+143 -8
View File
@@ -1,5 +1,6 @@
import { realpath, stat } from "node:fs/promises";
import { isAbsolute } from "node:path";
import type { Stats } from "node:fs";
import { open, realpath, stat } from "node:fs/promises";
import { isAbsolute, join, sep } from "node:path";
import { fileURLToPath } from "node:url";
/** Resolve one local file URL while rejecting remote hosts and encoded separators. */
@@ -31,14 +32,148 @@ export const authorizedElectronFile = async (
return undefined;
}
if (!isAbsolute(path) || path.includes("\0")) return undefined;
let canonical: string;
try {
if (!(await stat(path)).isFile()) return undefined;
canonical = await realpath(path);
if ((await stat(path)).isFile()) return await realpath(path);
} catch (cause: unknown) {
// Missing or unreadable files cannot authorize a local file.
// Electron serves packaged pages from inside app.asar, which the host
// filesystem cannot stat; resolve those through the archive header below.
void cause;
return undefined;
}
return canonical;
return await authorizedAsarMember(path);
};
/**
* Resolve `<archive>.asar/<member>` 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 (packed or unpacked).
*/
const authorizedAsarMember = async (
path: string,
): Promise<string | undefined> => {
const segments = path.split(sep);
for (let index = 1; index < segments.length - 1; index += 1) {
if (!(segments[index] ?? "").toLowerCase().endsWith(".asar")) continue;
const archive = segments.slice(0, index + 1).join(sep);
const member = segments.slice(index + 1);
if (member.some((part) => part === "" || part === "." || part === ".."))
return undefined;
try {
const metadata = await stat(archive);
if (!metadata.isFile()) continue;
const canonical = await realpath(archive);
const files = await asarFiles(canonical, metadata);
return isAsarRegularFile(files, member)
? join(canonical, ...member)
: undefined;
} catch (cause: unknown) {
// An unreadable or malformed archive cannot authorize its members.
void cause;
return undefined;
}
}
return undefined;
};
type ArchiveIdentity = Pick<Stats, "dev" | "ino" | "size" | "mtimeMs">;
// This is a retained-memory budget for JSON headers, shared across all archives.
const MAX_HEADER_BYTES = 16 * 1024 * 1024;
const MAX_CACHED_HEADERS = 8;
const headerCache = new Map<
string,
{
readonly identity: string;
readonly files: unknown;
readonly bytes: number;
}
>();
const archiveIdentity = (metadata: ArchiveIdentity): string =>
`${String(metadata.dev)}:${String(metadata.ino)}:${String(metadata.size)}:${String(metadata.mtimeMs)}`;
/** Read the two Chromium pickle headers on one handle before allocating JSON. */
const asarFiles = async (
archive: string,
metadata: ArchiveIdentity,
): Promise<unknown> => {
const identity = archiveIdentity(metadata);
const cached = headerCache.get(archive);
if (cached?.identity === identity) return cached.files;
const handle = await open(archive, "r");
let files: unknown;
let jsonBytes: number;
try {
const opened = await handle.stat();
if (!opened.isFile() || archiveIdentity(opened) !== identity)
throw new Error("ASAR identity changed before header read");
const prefix = Buffer.alloc(16);
const read = await handle.read(prefix, 0, prefix.length, 0);
if (read.bytesRead !== prefix.length || prefix.readUInt32LE(0) !== 4)
throw new Error("Truncated or invalid ASAR size pickle");
const headerBytes = prefix.readUInt32LE(4);
const payloadBytes = prefix.readUInt32LE(8);
jsonBytes = prefix.readUInt32LE(12);
if (
headerBytes < 8 ||
headerBytes > opened.size - 8 ||
payloadBytes !== headerBytes - 4 ||
jsonBytes > payloadBytes - 4 ||
jsonBytes > MAX_HEADER_BYTES
)
throw new Error(
"ASAR JSON header leaves the file or exceeds its memory budget",
);
const json = Buffer.alloc(jsonBytes);
let offset = 0;
while (offset < json.length) {
const part = await handle.read(
json,
offset,
json.length - offset,
16 + offset,
);
if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header");
offset += part.bytesRead;
}
if (archiveIdentity(await handle.stat()) !== identity)
throw new Error("ASAR identity changed during header read");
const parsed: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(json),
);
if (!isRecord(parsed) || !isRecord(parsed.files))
throw new Error("ASAR header has no file tree");
files = parsed.files;
} finally {
await handle.close();
}
headerCache.delete(archive);
headerCache.set(archive, { identity, files, bytes: jsonBytes });
const retainedBytes = () =>
[...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0);
while (
headerCache.size > MAX_CACHED_HEADERS ||
retainedBytes() > MAX_HEADER_BYTES
)
headerCache.delete(headerCache.keys().next().value ?? archive);
return files;
};
const isAsarRegularFile = (
files: unknown,
member: readonly string[],
): boolean => {
let node: unknown = { files };
for (const part of member) {
const children = isRecord(node) ? node.files : undefined;
if (!isRecord(children) || !Object.hasOwn(children, part)) return false;
node = children[part];
}
return (
isRecord(node) &&
typeof node.size === "number" &&
!("files" in node) &&
!("link" in node)
);
};
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
+11 -2
View File
@@ -195,13 +195,22 @@ const registerExtractionCommand = (
args: z.object({
path: z.string().describe("Application or package path"),
}),
options: z.object({}),
run: ({ args }) =>
options: z.object({
integrityPolicy: z
.enum(["fail", "record-and-continue"])
.default("fail")
.describe("Behavior when declared artifact integrity does not match"),
}),
alias: {
integrityPolicy: "integrity-policy",
},
run: ({ args, options }) =>
logCliCommand(logger, "extract-artifact", () =>
runProviderAnalysis(
args.path,
"extract_artifact",
{
integrity_policy: options.integrityPolicy,
output_root: createArtifactExtractionDestination(),
},
logger,
+10 -6
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { ToolContract } from "./toolContractTypes.js";
import { artifactOutputSchemas } from "./toolOutputSchemaGroups.js";
import { artifactIntegrityPolicySchema } from "../domain/artifactIntegrityPolicy.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { toolContractMetadata } from "./toolEffects.js";
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
@@ -10,16 +11,19 @@ import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js";
/** Exact caller boundary for deterministic artifact inventory. */
export const artifactInventoryInputSchema = z.strictObject({
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
integrity_policy: artifactIntegrityPolicySchema,
});
/** Extraction needs no selector: it materializes every regular child file. */
export const artifactExtractionInputSchema = z.strictObject({});
export const artifactExtractionInputSchema = z.strictObject({
integrity_policy: artifactIntegrityPolicySchema,
});
/** Provider input containing the destination chosen by the local adapter. */
export const artifactExtractionExecutionSchema = z.strictObject({
output_root: z.string().min(1),
});
export const artifactExtractionExecutionSchema =
artifactExtractionInputSchema.extend({
output_root: z.string().min(1),
});
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
@@ -68,7 +72,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
artifact(
"extract_artifact",
"Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
"Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, and verifies cleanup. Integrity mismatches fail by default; record-and-continue extracts the observed bytes and returns each mismatch as an explicitly untrusted integrity contradiction.",
artifactExtractionInputSchema,
),
artifact(
+25 -9
View File
@@ -190,8 +190,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
if (error.userMessage !== undefined) return error.userMessage;
const standardMessage = standardErrorMessage(error._tag);
if (standardMessage !== undefined) return standardMessage;
if (error instanceof ArtifactOperationError)
return artifactMessage(error.operation, error.reason, error.detail);
if (error instanceof ArtifactOperationError) return artifactMessage(error);
if (error instanceof EvidenceReferenceError)
return error.reason === "missing"
? `Evidence ${error.evidenceId} is not retained in this session. Supply complete inline Evidence, re-run its producer, or import its bundle before using this reference.`
@@ -326,20 +325,37 @@ const START_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"HopperStartError",
]);
const artifactMessage = (
operation: ArtifactOperationError["operation"],
reason: ArtifactOperationError["reason"],
detail?: string,
): string => {
/** Operations that accept integrity_policy for declared-integrity mismatches. */
const INTEGRITY_POLICY_OPERATIONS: ReadonlySet<
ArtifactOperationError["operation"]
> = new Set([
"inventory_artifact",
"inspect_artifact",
"extract_artifact",
"analyze_javascript_application",
]);
const artifactMessage = ({
operation,
reason,
artifactDetails: details,
detail,
}: ArtifactOperationError): string => {
if (reason === "cancelled")
return "Artifact operation was cancelled. Start it again when ready.";
if (reason === "limit")
return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.";
if (reason === "path") return artifactPathMessage(detail);
if (reason === "unavailable" && details?.unpacked === true)
return `ASAR unpacked companion bytes are unavailable for ${details.logicalPath}. Select the archive in place with its .unpacked directory beside it, then retry.`;
if (reason === "unavailable")
return "Artifact processing is unavailable for the current target or host. Check artifact support and required tools.";
if (reason === "integrity" && operation === "analyze_javascript_application")
return "Artifact bytes contradict declared integrity. If expected, rerun analyze_javascript_application with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.";
if (reason === "integrity" && INTEGRITY_POLICY_OPERATIONS.has(operation))
return `${
details?.unpacked === true && details.calculatedSha256 !== null
? `Declared ASAR integrity for unpacked entry ${details.logicalPath} contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive.`
: "Artifact bytes contradict declared integrity."
} If expected, rerun ${operation === "inventory_artifact" ? "inspect_artifact" : operation} with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.`;
if (reason === "format" || reason === "integrity")
return "Artifact is invalid or has changed. Get a fresh copy and try again.";
return "Artifact could not be read or written. Check file access and try again.";
+1
View File
@@ -226,6 +226,7 @@ export const artifactExtractionResultSchema = z.object({
residual_paths: z.array(boundedRelativePathSchema),
}),
provenance: z.array(artifactCommandSchema),
integrity_contradictions: z.array(integrityContradictionSchema),
limitations: z.array(z.string()),
});
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
/**
* Caller choice for declared-versus-observed artifact integrity mismatches.
*
* `fail` rejects the operation; `record-and-continue` keeps the observed bytes
* as explicitly untrusted Evidence and reports each contradiction.
*/
export const artifactIntegrityPolicySchema = z
.enum(["fail", "record-and-continue"])
.default("fail");
/** Parsed artifact integrity policy. */
export type ArtifactIntegrityPolicyName = z.infer<
typeof artifactIntegrityPolicySchema
>;
@@ -13,6 +13,7 @@ import {
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
import { integrityContradictionSchema } from "../artifactGraph.js";
import { artifactIntegrityPolicySchema } from "../artifactIntegrityPolicy.js";
import { localPathStringSchema } from "../localPath.js";
const countSchema = z.number().int().min(0);
@@ -21,7 +22,7 @@ const countSchema = z.number().int().min(0);
export const analyzeJavaScriptApplicationInputSchema = z.strictObject({
input_path: localPathStringSchema,
format: z.enum(["auto", "asar", "directory"]).default("auto"),
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
integrity_policy: artifactIntegrityPolicySchema,
});
/** Compact counts for the high-level Electron architecture/security surface. */
@@ -1,7 +1,8 @@
import { rm, writeFile } from "node:fs/promises";
import { mkdir, realpath, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { createPackage } from "@electron/asar";
import { afterEach, describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -199,6 +200,51 @@ describe("CdpElectronProvider target selection", () => {
});
});
describe("CdpElectronProvider packaged pages", () => {
it("lists and inspects packaged pages served from inside app.asar", async () => {
const root = await createTestTempDirectory("rea-electron-asar-provider-");
temporary.push(root);
const source = join(root, "source");
await mkdir(source);
await writeFile(
join(source, "index.html"),
"<script src='app.js'></script>",
);
await writeFile(join(source, "app.js"), "export const app = true;");
const archive = join(root, "app.asar");
await createPackage(source, archive);
const packaged = await realpath(archive);
const browser = await startFakeCdpBrowser({
electronFileUrl: `${pathToFileURL(join(archive, "index.html")).href}?isBookDoubleClicked=0#/library`,
});
browsers.push(browser);
const provider = new CdpElectronProvider();
const listed = await provider.listTargets(
listElectronTargetsInputSchema.parse({ cdp_endpoint: browser.endpoint }),
);
if (!listed.ok) throw listed.error;
expect(listed.value.targets).toEqual([
expect.objectContaining({
target_id: "electron-page",
file_path: join(packaged, "index.html"),
}),
]);
const inspected = await provider.inspectPage(
inspectElectronPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "electron-page",
observation_ms: 0,
}),
);
if (!inspected.ok) throw inspected.error;
expect(inspected.value.target.file_path).toBe(join(packaged, "index.html"));
expect(inspected.value.scripts.items).toEqual([
expect.objectContaining({ file_path: join(packaged, "app.js") }),
]);
});
});
describe("CdpElectronProvider capture", () => {
it.each([{}, { scriptSource: null }, { scriptSource: 123 }])(
"rejects malformed Electron source replies instead of inventing source: %j",
@@ -1,7 +1,8 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
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";
@@ -48,6 +49,43 @@ describe("Electron file scope", () => {
).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");
@@ -56,4 +94,29 @@ describe("Electron file scope", () => {
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();
});
});
+9 -6
View File
@@ -18,6 +18,13 @@ import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComp
import { createServer } from "../../../src/server/createServer.js";
import { observed } from "../../fixtures/analysisExecution.js";
const integrityMessage = (unpacked: boolean): string =>
`${
unpacked
? "Declared ASAR integrity for unpacked entry main.js contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive."
: "Artifact bytes contradict declared integrity."
} If expected, rerun inspect_artifact with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.`;
it.each([
{ unpacked: false, label: "embedded" },
{ unpacked: true, label: "unpacked" },
@@ -69,8 +76,7 @@ it.each([
error: {
code: "artifact_integrity_mismatch",
category: "integrity_mismatch",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
message: integrityMessage(unpacked),
details: {
logical_path: "main.js",
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
@@ -78,10 +84,7 @@ it.each([
unpacked,
},
retryable: false,
remediation: {
action:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
},
remediation: { action: integrityMessage(unpacked) },
},
});
} finally {
@@ -0,0 +1,95 @@
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js";
const openArchive = async (archive: string): Promise<Client> => {
const session = createTestBinarySession(new ArtifactProvider(process.env));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "asar-unpacked-extraction", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
onTestFinished(async () => {
await Promise.allSettled([client.close(), server.close(), session.close()]);
});
const opened = await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
expect(opened.isError).not.toBe(true);
return client;
};
it("extracts a rewritten unpacked companion only when the caller records the mismatch", async () => {
const { archive, addon, stripped } = await createStrippedAsarAddon();
const client = await openArchive(archive);
const strict = await client.callTool({
name: "extract_artifact",
arguments: {},
});
expect(parseMcpToolError(strict).error).toMatchObject({
code: "artifact_integrity_mismatch",
message: expect.stringContaining(
`Declared ASAR integrity for unpacked entry ${addon} contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive. If expected, rerun extract_artifact with integrity_policy=record-and-continue`,
),
details: { logical_path: addon, unpacked: true },
});
const continued = await client.callTool({
name: "extract_artifact",
arguments: { integrity_policy: "record-and-continue" },
});
expect(continued.isError, JSON.stringify(continued)).not.toBe(true);
const evidence = parseEvidence(continued.structuredContent);
expect(evidence.parameters).toMatchObject({
integrity_policy: "record-and-continue",
});
const result = z
.object({
output_root: z.string(),
integrity_contradictions: z.array(
z.object({ logical_path: z.string(), trust: z.string() }),
),
limitations: z.array(z.string()),
})
.parse(evidence.normalized_result);
onTestFinished(() =>
rm(result.output_root, { recursive: true, force: true }),
);
expect(await readFile(join(result.output_root, addon))).toEqual(stripped);
expect(result.integrity_contradictions).toEqual([
{ logical_path: addon, trust: "observed-untrusted" },
]);
expect(result.limitations).toContain(
"1 extracted file(s) contradict declared integrity metadata; their bytes are observed-untrusted.",
);
});
it("names a missing unpacked companion instead of reporting a changed artifact", async () => {
const { archive, addon } = await createStrippedAsarAddon();
await rm(join(`${archive}.unpacked`, addon));
const client = await openArchive(archive);
const extracted = await client.callTool({
name: "extract_artifact",
arguments: {},
});
expect(parseMcpToolError(extracted).error).toMatchObject({
code: "artifact_operation_failed",
category: "unavailable",
message: `ASAR unpacked companion bytes are unavailable for ${addon}. Select the archive in place with its .unpacked directory beside it, then retry.`,
details: { logical_path: addon, unpacked: true },
});
});
@@ -121,11 +121,8 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr
throw new Error("Unavailable active ASAR member must fail extraction");
expect(projectAnalysisError(activeResult.error)).toMatchObject({
code: "artifact_operation_failed",
details: {
operation: "extract_artifact",
reason: "format",
detail: expect.stringContaining("native/addon.node"),
},
category: "unavailable",
details: { logical_path: "native/addon.node", unpacked: true },
});
await expect(access(activeOutput)).rejects.toThrow();
});
@@ -139,6 +136,7 @@ const inventoriedExtraction = async (source: string, output: string) => {
inputFormat: "asar",
outputRoot: output,
environment: process.env,
integrityPolicy: "fail",
},
source,
snapshot,