Files
reverse-engineering-anything/src/artifacts/AsarArtifactReader.ts
T
morluto eb97da3388 fix(artifacts): retain ASAR resources through cleanup failures
Give snapshot capture, header admission, packed members, and container streams one file-handle owner. Serialize adoption with reader shutdown, retain failed closes for retry, and remove snapshots only after descriptor cleanup succeeds.

Cover unread members, pending opens, zero-byte members, and paired format/cleanup failures. Keep intentionally unrecoverable test fixtures hygienic and strengthen managed identity and process cleanup-authority expectations.

Validation: full npm run check; 99 targeted tests and temporary-directory hygiene; exact discovery of 877 test files. No real vendor-provider verification was performed.
2026-10-12 05:05:21 +08:00

762 lines
24 KiB
TypeScript

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 { listPackage, statFile, 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 { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.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 AsarListedPath[];
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);
this.#archiveSize = admitted.archiveBytes;
this.#headerSize = admitted.headerBytes;
abortIfNeeded(signal);
const ordered = orderAsarListing(
listPackage(snapshotPath, { isPack: false }),
);
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 { listed, path } of paths) {
abortIfNeeded(signal);
if (path.length === 0) continue;
const providerPath = listed.startsWith(sep)
? listed.slice(sep.length)
: listed;
let metadata: ReturnType<typeof statFile>;
try {
metadata = statFile(this.#snapshotPath(), providerPath, false);
} catch (cause: unknown) {
throw asarFailure(this.path, `stat ${path}`, cause);
}
const kind =
"files" in metadata
? "directory"
: "link" in metadata
? "symlink"
: "file";
const entry: ArtifactEntry = {
path,
kind,
declaredSize: "size" in metadata ? metadata.size : null,
compressedSize: null,
executable: "executable" in metadata && metadata.executable,
encrypted: false,
byteOffset: null,
declaredSha256:
"integrity" in metadata &&
metadata.integrity.algorithm === "SHA256" &&
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? 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) {
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) };
} 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> {
abortIfNeeded(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();
abortIfNeeded(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 () => {
abortIfNeeded(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 () => {
abortIfNeeded(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 }),
signal === undefined ? {} : { signal },
);
const completedInput = inputHandle;
inputHandle = undefined;
await this.#closeOwnedHandle(completedInput);
this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") };
} catch (cause: unknown) {
let failure =
signal?.aborted === true
? new ArtifactReaderFailure("cancelled", "ASAR operation cancelled", {
cause,
})
: 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 {
abortIfNeeded(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}`,
{},
{
logicalPath: entry.path,
declaredSha256: entry.declaredSha256,
calculatedSha256: null,
unpacked: true,
},
);
}
abortIfNeeded(signal);
const source = this.#ownedReadStream(openedHandle, { start: 0 });
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;
},
): Readable {
const { releaseOnClose, ...readOptions } = options;
const source = readFileHandleChunks(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 = Extract<ReturnType<typeof statFile>, { 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;
};
type AsarArtifactReaderOptions = {
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 },
{
logicalPath: entry.path,
declaredSha256: entry.declaredSha256,
calculatedSha256: null,
unpacked: true,
},
);
return asarFailure(archivePath, `read ${entry.path}`, cause);
};
const isAsarFileMetadata = (
metadata: ReturnType<typeof statFile>,
): metadata is AsarFileMetadata =>
"size" in metadata &&
typeof metadata.size === "number" &&
!("files" in metadata) &&
!("link" in metadata);
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 abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
};
const waitForOperation = async (
operation: Promise<void>,
signal?: AbortSignal,
): Promise<void> => {
if (signal === undefined) return operation;
abortIfNeeded(signal);
await new Promise<void>((resolve, reject) => {
const removeAbortListener = (): void =>
signal.removeEventListener("abort", onAbort);
const onAbort = (): void => {
removeAbortListener();
reject(
new ArtifactReaderFailure("cancelled", "ASAR operation cancelled"),
);
};
signal.addEventListener("abort", onAbort, { once: true });
operation.then(
() => {
removeAbortListener();
resolve();
},
(cause: unknown) => {
removeAbortListener();
reject(cause);
},
);
});
};
interface AsarListedPath {
readonly listed: string;
readonly path: string;
}
/**
* Pair each host-spelled listing with its portable archive path, ordered by
* the portable path so Windows and POSIX hosts agree.
*
* @electron/asar returns paths assembled with the host path module. Keep that
* spelling for its API calls, but expose archive paths with portable
* separators. On POSIX, backslashes can be literal filename characters and
* must remain untouched.
*/
export const orderAsarListing = (
listed: readonly string[],
separator: string = sep,
): AsarListedPath[] =>
listed
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
const toArtifactPath = (listed: string, separator: string): string => {
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
return portable.replace(/^\/+|\/+$/gu, "");
};
const asarFailure = (
path: string,
operation: string,
cause: unknown,
): ArtifactReaderFailure => {
if (cause instanceof ArtifactReaderFailure) return cause;
if (isFilesystemFailure(cause))
return new ArtifactReaderFailure(
"io",
`Could not ${operation} ASAR at ${path}: ${cause.message}`,
{ cause },
);
return new ArtifactReaderFailure(
"format",
`Malformed ASAR during ${operation}: ${path}`,
{ cause },
);
};
const isFilesystemFailure = (
cause: unknown,
): cause is NodeJS.ErrnoException & Error =>
cause instanceof Error &&
"errno" in cause &&
typeof cause.errno === "number" &&
"syscall" in cause &&
typeof cause.syscall === "string";
const isMissingFile = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";