From ac9e85564fdc6e407fc604ef97553d155ae55300 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 2 Jul 2026 01:51:10 +0200 Subject: [PATCH] fix(coding-agent): resolved hashline snapshot tag collisions using exact matches - Guard against 16-bit snapshot tag collisions by requiring live text to be byte-identical to the retained snapshot. - Transition base text resolution to query exact matches via `snapshots.byHashExact`. - Prevent applying incorrect preview edits when live file contents drift to a colliding state. --- .../coding-agent/src/edit/hashline/diff.ts | 15 ++++- packages/coding-agent/test/edit-diff.test.ts | 57 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/edit/hashline/diff.ts b/packages/coding-agent/src/edit/hashline/diff.ts index 5c04d4b72..22ac8e9bc 100644 --- a/packages/coding-agent/src/edit/hashline/diff.ts +++ b/packages/coding-agent/src/edit/hashline/diff.ts @@ -180,7 +180,8 @@ function resolvePreviewEdits(args: { }): readonly Edit[] { const { section, absolutePath, normalized, snapshots, expected, liveMatches, edits } = args; if (!hasBlockEdit(edits)) return edits; - const baseText = expected === undefined || liveMatches ? normalized : snapshots.byHash(absolutePath, expected)?.text; + const baseText = + expected === undefined || liveMatches ? normalized : snapshots.byHashExact(absolutePath, expected)?.text; if (baseText === undefined) { throw createMismatchError(section, absolutePath, normalized, snapshots, expected ?? ""); } @@ -199,7 +200,17 @@ function applyPreviewEdits(args: { if (!options.skipHashValidation && expected === undefined) { throw new Error(missingSnapshotTagMessage(section.path)); } - const liveMatches = expected !== undefined && computeFileHash(normalized) === expected; + // A 16-bit tag can collide across two different file states, so hash + // equality alone does not prove the live text IS the snapshot the model's + // anchors were minted against (mirrors Patcher's apply-time guard). When + // the store retains text for the tag, require it to be unambiguous and + // byte-identical to live; otherwise fall through to recovery/reject below + // exactly as if the hash had not matched. + const liveMatches = + expected !== undefined && + computeFileHash(normalized) === expected && + (snapshots.byHash(absolutePath, expected) === null || + snapshots.byHashExact(absolutePath, expected)?.text === normalized); const edits = parsePreviewEdits(section, options.streaming); const resolved = resolvePreviewEdits({ section, absolutePath, normalized, snapshots, expected, liveMatches, edits }); if (options.skipHashValidation || expected === undefined || liveMatches) return applyEdits(normalized, resolved); diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 51de5e49d..0e558e50d 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -298,6 +298,63 @@ describe("computeHashlineDiff", () => { expect(result.error).toContain('internal scheme "local://"'); } }); + + // A 16-bit snapshot tag can collide across two different file states. The + // preview must mirror Patcher's apply-time guard: hash equality alone never + // proves the live text IS the snapshot the anchors were minted against. + test("rejects the no-drift path when the live text is a colliding ambiguous tag", async () => { + // Both texts hash to `1D84` (pinned in hashline's collision tests). + const SNAPSHOT_TEXT = "line one 263\nline two 4471\n"; + const LIVE_TEXT = "line one 410\nline two 6970\n"; + const sourcePath = path.join(tempDir, "source.txt"); + await Bun.write(sourcePath, LIVE_TEXT); + + const snapshotStore = new InMemorySnapshotStore(); + // Anchors were minted against SNAPSHOT_TEXT; the live file is the + // colliding LIVE_TEXT, also retained (e.g. read after an external + // write). The tag is ambiguous — previewing the SWAP against live + // would show the model's payload landing on unrelated content. + const tag = snapshotStore.record(sourcePath, SNAPSHOT_TEXT); + snapshotStore.record(sourcePath, LIVE_TEXT); + + const result = await computeHashlineDiff( + { input: `${formatHashlineHeader(sourcePath, tag)}\nSWAP 2.=2:\n+edited from snapshot` }, + tempDir, + snapshotStore, + ); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toContain("file changed between read and edit"); + } + }); + + test("rejects the no-drift path when the live text collides with the single retained snapshot", async () => { + const SNAPSHOT_TEXT = "line one 263\nline two 4471\n"; + const LIVE_TEXT = "line one 410\nline two 6970\n"; + const sourcePath = path.join(tempDir, "source.txt"); + await Bun.write(sourcePath, LIVE_TEXT); + + const snapshotStore = new InMemorySnapshotStore(); + // Only SNAPSHOT_TEXT is retained; live drifted to a colliding text the + // store never saw. computeFileHash(live) === tag, but the retained + // text differs — recovery (3-way merge from SNAPSHOT_TEXT) must run + // instead of anchoring directly onto the collider. Here the merge + // cannot apply (every line differs), so the preview surfaces the + // drift error rather than a bogus diff. + const tag = snapshotStore.record(sourcePath, SNAPSHOT_TEXT); + + const result = await computeHashlineDiff( + { input: `${formatHashlineHeader(sourcePath, tag)}\nSWAP 2.=2:\n+edited from snapshot` }, + tempDir, + snapshotStore, + ); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toContain("file changed between read and edit"); + } + }); }); describe("computeEditDiff", () => {