Preserve duplicate archive occurrences in inventory and reject an ambiguous materialization before acquiring readers or output resources. Keep path collisions distinct from integrity failures. Refs #4271, #4273 Co-authored-by: Liu Jie <maxazure@gmail.com>
121 lines
4.5 KiB
TypeScript
121 lines
4.5 KiB
TypeScript
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
|
import { expect } from "vitest";
|
|
|
|
import {
|
|
artifactExtractionResultSchema,
|
|
artifactInventoryResultSchema,
|
|
} from "../../src/domain/artifactGraph.js";
|
|
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
|
|
import { parseEvidence } from "../../src/domain/evidence.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../support/cli/cliFixture.js";
|
|
|
|
cliTest(
|
|
"refuses NFC-colliding ZIP members without an integrity retry",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-zip-nfc-collision-");
|
|
const archive = join(root, "collision.zip");
|
|
const temporary = join(root, "temporary");
|
|
await mkdir(temporary);
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("café.txt", new TextReader("NFC bytes"));
|
|
await writer.add("cafe\u0301.txt", new TextReader("NFD bytes"));
|
|
await writeFile(archive, await writer.close());
|
|
|
|
const inspected = await cli.run({
|
|
arguments: ["inspect-artifact", archive, "--json"],
|
|
});
|
|
expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0);
|
|
const inspection = artifactInspectionResultSchema.parse(
|
|
parseEvidence(inspected.json).normalized_result,
|
|
);
|
|
const inventoryEvidence = inspection.substeps[0]?.evidence;
|
|
if (inventoryEvidence === undefined)
|
|
throw new Error("CLI inspection did not retain ZIP inventory Evidence");
|
|
const inventory = artifactInventoryResultSchema.parse(
|
|
inventoryEvidence.normalized_result,
|
|
);
|
|
const collisions = inventory.occurrences.filter(
|
|
({ logical_path }) => logical_path === "café.txt",
|
|
);
|
|
expect(collisions).toHaveLength(2);
|
|
expect(
|
|
new Set(collisions.map(({ occurrence_id }) => occurrence_id)).size,
|
|
).toBe(2);
|
|
expect(new Set(collisions.map(({ artifact_id }) => artifact_id)).size).toBe(
|
|
2,
|
|
);
|
|
const nodesById = new Map(
|
|
inventory.nodes.map(({ artifact_id, sha256 }) => [artifact_id, sha256]),
|
|
);
|
|
expect(
|
|
new Set(
|
|
collisions.map(({ artifact_id }) =>
|
|
artifact_id === null ? null : nodesById.get(artifact_id),
|
|
),
|
|
).size,
|
|
).toBe(2);
|
|
|
|
for (const integrityPolicy of ["fail", "record-and-continue"] as const) {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"extract-artifact",
|
|
archive,
|
|
"--integrity-policy",
|
|
integrityPolicy,
|
|
"--json",
|
|
],
|
|
environment: { TMPDIR: temporary, TEMP: temporary, TMP: temporary },
|
|
});
|
|
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
|
|
expect(result.json).toMatchObject({
|
|
code: "artifact_operation_failed",
|
|
message: expect.stringContaining(
|
|
'Artifact extraction path collision: "café.txt"',
|
|
),
|
|
details: { operation: "extract_artifact", reason: "path" },
|
|
});
|
|
expect(result.stdout).not.toContain(
|
|
"integrity_policy=record-and-continue",
|
|
);
|
|
expect(await readdir(temporary)).toEqual([]);
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"extracts one decomposed ZIP name and distinct names with their exact bytes",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-zip-distinct-paths-");
|
|
const archive = join(root, "distinct.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("cafe\u0301.txt", new TextReader("decomposed bytes"));
|
|
await writer.add("other.txt", new TextReader("distinct bytes"));
|
|
await writeFile(archive, await writer.close());
|
|
|
|
const result = await cli.run({
|
|
arguments: ["extract-artifact", archive, "--json"],
|
|
});
|
|
expect(result.exitCode, result.stdout + result.stderr).toBe(0);
|
|
const extraction = artifactExtractionResultSchema.parse(
|
|
parseEvidence(result.json).normalized_result,
|
|
);
|
|
try {
|
|
expect(
|
|
extraction.artifacts.map(({ relative_path }) => relative_path),
|
|
).toEqual(["café.txt", "other.txt"]);
|
|
expect(
|
|
await readFile(join(extraction.output_root, "café.txt"), "utf8"),
|
|
).toBe("decomposed bytes");
|
|
expect(
|
|
await readFile(join(extraction.output_root, "other.txt"), "utf8"),
|
|
).toBe("distinct bytes");
|
|
} finally {
|
|
await rm(extraction.output_root, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|