fix(artifacts): retain relative paths in directory identities (#580)
This commit is contained in:
parent
8eac290e8d
commit
348323dd23
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue