Files
rea/src/artifacts/AsarArtifactReader.ts
Kaoru0822 8451b961ad 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>
2026-10-21 21:18:22 -07:00

702 lines
22 KiB
TypeScript

import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
import { constants, createWriteStream, type Stats } from "node:fs";
import {
lstat,
mkdtemp,
open,
realpath,
rm,
type FileHandle,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { createHash } from "node:crypto";
import { isAbsolute, join, relative, sep } from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { getRawHeader, uncache } from "@electron/asar";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
} from "./ArtifactReader.js";
import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
import { admitAsarHeader } from "./AsarHeader.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import type { FileHandleChunkStream } from "../filesystem/readFileHandleChunks.js";
import { readArtifactFileChunks } from "./readArtifactFileChunks.js";
import {
assertAsarReadActive,
asarCancellationFailure,
asarFailure,
} from "./AsarFailure.js";
import {
readAsarListing,
type AsarListedEntry,
type AsarMetadata,
} from "./AsarListing.js";
/**
* Official Electron ASAR adapter with range-streamed member reads.
*
* An entry marked `unpacked` is metadata, not an integrity exemption: Electron
* stores its bytes beside the archive in `<archive>.unpacked`, and callers must
* hash those companion bytes against the archive's declared integrity value.
*/
export class AsarArtifactReader implements ArtifactReader {
readonly format = "asar" as const;
#archiveSize: number | undefined;
#headerSize: number | undefined;
readonly #entries = new Map<string, AsarEntryState>();
readonly #ownedHandles = new Map<SharedAsarFileHandle, OwnedAsarHandle>();
#snapshot: { readonly path: string; readonly sha256: string } | undefined;
#snapshotRoot: string | undefined;
#snapshotOperation: Promise<void> = Promise.resolve();
constructor(
private readonly path: string,
private readonly options: AsarArtifactReaderOptions = {},
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
let paths: readonly AsarListedEntry[];
try {
await this.prepareContainer(undefined, signal);
paths = await this.#withSnapshotOperation(async () => {
const snapshotPath = this.#snapshotPath();
this.#resetArchiveState();
this.#entries.clear();
const admitted = await this.#admitHeader(snapshotPath, signal);
this.#archiveSize = admitted.archiveBytes;
this.#headerSize = admitted.headerBytes;
assertAsarReadActive(signal);
const ordered = await readAsarListing(
getRawHeader(snapshotPath).header,
signal,
);
const archiveMetadata = await lstat(snapshotPath);
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
throw new ArtifactReaderFailure(
"format",
"ASAR container is not a regular file",
);
this.#archiveSize = archiveMetadata.size;
return ordered;
}, signal);
} catch (cause: unknown) {
throw asarFailure(this.path, "inventory", cause);
}
for (const { metadata, path } of paths) {
assertAsarReadActive(signal);
const providerPath = path.split("/").join(sep);
const kind =
metadata.files !== undefined
? "directory"
: metadata.link !== undefined
? "symlink"
: "file";
const entry: ArtifactEntry = {
path,
kind,
declaredSize: metadata.size ?? null,
compressedSize: null,
executable: metadata.executable === true,
encrypted: false,
byteOffset: null,
declaredIntegrity:
metadata.integrity !== undefined &&
metadata.integrity.algorithm === "SHA256" &&
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? { algorithm: "sha256", digest: metadata.integrity.hash }
: null,
unpacked: "unpacked" in metadata && metadata.unpacked === true,
limitations:
kind === "symlink"
? ["ASAR symlink target was not followed or disclosed."]
: [],
adapterKey: providerPath,
};
if (kind === "file" && isAsarFileMetadata(metadata))
this.#entries.set(providerPath, {
metadata,
entry: copyArtifactEntry(entry),
});
yield entry;
}
}
async #admitHeader(snapshotPath: string, signal?: AbortSignal) {
const headerHandle = await this.#openOwned(
snapshotPath,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
let admission:
| {
readonly ok: true;
readonly value: Awaited<ReturnType<typeof admitAsarHeader>>;
}
| { readonly ok: false; readonly cause: unknown };
try {
admission = {
ok: true,
value: await admitAsarHeader(
headerHandle,
signal,
this.options.headerAdmission,
),
};
} catch (cause: unknown) {
admission = { ok: false, cause };
}
try {
await this.#closeOwnedHandle(headerHandle);
} catch (cleanupCause: unknown) {
if (!admission.ok)
throw ArtifactReaderFailure.withCleanup(
admission.cause,
ArtifactReaderFailure.cleanupObservation(cleanupCause, snapshotPath),
);
throw cleanupCause;
}
if (!admission.ok) throw admission.cause;
return admission.value;
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
return this.#withSnapshotOperation(
() => this.#openEntry(entry, signal),
signal,
);
}
async #openEntry(
entry: ArtifactEntry,
signal?: AbortSignal,
): Promise<Readable> {
assertAsarReadActive(signal);
const state = this.#entries.get(entry.adapterKey);
if (state === undefined && entry.kind !== "file")
throw new ArtifactReaderFailure(
"format",
"ASAR entry is not a regular file",
);
if (state === undefined)
throw new ArtifactReaderFailure(
"integrity",
`ASAR entry was not produced by this reader: ${entry.path}`,
);
if (!sameArtifactEntry(entry, state.entry))
throw new ArtifactReaderFailure(
"integrity",
`ASAR entry metadata changed since inventory: ${state.entry.path}`,
);
if (state.entry.unpacked)
return this.#openUnpackedStream(state.entry, signal);
return this.#openPackedStream(state, signal);
}
async #openPackedStream(
state: AsarEntryState,
signal?: AbortSignal,
): Promise<Readable> {
const { metadata, entry: producedEntry } = state;
const archiveSize = this.#archiveSize;
const headerSize = this.#headerSize;
const offset =
metadata.offset === undefined
? undefined
: parseArchiveOffset(metadata.offset);
const start =
offset === undefined || headerSize === undefined
? undefined
: 8 + headerSize + offset;
if (
archiveSize === undefined ||
start === undefined ||
!Number.isSafeInteger(start) ||
metadata.size < 0 ||
!Number.isSafeInteger(metadata.size) ||
start > archiveSize ||
metadata.size > archiveSize - start
)
throw new ArtifactReaderFailure(
"format",
`ASAR entry range is outside its container: ${producedEntry.path}`,
);
const snapshotPath = this.#snapshotPath();
let handle: SharedAsarFileHandle | undefined;
try {
const openedHandle = await this.#openOwned(
snapshotPath,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
handle = openedHandle;
const observed = await openedHandle.stat();
assertAsarReadActive(signal);
if (!observed.isFile() || observed.size !== archiveSize)
throw new ArtifactReaderFailure(
"integrity",
`ASAR container changed before read: ${producedEntry.path}`,
);
if (metadata.size === 0) {
handle = undefined;
await closeAsarHandle(
() => this.#closeOwnedHandle(openedHandle),
signal,
);
return Readable.from([]);
}
const source = this.#ownedReadStream(openedHandle, {
start,
end: start + metadata.size - 1,
signal,
});
handle = undefined;
const output = readValidatedAsarEntry(
source,
metadata.size,
producedEntry.path,
this.path,
signal,
);
this.#setOwnedOutput(openedHandle, output);
return output;
} catch (cause: unknown) {
const failure = asarFailure(
this.path,
`read ${producedEntry.path}`,
cause,
);
if (handle !== undefined) {
try {
await this.#closeOwnedHandle(handle);
} catch (cleanupCause: unknown) {
throw ArtifactReaderFailure.withCleanup(
failure,
ArtifactReaderFailure.cleanupObservation(
cleanupCause,
snapshotPath,
),
);
}
}
throw failure;
}
}
/** Capture once per reader lifetime; bind all interpretation to these exact bytes. */
async prepareContainer(
expectedSha256?: string,
signal?: AbortSignal,
): Promise<void> {
await this.#withSnapshotOperation(async () => {
assertAsarReadActive(signal);
if (this.#snapshot === undefined && this.#snapshotRoot !== undefined)
await this.#closeSnapshot();
if (this.#snapshot === undefined) await this.#captureSnapshot(signal);
if (
expectedSha256 !== undefined &&
this.#snapshot?.sha256 !== expectedSha256
)
throw new ArtifactReaderFailure(
"integrity",
`ASAR container changed before interpretation: ${this.path}`,
);
}, signal);
}
/** Read the same captured container that supplies headers and packed members. */
async openContainer(signal?: AbortSignal): Promise<Readable> {
await this.prepareContainer(undefined, signal);
return this.#withSnapshotOperation(async () => {
assertAsarReadActive(signal);
const snapshotPath = this.#snapshotPath();
const handle = await this.#openOwned(
snapshotPath,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
return this.#ownedReadStream(handle, { signal });
}, signal);
}
async close(): Promise<void> {
await this.#withSnapshotOperation(() => this.#closeSnapshot());
}
async #captureSnapshot(signal?: AbortSignal): Promise<void> {
let inputHandle: SharedAsarFileHandle | undefined;
try {
this.#snapshotRoot = await mkdtemp(join(tmpdir(), "rea-asar-snapshot-"));
const snapshotPath = join(this.#snapshotRoot, "container.asar");
const hash = createHash("sha256");
inputHandle = await this.#openOwned(
this.path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
const source = this.#ownedReadStream(inputHandle, {
signal,
releaseOnClose: false,
});
const digesting = new Transform({
transform(chunk: Buffer, _encoding, callback) {
hash.update(chunk);
callback(null, chunk);
},
});
await pipeline(
source,
digesting,
createWriteStream(snapshotPath, { flags: "wx", mode: 0o600 }),
);
const completedInput = inputHandle;
inputHandle = undefined;
await this.#closeOwnedHandle(completedInput);
assertAsarReadActive(signal);
this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") };
} catch (cause: unknown) {
let failure = asarFailure(this.path, "snapshot", cause);
if (inputHandle !== undefined) {
const failedInput = inputHandle;
inputHandle = undefined;
try {
await this.#closeOwnedHandle(failedInput);
} catch (cleanupCause: unknown) {
failure = ArtifactReaderFailure.withCleanup(
failure,
ArtifactReaderFailure.cleanupObservation(cleanupCause, this.path),
);
}
}
try {
if (this.#ownedHandles.size === 0) await this.#closeSnapshot();
} catch (cleanupCause: unknown) {
throw ArtifactReaderFailure.withCleanup(
failure,
ArtifactReaderFailure.cleanupObservation(
cleanupCause,
this.#snapshotRoot ?? this.path,
),
);
}
throw failure;
}
}
async #closeSnapshot(): Promise<void> {
await this.#closeOwnedHandles();
const root = this.#snapshotRoot;
if (root !== undefined) {
try {
await (this.options.removeSnapshot ?? defaultRemoveSnapshot)(root);
if (this.#snapshotRoot === root) this.#snapshotRoot = undefined;
} catch (cause: unknown) {
throw ArtifactReaderFailure.withCleanup(cause, {
reason: `Could not remove ASAR snapshot: ${root}`,
resources: [root],
});
}
}
if (root === undefined || this.#snapshotRoot === undefined) {
if (this.#snapshot !== undefined) uncache(this.#snapshot.path);
this.#snapshot = undefined;
this.#resetArchiveState();
this.#entries.clear();
}
}
async #withSnapshotOperation<Value>(
operation: () => Promise<Value>,
signal?: AbortSignal,
): Promise<Value> {
const previous = this.#snapshotOperation;
let release!: () => void;
this.#snapshotOperation = new Promise<void>((resolve) => {
release = resolve;
});
try {
await waitForOperation(previous, signal);
} catch (cause: unknown) {
void previous.then(release, release);
throw cause;
}
try {
assertAsarReadActive(signal);
return await operation();
} finally {
release();
}
}
#snapshotPath(): string {
if (this.#snapshot === undefined)
throw new ArtifactReaderFailure(
"integrity",
"ASAR snapshot is not available",
);
return this.#snapshot.path;
}
provenance(): readonly [] {
return [];
}
async #openUnpackedStream(
entry: ArtifactEntry,
signal?: AbortSignal,
): Promise<Readable> {
const unpackedRoot = `${this.path}.unpacked`;
let handle: SharedAsarFileHandle | undefined;
try {
const rootMetadata = await lstat(unpackedRoot);
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink())
throw new ArtifactReaderFailure(
"path",
"ASAR unpacked companion is not a regular directory",
);
const canonicalRoot = await realpath(unpackedRoot);
const candidate = join(unpackedRoot, entry.adapterKey);
const canonical = await realpath(candidate);
const relativePath = relative(canonicalRoot, canonical);
if (
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
)
throw new ArtifactReaderFailure(
"path",
`ASAR unpacked entry escaped its companion directory: ${entry.path}`,
);
const pathMetadata = await lstat(candidate);
if (!pathMetadata.isFile() || pathMetadata.isSymbolicLink())
throw new ArtifactReaderFailure(
"path",
`ASAR unpacked entry is not a regular file: ${entry.path}`,
);
handle = await (this.options.openUnpackedFile ?? open)(
canonical,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
const openedHandle = handle;
this.#adoptHandle(openedHandle, canonical);
const openedMetadata = await openedHandle.stat();
if (
!openedMetadata.isFile() ||
openedMetadata.dev !== pathMetadata.dev ||
openedMetadata.ino !== pathMetadata.ino
) {
throw new ArtifactReaderFailure(
"integrity",
`ASAR unpacked entry changed before read: ${entry.path}`,
{},
artifactIntegrityDetails(
entry.path,
entry.declaredIntegrity,
null,
true,
),
);
}
assertAsarReadActive(signal);
const source = this.#ownedReadStream(openedHandle, { start: 0, signal });
handle = undefined;
const output = readValidatedAsarEntry(
source,
undefined,
entry.path,
this.path,
signal,
);
this.#setOwnedOutput(openedHandle, output);
return output;
} catch (cause: unknown) {
let failure = asarUnpackedEntryFailure(this.path, entry, cause);
if (handle !== undefined && this.#ownedHandles.has(handle)) {
try {
await this.#closeOwnedHandle(handle);
} catch (cleanupCause: unknown) {
failure = ArtifactReaderFailure.withCleanup(
failure,
ArtifactReaderFailure.cleanupObservation(
cleanupCause,
this.#ownedHandles.get(handle)?.path ?? entry.path,
),
);
}
}
throw failure;
}
}
async #closeOwnedHandle(handle: SharedAsarFileHandle): Promise<void> {
const owned = this.#ownedHandles.get(handle);
if (owned === undefined) return;
owned.output?.destroy();
owned.source?.destroy();
try {
await owned.owner.close();
} catch (cause: unknown) {
throw ArtifactReaderFailure.withCleanup(
cause,
ArtifactReaderFailure.cleanupObservation(cause, owned.path),
);
}
this.#ownedHandles.delete(handle);
}
async #closeOwnedHandles(): Promise<void> {
let failure: ArtifactReaderFailure | undefined;
for (const [handle, owned] of this.#ownedHandles) {
try {
await this.#closeOwnedHandle(handle);
} catch (cause: unknown) {
failure = ArtifactReaderFailure.withCleanup(
failure ?? cause,
ArtifactReaderFailure.cleanupObservation(cause, owned.path),
);
}
}
if (failure !== undefined) throw failure;
}
async #openOwned(path: string, flags: number): Promise<SharedAsarFileHandle> {
const handle = await (this.options.openFile ?? open)(path, flags);
this.#adoptHandle(handle, path);
return handle;
}
#adoptHandle(handle: SharedAsarFileHandle, path: string): void {
this.#ownedHandles.set(handle, {
path,
owner: new OwnedFileHandle(handle),
});
}
#ownedReadStream(
handle: SharedAsarFileHandle,
options: {
readonly start?: number;
readonly end?: number;
readonly signal?: AbortSignal | undefined;
readonly releaseOnClose?: boolean;
},
): FileHandleChunkStream {
const { releaseOnClose, ...readOptions } = options;
const source = readArtifactFileChunks(handle, readOptions);
const owned = this.#ownedHandles.get(handle);
if (owned === undefined)
throw new ArtifactReaderFailure("integrity", "ASAR handle has no owner");
owned.source = source;
owned.output = source;
if (releaseOnClose !== false)
source.once("close", () => {
void this.#closeOwnedHandle(handle).catch(() => undefined);
});
return source;
}
#setOwnedOutput(handle: SharedAsarFileHandle, output: Readable): void {
const owned = this.#ownedHandles.get(handle);
if (owned !== undefined) owned.output = output;
}
#resetArchiveState(): void {
this.#archiveSize = undefined;
this.#headerSize = undefined;
}
}
type AsarFileMetadata = AsarMetadata & { readonly size: number };
type AsarEntryState = {
readonly metadata: AsarFileMetadata;
readonly entry: ArtifactEntry;
};
type SharedAsarFileHandle = Pick<FileHandle, "fd" | "read" | "close"> & {
stat(): Promise<Stats>;
};
type OpenAsarFile = (
path: string,
flags: number,
) => Promise<SharedAsarFileHandle>;
type OwnedAsarHandle = {
readonly path: string;
readonly owner: OwnedFileHandle<SharedAsarFileHandle>;
source?: Readable;
output?: Readable;
};
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>;
};
const defaultRemoveSnapshot = (path: string): Promise<void> =>
rm(path, { recursive: true, force: true });
const asarUnpackedEntryFailure = (
archivePath: string,
entry: ArtifactEntry,
cause: unknown,
): ArtifactReaderFailure => {
if (cause instanceof ArtifactReaderFailure) return cause;
if (entry.unpacked && isMissingFile(cause))
return new ArtifactReaderFailure(
"unavailable",
`ASAR unpacked entry bytes are unavailable: ${entry.path}`,
{ cause },
artifactIntegrityDetails(entry.path, entry.declaredIntegrity, null, true),
);
return asarFailure(archivePath, `read ${entry.path}`, cause);
};
const isAsarFileMetadata = (
metadata: AsarMetadata,
): metadata is AsarFileMetadata =>
typeof metadata.size === "number" &&
metadata.files === undefined &&
metadata.link === undefined;
const parseArchiveOffset = (value: string): number | undefined => {
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
const offset = Number(value);
return Number.isSafeInteger(offset) ? offset : undefined;
};
const waitForOperation = async (
operation: Promise<void>,
signal?: AbortSignal,
): Promise<void> => {
if (signal === undefined) return operation;
assertAsarReadActive(signal);
await new Promise<void>((resolve, reject) => {
const removeAbortListener = (): void =>
signal.removeEventListener("abort", onAbort);
const onAbort = (): void => {
removeAbortListener();
reject(asarCancellationFailure(signal.reason));
};
signal.addEventListener("abort", onAbort, { once: true });
operation.then(
() => {
removeAbortListener();
resolve();
},
(cause: unknown) => {
removeAbortListener();
reject(cause);
},
);
});
};
const isMissingFile = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";