diff --git a/src/application/ArtifactGraphConstruction.test.ts b/src/application/ArtifactGraphConstruction.test.ts new file mode 100644 index 000000000..1454a504c --- /dev/null +++ b/src/application/ArtifactGraphConstruction.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { compareDirectoryChildNames } from "./ArtifactGraphConstruction.js"; + +const tiedNames = ["a.txt", "a\u200b.txt", "a\u200c.txt", "a\u200d.txt"]; + +describe("directory child-name ordering", () => { + it("orders the tied subset lexically with reflexivity and antisymmetry", () => { + for (const left of tiedNames) { + expect(compareDirectoryChildNames(left, left)).toBe(0); + for (const right of tiedNames) { + expect(left.localeCompare(right)).toBe(0); + const compared = compareDirectoryChildNames(left, right); + const lexical = left < right ? -1 : left > right ? 1 : 0; + expect(compared).toBe(lexical); + expect(compared + compareDirectoryChildNames(right, left)).toBe(0); + } + } + }); + + it("is transitive across the tied subset", () => { + for (const first of tiedNames) + for (const second of tiedNames) + for (const third of tiedNames) + if ( + compareDirectoryChildNames(first, second) <= 0 && + compareDirectoryChildNames(second, third) <= 0 + ) + expect( + compareDirectoryChildNames(first, third), + ).toBeLessThanOrEqual(0); + }); + + it.each([ + ["a.txt", "b.txt"], + ["z.txt", "ä.txt"], + ["Z", "a"], + ["a/data.txt", "b/data.txt"], + ])("preserves the current non-tied collation for %s / %s", (left, right) => { + const collated = left.localeCompare(right); + expect(collated).not.toBe(0); + expect(compareDirectoryChildNames(left, right)).toBe(collated); + expect(compareDirectoryChildNames(right, left)).toBe( + right.localeCompare(left), + ); + }); +}); diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts index f3633089f..e37055c6c 100644 --- a/src/application/ArtifactGraphConstruction.ts +++ b/src/application/ArtifactGraphConstruction.ts @@ -61,6 +61,17 @@ export const createOccurrence = ( limitations: [...entry.limitations], }); +/** Preserve collation order and break distinct-name ties by UTF-16 code units. */ +export const compareDirectoryChildNames = ( + left: string, + right: string, +): number => { + const collated = left.localeCompare(right); + if (collated !== 0) return collated; + if (left < right) return -1; + return left > right ? 1 : 0; +}; + export const materializeDirectoryNodes = ( occurrences: MutableOccurrence[], nodes: Map, @@ -86,9 +97,7 @@ export const materializeDirectoryNodes = ( artifact_id, entry_kind, })) - .sort((left, right) => - String(left.name).localeCompare(String(right.name)), - ); + .sort((left, right) => compareDirectoryChildNames(left.name, right.name)); const node = createArtifactNode({ sha256: digestCanonical({ kind: "directory", children }), size: 0, diff --git a/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts index b36cf2d3d..e85eb4ed8 100644 --- a/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts +++ b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts @@ -176,3 +176,102 @@ describe("virtual directory relative-path identity", () => { ); }); }); + +const tiedPermutations = [ + ["a", "a\u200b", "a\u200c"], + ["a", "a\u200c", "a\u200b"], + ["a\u200b", "a", "a\u200c"], + ["a\u200b", "a\u200c", "a"], + ["a\u200c", "a", "a\u200b"], + ["a\u200c", "a\u200b", "a"], +]; + +describe("virtual directory collation ties", () => { + it.each([ + { suffix: ".txt", equalBytes: false }, + { suffix: ".txt", equalBytes: true }, + { suffix: "/data.txt", equalBytes: false }, + { suffix: "/data.txt", equalBytes: true }, + ])( + "keeps tied $suffix names stable with equalBytes=$equalBytes", + async ({ suffix, equalBytes }) => { + const observe = (directory: string, names: readonly string[]) => + observeZip([ + [`${directory}/`, ""], + ...names.map((name): readonly [string, string] => [ + `${directory}/${name}${suffix}`, + equalBytes ? "identical bytes" : `bytes for ${name}`, + ]), + ]); + const names = ["a", "a\u200b", "a\u200c"]; + const reference = await observe("pkg", names); + for (const permutation of tiedPermutations) { + const observed = await observe("pkg", permutation); + expect(occurrenceAt(observed.inventory, "pkg").artifact_id).toBe( + occurrenceAt(reference.inventory, "pkg").artifact_id, + ); + for (const name of names) { + const path = `pkg/${name}${suffix}`; + expect(occurrenceAt(observed.inventory, path).artifact_id).toBe( + occurrenceAt(reference.inventory, path).artifact_id, + ); + } + const comparison = compareArtifacts( + reference.evidence, + observed.evidence, + ); + expect( + comparison.changes.map(({ logical_path }) => logical_path), + ).toEqual( + reference.inventory.manifest.root_sha256 === + observed.inventory.manifest.root_sha256 + ? [] + : ["."], + ); + } + const moved = await observe("moved", names); + expect(occurrenceAt(moved.inventory, "moved").artifact_id).toBe( + occurrenceAt(reference.inventory, "pkg").artifact_id, + ); + }, + ); + + it("retains the legacy non-tied Unicode ordering and exact identity", async () => { + const { inventory } = await observeZip([ + ["pkg/", ""], + ["pkg/z.txt", "Z"], + ["pkg/ä.txt", "A"], + ["empty/", ""], + ]); + expect("z.txt".localeCompare("ä.txt")).not.toBe(0); + expect(occurrenceAt(inventory, "pkg").artifact_id).toBe( + legacyDirectoryId(inventory, ["pkg/z.txt", "pkg/ä.txt"]), + ); + expect(occurrenceAt(inventory, "empty").artifact_id).toBe( + legacyDirectoryId(inventory, []), + ); + }); + + it("keeps distinct collating-equal names significant during a rename", async () => { + const left = await observeZip([ + ["pkg/", ""], + ["pkg/a.txt", "same bytes"], + ]); + const right = await observeZip([ + ["pkg/", ""], + ["pkg/a\u200b.txt", "same bytes"], + ]); + expect("a.txt".localeCompare("a\u200b.txt")).toBe(0); + expect(occurrenceAt(left.inventory, "pkg").artifact_id).not.toBe( + occurrenceAt(right.inventory, "pkg").artifact_id, + ); + expect( + compareArtifacts(left.evidence, right.evidence).changes, + ).toContainEqual( + expect.objectContaining({ + logical_path: "pkg", + classification: "changed", + }), + ); + }); +});