fix(hashline): reject ambiguous colliding snapshot tags
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user