diff --git a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md index 55d3c6c874..026832c519 100644 --- a/.agents/skills/reverse-engineer-anything/references/javascript-applications.md +++ b/.agents/skills/reverse-engineer-anything/references/javascript-applications.md @@ -22,10 +22,10 @@ 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. +ASAR header/index admission defaults to 16 MiB / 16777216 bytes; +`REA_ASAR_MAX_HEADER_BYTES` (at most 256 MiB, set where REA starts) changes it. +`source_paths`, `detail`, and `max_heap_mb` do not bypass or raise it. Or +analyze an extracted directory. Exact `#alias` specifiers resolve through the importer's enclosing package `imports` map to package-local files; nested `node_modules` diff --git a/docs/electron-observation.md b/docs/electron-observation.md index db396e42a1..50561742d8 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -247,8 +247,8 @@ capture provides them. 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. +budget used by static ASAR reads: 16 MiB / 16777216 bytes by default, or +`REA_ASAR_MAX_HEADER_BYTES` (at most 256 MiB) when set. 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 that many bytes +of already-admitted encoded headers across eight archives. Evicting that cache does not raise admission. diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 02d73a132e..575c20e7aa 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -106,13 +106,16 @@ 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 +ASAR header/index admission defaults to 16 MiB / 16777216 bytes. Set +`REA_ASAR_MAX_HEADER_BYTES` in the environment that starts REA to a decimal +byte count of at most 268435456 (256 MiB) to raise or lower it; restart REA +after changing it. Invalid values are configuration errors, never a silent +fallback. 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 the +effective allowance, the failure names it, whether it came from the default or +the setting, and the setting's limits. Alternatively, analyze an extracted +application directory, which does not admit an ASAR header. Repeating the unchanged request fails the same way. ## Read the analysis diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 942325b394..1ba489e52b 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -1,3 +1,7 @@ +import { + ASAR_HEADER_ADMISSION_SETTING, + parseAsarHeaderAdmission, +} from "../../config/asarHeaderAdmission.js"; import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -29,11 +33,16 @@ import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; const OPERATION = "analyze_javascript_application" as const; +/** Execution options plus the composition root's environment for admission settings. */ +export interface JavaScriptApplicationOptions extends ExecutionOptions { + readonly environment?: Readonly>; +} + /** Statically analyze one local JavaScript/Electron application. */ export const analyzeJavaScriptApplication = async ( rawInput: unknown, scope: ArtifactResourceScope, - options: ExecutionOptions = {}, + options: JavaScriptApplicationOptions = {}, ): Promise> => { const parsed = analyzeJavaScriptApplicationInputSchema.safeParse(rawInput); if (!parsed.success) @@ -47,8 +56,13 @@ export const analyzeJavaScriptApplication = async ( export const analyzeJavaScriptApplicationValidated = async ( input: z.output, scope: ArtifactResourceScope, - options: ExecutionOptions = {}, + options: JavaScriptApplicationOptions = {}, ): Promise> => { + // An invalid setting is refused before analysis, never replaced by the default. + const asarHeaderAdmission = parseAsarHeaderAdmission( + options.environment?.[ASAR_HEADER_ADMISSION_SETTING], + ); + if (!asarHeaderAdmission.ok) return asarHeaderAdmission; let reconstructed: JavaScriptArtifactReconstructionResult | undefined; let result: JavaScriptApplicationAnalysisResult | undefined; let evidence: Evidence | undefined; @@ -66,6 +80,7 @@ export const analyzeJavaScriptApplicationValidated = async ( scope, options.signal, options.progress, + asarHeaderAdmission.value, ); const { electron_summary: summary, ...application } = reconstructed; await options.progress?.report({ diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 999bdbf57c..df1b9dbc59 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_ASAR_HEADER_ADMISSION, + type AsarHeaderAdmission, +} from "../../config/asarHeaderAdmission.js"; import { constants } from "node:fs"; import { access, lstat, realpath } from "node:fs/promises"; import { setImmediate } from "node:timers/promises"; @@ -75,6 +79,7 @@ export const reconstructJavaScriptArtifact = async ( scope: ArtifactResourceScope, signal?: AbortSignal, progress?: ProgressReporter, + asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, ): Promise => scope.run(async () => { const reportPhase = async ( @@ -97,13 +102,14 @@ export const reconstructJavaScriptArtifact = async ( resourceScope: scope, signal, integrity: { mode: input.integrity_policy }, + asarHeaderAdmission, }); if (snapshot.manifest.root_format !== format) throw new ArtifactReaderFailure( "format", `Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`, ); - const reader = createReader(path, format); + const reader = createReader(path, format, asarHeaderAdmission); const readerOwner = { kind: "reader" as const, reader, @@ -127,6 +133,7 @@ export const reconstructJavaScriptArtifact = async ( scope, signal, input.source_paths, + asarHeaderAdmission, ); // Release acquisition before analysis consumes these owned file facts. const earlyCleanup = await scope.release(readerOwner); diff --git a/src/artifacts/ArtifactClassificationProbe.ts b/src/artifacts/ArtifactClassificationProbe.ts index b9db6b6c53..d6e4e9c788 100644 --- a/src/artifacts/ArtifactClassificationProbe.ts +++ b/src/artifacts/ArtifactClassificationProbe.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { createAsarHeaderSignatureProbe, type FinalAsarHeaderProbeObservation, @@ -105,9 +106,11 @@ const finishClassificationEvidence = ( }; /** Share acquisition bounds and signature observations across files and streams. */ -export const createArtifactClassificationProbe = () => { +export const createArtifactClassificationProbe = ( + asarHeaderAdmission?: AsarHeaderAdmission, +) => { const xml = createXmlPlistSignatureProbe(); - const asar = createAsarHeaderSignatureProbe(); + const asar = createAsarHeaderSignatureProbe(asarHeaderAdmission); const blocks: Buffer[] = []; let retainedBytes = 0; let observedBytes = 0; diff --git a/src/artifacts/ArtifactHash.ts b/src/artifacts/ArtifactHash.ts index 595c636741..7c462eb3c8 100644 --- a/src/artifacts/ArtifactHash.ts +++ b/src/artifacts/ArtifactHash.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { createHash } from "node:crypto"; import type { Readable } from "node:stream"; @@ -30,10 +31,11 @@ export const hashReadable = async ( stream: Readable, signal?: AbortSignal, integrityAlgorithm?: "sha256" | "md5", + asarHeaderAdmission?: AsarHeaderAdmission, ): Promise => { const hash = createHash("sha256"); const md5 = integrityAlgorithm === "md5" ? createHash("md5") : undefined; - const probe = createArtifactClassificationProbe(); + const probe = createArtifactClassificationProbe(asarHeaderAdmission); let bytes = 0; for await (const raw of stream) { abortIfNeeded(signal); diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts index 54e9eb5052..7a279e0bc3 100644 --- a/src/artifacts/ArtifactOperationFailure.ts +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -5,7 +5,10 @@ import { AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../domain/artifactOperationError.js"; -import { BinaryTargetError } from "../domain/configurationErrors.js"; +import { + BinaryTargetError, + ConfigurationError, +} from "../domain/configurationErrors.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { artifactFileCancellationCause, @@ -40,13 +43,14 @@ export const translateArtifactFailure = ( bindArtifactFileCleanup(cause, error); return error; } - // Caller-selection, target and unsupported-target failures are already typed; + // Caller-selection, target, unsupported-target and configuration failures are already typed; // keep their correction details instead of reducing them to an I/O failure. if ( cause instanceof AnalysisInputError || cause instanceof AnalysisCancelledError || cause instanceof AnalysisUnsupportedTargetError || - cause instanceof BinaryTargetError + cause instanceof BinaryTargetError || + cause instanceof ConfigurationError ) return cause; return new ArtifactOperationError(operation, "io"); diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index bba244b4c5..68daecc832 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -1,3 +1,4 @@ +import { resolveAsarHeaderAdmission } from "./AsarHeader.js"; import { artifactTargetRefusal } from "../domain/apple/artifactTargetApplicability.js"; import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -480,6 +481,7 @@ class ArtifactClient implements AnalysisClient { return inventoryArtifact(this.target.sourcePath ?? this.target.path, { resourceScope: this.#resourceScope, environment: this.environment, + asarHeaderAdmission: resolveAsarHeaderAdmission(this.environment), ...(options?.signal === undefined ? {} : { signal: options.signal }), integrity: { mode: parsed.integrity_policy }, }); diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 9904c3298d..5a872a95ab 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js"; import { constants, createWriteStream, type Stats } from "node:fs"; import { @@ -142,7 +143,11 @@ export class AsarArtifactReader implements ArtifactReader { try { admission = { ok: true, - value: await admitAsarHeader(headerHandle, signal), + value: await admitAsarHeader( + headerHandle, + signal, + this.options.headerAdmission, + ), }; } catch (cause: unknown) { admission = { ok: false, cause }; @@ -622,7 +627,9 @@ type OwnedAsarHandle = { source?: Readable; output?: Readable; }; -type AsarArtifactReaderOptions = { +export type AsarArtifactReaderOptions = { + /** Header/index admission resolved by the caller's composition root; default otherwise. */ + readonly headerAdmission?: AsarHeaderAdmission; readonly openFile?: OpenAsarFile; readonly openUnpackedFile?: OpenAsarFile; readonly removeSnapshot?: (path: string) => Promise; diff --git a/src/artifacts/AsarHeader.ts b/src/artifacts/AsarHeader.ts index dc62efb580..c1fa9635b5 100644 --- a/src/artifacts/AsarHeader.ts +++ b/src/artifacts/AsarHeader.ts @@ -4,40 +4,63 @@ import type { FileHandle } from "node:fs/promises"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + ASAR_HEADER_ADMISSION_SETTING, + DEFAULT_ASAR_HEADER_ADMISSION, + DEFAULT_ASAR_MAX_HEADER_BYTES, + MAXIMUM_ASAR_MAX_HEADER_BYTES, + parseAsarHeaderAdmission, + type AsarHeaderAdmission, +} from "../config/asarHeaderAdmission.js"; /** - * 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. + * Resolve the admission from a composition root's selected environment; an + * invalid setting is refused, not ignored. Callers without an environment use + * the default. */ -export const MAX_ASAR_HEADER_BYTES = 16 * 1024 * 1024; +export const resolveAsarHeaderAdmission = ( + environment: Readonly>, +): AsarHeaderAdmission => { + const parsed = parseAsarHeaderAdmission( + environment[ASAR_HEADER_ADMISSION_SETTING], + ); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; 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 byteAllowance = (bytes: number): string => { const mebibytes = bytes / MEBIBYTE; - const allowance = Number.isInteger(mebibytes) + return 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 }, +/** User-visible recovery for the effective ASAR header/index admission budget. */ +export const asarHeaderAdmissionRecovery = ( + admission: AsarHeaderAdmission, +): string => + [ + `ASAR header/index admission is limited to ${byteAllowance(admission.maximumBytes)}${admission.source === "setting" ? ` by ${ASAR_HEADER_ADMISSION_SETTING}` : ""}.`, + `Set ${ASAR_HEADER_ADMISSION_SETTING} in the environment that starts REA to a byte count up to ${byteAllowance(MAXIMUM_ASAR_MAX_HEADER_BYTES)} (default ${byteAllowance(DEFAULT_ASAR_MAX_HEADER_BYTES)}), then restart REA.`, + "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.", + "Alternatively, analyze an extracted application directory, which does not admit an ASAR header.", + "Repeating the unchanged request fails the same way.", + ].join(" "); + +/** Refuse an ASAR whose header/index exceeds the effective admission budget. */ +export const asarHeaderAdmissionFailure = ( + admission: AsarHeaderAdmission, +): ArtifactReaderFailure => + new ArtifactReaderFailure("limit", asarHeaderAdmissionRecovery(admission), { + admission: { kind: "asar-header", ...admission }, }); -/** Absolute archive prefix required to inspect an admitted header. */ -export const MAX_ASAR_HEADER_PROBE_BYTES = MAX_ASAR_HEADER_BYTES + 8; +/** Absolute archive prefix required to inspect a header under this admission. */ +export const asarHeaderProbeBytes = (admission: AsarHeaderAdmission): number => + admission.maximumBytes + 8; export interface AsarHeaderExtents { readonly archiveBytes: number; @@ -77,6 +100,7 @@ export type AsarHeaderProbeObservation = readonly extents?: AsarHeaderProbeExtents; readonly scannedJsonBytes: number; readonly limitBytes: number; + readonly admission: AsarHeaderAdmission; }; export type FinalAsarHeaderProbeObservation = Exclude< @@ -136,6 +160,7 @@ export const parseAsarHeaderExtents = ( * scans the declared JSON bytes until the first non-JSON-whitespace byte. */ export interface AsarHeaderSignatureProbe { + readonly admission: AsarHeaderAdmission; readonly limit: number; readonly observation: AsarHeaderProbeObservation; write(input: Uint8Array): AsarHeaderProbeObservation; @@ -143,13 +168,17 @@ export interface AsarHeaderSignatureProbe { } class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe { - readonly limit = MAX_ASAR_HEADER_PROBE_BYTES; + readonly limit: number; readonly #fixed = Buffer.alloc(16); #fixedBytes = 0; #scannedJsonBytes = 0; #extents: Omit | undefined; #stop: ProbeStop = { state: "pending" }; + constructor(readonly admission: AsarHeaderAdmission) { + this.limit = asarHeaderProbeBytes(admission); + } + get observation(): AsarHeaderProbeObservation { return this.#observation(); } @@ -227,7 +256,7 @@ class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe { ) { this.#stop = { state: "rejected", reason: "archive-too-short" }; } else if ( - this.#extents.headerBytes > MAX_ASAR_HEADER_BYTES && + this.#extents.headerBytes > this.admission.maximumBytes && this.#stop.state === "matched" ) { this.#stop = { state: "limit" }; @@ -279,6 +308,7 @@ class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe { ...(extents === undefined ? {} : { extents }), scannedJsonBytes: this.#scannedJsonBytes, limitBytes: this.limit, + admission: this.admission, }; case "matched": return { @@ -308,6 +338,7 @@ class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe { extents: finalizedExtents, scannedJsonBytes: this.#scannedJsonBytes, limitBytes: this.limit, + admission: this.admission, }; } return { @@ -346,6 +377,7 @@ const isJsonWhitespace = (value: number): boolean => export const admitAsarHeader = async ( handle: Pick & { stat(): Promise }, signal?: AbortSignal, + admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, ): Promise => { assertArtifactReadActive(signal); const observed = await handle.stat(); @@ -372,13 +404,14 @@ 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 asarHeaderAdmissionFailure(); + if (parsed.value.headerBytes > admission.maximumBytes) + throw asarHeaderAdmissionFailure(admission); return { archiveBytes: observed.size, ...parsed.value, }; }; -export const createAsarHeaderSignatureProbe = (): AsarHeaderSignatureProbe => - new AsarHeaderSignatureProbeImpl(); +export const createAsarHeaderSignatureProbe = ( + admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, +): AsarHeaderSignatureProbe => new AsarHeaderSignatureProbeImpl(admission); diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 2b5b591f1b..2130a47ec0 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -1,3 +1,4 @@ +import { resolveAsarHeaderAdmission } from "../AsarHeader.js"; import { locateGodotPck } from "../GodotPckLocation.js"; import { withArtifactRegularFile } from "../ArtifactFileOperation.js"; import type { ArtifactCleanupAttempt } from "../ArtifactResourceScope.js"; @@ -75,6 +76,7 @@ const extractArtifactInScope = async ( resourceScope: input.resourceScope, signal, environment: input.environment, + asarHeaderAdmission: resolveAsarHeaderAdmission(input.environment), integrity: { mode: input.integrityPolicy }, }); return materializeArtifactInventoryInScope( @@ -483,7 +485,10 @@ const createReader = async ( return new PyInstallerArtifactReader(path); if (await requireExtractableFormat(path, input, signal)) return new DirectoryArtifactReader(path); - if (format === "asar") return new AsarArtifactReader(path); + if (format === "asar") + return new AsarArtifactReader(path, { + headerAdmission: resolveAsarHeaderAdmission(input.environment), + }); if (isZipPackageFormat(format)) return new ZipArtifactReader(path, format); return new MachOSliceArtifactReader(path, input.environment); }; diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index bad8bdc6ff..8c768c9909 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -529,7 +529,8 @@ export const classifyArtifactContent = ( path.toLowerCase().endsWith(".asar") && asarHeader.state !== "rejected" ) { - if (asarHeader.state === "limit") throw asarHeaderAdmissionFailure(); + if (asarHeader.state === "limit") + throw asarHeaderAdmissionFailure(asarHeader.admission); if (asarHeader.state === "matched") return { kind: "container", format: "asar" }; } diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 0e20fe67cc..1b10028d03 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { locateGodotPck } from "../GodotPckLocation.js"; import { sameRegularFileState } from "../../filesystem/RegularFile.js"; import type { FileHandle } from "node:fs/promises"; @@ -78,7 +79,10 @@ export const classifyAndHashRootForInventory = async ( path: string, directory: boolean, expectedMetadata: Stats, - options: Pick, + options: Pick< + ArtifactInventoryOptions, + "resourceScope" | "signal" | "asarHeaderAdmission" + >, ): Promise => { if (directory) return { format: "directory", digest: null, rootSource: undefined }; @@ -106,7 +110,12 @@ export const classifyAndHashRootForInventory = async ( "integrity", `Root artifact changed before inventory: ${path}`, ); - const format = await classifyRootFormat(path, handle, options.signal); + const format = await classifyRootFormat( + path, + handle, + options.signal, + options.asarHeaderAdmission, + ); const digest = await hashStableRootArtifactHandle( path, handle, @@ -144,8 +153,9 @@ const classifyRootFormat = async ( path: string, handle: FileHandle, signal?: AbortSignal, + asarHeaderAdmission?: AsarHeaderAdmission, ): Promise => { - const probe = createArtifactClassificationProbe(); + const probe = createArtifactClassificationProbe(asarHeaderAdmission); const chunk = Buffer.alloc(64 * 1024); while (probe.length < probe.limit) { abortIfNeeded(signal); diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 8b9956a89e..4951573d44 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { AsarArtifactReader } from "../AsarArtifactReader.js"; import type { ArtifactReader, @@ -24,6 +25,7 @@ export const createReader = ( format: ArtifactOccurrence["artifact_format"], environment: Readonly, rootSource?: StableRegularFileDescriptor, + asarHeaderAdmission?: AsarHeaderAdmission, ): ArtifactReader | undefined => { if (isZipPackageFormat(format)) return new ZipArtifactReader(path, format, undefined, rootSource); @@ -31,7 +33,12 @@ export const createReader = ( case "directory": return new DirectoryArtifactReader(path); case "asar": - return new AsarArtifactReader(path); + return new AsarArtifactReader( + path, + asarHeaderAdmission === undefined + ? {} + : { headerAdmission: asarHeaderAdmission }, + ); case "pyinstaller": return new PyInstallerArtifactReader(path, rootSource); case "godot-pck": diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 99b9cc4ace..7a2b6a3013 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -84,6 +84,7 @@ export const scanCanonicalArtifactInventoryInScope = async ( rootFormat, options.environment ?? {}, rootSource, + options.asarHeaderAdmission, ); } catch (cause: unknown) { readerCreationFailure = { cause }; @@ -101,7 +102,13 @@ export const scanCanonicalArtifactInventoryInScope = async ( caseCollisionLimitation, unknownParentOccurrences, traversal, - } = await scanReader(reader, ownedReaders, options.signal, integrity); + } = await scanReader( + reader, + ownedReaders, + options.signal, + integrity, + options.asarHeaderAdmission, + ); outcome = { kind: "completed", snapshot: await buildInventorySnapshot({ diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index 9d0be9d0b8..dc97a77d37 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { artifactIntegrityPair, artifactIntegrityDetails, @@ -56,6 +57,7 @@ interface ScanContext { readonly reader: ArtifactReader; readonly signal: AbortSignal | undefined; readonly integrity: ArtifactIntegrityPolicy; + readonly asarHeaderAdmission: AsarHeaderAdmission | undefined; readonly nodes: Map; readonly occurrences: MutableOccurrence[]; readonly pendingContradictions: PendingIntegrityContradiction[]; @@ -72,6 +74,7 @@ export const scanReader = async ( ownedReaders: ArtifactReader[], signal?: AbortSignal, integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY, + asarHeaderAdmission?: AsarHeaderAdmission, ): Promise<{ readonly nodes: Map; readonly occurrences: MutableOccurrence[]; @@ -94,6 +97,7 @@ export const scanReader = async ( reader, signal, integrity, + asarHeaderAdmission, nodes, occurrences, pendingContradictions, @@ -248,7 +252,12 @@ const visitArtifactEntries = async ( digested.classification.format === "asar" && !digested.mismatched ) { - const nested = new AsarArtifactReader(entry.adapterKey); + const nested = new AsarArtifactReader( + entry.adapterKey, + context.asarHeaderAdmission === undefined + ? {} + : { headerAdmission: context.asarHeaderAdmission }, + ); context.ownedReaders.push(nested); await nested.prepareContainer(digested.node.sha256, context.signal); // Only traversed containers can own members, not opaque ASAR-named files. @@ -304,6 +313,7 @@ const digestArtifactEntry = async ( await currentReader.open(entry, context.signal), context.signal, entry.declaredIntegrity?.algorithm, + context.asarHeaderAdmission, ); const claim = entry.declaredIntegrity; const observed = claim?.algorithm === "md5" ? digest.md5 : digest.sha256; diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts index e9d03196ca..fdab64a68a 100644 --- a/src/artifacts/inventory/types.ts +++ b/src/artifacts/inventory/types.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import type { ArtifactResourceScope } from "../ArtifactResourceScope.js"; /** Resolved integrity behavior admitted to the artifact scanner. */ @@ -15,4 +16,6 @@ export interface ArtifactInventoryOptions { readonly signal?: AbortSignal | undefined; readonly integrity?: ArtifactIntegrityPolicy | undefined; readonly environment?: Readonly | undefined; + /** ASAR header/index admission resolved from the owner's environment; default when absent. */ + readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined; } diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index 1714abaae2..d052c48d60 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { createHash } from "node:crypto"; import { isAbsolute } from "node:path"; import type { Readable } from "node:stream"; @@ -52,6 +53,7 @@ interface ReadContext { readonly files: JavaScriptArtifactFile[]; readonly containers: JavaScriptArtifactContainer[]; readonly signal: AbortSignal | undefined; + readonly asarHeaderAdmission: AsarHeaderAdmission | undefined; readonly selectedPaths: ReadonlyMap | undefined; textBytes: number; invalidUtf8: number; @@ -70,6 +72,7 @@ export const readJavaScriptArtifactFiles = async ( resources: ArtifactResourceScope, signal?: AbortSignal, sourcePaths?: readonly string[], + asarHeaderAdmission?: AsarHeaderAdmission, ): Promise => { const inventory = expectedInventory(snapshot); const expected = inventory.files; @@ -129,6 +132,7 @@ export const readJavaScriptArtifactFiles = async ( files: [], containers: [], signal, + asarHeaderAdmission, selectedPaths, textBytes: 0, invalidUtf8: 0, @@ -250,7 +254,12 @@ const visitReader = async ( context.signal, ); context.containers.push(inventory); - const nested = new AsarArtifactReader(entry.adapterKey); + const nested = new AsarArtifactReader( + entry.adapterKey, + context.asarHeaderAdmission === undefined + ? {} + : { headerAdmission: context.asarHeaderAdmission }, + ); ownedReaders.push({ reader: nested, path }); stack.push({ reader: nested, diff --git a/src/artifacts/javascript/JavaScriptArtifactReader.ts b/src/artifacts/javascript/JavaScriptArtifactReader.ts index 4d2b88c528..a5ab77d8d6 100644 --- a/src/artifacts/javascript/JavaScriptArtifactReader.ts +++ b/src/artifacts/javascript/JavaScriptArtifactReader.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import { AsarArtifactReader } from "../AsarArtifactReader.js"; import type { ArtifactReader } from "../ArtifactReader.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; @@ -6,7 +7,13 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; export const createJavaScriptArtifactReader = ( path: string, format: "asar" | "directory", + asarHeaderAdmission?: AsarHeaderAdmission, ): ArtifactReader => format === "asar" - ? new AsarArtifactReader(path) + ? new AsarArtifactReader( + path, + asarHeaderAdmission === undefined + ? {} + : { headerAdmission: asarHeaderAdmission }, + ) : new DirectoryArtifactReader(path); diff --git a/src/browser/CdpElectronInspection.test.ts b/src/browser/CdpElectronInspection.test.ts index 259f169c48..3d43b659bb 100644 --- a/src/browser/CdpElectronInspection.test.ts +++ b/src/browser/CdpElectronInspection.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_ASAR_HEADER_ADMISSION } from "../config/asarHeaderAdmission.js"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -54,6 +55,7 @@ describe("Electron frame capture", () => { }, }, completeness, + DEFAULT_ASAR_HEADER_ADMISSION, ); expect(frames.map((frame) => frame.frame_id)).toEqual(["frame-main"]); @@ -133,6 +135,7 @@ describe("Electron frame capture", () => { scripts, frameIds: new Set(["frame-old", "frame-new"]), completeness, + asarHeaderAdmission: DEFAULT_ASAR_HEADER_ADMISSION, }); expect(capture.total).toBe(4); @@ -212,6 +215,7 @@ it("admits a prefix and fitting suffix around an oversized Electron source", asy scripts, frameIds: new Set(), completeness, + asarHeaderAdmission: DEFAULT_ASAR_HEADER_ADMISSION, }); const byHash = new Map( capture.items.map((item) => [item.cdp_hash, item.source]), diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index b7e0597b5f..1e1b75c842 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { digestCanonicalValue } from "../domain/canonicalDigest.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import type { @@ -38,6 +39,8 @@ interface ElectronContext { readonly discovery: CdpEndpointDiscovery; readonly target: CdpEndpointTarget; readonly input: InspectElectronPageInput; + /** Header admission resolved by the provider's composition root. */ + readonly asarHeaderAdmission: AsarHeaderAdmission; readonly signal?: AbortSignal; readonly progress?: ProgressReporter; } @@ -157,6 +160,7 @@ const authorizeInitialFrames = async ( const frames = await captureFrames( result, state.completeness, + context.asarHeaderAdmission, context.signal, ); if (frames[0] === undefined) @@ -200,6 +204,7 @@ const captureElectronContent = async ( scripts: state.scripts, frameIds, completeness: state.completeness, + asarHeaderAdmission: context.asarHeaderAdmission, }); const workers = await captureElectronWorkers({ connection: context.connection, @@ -209,6 +214,7 @@ const captureElectronContent = async ( frameIds, completeness: state.completeness, limitations: state.limitations, + asarHeaderAdmission: context.asarHeaderAdmission, }); return { dom, scripts, resources, workers }; }; @@ -224,7 +230,11 @@ const assertStableMainFrame = async ( context.sessionId, context.signal, ); - const afterPath = await mainFilePath(result, context.signal); + const afterPath = await mainFilePath( + result, + context.asarHeaderAdmission, + context.signal, + ); if (afterPath === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); if (state.navigationDuringCapture || afterPath !== expectedPath) @@ -244,6 +254,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => { export const captureFrames = async ( result: unknown, completeness: CdpCaptureCompleteness, + asarHeaderAdmission: AsarHeaderAdmission, signal?: AbortSignal, ): Promise => { const frameTree = recordValue(requiredRecord(result).frameTree); @@ -264,6 +275,7 @@ export const captureFrames = async ( cdpStringValue(frame.url) ?? "", signal, "inspect_electron_page", + asarHeaderAdmission, ); if (path === undefined) { completeness.exclude("frames", "out_of_target_scope"); @@ -301,6 +313,7 @@ const captureResources = async ( cdpStringValue(resource.url) ?? "", context.signal, "inspect_electron_page", + context.asarHeaderAdmission, ); if (path === undefined) { completeness.exclude("resources", "out_of_target_scope"); @@ -361,6 +374,7 @@ const captureDom = async ( documentUrl, context.signal, "inspect_electron_page", + context.asarHeaderAdmission, )) === undefined ) { completeness.exclude("dom", "out_of_target_scope"); @@ -399,6 +413,7 @@ const captureDom = async ( const mainFilePath = async ( result: unknown, + asarHeaderAdmission: AsarHeaderAdmission, signal?: AbortSignal, ): Promise => { const frameTree = recordValue(requiredRecord(result).frameTree); @@ -407,6 +422,7 @@ const mainFilePath = async ( cdpStringValue(frame?.url) ?? "", signal, "inspect_electron_page", + asarHeaderAdmission, ); }; diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index cdbfeb2587..049a5c7f3a 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_ASAR_HEADER_ADMISSION, + type AsarHeaderAdmission, +} from "../config/asarHeaderAdmission.js"; import type { ExecutionOptions, ProviderIdentity, @@ -46,6 +50,14 @@ import { /** Passive Electron provider for local file pages exposed by loopback CDP. */ export class CdpElectronProvider implements ElectronObservationPort { readonly #owner = new CdpTargetSessionOwner(); + readonly #asarHeaderAdmission: AsarHeaderAdmission; + + /** The admission comes from the composition root's environment; default otherwise. */ + constructor( + asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, + ) { + this.#asarHeaderAdmission = asarHeaderAdmission; + } #activeOperations = 0; #closing = false; #closePromise: Promise | undefined; @@ -148,6 +160,7 @@ export class CdpElectronProvider implements ElectronObservationPort { target.url, options.signal, "list_electron_targets", + this.#asarHeaderAdmission, ); if (path === undefined) { examined += 1; @@ -271,6 +284,7 @@ export class CdpElectronProvider implements ElectronObservationPort { const target = await authorizeTarget( discovery.targets, input.target_id, + this.#asarHeaderAdmission, options.signal, ); targetSession = await openCdpTargetSession( @@ -293,6 +307,7 @@ export class CdpElectronProvider implements ElectronObservationPort { discovery, target, input, + asarHeaderAdmission: this.#asarHeaderAdmission, ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.progress === undefined ? {} @@ -417,6 +432,7 @@ export class CdpElectronProvider implements ElectronObservationPort { const authorizeTarget = async ( targets: readonly CdpEndpointTarget[], targetId: string, + asarHeaderAdmission: AsarHeaderAdmission, signal?: AbortSignal, ): Promise => { throwIfElectronOperationCancelled(signal, "inspect_electron_page"); @@ -429,6 +445,7 @@ const authorizeTarget = async ( target.url, signal, "inspect_electron_page", + asarHeaderAdmission, ); throwIfElectronOperationCancelled(signal, "inspect_electron_page"); if (path === undefined) diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index e8e5c77edc..fdeeba0882 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import type { ElectronPageInspection, InspectElectronPageInput, @@ -26,6 +27,7 @@ interface ElectronScriptCaptureInput { readonly scripts: readonly ElectronScriptDraft[]; readonly frameIds: ReadonlySet; readonly completeness: CdpCaptureCompleteness; + readonly asarHeaderAdmission: AsarHeaderAdmission; } /** Normalize local script metadata and optionally approved source. */ @@ -51,6 +53,7 @@ export const captureElectronScripts = async ( script.rawUrl, input.signal, "inspect_electron_page", + input.asarHeaderAdmission, ); if (path === undefined) { input.completeness.exclude("scripts", "out_of_target_scope"); diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index b6e17fdb7a..f7acb95825 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -1,3 +1,4 @@ +import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import type { ElectronPageInspection, InspectElectronPageInput, @@ -20,6 +21,7 @@ export const captureElectronWorkers = async (input: { readonly frameIds: ReadonlySet; readonly completeness: CdpCaptureCompleteness; readonly limitations: string[]; + readonly asarHeaderAdmission: AsarHeaderAdmission; }): Promise => { const result = await optionalCdpCommand( { @@ -64,6 +66,7 @@ export const captureElectronWorkers = async (input: { cdpStringValue(target.url) ?? "", input.signal, "inspect_electron_page", + input.asarHeaderAdmission, ); if (path === undefined) { input.completeness.exclude("workers", "out_of_target_scope"); diff --git a/src/browser/ElectronFileScope.ts b/src/browser/ElectronFileScope.ts index 0ca6db7be4..fb8a6be882 100644 --- a/src/browser/ElectronFileScope.ts +++ b/src/browser/ElectronFileScope.ts @@ -19,8 +19,12 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { admitAsarHeader, - MAX_ASAR_HEADER_BYTES, + asarHeaderAdmissionFailure, } from "../artifacts/AsarHeader.js"; +import { + DEFAULT_ASAR_HEADER_ADMISSION, + type AsarHeaderAdmission, +} from "../config/asarHeaderAdmission.js"; type ElectronFileOperation = | "inspect_electron_page" @@ -37,10 +41,12 @@ export const authorizedElectronFile = async ( value: string, signal?: AbortSignal, operation: ElectronFileOperation = "inspect_electron_page", + asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, ): Promise => { try { return await resources.run( - async () => await resolveAuthorizedElectronFile(value, signal), + async () => + await resolveAuthorizedElectronFile(value, signal, asarHeaderAdmission), ); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) @@ -110,7 +116,8 @@ export const mapElectronFileAuthorizationFailure = ( const resolveAuthorizedElectronFile = async ( value: string, - signal?: AbortSignal, + signal: AbortSignal | undefined, + asarHeaderAdmission: AsarHeaderAdmission, ): Promise => { throwIfAborted(signal); if (/%(?:2f|5c)/iu.test(value)) return undefined; @@ -153,7 +160,7 @@ const resolveAuthorizedElectronFile = async ( void cause; } throwIfAborted(signal); - return await authorizedAsarMember(path, signal); + return await authorizedAsarMember(path, signal, asarHeaderAdmission); }; /** @@ -163,7 +170,8 @@ const resolveAuthorizedElectronFile = async ( */ const authorizedAsarMember = async ( path: string, - signal?: AbortSignal, + signal: AbortSignal | undefined, + asarHeaderAdmission: AsarHeaderAdmission, ): Promise => { const segments = path.split(sep); for (let index = 1; index < segments.length - 1; index += 1) { @@ -179,7 +187,13 @@ const authorizedAsarMember = async ( if (!metadata.isFile()) continue; const canonical = await realpath(archive); throwIfAborted(signal); - const files = await asarFiles(canonical, metadata, signal, resources); + const files = await asarFiles( + canonical, + metadata, + asarHeaderAdmission, + signal, + resources, + ); throwIfAborted(signal); return isAsarRegularFile(files, member) ? join(canonical, ...member) @@ -207,6 +221,7 @@ const headerCache = new Map< readonly identity: string; readonly files: unknown; readonly bytes: number; + readonly headerBytes: number; } >(); const archiveIdentity = (metadata: ArchiveIdentity): string => @@ -216,13 +231,19 @@ const archiveIdentity = (metadata: ArchiveIdentity): string => const asarFiles = async ( archive: string, metadata: ArchiveIdentity, + admission: AsarHeaderAdmission, signal?: AbortSignal, resources?: ArtifactResourceScope, ): Promise => { throwIfAborted(signal); const identity = archiveIdentity(metadata); const cached = headerCache.get(archive); - if (cached?.identity === identity) return cached.files; + if (cached?.identity === identity) { + // A header cached under a larger caller's admission stays refused here. + if (cached.headerBytes > admission.maximumBytes) + throw asarHeaderAdmissionFailure(admission); + return cached.files; + } const handle = await open(archive, "r"); const admitted = await withArtifactFileHandle( archive, @@ -231,14 +252,16 @@ const asarFiles = async ( signal, async (file) => { let jsonBytes: number; + let headerBytes: number; throwIfAborted(signal); const opened = await file.stat(); throwIfAborted(signal); if (!opened.isFile() || archiveIdentity(opened) !== identity) throw new Error("ASAR identity changed before header read"); - const header = await admitAsarHeader(file, signal); + const header = await admitAsarHeader(file, signal, admission); throwIfAborted(signal); jsonBytes = header.jsonBytes; + headerBytes = header.headerBytes; const json = Buffer.alloc(jsonBytes); let offset = 0; while (offset < json.length) { @@ -262,7 +285,7 @@ const asarFiles = async ( ); if (!isRecord(parsed) || !isRecord(parsed.files)) throw new Error("ASAR header has no file tree"); - return { files: parsed.files, jsonBytes }; + return { files: parsed.files, jsonBytes, headerBytes }; }, ); throwIfAborted(signal); @@ -271,12 +294,13 @@ const asarFiles = async ( identity, files: admitted.files, bytes: admitted.jsonBytes, + headerBytes: admitted.headerBytes, }); const retainedBytes = () => [...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0); while ( headerCache.size > MAX_CACHED_HEADERS || - retainedBytes() > MAX_ASAR_HEADER_BYTES + retainedBytes() > admission.maximumBytes ) headerCache.delete(headerCache.keys().next().value ?? archive); return admitted.files; diff --git a/src/cli.ts b/src/cli.ts index 07b7c1b7f4..b987af093a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -121,7 +121,12 @@ export const createCli = ( registerSetupCommands(cli, logger, environment); registerReverseEngineeringCommands(cli, logger, environment); registerCutterCommands(cli, logger, environment); - registerCoreAnalysisCommands(cli, logger, analysis.runDirectAnalysis); + registerCoreAnalysisCommands( + cli, + logger, + analysis.runDirectAnalysis, + environment, + ); registerUtilityCommands(cli, logger, environment, analysis); registerWasmCommands(cli, logger, environment); registerCaptureCommands(cli, logger, environment); @@ -160,7 +165,7 @@ export const createCli = ( registerAdvancedBrowserCommands(cli, logger); registerBrowserScenarioCommands(cli, logger, environment); registerElectronCommands(cli, logger, environment); - registerJavaScriptRuntimeObservationCommands(cli, logger); + registerJavaScriptRuntimeObservationCommands(cli, logger, environment); registerApplicationCommands(cli, logger); presentOmittableDefaults(cli); if (resultOutput !== undefined) streamCliCommandResults(cli, resultOutput); diff --git a/src/cli/coreAnalysisCommands.ts b/src/cli/coreAnalysisCommands.ts index 999c1b23e7..7211dd2215 100644 --- a/src/cli/coreAnalysisCommands.ts +++ b/src/cli/coreAnalysisCommands.ts @@ -9,7 +9,8 @@ export const registerCoreAnalysisCommands = ( cli: CliInstance, logger: Logger, runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], + environment: Readonly>, ): void => { - registerCoreBinaryCommands(cli, logger, runDirectAnalysis); + registerCoreBinaryCommands(cli, logger, runDirectAnalysis, environment); registerCoreNativeCommands(cli, logger, runDirectAnalysis); }; diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 21ca05f242..74245a5729 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -23,8 +23,9 @@ export const registerCoreBinaryCommands = ( cli: CliInstance, logger: Logger, runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], + environment: Readonly>, ): void => { - registerOverviewCommands(cli, logger, runDirectAnalysis); + registerOverviewCommands(cli, logger, runDirectAnalysis, environment); registerDecompileCommand(cli, logger, runDirectAnalysis); registerFunctionCommand(cli, logger, runDirectAnalysis); registerInstructionsCommand(cli, logger, runDirectAnalysis); @@ -37,6 +38,7 @@ const registerOverviewCommands = ( cli: CliInstance, logger: Logger, runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], + environment: Readonly>, ): void => { const overviewOptions = z.object({ snapshot: snapshotSelectionOption, @@ -88,6 +90,7 @@ const registerOverviewCommands = ( ? {} : { source_paths: options.sourcePath }), }, + environment, signal, ), ), diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 0d9577996e..3762918538 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -38,10 +38,10 @@ export const registerElectronCommands = ( ) => ElectronActiveObservationPort; } = {}, ): void => { - registerElectronObservationCommands(cli, logger); + registerElectronObservationCommands(cli, logger, environment); registerElectronFuseCommand(cli, logger); registerElectronActiveCommand(cli, logger, environment, options); - registerJavaScriptApplicationCommand(cli, logger); + registerJavaScriptApplicationCommand(cli, logger, environment); registerJavaScriptRuntimeReconciliationCommand(cli, logger); }; @@ -199,14 +199,16 @@ const registerJavaScriptRuntimeReconciliationCommand = ( const registerElectronObservationCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly>, ): void => { - registerElectronTargetList(cli, logger); - registerElectronPageInspection(cli, logger); + registerElectronTargetList(cli, logger, environment); + registerElectronPageInspection(cli, logger, environment); }; const registerElectronTargetList = ( cli: ReturnType, logger: Logger, + environment: Readonly>, ): void => { cli.command(CLI_COMMANDS.listElectronTargets, { description: @@ -217,7 +219,7 @@ const registerElectronTargetList = ( run: ({ args }) => withCommandCancellation((signal) => logCliCommand(logger, "list-electron-targets", async () => { - const context = await electronObservationContext(); + const context = await electronObservationContext(environment); const input = { cdp_endpoint: args.endpoint, }; @@ -250,6 +252,7 @@ const registerElectronTargetList = ( const registerElectronPageInspection = ( cli: ReturnType, logger: Logger, + environment: Readonly>, ): void => { cli.command(CLI_COMMANDS.inspectElectronPage, { description: "Passively inspect one Electron file page", @@ -261,7 +264,7 @@ const registerElectronPageInspection = ( run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, "inspect-electron-page", async () => { - const context = await electronObservationContext(); + const context = await electronObservationContext(environment); const input = { cdp_endpoint: args.endpoint, target_id: args.targetId, @@ -298,6 +301,7 @@ const registerElectronPageInspection = ( const registerJavaScriptApplicationCommand = ( cli: ReturnType, logger: Logger, + environment: Readonly>, ): void => { cli.command(CLI_COMMANDS.analyzeJavaScriptApplication, { description: @@ -344,6 +348,7 @@ const registerJavaScriptApplicationCommand = ( ? {} : { analysis_timeout_ms: options.analysisTimeoutMs }), }, + environment, signal, ), ), @@ -351,10 +356,12 @@ const registerJavaScriptApplicationCommand = ( }); }; -const electronObservationContext = async () => { +const electronObservationContext = async ( + environment: Readonly>, +) => { const { createElectronObservationProvider } = await import("../composition/electronObservation.js"); - return { provider: createElectronObservationProvider() }; + return { provider: createElectronObservationProvider(environment) }; }; const inputError = ( diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts index f4fcc95575..5868882eff 100644 --- a/src/cli/javascriptApplicationAnalysis.ts +++ b/src/cli/javascriptApplicationAnalysis.ts @@ -46,6 +46,7 @@ export const closeCliJavaScriptResources = (): Promise => { /** Execute the shared CLI boundary for static JavaScript analysis. */ export const runCliJavaScriptApplicationAnalysis = async ( input: unknown, + environment: Readonly>, signal?: AbortSignal, ): Promise => { const { analyzeJavaScriptApplication } = @@ -58,6 +59,7 @@ export const runCliJavaScriptApplicationAnalysis = async ( ); const resources = cliAnalysisScope(); const result = await analyzeJavaScriptApplication(input, resources, { + environment, progress, ...(signal === undefined ? {} : { signal }), }); diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts index 49e4907b07..d64b7d1dc7 100644 --- a/src/cliJavaScriptRuntimeCommands.ts +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -37,6 +37,7 @@ const observeOptionsSchema = z.object({ export const registerJavaScriptRuntimeObservationCommands = ( cli: ReturnType, logger: Logger, + environment: Readonly>, ): void => { cli.command(CLI_COMMANDS.listJavaScriptRuntimeTargets, { description: "List Node/Electron V8 Inspector targets", @@ -49,7 +50,7 @@ export const registerJavaScriptRuntimeObservationCommands = ( logger, CLI_COMMANDS.listJavaScriptRuntimeTargets, async () => { - const context = runtimeContext(); + const context = runtimeContext(environment); const input = { inspector_endpoint: args.endpoint, }; @@ -94,7 +95,7 @@ export const registerJavaScriptRuntimeObservationCommands = ( logger, CLI_COMMANDS.observeJavaScriptRuntime, async () => { - const context = runtimeContext(); + const context = runtimeContext(environment); const input = { inspector_endpoint: args.endpoint, target_id: args.targetId, @@ -127,8 +128,10 @@ export const registerJavaScriptRuntimeObservationCommands = ( }); }; -const runtimeContext = () => ({ - provider: createJavaScriptRuntimeObservationProvider(), +const runtimeContext = ( + environment: Readonly>, +) => ({ + provider: createJavaScriptRuntimeObservationProvider(environment), }); const cliError = ( diff --git a/src/composition/electronObservation.ts b/src/composition/electronObservation.ts index cf65a109ad..199fa4be17 100644 --- a/src/composition/electronObservation.ts +++ b/src/composition/electronObservation.ts @@ -1,6 +1,9 @@ +import { resolveAsarHeaderAdmission } from "../artifacts/AsarHeader.js"; import type { ElectronObservationPort } from "../application/javascript/ElectronObservationPort.js"; import { CdpElectronProvider } from "../browser/CdpElectronProvider.js"; /** Construct a fresh provider without opening a target or acquiring an engine. */ -export const createElectronObservationProvider = (): ElectronObservationPort => - new CdpElectronProvider(); +export const createElectronObservationProvider = ( + environment: Readonly>, +): ElectronObservationPort => + new CdpElectronProvider(resolveAsarHeaderAdmission(environment)); diff --git a/src/composition/javascriptRuntimeObservation.ts b/src/composition/javascriptRuntimeObservation.ts index defda49bf3..ef07e9aa1c 100644 --- a/src/composition/javascriptRuntimeObservation.ts +++ b/src/composition/javascriptRuntimeObservation.ts @@ -1,6 +1,9 @@ +import { resolveAsarHeaderAdmission } from "../artifacts/AsarHeader.js"; import type { JavaScriptRuntimeObservationPort } from "../application/javascript/JavaScriptRuntimeObservationPort.js"; import { V8InspectorProvider } from "../inspector/V8InspectorProvider.js"; /** Construct a fresh provider without opening a target or acquiring an engine. */ -export const createJavaScriptRuntimeObservationProvider = - (): JavaScriptRuntimeObservationPort => new V8InspectorProvider(); +export const createJavaScriptRuntimeObservationProvider = ( + environment: Readonly>, +): JavaScriptRuntimeObservationPort => + new V8InspectorProvider(resolveAsarHeaderAdmission(environment)); diff --git a/src/composition/optionalObservationProviders.ts b/src/composition/optionalObservationProviders.ts index 7524ee01fc..8dd470f812 100644 --- a/src/composition/optionalObservationProviders.ts +++ b/src/composition/optionalObservationProviders.ts @@ -37,7 +37,7 @@ export const createOptionalObservationFactories = ( electronObservation: async () => ( await import("./electronObservation.js") - ).createElectronObservationProvider(), + ).createElectronObservationProvider(environment), electronActiveObservation: async () => (await import("./electronScenario.js")).createElectronScenarioProvider( environment, @@ -45,7 +45,7 @@ export const createOptionalObservationFactories = ( javascriptRuntimeObservation: async () => ( await import("./javascriptRuntimeObservation.js") - ).createJavaScriptRuntimeObservationProvider(), + ).createJavaScriptRuntimeObservationProvider(environment), }; }; diff --git a/src/config/asarHeaderAdmission.ts b/src/config/asarHeaderAdmission.ts new file mode 100644 index 0000000000..e8396d8f30 --- /dev/null +++ b/src/config/asarHeaderAdmission.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; + +export const ASAR_HEADER_ADMISSION_SETTING = "REA_ASAR_MAX_HEADER_BYTES"; + +/** Header/index bytes admitted when the setting is unset. */ +export const DEFAULT_ASAR_MAX_HEADER_BYTES = 16 * 1024 * 1024; + +/** + * Largest configurable admission. The header JSON is decoded into one string + * and parsed in memory; 256 MiB keeps that text and its parse copy well below + * Node's single-string ceiling of about 512 Mi UTF-16 code units. A lower value + * only refuses more archives, so the setting has no floor. + */ +export const MAXIMUM_ASAR_MAX_HEADER_BYTES = 256 * 1024 * 1024; + +/** Effective ASAR header/index admission and where its value came from. */ +export interface AsarHeaderAdmission { + readonly maximumBytes: number; + readonly source: "default" | "setting"; +} + +export const DEFAULT_ASAR_HEADER_ADMISSION: AsarHeaderAdmission = { + maximumBytes: DEFAULT_ASAR_MAX_HEADER_BYTES, + source: "default", +}; + +export const asarMaxHeaderBytesConstraint = `${ASAR_HEADER_ADMISSION_SETTING} must be a positive decimal integer of at most ${String(MAXIMUM_ASAR_MAX_HEADER_BYTES)} bytes (256 MiB); the default is ${String(DEFAULT_ASAR_MAX_HEADER_BYTES)} bytes (16 MiB).`; + +/** Optional decimal byte count for the ASAR header/index admission. */ +export const asarMaxHeaderBytesSchema = z + .string() + .regex(/^[1-9]\d*$/u, { error: asarMaxHeaderBytesConstraint }) + .transform(Number) + .pipe( + z.number().int().max(MAXIMUM_ASAR_MAX_HEADER_BYTES, { + error: asarMaxHeaderBytesConstraint, + }), + ); + +/** Resolve the effective admission from one environment value. */ +export const parseAsarHeaderAdmission = ( + value: string | undefined, +): Result => { + if (value === undefined) return ok(DEFAULT_ASAR_HEADER_ADMISSION); + const parsed = asarMaxHeaderBytesSchema.safeParse(value); + return parsed.success + ? ok({ maximumBytes: parsed.data, source: "setting" }) + : err( + new ConfigurationError(asarMaxHeaderBytesConstraint, { + settings: [ + { + setting: ASAR_HEADER_ADMISSION_SETTING, + constraint: asarMaxHeaderBytesConstraint, + }, + ], + }), + ); +}; diff --git a/src/config/environment.ts b/src/config/environment.ts index 0e8889ca53..80d84c37d5 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -18,11 +18,13 @@ import { import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js"; import { mcpOutputSchemaProfileSchema } from "./mcpOutputSchemaProfile.js"; import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js"; +import { asarMaxHeaderBytesSchema } from "./asarHeaderAdmission.js"; import { parseLoaderArgs, parseStringArray } from "./parsers.js"; const environmentSchema = z.object({ REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"), REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(), + REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema.optional(), REA_MCP_INPUT_SCHEMA_PROFILE: mcpInputSchemaProfileSchema.optional(), REA_MCP_OUTPUT_SCHEMA_PROFILE: mcpOutputSchemaProfileSchema.optional(), REA_IDA_MCP_CONFIG: z diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 678e91926b..61aabf402a 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -379,6 +379,8 @@ const artifactStateErrorDetails = ( : { admission: error.admission.kind, maximum_header_bytes: error.admission.maximumBytes, + maximum_header_bytes_source: error.admission.source, + setting: "REA_ASAR_MAX_HEADER_BYTES", }), }; if (error instanceof UnknownRegistryError) return { reason: error.reason }; diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 8e4fc38797..e16e767707 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -7,10 +7,11 @@ import { type AnalysisErrorOptions, } from "./analysisErrorBase.js"; -/** Fixed admission named by the producing reader, with its effective byte ceiling. */ +/** Admission named by the producing reader, with its effective byte ceiling and its source. */ export interface ArtifactAdmissionLimit { readonly kind: "asar-header"; readonly maximumBytes: number; + readonly source: "default" | "setting"; } /** Artifact inventory or extraction failed a typed safety boundary. */ diff --git a/src/inspector/JavaScriptRuntimeScope.ts b/src/inspector/JavaScriptRuntimeScope.ts index a91041bf3d..1bc51a13b0 100644 --- a/src/inspector/JavaScriptRuntimeScope.ts +++ b/src/inspector/JavaScriptRuntimeScope.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_ASAR_HEADER_ADMISSION, + type AsarHeaderAdmission, +} from "../config/asarHeaderAdmission.js"; import { pathToFileURL } from "node:url"; import { @@ -51,10 +55,16 @@ export const authorizeRuntimeLocation = async ( operation: | "observe_javascript_runtime" | "list_javascript_runtime_targets" = "observe_javascript_runtime", + asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, ): Promise => { throwIfRuntimeObservationCancelled(signal, operation); if (value.startsWith("file:")) { - const filePath = await authorizedElectronFile(value, signal, operation); + const filePath = await authorizedElectronFile( + value, + signal, + operation, + asarHeaderAdmission, + ); throwIfRuntimeObservationCancelled(signal, operation); return filePath === undefined ? { allowed: false, reason: "not_approved" } @@ -103,6 +113,7 @@ export const authorizeRuntimeTargetLocation = async ( operation: | "observe_javascript_runtime" | "list_javascript_runtime_targets" = "list_javascript_runtime_targets", + asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, ): Promise< | { readonly allowed: true; @@ -116,10 +127,20 @@ export const authorizeRuntimeTargetLocation = async ( !/^node\.js\/v/iu.test(context.product) || !value.startsWith("file://") ) - return authorizeRuntimeLocation(value, signal, operation); + return authorizeRuntimeLocation( + value, + signal, + operation, + asarHeaderAdmission, + ); const reportedPath = value.slice("file://".length); if (!reportedPath.startsWith("/") && !/^[a-z]:[/\\_]/iu.test(reportedPath)) - return authorizeRuntimeLocation(value, signal, operation); + return authorizeRuntimeLocation( + value, + signal, + operation, + asarHeaderAdmission, + ); // Node concatenates the pathname, then replaces quotes and backslashes with // underscores. A remaining underscore cannot prove which pathname was used. @@ -129,6 +150,7 @@ export const authorizeRuntimeTargetLocation = async ( pathToFileURL(reportedPath).href, signal, operation, + asarHeaderAdmission, ); if (decision.allowed) return decision; } diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index d6a78d726f..4f7c6e09c1 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_ASAR_HEADER_ADMISSION, + type AsarHeaderAdmission, +} from "../config/asarHeaderAdmission.js"; import type { ExecutionOptions, ProviderIdentity, @@ -36,6 +40,7 @@ import { bindCdpTransportCleanup, } from "../browser/CdpTargetSessionOwner.js"; import { + authorizeRuntimeLocation, authorizeRuntimeTargetLocation, inspectorExclusionKey, throwIfRuntimeObservationCancelled, @@ -162,6 +167,15 @@ export interface CaptureState { /** Attach-only provider; sends only Runtime.enable and Debugger.enable. */ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { + readonly #asarHeaderAdmission: AsarHeaderAdmission; + + /** The admission comes from the composition root's environment; default otherwise. */ + constructor( + asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, + ) { + this.#asarHeaderAdmission = asarHeaderAdmission; + } + identity(): ProviderIdentity { return V8_INSPECTOR_PROVIDER_IDENTITY; } @@ -221,6 +235,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { { type: target.type, product: discovery.runtime.product }, options.signal, "list_javascript_runtime_targets", + this.#asarHeaderAdmission, ); if (!decision.allowed) { excluded[inspectorExclusionKey(decision.reason)] += 1; @@ -346,6 +361,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { discovery.targets, input, discovery.runtime.product, + this.#asarHeaderAdmission, options.signal, ); if (input.runtime_kind !== undefined) @@ -375,6 +391,13 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { runtime: discovery.runtime, target, state: captureState, + authorizeLocation: (rawUrl, signal) => + authorizeRuntimeLocation( + rawUrl, + signal, + "observe_javascript_runtime", + this.#asarHeaderAdmission, + ), ...(options.signal === undefined ? {} : { signal: options.signal }), }); outcome = ok(javascriptRuntimeObservationSchema.parse(result)); @@ -496,6 +519,7 @@ const authorizedTarget = async ( targets: readonly V8InspectorTarget[], input: ObserveJavaScriptRuntimeInput, product: string, + asarHeaderAdmission: AsarHeaderAdmission, signal?: AbortSignal, ): Promise => { throwIfRuntimeObservationCancelled(signal); @@ -510,6 +534,7 @@ const authorizedTarget = async ( { type: target.type, product }, signal, "observe_javascript_runtime", + asarHeaderAdmission, ); throwIfRuntimeObservationCancelled(signal); if ( diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 51d85aeceb..8796072739 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1003,6 +1003,7 @@ const registerObservationTools = ({ recordEvidenceWithUnknown, withAdmittedAnalysis, artifactResources, + environment, }: ServerToolContext): void => { const common = { logger, recordEvidence, withAdmittedAnalysis }; registerWebScriptTool(server, { ...common, artifactResources }); @@ -1019,6 +1020,7 @@ const registerObservationTools = ({ registerElectronTools(server, { ...common, artifactResources, + environment, evidenceById, electron: options.electronObservation, electronActive: options.electronActiveObservation, diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index 2fd5a6d036..41f7a6b7c4 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -41,6 +41,8 @@ import { deliverEvidenceResult } from "./contractToolHandler.js"; interface ElectronToolRegistration { readonly artifactResources: ArtifactResourceScope; + /** The server's selected environment; JavaScript analysis reads its admission settings. */ + readonly environment: Readonly>; readonly logger: Logger; readonly observationLoadFailure?: OptionalProviderLoadFailure | undefined; readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined; @@ -115,7 +117,7 @@ export const registerElectronTools = ( const analyzed = await analyzeJavaScriptApplicationValidated( request, options.artifactResources, - { signal, progress }, + { signal, progress, environment: options.environment }, ); return detail === "summary" && analyzed.ok ? summarizeRetainedAnalysis(analyzed.value, options) diff --git a/tests/boundary/cli/cliJavaScriptResourceClose.test.ts b/tests/boundary/cli/cliJavaScriptResourceClose.test.ts index 2ba0873d39..90644a7001 100644 --- a/tests/boundary/cli/cliJavaScriptResourceClose.test.ts +++ b/tests/boundary/cli/cliJavaScriptResourceClose.test.ts @@ -15,7 +15,7 @@ afterEach(async () => { /** Create the process-owned scope without starting a worker. */ const openCliScope = (): Promise => - runCliJavaScriptApplicationAnalysis({}); + runCliJavaScriptApplicationAnalysis({}, {}); it("retries JavaScript worker shutdown when the first close fails", async () => { let calls = 0; diff --git a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts index b2f6f56697..08bc19230d 100644 --- a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts +++ b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts @@ -19,7 +19,7 @@ import { import { asarHeaderBytes } from "../../fixtures/asarHeader.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js"; -import { MAX_ASAR_HEADER_BYTES } from "../../../src/artifacts/AsarHeader.js"; +import { DEFAULT_ASAR_MAX_HEADER_BYTES } from "../../../src/config/asarHeaderAdmission.js"; it.skipIf(process.platform === "win32")( "rejects a FIFO artifact root without waiting for a writer", @@ -136,7 +136,7 @@ it("reports the bounded probe limit for an oversized ASAR candidate without pena const candidatePath = join(root, "large.asar"); const opaquePath = join(root, "opaque.bin"); const jsonObject = Buffer.from('{"files":{}}'); - const signatureOffset = MAX_ASAR_HEADER_BYTES + 9; + const signatureOffset = DEFAULT_ASAR_MAX_HEADER_BYTES + 9; const whitespaceBytes = signatureOffset - 16; const jsonBytes = whitespaceBytes + jsonObject.length; const headerBytes = 8 + Math.ceil(jsonBytes / 4) * 4; diff --git a/tests/boundary/filesystem/asarHeaderAdmission.test.ts b/tests/boundary/filesystem/asarHeaderAdmission.test.ts index e3dd60d78a..497c1c6af2 100644 --- a/tests/boundary/filesystem/asarHeaderAdmission.test.ts +++ b/tests/boundary/filesystem/asarHeaderAdmission.test.ts @@ -1,15 +1,23 @@ import { mkdir, open, readFile, truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createPackage } from "@electron/asar"; -import { expect, it, vi } from "vitest"; +import { afterEach, 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 { parseEnvironment } from "../../../src/config/environment.js"; +import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; +import { + DEFAULT_ASAR_HEADER_ADMISSION, + MAXIMUM_ASAR_MAX_HEADER_BYTES, + parseAsarHeaderAdmission, + type AsarHeaderAdmission, + DEFAULT_ASAR_MAX_HEADER_BYTES, +} from "../../../src/config/asarHeaderAdmission.js"; import { ArtifactOperationError } from "../../../src/domain/artifactOperationError.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; @@ -26,11 +34,11 @@ const createFixture = async () => { return { archive, original: await readFile(archive) }; }; -const admit = async (path: string) => { +const admit = async (path: string, admission?: AsarHeaderAdmission) => { const handle = await open(path, "r"); const read = vi.spyOn(handle, "read"); try { - return await admitAsarHeader(handle); + return await admitAsarHeader(handle, undefined, admission); } finally { // Admission must inspect only the fixed prefix, even for forged extents. const calls = [...read.mock.calls]; @@ -112,10 +120,10 @@ it("rejects contradictory and unsigned-boundary extents before decoding", async it("separates the header decode budget from container validity at its exact boundary", async () => { const root = await createTestTempDirectory("rea-asar-header-budget-"); const archive = join(root, "budget.asar"); - expect(MAX_ASAR_HEADER_BYTES).toBe(16 * 1024 * 1024); + expect(DEFAULT_ASAR_MAX_HEADER_BYTES).toBe(16 * 1024 * 1024); for (const headerBytes of [ - MAX_ASAR_HEADER_BYTES, - MAX_ASAR_HEADER_BYTES + 4, + DEFAULT_ASAR_MAX_HEADER_BYTES, + DEFAULT_ASAR_MAX_HEADER_BYTES + 4, ]) { const prefix = Buffer.alloc(16); prefix.writeUInt32LE(4, 0); @@ -126,7 +134,7 @@ it("separates the header decode budget from container validity at its exact boun // Supply the declared extent without allocating a header-sized JS buffer. // This tests extent admission only; the padding is not valid archive JSON. await truncate(archive, headerBytes + 8); - if (headerBytes === MAX_ASAR_HEADER_BYTES) + if (headerBytes === DEFAULT_ASAR_MAX_HEADER_BYTES) await expect(admit(archive)).resolves.toEqual({ archiveBytes: headerBytes + 8, headerBytes, @@ -135,10 +143,11 @@ it("separates the header decode budget from container validity at its exact boun else await expect(admit(archive)).rejects.toMatchObject({ reason: "limit", - message: asarHeaderAdmissionRecovery(), + message: asarHeaderAdmissionRecovery(DEFAULT_ASAR_HEADER_ADMISSION), admission: { kind: "asar-header", - maximumBytes: MAX_ASAR_HEADER_BYTES, + maximumBytes: DEFAULT_ASAR_MAX_HEADER_BYTES, + source: "default", }, }); } @@ -147,7 +156,7 @@ it("separates the header decode budget from container validity at its exact boun const writeOversizedHeaderArchive = async (): Promise => { const root = await createTestTempDirectory("rea-asar-header-recovery-"); const archive = join(root, "app.asar"); - const headerBytes = MAX_ASAR_HEADER_BYTES + 4; + const headerBytes = DEFAULT_ASAR_MAX_HEADER_BYTES + 4; const json = Buffer.from("{}"); const prefix = Buffer.alloc(16); prefix.writeUInt32LE(4, 0); @@ -159,14 +168,17 @@ const writeOversizedHeaderArchive = async (): Promise => { 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); +it("names the default header admission budget and its setting when source selection cannot bypass it", async () => { + const recovery = asarHeaderAdmissionRecovery(DEFAULT_ASAR_HEADER_ADMISSION); + expect(DEFAULT_ASAR_MAX_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`, + `${String(DEFAULT_ASAR_MAX_HEADER_BYTES / (1024 * 1024))} MiB / ${String(DEFAULT_ASAR_MAX_HEADER_BYTES)} bytes`, ); - expect(recovery).toContain("no runtime override"); + expect(recovery).toContain( + "Set REA_ASAR_MAX_HEADER_BYTES in the environment that starts REA to a byte count up to 256 MiB / 268435456 bytes (default 16 MiB / 16777216 bytes), then restart REA.", + ); + expect(recovery).not.toContain("no runtime override"); expect(recovery).toContain("Selecting fewer sources"); expect(recovery).toContain("does not bypass header admission"); expect(recovery).toContain("extracted application directory"); @@ -201,7 +213,9 @@ it("names the fixed header admission budget when source selection cannot bypass truncated: true, detail: recovery, admission: "asar-header", - maximum_header_bytes: MAX_ASAR_HEADER_BYTES, + maximum_header_bytes: DEFAULT_ASAR_MAX_HEADER_BYTES, + maximum_header_bytes_source: "default", + setting: "REA_ASAR_MAX_HEADER_BYTES", }, }); expect( @@ -240,3 +254,129 @@ it("keeps unrelated artifact limit recovery distinct from header admission", () "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.", ); }); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +it("admits a header above the default when the selected admission raises the budget", async () => { + const archive = await writeOversizedHeaderArchive(); + const headerBytes = DEFAULT_ASAR_MAX_HEADER_BYTES + 4; + await expect( + admit(archive, { maximumBytes: headerBytes - 1, source: "setting" }), + ).rejects.toMatchObject({ + reason: "limit", + admission: { maximumBytes: headerBytes - 1, source: "setting" }, + }); + await expect( + admit(archive, { maximumBytes: headerBytes, source: "setting" }), + ).resolves.toEqual({ + archiveBytes: headerBytes + 8, + headerBytes, + jsonBytes: 2, + }); +}); + +it("applies the caller-selected environment, not the ambient one, to inventory and JavaScript analysis", async () => { + const { archive, original } = await createFixture(); + const headerBytes = original.readUInt32LE(4); + const lowered = { REA_ASAR_MAX_HEADER_BYTES: String(headerBytes - 1) }; + const recovery = asarHeaderAdmissionRecovery({ + maximumBytes: headerBytes - 1, + source: "setting", + }); + expect(recovery).toContain( + `limited to ${String(headerBytes - 1)} bytes by REA_ASAR_MAX_HEADER_BYTES.`, + ); + // An ambient value that would refuse the archive must not reach callers + // whose selected environment leaves the setting unset. + vi.stubEnv("REA_ASAR_MAX_HEADER_BYTES", "1"); + for (const operation of ["inventory_artifact", "inspect_artifact"] as const) { + await expect( + createDirectAnalysis(lowered).runProviderAnalysis(archive, operation, {}), + ).resolves.toMatchObject({ + code: "truncated", + message: recovery, + details: { + maximum_header_bytes: headerBytes - 1, + maximum_header_bytes_source: "setting", + setting: "REA_ASAR_MAX_HEADER_BYTES", + }, + }); + await expect( + createDirectAnalysis({}).runProviderAnalysis(archive, operation, {}), + ).resolves.toMatchObject({ operation }); + } + const refused = await analyzeJavaScriptApplication( + { input_path: archive }, + { environment: lowered }, + ); + if (refused.ok) throw new Error("Lowered ASAR header admission succeeded"); + expect(projectAnalysisError(refused.error).message).toBe(recovery); + const admitted = await analyzeJavaScriptApplication( + { input_path: archive }, + { environment: { REA_ASAR_MAX_HEADER_BYTES: String(headerBytes) } }, + ); + expect(admitted.ok).toBe(true); + expect((await analyzeJavaScriptApplication({ input_path: archive })).ok).toBe( + true, + ); +}); + +it("refuses invalid settings at startup validation and at each owner", async () => { + expect(parseAsarHeaderAdmission(undefined)).toEqual({ + ok: true, + value: DEFAULT_ASAR_HEADER_ADMISSION, + }); + expect( + parseAsarHeaderAdmission(String(MAXIMUM_ASAR_MAX_HEADER_BYTES)), + ).toEqual({ + ok: true, + value: { maximumBytes: MAXIMUM_ASAR_MAX_HEADER_BYTES, source: "setting" }, + }); + for (const value of [ + "", + "0", + "-1", + "016777216", + "16MiB", + "1e6", + " 16777216", + String(MAXIMUM_ASAR_MAX_HEADER_BYTES + 1), + ]) { + const parsed = parseAsarHeaderAdmission(value); + if (parsed.ok) throw new Error(`Accepted ${JSON.stringify(value)}`); + expect(parsed.error.settings).toEqual([ + { + setting: "REA_ASAR_MAX_HEADER_BYTES", + constraint: expect.stringContaining("at most 268435456 bytes"), + }, + ]); + const startup = parseEnvironment({ REA_ASAR_MAX_HEADER_BYTES: value }); + if (startup.ok) + throw new Error(`Startup accepted ${JSON.stringify(value)}`); + expect(startup.error.settings).toContainEqual({ + setting: "REA_ASAR_MAX_HEADER_BYTES", + constraint: expect.stringContaining("at most 268435456 bytes"), + }); + } + + // Owners refuse rather than falling back to the default. + const { archive } = await createFixture(); + const invalid = { REA_ASAR_MAX_HEADER_BYTES: "0" }; + await expect( + createDirectAnalysis(invalid).runProviderAnalysis( + archive, + "inventory_artifact", + {}, + ), + ).resolves.toMatchObject({ code: "configuration_invalid" }); + const analyzed = await analyzeJavaScriptApplication( + { input_path: archive }, + { environment: invalid }, + ); + if (analyzed.ok) throw new Error("Invalid setting was ignored"); + expect(projectAnalysisError(analyzed.error)).toMatchObject({ + code: "configuration_invalid", + }); +}); diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 84043cc20c..bbcc511146 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -486,6 +486,11 @@ it.each([ { REA_MCP_MAX_RESPONSE_BYTES: "1" }, "REA_MCP_MAX_RESPONSE_BYTES", ], + [ + "ASAR header admission above its maximum", + { REA_ASAR_MAX_HEADER_BYTES: "268435457" }, + "REA_ASAR_MAX_HEADER_BYTES", + ], [ "malformed loader JSON", { HOPPER_LOADER_ARGS_JSON: "{" }, diff --git a/tests/boundary/filesystem/electronFileScope.test.ts b/tests/boundary/filesystem/electronFileScope.test.ts index 316da86624..b2ad1f1003 100644 --- a/tests/boundary/filesystem/electronFileScope.test.ts +++ b/tests/boundary/filesystem/electronFileScope.test.ts @@ -1,4 +1,10 @@ -import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + realpath, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -119,4 +125,34 @@ describe("Electron file scope", () => { ), ).toBeUndefined(); }); + it("authorizes ASAR members only under the provider's selected header admission", async () => { + const root = await createTestTempDirectory("rea-electron-admission-"); + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "index.html"), "page"); + const archive = join(root, "app.asar"); + await createPackageWithOptions(source, archive, {}); + const headerBytes = (await readFile(archive)).readUInt32LE(4); + const page = pathToFileURL(join(archive, "index.html")).href; + const admission = (maximumBytes: number) => + ({ maximumBytes, source: "setting" }) as const; + // The first read caches the header under an admission that fits it. + expect( + await authorizedElectronFile( + page, + undefined, + "inspect_electron_page", + admission(headerBytes), + ), + ).toBe(join(await realpath(archive), "index.html")); + // A provider with a lower admission must not reuse that cached header. + expect( + await authorizedElectronFile( + page, + undefined, + "inspect_electron_page", + admission(headerBytes - 1), + ), + ).toBeUndefined(); + }); }); diff --git a/tests/boundary/mcp/optionalProviderShutdown.test.ts b/tests/boundary/mcp/optionalProviderShutdown.test.ts index f3e1bfb931..354cb03241 100644 --- a/tests/boundary/mcp/optionalProviderShutdown.test.ts +++ b/tests/boundary/mcp/optionalProviderShutdown.test.ts @@ -85,7 +85,7 @@ it("refuses eager server publication after the shutdown snapshot and completed c }); it("retries failed process-owned optional cleanup without reclosing completed peers", async () => { - const runtime = createJavaScriptRuntimeObservationProvider(); + const runtime = createJavaScriptRuntimeObservationProvider({}); const browser = createBrowserObservationProvider(); const closeRuntime = runtime.close?.bind(runtime); const runtimeClose = vi.spyOn(runtime, "close"); diff --git a/tests/boundary/process/browserScenarioProbeLifecycle.test.ts b/tests/boundary/process/browserScenarioProbeLifecycle.test.ts index 452ca2a3a7..52960f90ae 100644 --- a/tests/boundary/process/browserScenarioProbeLifecycle.test.ts +++ b/tests/boundary/process/browserScenarioProbeLifecycle.test.ts @@ -22,7 +22,7 @@ it("keeps the shared browser scenario provider open when the SDK discards its di const browserClose = vi .spyOn(browserObservation, "close") .mockResolvedValue(undefined); - const electronObservation = createElectronObservationProvider(); + const electronObservation = createElectronObservationProvider({}); const electronClose = vi .spyOn(electronObservation, "close") .mockResolvedValue(undefined); @@ -119,7 +119,7 @@ it("closes optional providers when MCP server startup throws", async () => { const browserClose = vi .spyOn(browserObservation, "close") .mockResolvedValue(undefined); - const electronObservation = createElectronObservationProvider(); + const electronObservation = createElectronObservationProvider({}); const electronClose = vi .spyOn(electronObservation, "close") .mockResolvedValue(undefined); diff --git a/tests/fixtures/optionalObservationProviders.ts b/tests/fixtures/optionalObservationProviders.ts index 0e020133f1..cf772ab7e0 100644 --- a/tests/fixtures/optionalObservationProviders.ts +++ b/tests/fixtures/optionalObservationProviders.ts @@ -67,7 +67,7 @@ export const optionalObservationFactories = ( return { browserObservation: async () => browser, browserScenarioCapture: async () => createBrowserScenarioProvider({}), - electronObservation: async () => createElectronObservationProvider(), + electronObservation: async () => createElectronObservationProvider({}), electronActiveObservation: async () => createElectronScenarioProvider({}), javascriptRuntimeObservation: async () => runtime, }; diff --git a/tests/process-global/javascriptRuntimeObservationCli.test.ts b/tests/process-global/javascriptRuntimeObservationCli.test.ts index 0490195e80..1ae8d433db 100644 --- a/tests/process-global/javascriptRuntimeObservationCli.test.ts +++ b/tests/process-global/javascriptRuntimeObservationCli.test.ts @@ -150,7 +150,7 @@ describe("JavaScript runtime observation CLI parity", () => { }, }; const cli = Cli.create("rea-runtime-cleanup-test", { sync: false }); - registerJavaScriptRuntimeObservationCommands(cli, silentLogger); + registerJavaScriptRuntimeObservationCommands(cli, silentLogger, {}); let stdout = ""; await cli.serve( ["list-javascript-runtime-targets", "http://127.0.0.1:9229", "--json"], diff --git a/tests/support/javascriptApplicationScope.ts b/tests/support/javascriptApplicationScope.ts index 7f5d51b80f..fbb3b83422 100644 --- a/tests/support/javascriptApplicationScope.ts +++ b/tests/support/javascriptApplicationScope.ts @@ -1,6 +1,9 @@ import { withArtifactResourceScope } from "../fixtures/artifactInventory.js"; import type { ExecutionOptions } from "../../src/application/AnalysisProvider.js"; -import { analyzeJavaScriptApplication as analyzeOwned } from "../../src/application/javascript/JavaScriptApplicationService.js"; +import { + analyzeJavaScriptApplication as analyzeOwned, + type JavaScriptApplicationOptions, +} from "../../src/application/javascript/JavaScriptApplicationService.js"; import { reconstructJavaScriptArtifact as reconstructOwned } from "../../src/application/javascript/JavaScriptArtifactReconstruction.js"; import type { ProgressReporter } from "../../src/application/ProgressReporter.js"; import { readJavaScriptArtifactFiles as readOwned } from "../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; @@ -21,7 +24,7 @@ export const readJavaScriptArtifactFiles = ( /** Run a JavaScript analysis with an awaited, test-owned artifact scope. */ export const analyzeJavaScriptApplication = ( input: unknown, - options: ExecutionOptions = {}, + options: JavaScriptApplicationOptions = {}, ) => withArtifactResourceScope((scope) => analyzeOwned(input, scope, options)); /** Reconstruct a fixture with an awaited, test-owned artifact scope. */