fix(artifacts): bind iNES extraction admission to inventory
This commit is contained in:
parent
9b8916152b
commit
2f0e4d79ea
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue