From 2f0e4d79ea540de214c6daa04b6bc4a2b186ce3e Mon Sep 17 00:00:00 2001 From: 64579525 <17373302683@163.com> Date: Mon, 12 Oct 2026 17:15:12 +0800 Subject: [PATCH] fix(artifacts): bind iNES extraction admission to inventory --- .../extraction/ArtifactExtraction.ts | 45 +++++- .../inventory/hashStableRootArtifact.ts | 30 +++- .../boundary/filesystem/inesArtifact.test.ts | 143 +++++++++++++++++- 3 files changed, 203 insertions(+), 15 deletions(-) diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 42f434abe..ecd0ce7ac 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -4,7 +4,10 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js" import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import { AsarArtifactReader } from "../AsarArtifactReader.js"; import { InesArtifactReader } from "../InesArtifactReader.js"; -import { openRootArtifact } from "../inventory/hashStableRootArtifact.js"; +import { + openRootArtifact, + hashStableRootArtifactHandle, +} from "../inventory/hashStableRootArtifact.js"; import { ArtifactPathRegistry, normalizeArtifactPath, @@ -218,7 +221,12 @@ const materializeSelection = async ({ const byPath = new Map( selected.map((item) => [item.occurrence.logical_path, item]), ); - const reader = await createReader(sourcePath, input); + const reader = await createReader( + sourcePath, + input, + inventory.manifest, + signal, + ); const readerOwner: ArtifactResourceOwner = { kind: "reader" as const, reader, @@ -441,19 +449,37 @@ const createReader = async ( ArtifactExtractionInput, "inputPath" | "inputFormat" | "environment" | "resourceScope" >, + manifest: ArtifactGraphManifest, + signal?: AbortSignal, ): Promise => { const format = input.inputFormat; - if (await requireExtractableFormat(path, input)) - return new DirectoryArtifactReader(path); - if (format === "asar") return new AsarArtifactReader(path); - if (format === "ines") { - const owner = await openRootArtifact(path, input.resourceScope); + // The scanned container determines admission; a replaced path cannot select + // a different reader. Bind the reopened descriptor to its observed bytes. + if (manifest.root_format === "ines") { + const owner = await openRootArtifact( + path, + input.resourceScope, + signal, + "inventoried", + ); try { + const initial = await owner.handle.stat(); + const digest = await hashStableRootArtifactHandle( + path, + owner.handle, + initial, + signal, + ); + if (digest.sha256 !== manifest.root_sha256) + throw new ArtifactReaderFailure( + "integrity", + `Inventoried iNES source bytes changed: ${path}`, + ); return new InesArtifactReader( path, { handle: owner.handle, - initial: await owner.handle.stat(), + initial, }, owner, ); @@ -471,6 +497,9 @@ const createReader = async ( throw cause; } } + if (await requireExtractableFormat(path, input)) + return new DirectoryArtifactReader(path); + if (format === "asar") return new AsarArtifactReader(path); if (isZipFormat(format)) return new ZipArtifactReader(path, format); return new MachOSliceArtifactReader(path, input.environment); }; diff --git a/src/artifacts/inventory/hashStableRootArtifact.ts b/src/artifacts/inventory/hashStableRootArtifact.ts index 930798fe8..0829412b2 100644 --- a/src/artifacts/inventory/hashStableRootArtifact.ts +++ b/src/artifacts/inventory/hashStableRootArtifact.ts @@ -23,6 +23,7 @@ export const openRootArtifact = async ( path: string, resourceScope: ArtifactResourceScope, signal?: AbortSignal, + admission: "initial" | "inventoried" = "initial", ): Promise => { try { return new OwnedFileHandle( @@ -31,14 +32,35 @@ export const openRootArtifact = async ( } catch (cause: unknown) { const primary = cause instanceof RegularFileAdmissionFailure ? cause.cause : cause; + const changed = + admission === "inventoried" && + (primary instanceof NonRegularFileReadError || + (primary instanceof Error && + "code" in primary && + ["ENOENT", "ENOTDIR", "ELOOP", "EISDIR", "ENOTFILE"].includes( + String(primary.code), + ))); + // Normalize the primary failure before cleanup so failed closes keep both facts. const failure = - primary instanceof NonRegularFileReadError + signal?.aborted === true ? new ArtifactReaderFailure( - "format", - `Artifact root is not a regular file: ${path}`, + "cancelled", + "Artifact admission cancelled", { cause: primary }, ) - : primary; + : changed + ? new ArtifactReaderFailure( + "integrity", + `Inventoried artifact source changed: ${path}`, + { cause: primary }, + ) + : primary instanceof NonRegularFileReadError + ? new ArtifactReaderFailure( + "format", + `Artifact root is not a regular file: ${path}`, + { cause: primary }, + ) + : primary; if (cause instanceof RegularFileAdmissionFailure) { const resource = `root artifact descriptor for ${path}`; const cleanup = await resourceScope.release({ diff --git a/tests/boundary/filesystem/inesArtifact.test.ts b/tests/boundary/filesystem/inesArtifact.test.ts index dd1fc2945..8ac63b3f1 100644 --- a/tests/boundary/filesystem/inesArtifact.test.ts +++ b/tests/boundary/filesystem/inesArtifact.test.ts @@ -1,15 +1,29 @@ import { createHash } from "node:crypto"; -import { lstat, open, readFile, unlink, writeFile } from "node:fs/promises"; +import { + lstat, + mkdir, + open, + readFile, + unlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { inesFixture } from "../../fixtures/ines.js"; -import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; +import { + inventoryArtifact, + scanArtifactInventory, + materializeArtifactInventory, +} from "../../fixtures/artifactInventory.js"; import { InesArtifactReader } from "../../../src/artifacts/InesArtifactReader.js"; import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; -import { extractArtifact } from "../../../src/artifacts/extraction/ArtifactExtraction.js"; +import { + extractArtifact, + materializeArtifactInventory as materializeInScope, +} from "../../../src/artifacts/extraction/ArtifactExtraction.js"; const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); @@ -371,6 +385,129 @@ it.each([ }, ); +it.each(["removed", "directory", "malformed"])( + "rejects a %s source after inventory before extraction", + async (change) => { + const root = await createTestTempDirectory("rea-ines-extraction-change-"); + const path = join(root, "source.nes"); + const outputRoot = join(root, "output"); + await writeFile(path, inesFixture()); + const snapshot = await scanArtifactInventory(path); + await unlink(path); + if (change === "directory") await mkdir(path); + if (change === "malformed") await writeFile(path, "changed cartridge"); + await expect( + materializeArtifactInventory( + { + inputPath: path, + inputFormat: "ines", + outputRoot, + environment: process.env, + integrityPolicy: "fail", + }, + path, + snapshot, + ), + ).rejects.toMatchObject({ reason: "integrity" }); + await expect(lstat(outputRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.each(["EISDIR", "ENOTFILE", "EACCES", "cancelled"])( + "preserves the %s admission meaning while reopening extraction", + async (code) => { + const root = await createTestTempDirectory("rea-ines-reopen-error-"); + const path = join(root, "source.nes"); + await writeFile(path, inesFixture()); + const snapshot = await scanArtifactInventory(path); + const sample = await open(path, "r"); + const prototype = Object.getPrototypeOf(sample) as Pick< + typeof sample, + "stat" + >; + await sample.close(); + const originalStat = prototype.stat; + const controller = new AbortController(); + const stat = vi + .spyOn(prototype, "stat") + .mockImplementationOnce(async function (this: typeof sample, ...args) { + if (code !== "cancelled") + throw Object.assign(new Error("injected admission failure"), { + code, + }); + const result = await originalStat.call(this, ...args); + controller.abort(); + return result; + }); + try { + await expect( + materializeArtifactInventory( + { + inputPath: path, + inputFormat: "ines", + outputRoot: join(root, "output"), + environment: process.env, + integrityPolicy: "fail", + }, + path, + snapshot, + controller.signal, + ), + ).rejects.toMatchObject( + code === "EACCES" + ? { code } + : { reason: code === "cancelled" ? "cancelled" : "integrity" }, + ); + } finally { + stat.mockRestore(); + } + }, +); + +it("retains a failed close after rejecting changed extraction bytes", async () => { + const root = await createTestTempDirectory("rea-ines-reopen-cleanup-"); + const path = join(root, "source.nes"); + await writeFile(path, inesFixture()); + const snapshot = await scanArtifactInventory(path); + await writeFile(path, "changed source"); + const scope = new ArtifactResourceScope(); + const originalClose = OwnedFileHandle.prototype.close; + const owners: OwnedFileHandle[] = []; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.push(this); + if (owners.length === 1) throw new Error("injected close failure"); + await originalClose.call(this); + }); + try { + await expect( + materializeInScope( + { + inputPath: path, + inputFormat: "ines", + outputRoot: join(root, "output"), + environment: process.env, + integrityPolicy: "fail", + resourceScope: scope, + }, + path, + snapshot, + ), + ).rejects.toMatchObject({ + reason: "integrity", + cleanup: { reason: expect.stringContaining("injected close failure") }, + }); + expect(owners).toHaveLength(1); + await scope.close(); + expect(owners).toHaveLength(2); + expect(owners[1]).toBe(owners[0]); + } finally { + close.mockRestore(); + await scope.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");