fix(artifacts): bind ASAR interpretation to owned snapshots

Verify captured container bytes against their inventoried digest before listing or reading packed members. Isolate per-reader ASAR metadata, preserve original unpacked companions, and retain complete inventories when snapshot cleanup fails.
This commit is contained in:
morluto committed 2026-10-10 06:20:28 +08:00
1 parent bf934d5592
commit d79317cda0
7 files changed
+323 -78

No files matched your search

+123 -16
View File
@@ -1,7 +1,23 @@
import { constants, createReadStream, type Stats } from "node:fs";
import { lstat, open, realpath, type FileHandle } from "node:fs/promises";
import {
constants,
createReadStream,
createWriteStream,
type ReadStream,
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 } from "node:stream";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { getRawHeader, listPackage, statFile, uncache } from "@electron/asar";
@@ -24,24 +40,28 @@ export class AsarArtifactReader implements ArtifactReader {
#archiveSize: number | undefined;
#headerSize: number | undefined;
readonly #entries = new Map<string, AsarEntryState>();
#snapshot: { readonly path: string; readonly sha256: string } | undefined;
#snapshotRoot: string | undefined;
constructor(
private readonly path: string,
private readonly openUnpackedFile: OpenAsarUnpackedFile = open,
private readonly removeSnapshot: (path: string) => Promise<void> = (path) =>
rm(path, { recursive: true, force: true }),
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
let paths: string[];
try {
// ASAR caches headers by path even after the archive has been replaced.
await this.prepareContainer(undefined, signal);
const snapshotPath = this.#snapshotPath();
this.#resetArchiveState();
this.#entries.clear();
uncache(this.path);
paths = listPackage(this.path, { isPack: false }).sort((left, right) =>
paths = listPackage(snapshotPath, { isPack: false }).sort((left, right) =>
left.localeCompare(right, "en"),
);
this.#headerSize = getRawHeader(this.path).headerSize;
const archiveMetadata = await lstat(this.path);
this.#headerSize = getRawHeader(snapshotPath).headerSize;
const archiveMetadata = await lstat(snapshotPath);
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
throw new ArtifactReaderFailure(
"format",
@@ -64,7 +84,7 @@ export class AsarArtifactReader implements ArtifactReader {
: listed;
let metadata: ReturnType<typeof statFile>;
try {
metadata = statFile(this.path, providerPath, false);
metadata = statFile(this.#snapshotPath(), providerPath, false);
} catch (cause: unknown) {
throw asarFailure(this.path, `stat ${path}`, cause);
}
@@ -176,7 +196,7 @@ export class AsarArtifactReader implements ArtifactReader {
let handle: FileHandle | undefined;
try {
const openedHandle = await open(
this.path,
this.#snapshotPath(),
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
handle = openedHandle;
@@ -210,17 +230,104 @@ export class AsarArtifactReader implements ArtifactReader {
}
}
/** Open raw container bytes to verify the inventory identity before analysis. */
openContainer(signal?: AbortSignal): Readable {
/** Capture once per reader lifetime; bind all interpretation to these exact bytes. */
async prepareContainer(
expectedSha256?: string,
signal?: AbortSignal,
): Promise<void> {
abortIfNeeded(signal);
return createReadStream(this.path, signal === undefined ? {} : { signal });
if (this.#snapshot === undefined && this.#snapshotRoot !== undefined)
await this.close();
if (this.#snapshot === undefined) {
try {
this.#snapshotRoot = await mkdtemp(
join(tmpdir(), "rea-asar-snapshot-"),
);
const snapshotPath = join(this.#snapshotRoot, "container.asar");
const hash = createHash("sha256");
const handle = await open(
this.path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
const source = handle.createReadStream();
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 },
);
this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") };
} catch (cause: unknown) {
const primary =
signal?.aborted === true
? new ArtifactReaderFailure(
"cancelled",
"ASAR operation cancelled",
{ cause },
)
: asarFailure(this.path, "snapshot", cause);
try {
await this.close();
} catch (cleanupCause: unknown) {
throw ArtifactReaderFailure.withCleanup(
primary,
ArtifactReaderFailure.cleanupObservation(cleanupCause, this.path),
);
}
throw primary;
}
}
if (
expectedSha256 !== undefined &&
this.#snapshot.sha256 !== expectedSha256
)
throw new ArtifactReaderFailure(
"integrity",
`ASAR container changed before interpretation: ${this.path}`,
);
}
close(): Promise<void> {
uncache(this.path);
/** Read the same captured container that supplies headers and packed members. */
async openContainer(signal?: AbortSignal): Promise<ReadStream> {
await this.prepareContainer(undefined, signal);
return createReadStream(
this.#snapshotPath(),
signal === undefined ? {} : { signal },
);
}
async close(): Promise<void> {
const root = this.#snapshotRoot;
if (root !== undefined) {
try {
await this.removeSnapshot(root);
} catch (cause: unknown) {
throw ArtifactReaderFailure.withCleanup(cause, {
reason: `Could not remove ASAR snapshot: ${root}`,
resources: [root],
});
}
this.#snapshotRoot = undefined;
}
if (this.#snapshot !== undefined) uncache(this.#snapshot.path);
this.#snapshot = undefined;
this.#resetArchiveState();
this.#entries.clear();
return Promise.resolve();
}
#snapshotPath(): string {
if (this.#snapshot === undefined)
throw new ArtifactReaderFailure(
"integrity",
"ASAR snapshot is not available",
);
return this.#snapshot.path;
}
provenance(): readonly [] {
+25 -15
View File
@@ -1,6 +1,8 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import { lstat } from "node:fs/promises";
import type { Stats } from "node:fs";
@@ -61,6 +63,7 @@ export const scanCanonicalArtifactInventory = async (
options.environment ?? {},
options.signal,
);
const ownedReaders: ArtifactReader[] = reader === undefined ? [] : [reader];
let outcome:
| {
readonly kind: "completed";
@@ -68,8 +71,11 @@ export const scanCanonicalArtifactInventory = async (
}
| { readonly kind: "failed"; readonly cause: unknown };
try {
if (reader instanceof AsarArtifactReader)
await reader.prepareContainer(rootDigest?.sha256, options.signal);
const { nodes, occurrences, pendingContradictions } = await scanReader(
reader,
ownedReaders,
options.signal,
integrity,
);
@@ -90,22 +96,26 @@ export const scanCanonicalArtifactInventory = async (
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
try {
await reader?.close();
} catch (cleanupCause: unknown) {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cleanupCause,
`artifact reader for ${path}`,
);
const primary = outcome.kind === "failed" ? outcome.cause : cleanupCause;
throw ArtifactReaderFailure.withCleanup(
primary,
cleanup,
outcome.kind === "completed"
? { kind: "artifact-inventory", inventory: outcome.snapshot }
: undefined,
);
let cleanupFailure: ArtifactReaderFailure | undefined;
for (const owned of ownedReaders.reverse()) {
try {
await owned.close();
} catch (cleanupCause: unknown) {
const cleanup = ArtifactReaderFailure.cleanupObservation(
cleanupCause,
`artifact reader for ${path}`,
);
cleanupFailure = ArtifactReaderFailure.withCleanup(
cleanupFailure ??
(outcome.kind === "failed" ? outcome.cause : cleanupCause),
cleanup,
outcome.kind === "completed"
? { kind: "artifact-inventory", inventory: outcome.snapshot }
: undefined,
);
}
}
if (cleanupFailure !== undefined) throw cleanupFailure;
if (outcome.kind === "failed") throw outcome.cause;
return outcome.snapshot;
};
+8 -13
View File
@@ -52,10 +52,12 @@ interface ScanContext {
readonly occurrenceByPath: Map<string, MutableOccurrence>;
readonly registry: ArtifactPathRegistry;
readonly expandedContainerIds: Set<string>;
readonly ownedReaders: ArtifactReader[];
}
export const scanReader = async (
reader: ArtifactReader | undefined,
ownedReaders: ArtifactReader[],
signal?: AbortSignal,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<{
@@ -76,6 +78,7 @@ export const scanReader = async (
occurrenceByPath: new Map<string, MutableOccurrence>(),
registry: new ArtifactPathRegistry(),
expandedContainerIds: new Set<string>(),
ownedReaders,
};
await visitArtifactEntries(context, reader, "");
// Archive directories may appear after their children. Resolve containment
@@ -99,13 +102,11 @@ const visitArtifactEntries = async (
readonly reader: ArtifactReader;
readonly prefix: string;
readonly iterator: AsyncIterator<ArtifactEntry>;
readonly owned: boolean;
}> = [
{
reader: currentReader,
prefix,
iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](),
owned: false,
},
];
try {
@@ -115,7 +116,6 @@ const visitArtifactEntries = async (
const next = await frame.iterator.next();
if (next.done) {
stack.pop();
if (frame.owned) await frame.reader.close();
continue;
}
const entry = next.value;
@@ -164,27 +164,22 @@ const visitArtifactEntries = async (
}
context.occurrences.push(occurrence);
context.occurrenceByPath.set(logicalPath, occurrence);
if (expandableAsar && digested?.mismatched !== true) {
if (expandableAsar && digested !== undefined && !digested.mismatched) {
const nested = new AsarArtifactReader(entry.adapterKey);
context.ownedReaders.push(nested);
await nested.prepareContainer(digested.node.sha256, context.signal);
// Only traversed containers can own members, not opaque ASAR-named files.
context.expandedContainerIds.add(occurrence.occurrence_id);
stack.push({
reader: nested,
prefix: logicalPath,
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
owned: true,
});
}
}
} finally {
await Promise.allSettled(
stack
.filter(({ owned }) => owned)
.map(async ({ reader, iterator }) => {
await iterator.return?.();
await reader.close();
}),
);
// Readers remain owned by the inventory until its complete observation is built.
for (const { iterator } of stack) await iterator.return?.();
}
};
@@ -71,7 +71,7 @@ export const readJavaScriptArtifactFiles = async (
"Root artifact node is missing",
);
await verifyEntryBytes(
reader.openContainer(signal),
await reader.openContainer(signal),
{ path: ".", sha256: root.sha256, bytes: root.size },
signal,
);
@@ -114,16 +114,16 @@ const visitReader = async (
readonly prefix: string;
readonly containerSha256: string;
readonly iterator: AsyncIterator<ArtifactEntry>;
readonly owned: boolean;
}> = [
{
reader,
prefix,
containerSha256,
iterator: reader.entries(context.signal)[Symbol.asyncIterator](),
owned: false,
},
];
const ownedReaders: ArtifactReader[] = [];
let failure: { readonly cause: unknown } | undefined;
try {
while (stack.length > 0) {
const frame = stack.at(-1);
@@ -131,7 +131,6 @@ const visitReader = async (
const next = await frame.iterator.next();
if (next.done) {
stack.pop();
if (frame.owned) await frame.reader.close();
continue;
}
const entry = next.value;
@@ -175,13 +174,14 @@ const visitReader = async (
);
context.containers.push(inventory);
const nested = new AsarArtifactReader(entry.adapterKey);
ownedReaders.push(nested);
stack.push({
reader: nested,
prefix: path,
containerSha256: inventory.sha256,
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
owned: true,
});
await nested.prepareContainer(inventory.sha256, context.signal);
continue;
}
const expected = context.expected.get(path);
@@ -202,16 +202,33 @@ const visitReader = async (
text,
});
}
} catch (cause: unknown) {
failure = { cause };
} finally {
await Promise.allSettled(
stack
.filter(({ owned }) => owned)
.map(async ({ reader, iterator }) => {
await iterator.return?.();
await reader.close();
}),
);
for (const { iterator } of stack) {
try {
await iterator.return?.();
} catch (cause: unknown) {
failure ??= { cause };
}
}
for (const owned of ownedReaders.reverse()) {
try {
await owned.close();
} catch (cause: unknown) {
failure = {
cause: ArtifactReaderFailure.withCleanup(
failure?.cause ?? cause,
ArtifactReaderFailure.cleanupObservation(
cause,
`nested ASAR reader for ${prefix}`,
),
),
};
}
}
}
if (failure !== undefined) throw failure.cause;
};
const readText = async (