fix(hashline): reject ambiguous colliding snapshot tags

This commit is contained in:
can1357
2026-07-01 20:58:24 +02:00
parent 28843ec015
commit efee86afe5
2 changed files with 59 additions and 8 deletions
+18 -8
View File
@@ -524,14 +524,19 @@ export class Patcher {
// A 16-bit tag can collide across two different file states, so equality
// on `computeFileHash(normalized) === expected` alone is not enough to
// prove the live text IS the snapshot the tag names. Also require that,
// when a snapshot for `(path, expected)` is retained, its stored text
// matches the live text. Otherwise the tag collides and we route through
// recovery/mismatch instead of applying line-anchored edits to unrelated
// live content (issue #4075).
// when a snapshot for `(path, expected)` is retained, exactly one stored
// version carries the tag and its full text matches the live text. If
// multiple versions share the tag, the header is ambiguous: there is no
// safe way to know which stored text the model's line anchors came from.
const storedSnapshotsForTag =
expected === undefined
? []
: this.snapshots.findByHash(expected).filter(snapshot => snapshot.path === canonicalPath);
const ambiguousStoredTag = storedSnapshotsForTag.length > 1;
const storedSnapshotForTag = expected === undefined ? null : this.snapshots.byHash(canonicalPath, expected);
const hashMatches = expected !== undefined && computeFileHash(normalized) === expected;
const matchedSnapshot = hashMatches ? this.snapshots.byContent(canonicalPath, normalized) : null;
const anyStoredForTag = hashMatches ? this.snapshots.byHash(canonicalPath, expected as string) : null;
const liveMatches = hashMatches && (anyStoredForTag === null || matchedSnapshot !== null);
const liveMatches = hashMatches && !ambiguousStoredTag && (storedSnapshotForTag === null || matchedSnapshot !== null);
// Resolve `replace_block N:` edits to concrete ranges before recovery
// runs. Block anchors are expressed against the snapshot the section tag
@@ -546,8 +551,10 @@ export class Patcher {
const resolveWarnings: string[] = [];
let resolved: readonly Edit[] = edits;
if (hasBlockEdit(edits)) {
const baseText =
expected === undefined || liveMatches ? normalized : this.snapshots.byHash(canonicalPath, expected)?.text;
if (ambiguousStoredTag) {
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
}
const baseText = expected === undefined || liveMatches ? normalized : storedSnapshotForTag?.text;
if (baseText === undefined) {
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", false);
}
@@ -582,6 +589,9 @@ export class Patcher {
const result = applyEdits(normalized, resolved);
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
}
if (ambiguousStoredTag) {
throw this.#mismatchError(section, canonicalPath, normalized, expected ?? "", true);
}
// File drifted: try to replay the edit against the version the tag
// names and 3-way-merge it onto the live content.
const recovered = this.recovery.tryRecover({
+41
View File
@@ -158,6 +158,47 @@ describe("Patcher snapshot tag integrity", () => {
// model's edit anchored against the collider.
expect(fs.get(PATH)).toBe(LIVE_TEXT);
});
it("rejects an ambiguous colliding tag even when live text matches one retained collider", async () => {
const SNAPSHOT_TEXT = "line one 263\nline two 4471\n";
const LIVE_TEXT = "line one 410\nline two 6970\n";
expect(computeFileHash(SNAPSHOT_TEXT)).toBe(computeFileHash(LIVE_TEXT));
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(PATH, SNAPSHOT_TEXT, [1, 2]);
snapshots.record(PATH, LIVE_TEXT, [1, 2]);
const fs = new InMemoryFilesystem([[PATH, LIVE_TEXT]]);
const patcher = new Patcher({ fs, snapshots });
try {
await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+edited from snapshot`));
throw new Error("expected MismatchError");
} catch (error) {
expect(error).toBeInstanceOf(MismatchError);
}
expect(fs.get(PATH)).toBe(LIVE_TEXT);
});
it("rejects an ambiguous colliding tag before stale recovery can target the wrong snapshot", async () => {
const SNAPSHOT_TEXT = "line one 263\nline two 4471\n";
const COLLIDING_TEXT = "line one 410\nline two 6970\n";
const LIVE_TEXT = "line one 410\nline two 6970\nlive-added\n";
expect(computeFileHash(SNAPSHOT_TEXT)).toBe(computeFileHash(COLLIDING_TEXT));
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record(PATH, SNAPSHOT_TEXT, [1, 2]);
snapshots.record(PATH, COLLIDING_TEXT, [1, 2]);
const fs = new InMemoryFilesystem([[PATH, LIVE_TEXT]]);
const patcher = new Patcher({ fs, snapshots });
try {
await patcher.apply(Patch.parse(`[${PATH}#${tag}]\nSWAP 2.=2:\n+edited from snapshot`));
throw new Error("expected MismatchError");
} catch (error) {
expect(error).toBeInstanceOf(MismatchError);
}
expect(fs.get(PATH)).toBe(LIVE_TEXT);
});
});
describe("Patcher mandatory snapshot tag policy", () => {