fix(artifacts): bind iNES extraction admission to inventory

This commit is contained in:
64579525 2026-10-12 17:15:12 +08:00
parent 9b8916152b
commit 2f0e4d79ea
3 changed files with 203 additions and 15 deletions

View File

@ -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<ArtifactReader> => {
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);
};

View File

@ -23,6 +23,7 @@ export const openRootArtifact = async (
path: string,
resourceScope: ArtifactResourceScope,
signal?: AbortSignal,
admission: "initial" | "inventoried" = "initial",
): Promise<OwnedFileHandle> => {
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({

View File

@ -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");