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:
parent
bf934d5592
commit
d79317cda0
|
|
@ -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 [] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { lstat, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { buffer } from "node:stream/consumers";
|
||||
|
||||
import { createPackage, listPackage, uncache } from "@electron/asar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js";
|
||||
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
|
||||
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
|
@ -165,7 +168,121 @@ describe("ASAR inventory freshness", () => {
|
|||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("ASAR captured-byte identity", () => {
|
||||
it("rejects same-size nested-container replacement between hashing and interpretation", async () => {
|
||||
const fixture = await archiveFixture();
|
||||
const originalSize = (await lstat(fixture.archive)).size;
|
||||
const directory = new DirectoryArtifactReader(fixture.bundle);
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(fixture.bundle, {}, async () => ({
|
||||
format: directory.format,
|
||||
entries: (signal) => directory.entries(signal),
|
||||
provenance: () => directory.provenance(),
|
||||
close: () => directory.close(),
|
||||
async open(entry, signal) {
|
||||
const source = await directory.open(entry, signal);
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield* source;
|
||||
await fixture.replace();
|
||||
expect((await lstat(fixture.archive)).size).toBe(originalSize);
|
||||
})(),
|
||||
);
|
||||
},
|
||||
})),
|
||||
).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
message: expect.stringContaining("changed before interpretation"),
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates interleaved readers from replacement headers and packed bytes", async () => {
|
||||
const fixture = await archiveFixture();
|
||||
await writeFile(join(fixture.source, "second.js"), "original metadata");
|
||||
await createPackage(fixture.source, fixture.archive);
|
||||
const first = new AsarArtifactReader(fixture.archive);
|
||||
const second = new AsarArtifactReader(fixture.archive);
|
||||
const left = first.entries()[Symbol.asyncIterator]();
|
||||
const right = second.entries()[Symbol.asyncIterator]();
|
||||
try {
|
||||
const initial = await left.next();
|
||||
if (initial.done) throw new Error("Expected first member");
|
||||
expect(initial.value.path).toBe("old.js");
|
||||
await fixture.replace();
|
||||
await writeFile(join(fixture.source, "second.js"), "replaced metadata");
|
||||
await createPackage(fixture.source, fixture.archive);
|
||||
const replacement = await right.next();
|
||||
if (replacement.done) throw new Error("Expected replacement member");
|
||||
expect(replacement.value.path).toBe("new.js");
|
||||
const originalMember = await left.next();
|
||||
const replacementMember = await right.next();
|
||||
if (originalMember.done || replacementMember.done)
|
||||
throw new Error("Expected second members");
|
||||
expect(originalMember.value.declaredSha256).not.toBe(
|
||||
replacementMember.value.declaredSha256,
|
||||
);
|
||||
expect(
|
||||
(await buffer(await first.open(originalMember.value))).toString(),
|
||||
).toBe("original metadata");
|
||||
await first.close();
|
||||
expect(
|
||||
(await buffer(await second.open(replacementMember.value))).toString(),
|
||||
).toBe("replaced metadata");
|
||||
} finally {
|
||||
await left.return?.();
|
||||
await right.return?.();
|
||||
await first.close();
|
||||
await second.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a completed inventory and snapshot ownership when cleanup fails", async () => {
|
||||
const fixture = await archiveFixture();
|
||||
const attempts: string[] = [];
|
||||
const reader = new AsarArtifactReader(
|
||||
fixture.archive,
|
||||
undefined,
|
||||
async (path) => {
|
||||
attempts.push(path);
|
||||
if (attempts.length === 1) throw new Error("snapshot removal denied");
|
||||
await rm(path, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
try {
|
||||
const failure: unknown = await scanCanonicalArtifactInventory(
|
||||
fixture.archive,
|
||||
{},
|
||||
async () => reader,
|
||||
).catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({
|
||||
cleanup: { resources: [attempts[0]] },
|
||||
partialObservation: {
|
||||
kind: "artifact-inventory",
|
||||
inventory: {
|
||||
occurrences: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "old.js",
|
||||
hash_status: "verified",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
const root = attempts[0];
|
||||
if (root === undefined) throw new Error("Expected owned snapshot");
|
||||
expect((await lstat(root)).isDirectory()).toBe(true);
|
||||
await reader.close();
|
||||
expect(attempts).toEqual([root, root]);
|
||||
await expect(lstat(root)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ASAR reader lifecycle", () => {
|
||||
it("refreshes a header cached before the reader was created", async () => {
|
||||
const fixture = await archiveFixture();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -69,11 +69,6 @@ describe("ASAR entry streaming", () => {
|
|||
await writeFile(join(source, "small.js"), "module.exports = 1;\n");
|
||||
await createPackageWithOptions(source, archive, {});
|
||||
|
||||
const identity = await lstat(archive);
|
||||
const probe = await openFile(archive, "r");
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
await probe.close();
|
||||
await waitForNoMatchingDescriptor(identity);
|
||||
const reader = new AsarArtifactReader(archive);
|
||||
|
||||
try {
|
||||
|
|
@ -83,6 +78,12 @@ describe("ASAR entry streaming", () => {
|
|||
}
|
||||
const entry = entries.find(({ path }) => path === "small.js");
|
||||
if (entry === undefined) throw new Error("Expected packed ASAR member");
|
||||
const snapshot = await reader.openContainer();
|
||||
const identity = await lstat(snapshot.path);
|
||||
await buffer(snapshot);
|
||||
const probe = await openFile(snapshot.path, "r");
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
await probe.close();
|
||||
expect(await matchingDescriptorCount(identity)).toBe(0);
|
||||
const output = await reader.open(entry);
|
||||
expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0);
|
||||
|
|
@ -108,7 +109,6 @@ describe("ASAR entry streaming", () => {
|
|||
await writeFile(join(source, "small.js"), "module.exports = 1;\n");
|
||||
await createPackageWithOptions(source, archive, {});
|
||||
|
||||
const identity = await lstat(archive);
|
||||
const reader = new AsarArtifactReader(archive);
|
||||
|
||||
try {
|
||||
|
|
@ -116,6 +116,9 @@ describe("ASAR entry streaming", () => {
|
|||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries.find(({ path }) => path === "small.js");
|
||||
if (entry === undefined) throw new Error("Expected packed ASAR member");
|
||||
const snapshot = await reader.openContainer();
|
||||
const identity = await lstat(snapshot.path);
|
||||
await buffer(snapshot);
|
||||
const controller = new AbortController();
|
||||
const opening = reader.open(entry, controller.signal);
|
||||
controller.abort();
|
||||
|
|
@ -127,7 +130,7 @@ describe("ASAR entry streaming", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("classifies a missing packed member container as an I/O failure", async () => {
|
||||
it("keeps captured packed bytes readable after the original container disappears", async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-missing-member-");
|
||||
const source = join(root, "source");
|
||||
const archive = join(root, "fixture.asar");
|
||||
|
|
@ -142,10 +145,9 @@ describe("ASAR entry streaming", () => {
|
|||
const entry = entries.find(({ path }) => path === "small.js");
|
||||
if (entry === undefined) throw new Error("Expected packed ASAR member");
|
||||
await rm(archive);
|
||||
await expect(reader.open(entry)).rejects.toMatchObject({
|
||||
reason: "io",
|
||||
cause: { code: "ENOENT" },
|
||||
});
|
||||
expect((await buffer(await reader.open(entry))).toString()).toBe(
|
||||
"module.exports = 1;\n",
|
||||
);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
|
|
@ -205,7 +207,7 @@ describe("ASAR entry producer identity", () => {
|
|||
["packed to unpacked", false],
|
||||
["unpacked to packed", true],
|
||||
] as const)(
|
||||
"rejects a retained %s entry after a new entries pass",
|
||||
"rejects a retained %s entry after closing and recapturing the archive",
|
||||
async (_transition, wasUnpacked) => {
|
||||
const root = await createTestTempDirectory("rea-asar-entry-refresh-");
|
||||
const source = join(root, "source");
|
||||
|
|
@ -236,6 +238,7 @@ describe("ASAR entry producer identity", () => {
|
|||
archive,
|
||||
wasUnpacked ? {} : { unpack: "small.js" },
|
||||
);
|
||||
await reader.close();
|
||||
const currentEntries = [];
|
||||
for await (const entry of reader.entries()) currentEntries.push(entry);
|
||||
const current = currentEntries.find(({ path }) => path === "small.js");
|
||||
|
|
@ -345,7 +348,7 @@ describe("ASAR read failures", () => {
|
|||
);
|
||||
|
||||
it.each(["missing", "replaced"] as const)(
|
||||
"validates the container before returning a zero-length member when it is %s",
|
||||
"reads a captured zero-length member when the original container is %s",
|
||||
async (change) => {
|
||||
const root = await createTestTempDirectory("rea-asar-empty-member-");
|
||||
const source = join(root, "source");
|
||||
|
|
@ -363,11 +366,7 @@ describe("ASAR read failures", () => {
|
|||
if (change === "missing") await rm(archive);
|
||||
else await writeFile(archive, "replacement container");
|
||||
|
||||
await expect(reader.open(entry)).rejects.toMatchObject(
|
||||
change === "missing"
|
||||
? { reason: "io", cause: { code: "ENOENT" } }
|
||||
: { reason: "integrity" },
|
||||
);
|
||||
expect(await buffer(await reader.open(entry))).toHaveLength(0);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ describe("JavaScript artifact ingestion content identities", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("retains native filesystem errors for outer IO classification", async () => {
|
||||
it("retains filesystem error context in typed IO failures", async () => {
|
||||
const { archive } = await fixture();
|
||||
const snapshot = await scanArtifactInventory(archive);
|
||||
await rm(archive);
|
||||
|
|
@ -130,7 +130,7 @@ describe("JavaScript artifact ingestion content identities", () => {
|
|||
try {
|
||||
await expect(
|
||||
readJavaScriptArtifactFiles(reader, snapshot),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
).rejects.toMatchObject({ reason: "io", cause: { code: "ENOENT" } });
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue