diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index 26f8af7c16..2a90e288aa 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -17,13 +17,14 @@ export const isZipPackageFormat = ( ): format is ZipPackageFormat => ZIP_PACKAGE_FORMATS.some((candidate) => candidate === format); -/** Recognize local-header, empty-archive, or split-archive ZIP signatures, not archive validity. */ +/** Recognize local-header, classic/Zip64 empty-archive, or split ZIP signatures, not validity. */ export const hasZipSignature = (bytes: Uint8Array): boolean => bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && ((bytes[2] === 0x03 && bytes[3] === 0x04) || (bytes[2] === 0x05 && bytes[3] === 0x06) || + (bytes[2] === 0x06 && bytes[3] === 0x06) || (bytes[2] === 0x07 && bytes[3] === 0x08)); const END_OF_CENTRAL_DIRECTORY = 0x06054b50; diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 0f098fb05c..b771df65f7 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -1,8 +1,9 @@ -import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +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, it } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js"; import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; @@ -23,27 +24,69 @@ import { import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; -it("completes an empty ZIP extraction with an empty identity", async () => { - const root = await createTestTempDirectory("rea-empty-artifact-extraction-"); - const archive = join(root, "empty.zip"); - await writeOrderedZip(archive, []); - const archiveTarget = await parseBinaryTarget(archive); - if (!archiveTarget.ok) throw archiveTarget.error; - - const output = join(root, "output"); - const result = await withArtifactClient(archiveTarget.value, (client) => - client.execute("extract_artifact", { output_root: output }), +for (const zip64 of [false, true]) { + cliTest( + `inspects and extracts an empty ${zip64 ? "Zip64" : "classic ZIP"} without changing its identity`, + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-empty-artifact-extraction-", + ); + const archive = join(root, "empty.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter(), { + zip64, + supportZip64SplitFile: false, + }); + const bytes = Buffer.from(await writer.close()); + await writeFile(archive, bytes); + const digest = createHash("sha256").update(bytes).digest("hex"); + const inventory = await inventoryArtifact(archive); + expect(inventory.manifest).toMatchObject({ + root_sha256: digest, + root_format: "zip", + }); + expect(inventory.coverage).toEqual({ + scope: "container-entries", + status: "complete", + }); + 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 retained = artifactInventoryResultSchema.parse( + inspection.substeps[0]?.evidence?.normalized_result, + ); + expect(retained.manifest).toMatchObject({ + root_sha256: digest, + root_format: "zip", + }); + const extracted = await cli.run({ + arguments: ["extract-artifact", archive, "--json"], + }); + expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0); + const extraction = artifactExtractionResultSchema.parse( + parseEvidence(extracted.json).normalized_result, + ); + onTestFinished(() => + rm(extraction.output_root, { recursive: true, force: true }), + ); + expect(extraction.manifest).toMatchObject({ + root_sha256: digest, + root_format: "zip", + }); + expect(extraction.extraction_manifest).toMatchObject({ + source_manifest_id: extraction.manifest.manifest_id, + selected_occurrence_ids: [], + }); + expect(extraction.artifacts).toEqual([]); + expect(extraction.containment_verified).toBe(true); + expect(await readdir(extraction.output_root)).toEqual([]); + expect(await readFile(archive)).toEqual(bytes); + }, ); - if (!result.ok) throw result.error; - const extraction = artifactExtractionResultSchema.parse(result.value.result); - expect(extraction.extraction_manifest).toMatchObject({ - source_manifest_id: extraction.manifest.manifest_id, - selected_occurrence_ids: [], - }); - expect(extraction.artifacts).toEqual([]); - expect(extraction.containment_verified).toBe(true); - expect(await readdir(output)).toEqual([]); -}); +} const legacyControlNames = [ ["unflagged UTF-8-looking legacy bytes", Buffer.from("café"), false, "caf├⌐"], @@ -573,17 +616,22 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => { ); }); -it("rejects a truncated ZIP through artifact inventory", async () => { - const root = await createTestTempDirectory("rea-zip-malformed-"); - const path = join(root, "truncated.zip"); - await writeFile(path, Buffer.from([0x50, 0x4b, 0x03, 0x04])); +it.each([0x04034b50, 0x06064b50])( + "rejects a truncated ZIP signature %i through artifact inventory", + async (signature) => { + const root = await createTestTempDirectory("rea-zip-malformed-"); + const path = join(root, "truncated.zip"); + const bytes = Buffer.alloc(4); + bytes.writeUInt32LE(signature); + await writeFile(path, bytes); - await expectInventoryFailure( - path, - "format", - "ZIP archive is malformed: File format is not recognized", - ); -}); + await expectInventoryFailure( + path, + "format", + "ZIP archive is malformed: File format is not recognized", + ); + }, +); it("reports an unsupported ZIP compression method as unavailable", async () => { const root = await createTestTempDirectory("rea-zip-method-");