diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts index 7ea355d9c..864cc02cb 100644 --- a/src/application/ArtifactGraphConstruction.ts +++ b/src/application/ArtifactGraphConstruction.ts @@ -81,7 +81,8 @@ export const materializeDirectoryNodes = ( for (const directory of directories) { const children = (childrenByParent.get(directory.occurrence_id) ?? []) .map(({ logical_path, artifact_id, entry_kind }) => ({ - name: logical_path.split("/").at(-1), + // Intermediate directory entries may be absent from an archive. + name: logical_path.slice(directory.logical_path.length + 1), artifact_id, entry_kind, })) diff --git a/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts new file mode 100644 index 000000000..b36cf2d3d --- /dev/null +++ b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts @@ -0,0 +1,178 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { describe, expect, it } from "vitest"; + +import { digestCanonical } from "../../../src/application/ArtifactGraphConstruction.js"; +import { inventoryArtifact } from "../../../src/application/ArtifactInventory.js"; +import { compareArtifacts } from "../../../src/domain/artifactComparison.js"; +import type { ArtifactInventoryResult } from "../../../src/domain/artifactGraph.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const observeZip = async (entries: readonly (readonly [string, string])[]) => { + const root = await createTestTempDirectory("rea-directory-identity-"); + const path = join(root, "fixture.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const [name, content] of entries) + await writer.add(name, new TextReader(content), { + directory: name.endsWith("/"), + lastModDate: new Date("2020-01-01T00:00:00Z"), + }); + await writeFile(path, await writer.close()); + const inventory = await inventoryArtifact(path); + return { + inventory, + evidence: createEvidence( + { path, sha256: inventory.manifest.root_sha256, format: "zip" }, + { id: "rea-artifact-graph", name: "REA artifact graph", version: "1" }, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse(inventory), + confidence: "observed", + authority: "shipped-artifact", + }, + ), + }; +}; + +const occurrenceAt = (inventory: ArtifactInventoryResult, path: string) => { + const occurrence = inventory.occurrences.find( + ({ logical_path }) => logical_path === path, + ); + if (occurrence === undefined) throw new Error(`Missing ${path}`); + expect(occurrence.hash_status).toBe("verified"); + expect(occurrence.artifact_id).not.toBeNull(); + return occurrence; +}; + +// Preserve the pre-fix name-based hash for ordinary immediate children. +const legacyDirectoryId = ( + inventory: ArtifactInventoryResult, + paths: readonly string[], +) => + `art_${digestCanonical({ + sha256: digestCanonical({ + kind: "directory", + children: paths + .map((path) => { + const child = occurrenceAt(inventory, path); + return { + name: path.split("/").at(-1), + artifact_id: child.artifact_id, + entry_kind: child.entry_kind, + }; + }) + .sort((left, right) => + String(left.name).localeCompare(String(right.name)), + ), + }), + })}`; + +describe("virtual directory relative-path identity", () => { + it("detects a renamed implicit intermediate directory", async () => { + const left = await observeZip([ + ["pkg/", ""], + ["pkg/a/data.txt", "same bytes"], + ]); + const right = await observeZip([ + ["pkg/", ""], + ["pkg/b/data.txt", "same bytes"], + ]); + expect(occurrenceAt(left.inventory, "pkg").artifact_id).not.toBe( + occurrenceAt(right.inventory, "pkg").artifact_id, + ); + expect(occurrenceAt(left.inventory, "pkg/a/data.txt").artifact_id).toBe( + occurrenceAt(right.inventory, "pkg/b/data.txt").artifact_id, + ); + expect(left.inventory.occurrences.map((item) => item.logical_path)).toEqual( + [".", "pkg", "pkg/a/data.txt"], + ); + const comparison = compareArtifacts(left.evidence, right.evidence); + expect(comparison.changes).toContainEqual( + expect.objectContaining({ + logical_path: "pkg", + classification: "changed", + }), + ); + expect(comparison.summary).toMatchObject({ changed: 2, unchanged: 0 }); + }); + + it("ignores order of same-basename children beneath implicit directories", async () => { + const left = await observeZip([ + ["pkg/", ""], + ["pkg/a/data.txt", "A"], + ["pkg/b/data.txt", "B"], + ]); + const right = await observeZip([ + ["pkg/", ""], + ["pkg/b/data.txt", "B"], + ["pkg/a/data.txt", "A"], + ]); + expect(occurrenceAt(left.inventory, "pkg").artifact_id).toBe( + occurrenceAt(right.inventory, "pkg").artifact_id, + ); + const comparison = compareArtifacts(left.evidence, right.evidence); + expect(comparison.changes.map((change) => change.logical_path)).toEqual([ + ".", + ]); + expect(comparison.summary).toMatchObject({ changed: 1, unchanged: 3 }); + }); + + it.each(["data.txt", "implicit/data.txt"])( + "keeps directory relocation independent for %s", + async (suffix) => { + const left = await observeZip([ + ["pkg/", ""], + [`pkg/${suffix}`, "same bytes"], + ]); + const right = await observeZip([ + ["moved/", ""], + [`moved/${suffix}`, "same bytes"], + ]); + expect(occurrenceAt(left.inventory, "pkg").artifact_id).toBe( + occurrenceAt(right.inventory, "moved").artifact_id, + ); + }, + ); + + it("preserves exact legacy identities for immediate children and empty directories", async () => { + const { inventory } = await observeZip([ + ["pkg/", ""], + ["pkg/z.txt", "Z"], + ["pkg/a.txt", "A"], + ["empty/", ""], + ]); + expect(occurrenceAt(inventory, "pkg").artifact_id).toBe( + legacyDirectoryId(inventory, ["pkg/z.txt", "pkg/a.txt"]), + ); + expect(occurrenceAt(inventory, "empty").artifact_id).toBe( + legacyDirectoryId(inventory, []), + ); + }); + + it("preserves nested explicit-directory identities and rename detection", async () => { + const left = await observeZip([ + ["pkg/", ""], + ["pkg/a/", ""], + ["pkg/a/data.txt", "same bytes"], + ]); + const right = await observeZip([ + ["pkg/", ""], + ["pkg/b/", ""], + ["pkg/b/data.txt", "same bytes"], + ]); + expect(occurrenceAt(left.inventory, "pkg/a").artifact_id).toBe( + legacyDirectoryId(left.inventory, ["pkg/a/data.txt"]), + ); + expect(occurrenceAt(left.inventory, "pkg").artifact_id).toBe( + legacyDirectoryId(left.inventory, ["pkg/a"]), + ); + expect(occurrenceAt(left.inventory, "pkg").artifact_id).not.toBe( + occurrenceAt(right.inventory, "pkg").artifact_id, + ); + }); +});