mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(artifacts): make ASAR header admission configurable (#6209)
* feat(artifacts): make ASAR header admission configurable ASAR header/index admission was a fixed 16 MiB with no override, so operators with large Electron indexes could only extract the archive. REA_ASAR_MAX_HEADER_BYTES now sets it (default 16777216, maximum 268435456). One owner resolves the effective admission for the classification probe, inventory, ASAR reader and Electron file scope, including the Electron header cache budget. Startup and doctor validate the setting; admission refuses an invalid value instead of falling back. Recovery and error details name the effective value and its source. Fixes #6176 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(artifacts): resolve ASAR header admission from the caller's environment The admission read process.env, so composition roots that select their own environment (createDirectAnalysis, createServer, CLI, optional observation factories) could not raise or lower it, and an ambient value leaked into callers that left it unset. Nothing reads process.env now. ArtifactProvider and extraction resolve the admission from their environment; JavaScript analysis parses it from the environment its server or CLI caller passes and refuses an invalid value; the Electron and V8 providers receive it from their factories. Inventory, nested ASAR readers, classification probes and Electron page authorization carry that one value. A header cached by Electron under a larger admission is refused for a smaller one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a4bbab3c3
commit
8451b961ad
53 files changed
+660
-130
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
/** Statically analyze one local JavaScript/Electron application. */
|
||||
export const analyzeJavaScriptApplication = async (
|
||||
rawInput: unknown,
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
options: JavaScriptApplicationOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const parsed = analyzeJavaScriptApplicationInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
@@ -47,8 +56,13 @@ export const analyzeJavaScriptApplication = async (
|
||||
export const analyzeJavaScriptApplicationValidated = async (
|
||||
input: z.output<typeof analyzeJavaScriptApplicationInputSchema>,
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
options: JavaScriptApplicationOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
// 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({
|
||||
|
||||
@@ -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<JavaScriptArtifactReconstructionResult> =>
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HashResult> => {
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
+60
-27
@@ -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<Record<string, string | undefined>>,
|
||||
): 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<AsarHeaderExtents, "archiveBytes"> | 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<FileHandle, "read"> & { stat(): Promise<Stats> },
|
||||
signal?: AbortSignal,
|
||||
admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
): Promise<AsarHeaderExtents> => {
|
||||
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);
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "asarHeaderAdmission"
|
||||
>,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
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<ArtifactOccurrence["artifact_format"]> => {
|
||||
const probe = createArtifactClassificationProbe();
|
||||
const probe = createArtifactClassificationProbe(asarHeaderAdmission);
|
||||
const chunk = Buffer.alloc(64 * 1024);
|
||||
while (probe.length < probe.limit) {
|
||||
abortIfNeeded(signal);
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv>,
|
||||
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":
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, ArtifactNode>;
|
||||
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<string, ArtifactNode>;
|
||||
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;
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv> | undefined;
|
||||
/** ASAR header/index admission resolved from the owner's environment; default when absent. */
|
||||
readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined;
|
||||
}
|
||||
@@ -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<string, number> | undefined;
|
||||
textBytes: number;
|
||||
invalidUtf8: number;
|
||||
@@ -70,6 +72,7 @@ export const readJavaScriptArtifactFiles = async (
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
sourcePaths?: readonly string[],
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
): Promise<JavaScriptArtifactFileSet> => {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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]),
|
||||
|
||||
@@ -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<ElectronPageInspection["frames"]> => {
|
||||
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<string | undefined> => {
|
||||
const frameTree = recordValue(requiredRecord(result).frameTree);
|
||||
@@ -407,6 +422,7 @@ const mainFilePath = async (
|
||||
cdpStringValue(frame?.url) ?? "",
|
||||
signal,
|
||||
"inspect_electron_page",
|
||||
asarHeaderAdmission,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void> | 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<CdpEndpointTarget> => {
|
||||
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)
|
||||
|
||||
@@ -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<string>;
|
||||
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");
|
||||
|
||||
@@ -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<string>;
|
||||
readonly completeness: CdpCaptureCompleteness;
|
||||
readonly limitations: string[];
|
||||
readonly asarHeaderAdmission: AsarHeaderAdmission;
|
||||
}): Promise<ElectronPageInspection["workers"]> => {
|
||||
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");
|
||||
|
||||
@@ -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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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<string | undefined> => {
|
||||
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<unknown> => {
|
||||
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;
|
||||
|
||||
+7
-2
@@ -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);
|
||||
|
||||
@@ -9,7 +9,8 @@ export const registerCoreAnalysisCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): void => {
|
||||
registerCoreBinaryCommands(cli, logger, runDirectAnalysis);
|
||||
registerCoreBinaryCommands(cli, logger, runDirectAnalysis, environment);
|
||||
registerCoreNativeCommands(cli, logger, runDirectAnalysis);
|
||||
};
|
||||
@@ -23,8 +23,9 @@ export const registerCoreBinaryCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): 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<Record<string, string | undefined>>,
|
||||
): void => {
|
||||
const overviewOptions = z.object({
|
||||
snapshot: snapshotSelectionOption,
|
||||
@@ -88,6 +90,7 @@ const registerOverviewCommands = (
|
||||
? {}
|
||||
: { source_paths: options.sourcePath }),
|
||||
},
|
||||
environment,
|
||||
signal,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): void => {
|
||||
registerElectronTargetList(cli, logger);
|
||||
registerElectronPageInspection(cli, logger);
|
||||
registerElectronTargetList(cli, logger, environment);
|
||||
registerElectronPageInspection(cli, logger, environment);
|
||||
};
|
||||
|
||||
const registerElectronTargetList = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): 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<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): 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<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): 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<Record<string, string | undefined>>,
|
||||
) => {
|
||||
const { createElectronObservationProvider } =
|
||||
await import("../composition/electronObservation.js");
|
||||
return { provider: createElectronObservationProvider() };
|
||||
return { provider: createElectronObservationProvider(environment) };
|
||||
};
|
||||
|
||||
const inputError = (
|
||||
|
||||
@@ -46,6 +46,7 @@ export const closeCliJavaScriptResources = (): Promise<void> => {
|
||||
/** Execute the shared CLI boundary for static JavaScript analysis. */
|
||||
export const runCliJavaScriptApplicationAnalysis = async (
|
||||
input: unknown,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JsonValue> => {
|
||||
const { analyzeJavaScriptApplication } =
|
||||
@@ -58,6 +59,7 @@ export const runCliJavaScriptApplicationAnalysis = async (
|
||||
);
|
||||
const resources = cliAnalysisScope();
|
||||
const result = await analyzeJavaScriptApplication(input, resources, {
|
||||
environment,
|
||||
progress,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ const observeOptionsSchema = z.object({
|
||||
export const registerJavaScriptRuntimeObservationCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): 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<Record<string, string | undefined>>,
|
||||
) => ({
|
||||
provider: createJavaScriptRuntimeObservationProvider(environment),
|
||||
});
|
||||
|
||||
const cliError = (
|
||||
|
||||
@@ -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<Record<string, string | undefined>>,
|
||||
): ElectronObservationPort =>
|
||||
new CdpElectronProvider(resolveAsarHeaderAdmission(environment));
|
||||
@@ -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<Record<string, string | undefined>>,
|
||||
): JavaScriptRuntimeObservationPort =>
|
||||
new V8InspectorProvider(resolveAsarHeaderAdmission(environment));
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<AsarHeaderAdmission, ConfigurationError> => {
|
||||
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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<RuntimeLocationDecision> => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<AuthorizedV8InspectorTarget> => {
|
||||
throwIfRuntimeObservationCancelled(signal);
|
||||
@@ -510,6 +534,7 @@ const authorizedTarget = async (
|
||||
{ type: target.type, product },
|
||||
signal,
|
||||
"observe_javascript_runtime",
|
||||
asarHeaderAdmission,
|
||||
);
|
||||
throwIfRuntimeObservationCancelled(signal);
|
||||
if (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, string | undefined>>;
|
||||
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)
|
||||
|
||||
@@ -15,7 +15,7 @@ afterEach(async () => {
|
||||
|
||||
/** Create the process-owned scope without starting a worker. */
|
||||
const openCliScope = (): Promise<unknown> =>
|
||||
runCliJavaScriptApplicationAnalysis({});
|
||||
runCliJavaScriptApplicationAnalysis({}, {});
|
||||
|
||||
it("retries JavaScript worker shutdown when the first close fails", async () => {
|
||||
let calls = 0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> => {
|
||||
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<string> => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -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: "{" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user