From 574f6bb3bdcaedc42ab8564f5073a7d46da061e8 Mon Sep 17 00:00:00 2001 From: 64579525 <17373302683@163.com> Date: Mon, 12 Oct 2026 15:50:02 +0800 Subject: [PATCH] fix(artifacts): preserve iNES source disappearance as integrity failure --- src/artifacts/InesArtifactReader.ts | 14 +++++- .../boundary/filesystem/inesArtifact.test.ts | 44 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/artifacts/InesArtifactReader.ts b/src/artifacts/InesArtifactReader.ts index 91790ef85..cfb05f5f4 100644 --- a/src/artifacts/InesArtifactReader.ts +++ b/src/artifacts/InesArtifactReader.ts @@ -178,7 +178,19 @@ export class InesArtifactReader implements ArtifactReader { throw new ArtifactReaderFailure("unavailable", "iNES reader is closed"); const [opened, selected] = await Promise.all([ this.source.handle.stat(), - lstat(this.path), + lstat(this.path).catch((cause: unknown) => { + if ( + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code)) + ) + throw new ArtifactReaderFailure( + "integrity", + "iNES source changed during reading", + { cause }, + ); + throw cause; + }), ]); if ( !sameRegularFileState(this.source.initial, opened) || diff --git a/tests/boundary/filesystem/inesArtifact.test.ts b/tests/boundary/filesystem/inesArtifact.test.ts index 6d7651d48..bc54a2c59 100644 --- a/tests/boundary/filesystem/inesArtifact.test.ts +++ b/tests/boundary/filesystem/inesArtifact.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { lstat, open, readFile, writeFile } from "node:fs/promises"; +import { lstat, open, readFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; @@ -191,6 +191,48 @@ it("rejects forged region ranges, cancellation and a changed retained source", a } }); +it.each(["before", "during"])( + "reports source removal %s a region read as integrity failure", + async (when) => { + const root = await createTestTempDirectory("rea-ines-removed-source-"); + const path = join(root, "source.nes"); + await writeFile(path, inesFixture()); + const handle = await open(path, "r"); + const reader = new InesArtifactReader(path, { + handle, + initial: await handle.stat(), + }); + try { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + const entry = entries[0]; + if (entry === undefined) throw new Error("No header region"); + if (when === "before") { + await unlink(path); + await expect(reader.open(entry)).rejects.toMatchObject({ + reason: "integrity", + cause: { code: "ENOENT" }, + }); + } else { + const stream = await reader.open(entry); + await unlink(path); + await expect( + (async () => { + for await (const chunk of stream) + expect(Buffer.isBuffer(chunk)).toBe(true); + })(), + ).rejects.toMatchObject({ + reason: "integrity", + cause: { code: "ENOENT" }, + }); + } + } finally { + await reader.close(); + await handle.close(); + } + }, +); + it("releases an extraction-owned source descriptor on repeated close", async () => { const root = await createTestTempDirectory("rea-ines-owned-source-"); const path = join(root, "source.nes");