Files
reverse-engineering-anything/src/artifacts/AsarHeader.ts
T
morluto 399e2226ca refactor(resources): remove redundant helper exports and lease aliases
Keep ASAR header details and the Godot header parser module-local. Use the canonical lease types in Hopper consumers, including release-cache publication, while preserving acquisition and cleanup behavior.
2026-10-24 23:11:02 +08:00

418 lines
14 KiB
TypeScript

import type { Stats } from "node:fs";
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";
/**
* 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 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;
const byteAllowance = (bytes: number): string => {
const mebibytes = bytes / MEBIBYTE;
return Number.isInteger(mebibytes)
? `${String(mebibytes)} MiB / ${String(bytes)} bytes`
: `${String(bytes)} 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 a header under this admission. */
const asarHeaderProbeBytes = (admission: AsarHeaderAdmission): number =>
admission.maximumBytes + 8;
export interface AsarHeaderExtents {
readonly archiveBytes: number;
readonly headerBytes: number;
readonly jsonBytes: number;
}
export type AsarHeaderProbeExtents = Pick<
AsarHeaderExtents,
"headerBytes" | "jsonBytes"
> & { readonly archiveBytes?: number };
export type AsarHeaderProbeObservation =
| {
readonly state: "pending";
readonly extents?: AsarHeaderProbeExtents;
readonly scannedJsonBytes: number;
}
| {
/** The declared JSON begins with an object token; this does not validate JSON. */
readonly state: "matched";
readonly extents: AsarHeaderProbeExtents;
readonly signatureOffset: number;
readonly scannedJsonBytes: number;
}
| {
readonly state: "rejected";
readonly reason:
| "invalid-pickle-extents"
| "archive-too-short"
| "json-not-object";
readonly extents?: AsarHeaderProbeExtents;
readonly scannedJsonBytes: number;
}
| {
readonly state: "limit";
readonly extents?: AsarHeaderProbeExtents;
readonly scannedJsonBytes: number;
readonly limitBytes: number;
readonly admission: AsarHeaderAdmission;
};
export type FinalAsarHeaderProbeObservation = Exclude<
AsarHeaderProbeObservation,
{ readonly state: "pending" }
>;
/**
* Parse the fixed two-Pickle prefix and optionally check it against the actual
* observed archive size. This establishes byte extents only; JSON validity and
* the ASAR header schema belong to the decoder at the interpretation boundary.
*/
const parseAsarHeaderExtents = (
prefix: Uint8Array,
archiveBytes?: number,
): Result<Omit<AsarHeaderExtents, "archiveBytes">, ArtifactReaderFailure> => {
if (prefix.byteLength < 16)
return err(
new ArtifactReaderFailure(
"format",
"Truncated ASAR Pickle extent prefix",
),
);
const bytes = Buffer.from(prefix.buffer, prefix.byteOffset, 16);
const headerBytes = bytes.readUInt32LE(4);
const payloadBytes = bytes.readUInt32LE(8);
const jsonBytes = bytes.readUInt32LE(12);
if (
bytes.readUInt32LE(0) !== 4 ||
headerBytes < 8 ||
payloadBytes !== headerBytes - 4 ||
jsonBytes > payloadBytes - 4
)
return err(
new ArtifactReaderFailure(
"format",
"ASAR header has invalid Pickle extents",
),
);
if (
archiveBytes !== undefined &&
(!Number.isSafeInteger(archiveBytes) ||
archiveBytes < 8 ||
headerBytes > archiveBytes - 8)
)
return err(
new ArtifactReaderFailure(
"format",
"ASAR header leaves its observed container",
),
);
return ok({ headerBytes, jsonBytes });
};
/**
* Bounded ASAR signature probe. It retains only the 16-byte fixed prefix and
* 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;
finish(archiveBytes: number): FinalAsarHeaderProbeObservation;
}
class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe {
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();
}
/** Consume sequential archive bytes without retaining JSON or member data. */
write(input: Uint8Array): AsarHeaderProbeObservation {
if (this.#stop.state === "finalized") return this.#stop.observation;
const bytes = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
let offset = 0;
while (offset < bytes.length && !this.#isTerminal()) {
if (this.#fixedBytes < this.#fixed.length) {
const count = Math.min(
this.#fixed.length - this.#fixedBytes,
bytes.length - offset,
);
bytes.copy(this.#fixed, this.#fixedBytes, offset, offset + count);
this.#fixedBytes += count;
offset += count;
if (this.#fixedBytes === this.#fixed.length) this.#parseFixed();
continue;
}
const extents = this.#extents;
if (extents === undefined || this.#stop.state === "matched") break;
const jsonRemaining = extents.jsonBytes - this.#scannedJsonBytes;
if (jsonRemaining <= 0) {
this.#stop = { state: "rejected", reason: "json-not-object" };
break;
}
const archiveBytesScanned = 16 + this.#scannedJsonBytes;
if (archiveBytesScanned >= this.limit) {
this.#stop = { state: "limit" };
break;
}
const count = Math.min(
bytes.length - offset,
jsonRemaining,
this.limit - archiveBytesScanned,
);
for (let index = 0; index < count; index += 1) {
const value = bytes[offset + index]!;
if (!isJsonWhitespace(value)) {
if (value === 0x7b)
this.#stop = {
state: "matched",
signatureOffset: 16 + this.#scannedJsonBytes,
extents,
};
else this.#stop = { state: "rejected", reason: "json-not-object" };
break;
}
this.#scannedJsonBytes += 1;
}
offset += count;
if (this.#stop.state !== "pending") break;
if (this.#scannedJsonBytes >= extents.jsonBytes) {
this.#stop = { state: "rejected", reason: "json-not-object" };
break;
}
if (16 + this.#scannedJsonBytes >= this.limit)
this.#stop = { state: "limit" };
}
return this.#observation();
}
/** Finalize against the actual byte count; a signature alone is not validity. */
finish(archiveBytes: number): FinalAsarHeaderProbeObservation {
if (this.#stop.state === "finalized") return this.#stop.observation;
if (this.#fixedBytes < this.#fixed.length) {
this.#stop = { state: "rejected", reason: "invalid-pickle-extents" };
} else if (this.#extents !== undefined) {
if (
!Number.isSafeInteger(archiveBytes) ||
archiveBytes < 8 ||
this.#extents.headerBytes > archiveBytes - 8
) {
this.#stop = { state: "rejected", reason: "archive-too-short" };
} else if (
this.#extents.headerBytes > this.admission.maximumBytes &&
this.#stop.state === "matched"
) {
this.#stop = { state: "limit" };
}
}
const observation = this.#observation(archiveBytes);
this.#stop = { state: "finalized", observation };
return observation;
}
#parseFixed(): void {
const result = parseAsarHeaderExtents(this.#fixed);
if (!result.ok) {
this.#stop = { state: "rejected", reason: "invalid-pickle-extents" };
return;
}
this.#extents = result.value;
if (this.#extents.jsonBytes === 0)
this.#stop = { state: "rejected", reason: "json-not-object" };
}
#isTerminal(): boolean {
return this.#stop.state !== "pending";
}
#observation(): AsarHeaderProbeObservation;
#observation(archiveBytes: number): FinalAsarHeaderProbeObservation;
#observation(
archiveBytes?: number,
): AsarHeaderProbeObservation | FinalAsarHeaderProbeObservation {
if (this.#stop.state === "finalized") return this.#stop.observation;
const extents: AsarHeaderProbeExtents | undefined =
this.#extents === undefined
? undefined
: archiveBytes === undefined
? this.#extents
: { archiveBytes, ...this.#extents };
switch (this.#stop.state) {
case "rejected":
return {
state: "rejected",
reason: this.#stop.reason,
...(extents === undefined ? {} : { extents }),
scannedJsonBytes: this.#scannedJsonBytes,
};
case "limit":
return {
state: "limit",
...(extents === undefined ? {} : { extents }),
scannedJsonBytes: this.#scannedJsonBytes,
limitBytes: this.limit,
admission: this.admission,
};
case "matched":
return {
state: "matched",
extents:
archiveBytes === undefined
? this.#stop.extents
: { archiveBytes, ...this.#stop.extents },
signatureOffset: this.#stop.signatureOffset,
scannedJsonBytes: this.#scannedJsonBytes,
};
case "pending":
if (archiveBytes !== undefined && this.#extents !== undefined) {
const finalizedExtents = {
archiveBytes,
...this.#extents,
};
if (this.#scannedJsonBytes >= this.#extents.jsonBytes)
return {
state: "rejected",
reason: "json-not-object",
extents: finalizedExtents,
scannedJsonBytes: this.#scannedJsonBytes,
};
return {
state: "limit",
extents: finalizedExtents,
scannedJsonBytes: this.#scannedJsonBytes,
limitBytes: this.limit,
admission: this.admission,
};
}
return {
state: "pending",
...(extents === undefined ? {} : { extents }),
scannedJsonBytes: this.#scannedJsonBytes,
};
}
}
}
type ProbeStop =
| { readonly state: "pending" }
| {
readonly state: "matched";
readonly signatureOffset: number;
readonly extents: Omit<AsarHeaderExtents, "archiveBytes">;
}
| {
readonly state: "rejected";
readonly reason:
| "invalid-pickle-extents"
| "archive-too-short"
| "json-not-object";
}
| { readonly state: "limit" }
| {
readonly state: "finalized";
readonly observation: FinalAsarHeaderProbeObservation;
};
const isJsonWhitespace = (value: number): boolean =>
value === 0x20 || value === 0x09 || value === 0x0a || value === 0x0d;
/** Admit Pickle extents before an upstream decoder allocates them. */
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();
assertArtifactReadActive(signal);
if (!observed.isFile())
throw new ArtifactReaderFailure(
"format",
"ASAR container is not a regular file",
);
const prefix = Buffer.alloc(16);
let bytesRead = 0;
while (bytesRead < prefix.length) {
assertArtifactReadActive(signal);
const read = await handle.read(
prefix,
bytesRead,
prefix.length - bytesRead,
bytesRead,
);
assertArtifactReadActive(signal);
if (read.bytesRead === 0)
throw new ArtifactReaderFailure("format", "Truncated ASAR size pickle");
bytesRead += read.bytesRead;
}
const parsed = parseAsarHeaderExtents(prefix, observed.size);
if (!parsed.ok) throw parsed.error;
if (parsed.value.headerBytes > admission.maximumBytes)
throw asarHeaderAdmissionFailure(admission);
return {
archiveBytes: observed.size,
...parsed.value,
};
};
export const createAsarHeaderSignatureProbe = (
admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION,
): AsarHeaderSignatureProbe => new AsarHeaderSignatureProbeImpl(admission);