fix(artifacts): name the ASAR header admission budget (#6172)

Keep the 16 MiB ASAR header/index ceiling fixed and report that budget,
the lack of a runtime override, and that source selection does not bypass
header admission.

Refs #5910

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 06:33:09 +08:00
1 parent 82e4e032df
commit d7ca93cc2c
11 files changed
+202 -15

No files matched your search

@@ -22,6 +22,11 @@ requires complete application analysis; `source_paths: []` is invalid, not an
unfiltered request. `detail: "summary"` reduces delivery
and does not by itself reduce analysis work.
ASAR header/index admission is fixed at 16 MiB / 16777216 bytes for this
release and has no runtime override. `source_paths`, `detail`, and
`max_heap_mb` do not bypass or raise it. Analyze an extracted directory, or
supply an archive whose header fits.
Exact `#alias` specifiers resolve through the importer's
enclosing package `imports` map to package-local files; nested `node_modules`
packages use their own map. Wildcard, recursive, and external-package import
+7 -1
View File
@@ -245,4 +245,10 @@ 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.
ASAR observation reads the header through one open file handle and validates its
declared lengths against the archive size. Header/index admission is the same
fixed 16 MiB / 16777216 byte budget used by static ASAR reads. This release has
no runtime override. An oversized header is not parsed; selecting a member path
does not bypass admission, and the page stays unauthorized. The header cache
separately retains at most 16 MiB of already-admitted encoded headers across
eight archives. Evicting that cache does not raise admission.
+11 -1
View File
@@ -106,6 +106,15 @@ only reduces delivery; source selection reduces text acquisition and analysis.
Explicit native provider or snapshot routing rejects `--source-path` rather
than ignoring it.
ASAR header/index admission is a fixed 16 MiB / 16777216 bytes for this
release. There is no runtime override. The archive index is read before any
member can be selected, so `source_paths`, `--source-path`, `detail:
"summary"`, `max_heap_mb`, and available host memory do not bypass or raise
it. When the header exceeds that allowance, the failure names this budget.
Analyze an extracted application directory, which does not admit an ASAR
header, or supply an archive whose header fits. Repeating the unchanged
request fails the same way.
## Read the analysis
`analyze_javascript_application` returns the complete analysis Evidence by
@@ -172,7 +181,8 @@ for the tool-result contract.
Use MCP `detail: "summary"` for a large application, then read selected views of
the retained analysis. Summary changes delivery only. To reduce source parsing,
select exact [source paths](#select-sources-before-analysis).
select exact [source paths](#select-sources-before-analysis). Source selection
does not bypass ASAR header/index admission.
If a complete response exceeds the transport budget, use its retained Evidence
ID for a summary, module page, or item, or export the bundle. See
@@ -188,6 +188,7 @@ const artifactFailureToOperationError = (
...(cause.partialObservation === undefined
? {}
: { partialObservation: cause.partialObservation }),
...(cause.admission === undefined ? {} : { admission: cause.admission }),
},
);
@@ -32,6 +32,9 @@ export const translateArtifactFailure = (
...(cause.partialObservation === undefined
? {}
: { partialObservation: cause.partialObservation }),
...(cause.admission === undefined
? {}
: { admission: cause.admission }),
},
);
bindArtifactFileCleanup(cause, error);
+9
View File
@@ -1,3 +1,4 @@
import type { ArtifactAdmissionLimit } from "../domain/artifactOperationError.js";
import type {
ArtifactIntegrityClaim,
ArtifactIntegrityDetails,
@@ -28,6 +29,8 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions {
readonly partialObservation?: AnalysisPartialObservation | undefined;
/** The constrained resource bound for a limit failure. */
readonly boundary?: ArtifactReaderBoundary;
/** Named admission whose recovery must not be inferred from other limit text. */
readonly admission?: ArtifactAdmissionLimit;
}
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
@@ -192,6 +195,8 @@ export class ArtifactReaderFailure extends Error {
readonly partialObservation: AnalysisPartialObservation | undefined;
/** The constrained bound for a `limit` reason, when the reader knows it. */
readonly boundary: ArtifactReaderBoundary | undefined;
/** Named admission for a limit whose public recovery is owned by the producer. */
readonly admission: ArtifactAdmissionLimit | undefined;
constructor(
readonly reason:
@@ -217,6 +222,7 @@ export class ArtifactReaderFailure extends Error {
options?.cleanupLeases ?? (this.cleanup === undefined ? [] : undefined);
this.partialObservation = options?.partialObservation;
this.boundary = options?.boundary;
this.admission = options?.admission;
}
/** Preserve the primary failure and observation. Supplied leases must cover all merged cleanup. */
@@ -261,6 +267,9 @@ export class ArtifactReaderFailure extends Error {
...(primary.boundary === undefined
? {}
: { boundary: primary.boundary }),
...(primary.admission === undefined
? {}
: { admission: primary.admission }),
...(mergedObservation === undefined
? {}
: { partialObservation: mergedObservation }),
+30 -5
View File
@@ -5,9 +5,37 @@ import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { err, ok, type Result } from "../domain/result.js";
/** Shared resource budget for the eagerly decoded ASAR header, not member bytes. */
/**
* Fixed header/index admission for this release. The header is decoded before
* any member path can be selected. There is no caller or configuration override.
*/
export const MAX_ASAR_HEADER_BYTES = 16 * 1024 * 1024;
const MEBIBYTE = 1024 * 1024;
/** User-visible recovery for the fixed ASAR header/index admission budget. */
export const asarHeaderAdmissionRecovery = (): string => {
const bytes = MAX_ASAR_HEADER_BYTES;
const mebibytes = bytes / MEBIBYTE;
const allowance = Number.isInteger(mebibytes)
? `${String(mebibytes)} MiB / ${String(bytes)} bytes`
: `${String(bytes)} bytes`;
return [
`ASAR header/index admission is limited to ${allowance}.`,
"This release has no runtime override.",
"Selecting fewer sources or narrowing an internal path does not bypass header admission, because the archive index is read before any member can be selected.",
"Raising max_heap_mb or available host memory does not raise it.",
"Analyze an extracted application directory, which does not admit an ASAR header, or supply an archive whose header fits this allowance.",
"Repeating the unchanged request fails the same way.",
].join(" ");
};
/** Refuse an ASAR whose header/index exceeds the fixed admission budget. */
export const asarHeaderAdmissionFailure = (): ArtifactReaderFailure =>
new ArtifactReaderFailure("limit", asarHeaderAdmissionRecovery(), {
admission: { kind: "asar-header", maximumBytes: MAX_ASAR_HEADER_BYTES },
});
/** Absolute archive prefix required to inspect an admitted header. */
export const MAX_ASAR_HEADER_PROBE_BYTES = MAX_ASAR_HEADER_BYTES + 8;
@@ -345,10 +373,7 @@ export const admitAsarHeader = async (
const parsed = parseAsarHeaderExtents(prefix, observed.size);
if (!parsed.ok) throw parsed.error;
if (parsed.value.headerBytes > MAX_ASAR_HEADER_BYTES)
throw new ArtifactReaderFailure(
"limit",
`ASAR header exceeds the ${String(MAX_ASAR_HEADER_BYTES)} byte decode budget`,
);
throw asarHeaderAdmissionFailure();
return {
archiveBytes: observed.size,
...parsed.value,
@@ -1,6 +1,6 @@
import { hasGodotPckHeader } from "../../domain/godotPckFormat.js";
import type { ArtifactClassificationEvidence } from "../ArtifactClassificationProbe.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { asarHeaderAdmissionFailure } from "../AsarHeader.js";
import { hasDestinationPath } from "../ArtifactPaths.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { isJavaScriptSourcePath } from "../../domain/javascript/javascriptArtifactFiles.js";
@@ -529,11 +529,7 @@ export const classifyArtifactContent = (
path.toLowerCase().endsWith(".asar") &&
asarHeader.state !== "rejected"
) {
if (asarHeader.state === "limit")
throw new ArtifactReaderFailure(
"limit",
"ASAR header exceeds the bounded classification or decode budget",
);
if (asarHeader.state === "limit") throw asarHeaderAdmissionFailure();
if (asarHeader.state === "matched")
return { kind: "container", format: "asar" };
}
+6
View File
@@ -374,6 +374,12 @@ const artifactStateErrorDetails = (
reason: error.reason,
...(error.reason === "limit" ? { truncated: true } : {}),
...(error.detail === undefined ? {} : { detail: error.detail }),
...(error.admission === undefined
? {}
: {
admission: error.admission.kind,
maximum_header_bytes: error.admission.maximumBytes,
}),
};
if (error instanceof UnknownRegistryError) return { reason: error.reason };
if (error instanceof EvidenceFileError)
+16 -1
View File
@@ -7,9 +7,17 @@ import {
type AnalysisErrorOptions,
} from "./analysisErrorBase.js";
/** Fixed admission named by the producing reader, with its effective byte ceiling. */
export interface ArtifactAdmissionLimit {
readonly kind: "asar-header";
readonly maximumBytes: number;
}
/** Artifact inventory or extraction failed a typed safety boundary. */
export class ArtifactOperationError extends AnalysisError {
readonly _tag = "ArtifactOperationError";
override readonly userMessage: string | undefined;
readonly admission: ArtifactAdmissionLimit | undefined;
constructor(
readonly operation:
@@ -37,7 +45,10 @@ export class ArtifactOperationError extends AnalysisError {
readonly artifactDetails?: ArtifactIntegrityDetails,
/** The specific constraint that failed, such as the colliding path. */
readonly detail?: string,
options?: AnalysisErrorOptions,
options?: AnalysisErrorOptions & {
readonly userMessage?: string;
readonly admission?: ArtifactAdmissionLimit;
},
) {
super(
artifactDetails === undefined
@@ -45,6 +56,10 @@ export class ArtifactOperationError extends AnalysisError {
: artifactDetailsMessage(operation, reason, artifactDetails),
options,
);
this.admission = options?.admission;
this.userMessage =
options?.userMessage ??
(options?.admission === undefined ? undefined : detail);
}
}
@@ -3,11 +3,18 @@ import { join } from "node:path";
import { createPackage } from "@electron/asar";
import { expect, it, vi } from "vitest";
import { translateArtifactFailure } from "../../../src/artifacts/ArtifactOperationFailure.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import {
admitAsarHeader,
asarHeaderAdmissionRecovery,
MAX_ASAR_HEADER_BYTES,
} from "../../../src/artifacts/AsarHeader.js";
import { ArtifactOperationError } from "../../../src/domain/artifactOperationError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
const createFixture = async () => {
const root = await createTestTempDirectory("rea-asar-header-admission-");
@@ -126,6 +133,110 @@ it("separates the header decode budget from container validity at its exact boun
jsonBytes: 2,
});
else
await expect(admit(archive)).rejects.toMatchObject({ reason: "limit" });
await expect(admit(archive)).rejects.toMatchObject({
reason: "limit",
message: asarHeaderAdmissionRecovery(),
admission: {
kind: "asar-header",
maximumBytes: MAX_ASAR_HEADER_BYTES,
},
});
}
});
const writeOversizedHeaderArchive = async (): Promise<string> => {
const root = await createTestTempDirectory("rea-asar-header-recovery-");
const archive = join(root, "app.asar");
const headerBytes = MAX_ASAR_HEADER_BYTES + 4;
const json = Buffer.from("{}");
const prefix = Buffer.alloc(16);
prefix.writeUInt32LE(4, 0);
prefix.writeUInt32LE(headerBytes, 4);
prefix.writeUInt32LE(headerBytes - 4, 8);
prefix.writeUInt32LE(json.length, 12);
await writeFile(archive, Buffer.concat([prefix, json]));
await truncate(archive, headerBytes + 8);
return archive;
};
it("names the fixed header admission budget when source selection cannot bypass it", async () => {
const recovery = asarHeaderAdmissionRecovery();
expect(MAX_ASAR_HEADER_BYTES).toBe(16 * 1024 * 1024);
expect(recovery).toContain("ASAR header/index admission");
expect(recovery).toContain(
`${String(MAX_ASAR_HEADER_BYTES / (1024 * 1024))} MiB / ${String(MAX_ASAR_HEADER_BYTES)} bytes`,
);
expect(recovery).toContain("no runtime override");
expect(recovery).toContain("Selecting fewer sources");
expect(recovery).toContain("does not bypass header admission");
expect(recovery).toContain("extracted application directory");
expect(recovery).not.toContain("Narrow the requested path");
const archive = await writeOversizedHeaderArchive();
let inventoryFailure: unknown;
try {
await inventoryArtifact(archive);
} catch (cause: unknown) {
inventoryFailure = cause;
}
expect(inventoryFailure).toBeInstanceOf(ArtifactReaderFailure);
if (!(inventoryFailure instanceof ArtifactReaderFailure))
throw new Error("inventory failure was not an artifact reader failure");
expect(inventoryFailure.cleanup).toBeUndefined();
const wrapped = ArtifactReaderFailure.withCleanup(inventoryFailure, {
reason: "close failed",
resources: ["header"],
});
expect(wrapped.message).toBe(recovery);
expect(wrapped.admission).toEqual(inventoryFailure.admission);
const inventory = projectAnalysisError(
translateArtifactFailure("inventory_artifact", inventoryFailure),
);
expect(inventory).toMatchObject({
code: "truncated",
message: recovery,
remediation: { action: recovery },
details: {
reason: "limit",
truncated: true,
detail: recovery,
admission: "asar-header",
maximum_header_bytes: MAX_ASAR_HEADER_BYTES,
},
});
expect(
projectAnalysisError(translateArtifactFailure("inspect_artifact", wrapped))
.message,
).toBe(recovery);
const selected = await analyzeJavaScriptApplication({
input_path: archive,
source_paths: ["package.json"],
max_heap_mb: 2048,
});
const unselected = await analyzeJavaScriptApplication({
input_path: archive,
});
if (selected.ok || unselected.ok)
throw new Error("ASAR header admission succeeded");
expect(projectAnalysisError(selected.error).message).toBe(recovery);
expect(projectAnalysisError(unselected.error).message).toBe(recovery);
expect(projectAnalysisError(selected.error).remediation.action).toBe(
recovery,
);
});
it("keeps unrelated artifact limit recovery distinct from header admission", () => {
expect(
projectAnalysisError(
new ArtifactOperationError(
"inventory_artifact",
"limit",
undefined,
"Directory traversal reached its examined-entry budget",
),
).message,
).toBe(
"Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.",
);
});