From d79317cda0bbe8240eb7e25b0a5de90dcffca5fa Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:19:41 +0800 Subject: [PATCH] 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. --- src/artifacts/AsarArtifactReader.ts | 139 ++++++++++++++++-- src/artifacts/inventory/scanCanonical.ts | 40 +++-- src/artifacts/inventory/scanReader.ts | 21 +-- .../javascript/JavaScriptArtifactFiles.ts | 43 ++++-- .../filesystem/asarInventoryFreshness.test.ts | 119 ++++++++++++++- .../asarStreamingAllocation.test.ts | 35 +++-- .../javascriptArtifactContentIdentity.test.ts | 4 +- 7 files changed, 323 insertions(+), 78 deletions(-) diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 02ebb1bbe..bedd9a5f2 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -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(); + #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 = (path) => + rm(path, { recursive: true, force: true }), ) {} async *entries(signal?: AbortSignal): AsyncIterable { 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; 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 { 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 { - uncache(this.path); + /** Read the same captured container that supplies headers and packed members. */ + async openContainer(signal?: AbortSignal): Promise { + await this.prepareContainer(undefined, signal); + return createReadStream( + this.#snapshotPath(), + signal === undefined ? {} : { signal }, + ); + } + + async close(): Promise { + 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 [] { diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index ca1324e17..abd524693 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -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; }; diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index 7de218b8a..1ee691a74 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -52,10 +52,12 @@ interface ScanContext { readonly occurrenceByPath: Map; readonly registry: ArtifactPathRegistry; readonly expandedContainerIds: Set; + 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(), registry: new ArtifactPathRegistry(), expandedContainerIds: new Set(), + 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; - 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?.(); } }; diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index a6aa2600f..cfe1e811b 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -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; - 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 ( diff --git a/tests/boundary/filesystem/asarInventoryFreshness.test.ts b/tests/boundary/filesystem/asarInventoryFreshness.test.ts index 91cdc81b6..da9db6731 100644 --- a/tests/boundary/filesystem/asarInventoryFreshness.test.ts +++ b/tests/boundary/filesystem/asarInventoryFreshness.test.ts @@ -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 { diff --git a/tests/boundary/filesystem/asarStreamingAllocation.test.ts b/tests/boundary/filesystem/asarStreamingAllocation.test.ts index 630091584..cc9438179 100644 --- a/tests/boundary/filesystem/asarStreamingAllocation.test.ts +++ b/tests/boundary/filesystem/asarStreamingAllocation.test.ts @@ -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(); } diff --git a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts index 1871e59c5..2acfb8911 100644 --- a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts @@ -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(); }