diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 7535e25f..dacd9f1b 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -49,6 +49,8 @@ import { decryptPayload, parseEncryptedFile, mergeIndexPayloads, + newMergeReport, + TOMBSTONE_RETENTION_CAP, parseVaultIndex, sha256Hex, type ModifyChanges, @@ -1263,6 +1265,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ) as Record; let deltaCount = 0; + // Collects what the merges below resolve silently, so the summary can + // report it. Declared here to cover delta replay as well as the final + // local merge. + const report = newMergeReport(); if (fetched.manifest.delta_since) { // Bind deltas to this manifest so a relay cannot replay one from // another snapshot lineage into the restore. @@ -1281,8 +1287,11 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { // preserve-current here too: the default would refuse any // Python profile that carries atlas state as soon as one delta // exists. + // Same collector as the final merge: a delta can just as easily + // carry an entry that displaces one the snapshot created. remote = mergeIndexPayloads(remote, incoming, sha256Hex(delta).slice(0, 16), { index0: "preserve-current", + report, }); deltaCount++; } @@ -1315,6 +1324,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { // block every restore into a Python-created profile. final = mergeIndexPayloads(local, remote, "nostr-restore", { index0: "preserve-current", + report, }); mode = "merged"; } @@ -1340,6 +1350,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { entry_count: Object.keys(index.entries).length, deltas_applied: deltaCount, local_backup: backupPath, + // A merge resolves same-id conflicts deterministically and + // silently. Deterministic is the design; silent is the problem — + // an entry the user created is simply gone afterwards. + ...(report.conflicts.length > 0 && { merge_conflicts: report.conflicts }), + ...(report.tombstonesEvicted > 0 && { + tombstones_evicted: report.tombstonesEvicted, + }), ...(collisions.length > 0 && { derivation_collisions: collisions.map((c) => ({ index: c.index, @@ -1349,6 +1366,22 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { }), }), ); + for (const c of report.conflicts) { + io.err( + `warning: both sides created a different entry at id ${c.id}; kept ` + + `${c.kept.kind} '${c.kept.label}', discarded ${c.discarded.kind} ` + + `'${c.discarded.label}'. The discarded entry is not recoverable ` + + `from this vault — the pre-restore copy is at ${backupPath ?? "(none)"}.`, + ); + } + if (report.tombstonesEvicted > 0) { + io.err( + `warning: ${report.tombstonesEvicted} tombstone(s) dropped at the ` + + `${TOMBSTONE_RETENTION_CAP}-deletion retention cap. Deletions older ` + + `than that are forgotten, so merging an old replica or snapshot can ` + + `bring those entries back.`, + ); + } for (const c of collisions) io.err(`warning: ${c.message}`); } finally { await pool.close(); diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 7c624910..8634fb00 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -138,7 +138,10 @@ export { safeInt, TOMBSTONE_RETENTION_CAP, MERGE_STRATEGY, + newMergeReport, type MergeOptions, + type MergeReport, + type MergeConflict, } from "./sync/merge.js"; export { KIND_MANIFEST, diff --git a/js/packages/core/src/sync/merge.ts b/js/packages/core/src/sync/merge.ts index 3271240e..97ec3882 100644 --- a/js/packages/core/src/sync/merge.ts +++ b/js/packages/core/src/sync/merge.ts @@ -272,7 +272,65 @@ function ensureIndex0Payload(data: unknown, options: MergeOptions = {}): Dict { return out; } +/** + * One id at which both sides independently created a DIFFERENT entry. + * + * The allocation watermark is per-replica, so two devices working offline can + * both allocate id N. The merge then resolves that id like any other -- by + * timestamp, then by hash -- and the loser is replaced with no trace. The + * user is not told; an entry they created simply is not there afterwards. + * + * Worse than a plain overwrite, because an id is a permanent BIP-85 + * derivation coordinate: the surviving entry derives from the same coordinate + * the discarded one did, so a password and an SSH key can end up sharing + * key material (see findDerivationCollisions). + * + * Resolving deterministically is the design and is frozen for parity with + * Python -- both implementations must reach the same vault from the same + * inputs. Reporting it is not part of that contract, and silence is the part + * that makes it dangerous. + */ +export interface MergeConflict { + /** The vault id both sides allocated. */ + id: string; + /** Kind and label of the entry that survived. */ + kept: { kind: string; label: string }; + /** Kind and label of the entry that was replaced. */ + discarded: { kind: string; label: string }; + /** True when the two sides disagreed about the entry's kind, not just its content. */ + differentKind: boolean; +} + +/** What a merge silently resolved, for callers that want to surface it. */ +export interface MergeReport { + /** Ids where both sides created different entries; the loser is gone. */ + conflicts: MergeConflict[]; + /** + * Tombstones dropped because the retention cap was reached. + * + * Past the cap the oldest deletions are forgotten, and merging a stale + * replica or an old relay snapshot then RESURRECTS entries the user + * deleted. That is a documented protocol limit shared with Python, but a + * user has no way to know the cap has actually started trimming their + * history unless something says so. + */ + tombstonesEvicted: number; +} + +/** A fresh, empty report — callers pass this in and read it afterwards. */ +export function newMergeReport(): MergeReport { + return { conflicts: [], tombstonesEvicted: 0 }; +} + export interface MergeOptions { + /** + * Collector for what the merge resolved silently. Optional and + * write-only: supplying it cannot change the merged result, so the + * deterministic outcome stays byte-identical to Python's with or without + * it. + */ + report?: MergeReport; + /** * What to do with `_system.index0`, Python's derived atlas state. * @@ -285,6 +343,42 @@ export interface MergeOptions { index0?: "reject" | "preserve-current"; } +function describeEntry(entry: Dict): { kind: string; label: string } { + return { + kind: String(entry["kind"] ?? entry["type"] ?? ""), + label: String(entry["label"] ?? ""), + }; +} + +/** + * Note that one side's entry replaced a genuinely different one at this id. + * + * Only a real divergence is worth reporting. Two replicas holding the same + * entry with different timestamps is the normal case -- an edit -- and + * flagging it would bury the case that matters in noise. The signal is that + * the two sides describe DIFFERENT things: a different kind, or a different + * label. + */ +function recordConflict( + report: MergeReport | undefined, + id: string, + kept: Dict, + discarded: Dict, +): void { + if (!report) return; + const keptDesc = describeEntry(kept); + const discardedDesc = describeEntry(discarded); + if (keptDesc.kind === discardedDesc.kind && keptDesc.label === discardedDesc.label) { + return; + } + report.conflicts.push({ + id, + kept: keptDesc, + discarded: discardedDesc, + differentKind: keptDesc.kind !== discardedDesc.kind, + }); +} + /** Deterministically merge two decrypted index payloads (Python parity). */ export function mergeIndexPayloads( currentRaw: unknown, @@ -360,7 +454,10 @@ export function mergeIndexPayloads( continue; } if (preferEntry(curEntry, incEntry)) { + recordConflict(options.report, key, incEntry, curEntry); curEntries[key] = incEntry; + } else { + recordConflict(options.report, key, curEntry, incEntry); } } @@ -410,6 +507,11 @@ export function mergeIndexPayloads( return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; }); if (items.length > TOMBSTONE_RETENTION_CAP) { + // Forgetting a deletion is how a deleted entry comes back: a stale + // replica still carrying it will reinstate it at the next merge. + if (options.report) { + options.report.tombstonesEvicted += items.length - TOMBSTONE_RETENTION_CAP; + } items = items.slice(-TOMBSTONE_RETENTION_CAP); } tombstones = Object.fromEntries(items); diff --git a/js/packages/core/test/mergeReport.test.ts b/js/packages/core/test/mergeReport.test.ts new file mode 100644 index 00000000..352a9daf --- /dev/null +++ b/js/packages/core/test/mergeReport.test.ts @@ -0,0 +1,186 @@ +/** + * What a merge resolves silently. + * + * The merge is a deterministic CRDT and its outcome is frozen for parity with + * Python — both implementations must reach the same vault from the same + * inputs, so the resolution rules are not up for negotiation. What was + * missing is that the user is never told a resolution happened: an entry they + * created on one device is simply absent afterwards, with no error and + * nothing in the summary. + * + * The load-bearing test in this file is the last one: passing a report must + * not change the merged result. If collecting the report could alter the + * outcome, it would be a parity divergence rather than an observation. + */ + +import { describe, expect, it } from "vitest"; +import { + mergeIndexPayloads, + newMergeReport, + TOMBSTONE_RETENTION_CAP, +} from "@seedpass/core"; + +function entry(kind: string, label: string, ts: number, extra: Record = {}) { + return { + kind, + type: kind, + label, + notes: "", + tags: [], + archived: false, + modified_ts: ts, + ...extra, + }; +} + +function index(entries: Record, extra: Record = {}) { + return { schema_version: 4, entries, ...extra }; +} + +describe("same-id conflicts are reported", () => { + it("names the kept and discarded entries when two replicas allocate one id", () => { + // The realistic case: two devices offline, both allocate id 5. + const local = index({ "5": entry("password", "bank.example", 1700000100, { length: 16 }) }); + const remote = index({ "5": entry("ssh", "deploy-key", 1700000200, { index: 5 }) }); + + const report = newMergeReport(); + const merged = mergeIndexPayloads(local, remote, "test", { report }) as { + entries: Record; + }; + + expect(report.conflicts).toHaveLength(1); + const conflict = report.conflicts[0]!; + expect(conflict.id).toBe("5"); + expect(conflict.differentKind).toBe(true); + // Whichever way the deterministic rules resolve it, the report must + // describe the actual outcome rather than assume one. + expect(conflict.kept.label).toBe(merged.entries["5"]!.label); + expect(conflict.discarded.label).not.toBe(merged.entries["5"]!.label); + expect([conflict.kept.label, conflict.discarded.label].sort()).toEqual([ + "bank.example", + "deploy-key", + ]); + }); + + it("reports a conflict whichever side happens to win", () => { + // Same pair, opposite timestamps, so the other side survives. Both + // directions must be reported — a report that only fires when the + // incoming side wins would miss half of all real losses. + const older = index({ "5": entry("password", "bank.example", 1700000100, { length: 16 }) }); + const newer = index({ "5": entry("ssh", "deploy-key", 1700000200, { index: 5 }) }); + + const forward = newMergeReport(); + mergeIndexPayloads(older, newer, "test", { report: forward }); + const backward = newMergeReport(); + mergeIndexPayloads(newer, older, "test", { report: backward }); + + expect(forward.conflicts).toHaveLength(1); + expect(backward.conflicts).toHaveLength(1); + }); + + it("stays quiet when both sides hold the same entry at different times", () => { + // An ordinary edit — the overwhelmingly common case. Reporting it would + // bury the case that matters in noise and train the user to ignore it. + const local = index({ "5": entry("password", "bank.example", 1700000100, { length: 16 }) }); + const remote = index({ + "5": entry("password", "bank.example", 1700000200, { length: 24 }), + }); + const report = newMergeReport(); + mergeIndexPayloads(local, remote, "test", { report }); + expect(report.conflicts).toEqual([]); + }); + + it("reports a same-kind conflict when the labels genuinely differ", () => { + const local = index({ "5": entry("password", "bank.example", 1700000100, { length: 16 }) }); + const remote = index({ "5": entry("password", "forum.example", 1700000200, { length: 16 }) }); + const report = newMergeReport(); + mergeIndexPayloads(local, remote, "test", { report }); + expect(report.conflicts).toHaveLength(1); + expect(report.conflicts[0]!.differentKind).toBe(false); + }); + + it("stays quiet on a merge with no id overlap", () => { + const local = index({ "1": entry("password", "a", 1700000100, { length: 16 }) }); + const remote = index({ "2": entry("password", "b", 1700000200, { length: 16 }) }); + const report = newMergeReport(); + const merged = mergeIndexPayloads(local, remote, "test", { report }) as { + entries: Record; + }; + expect(report.conflicts).toEqual([]); + expect(Object.keys(merged.entries).sort()).toEqual(["1", "2"]); + }); +}); + +describe("tombstone eviction is reported", () => { + it("counts deletions forgotten at the retention cap", () => { + // Past the cap the oldest deletions are dropped, and merging a stale + // replica then resurrects those entries. The user cannot otherwise know + // their deletion history has started to roll off. + const tombstones: Record = {}; + const overflow = 5; + for (let i = 0; i < TOMBSTONE_RETENTION_CAP + overflow; i++) { + tombstones[String(i)] = { + deleted_ts: 1700000000 + i, + entry_hash: "", + event_hash: "", + source: "test", + }; + } + const local = index({}, { _sync_meta: { tombstones } }); + const report = newMergeReport(); + mergeIndexPayloads(local, index({}), "test", { report }); + expect(report.tombstonesEvicted).toBe(overflow); + }); + + it("reports nothing when the cap has not been reached", () => { + const local = index( + {}, + { + _sync_meta: { + tombstones: { + "1": { deleted_ts: 1700000000, entry_hash: "", event_hash: "", source: "t" }, + }, + }, + }, + ); + const report = newMergeReport(); + mergeIndexPayloads(local, index({}), "test", { report }); + expect(report.tombstonesEvicted).toBe(0); + }); +}); + +describe("observing the merge does not change it", () => { + it("produces a byte-identical result with and without a report", () => { + // This is the parity guarantee. The merged vault must not depend on + // whether anyone was watching — otherwise the two implementations could + // reach different vaults from the same inputs, which is the one thing + // the deterministic merge exists to prevent. + const local = index( + { + "1": entry("password", "kept", 1700000100, { length: 16 }), + "5": entry("password", "loser", 1700000100, { length: 16 }), + }, + { + _sync_meta: { + next_index: 6, + tombstones: { + "9": { deleted_ts: 1700000050, entry_hash: "", event_hash: "", source: "t" }, + }, + }, + }, + ); + const remote = index( + { + "5": entry("ssh", "winner", 1700000200, { index: 5 }), + "7": entry("totp", "extra", 1700000300, { index: 7, period: 30, digits: 6 }), + }, + { _sync_meta: { next_index: 8, tombstones: {} } }, + ); + + const withReport = mergeIndexPayloads(local, remote, "test", { + report: newMergeReport(), + }); + const withoutReport = mergeIndexPayloads(local, remote, "test"); + expect(JSON.stringify(withReport)).toBe(JSON.stringify(withoutReport)); + }); +}); diff --git a/src/seedpass/core/sync_conflict.py b/src/seedpass/core/sync_conflict.py index 34e0f56b..46d4088c 100644 --- a/src/seedpass/core/sync_conflict.py +++ b/src/seedpass/core/sync_conflict.py @@ -1,7 +1,9 @@ from __future__ import annotations +import copy import hashlib import json +from dataclasses import dataclass, field from typing import Any from .index0 import ensure_index0_payload, merge_system_index0 @@ -208,12 +210,112 @@ def _merge_equal_ts_entries( return merged +@dataclass +class MergeConflict: + """One id at which both sides independently created a DIFFERENT entry. + + The allocation watermark is per-replica, so two devices working offline + can both allocate id N. The merge then resolves that id like any other -- + by timestamp, then by hash -- and the loser is replaced with no trace. The + user is not told; an entry they created simply is not there afterwards. + + Worse than a plain overwrite, because an id is a permanent BIP-85 + derivation coordinate: the surviving entry derives from the same + coordinate the discarded one did, so a password and an SSH key can end up + sharing key material (see ``derivation_collisions``). + + Resolving deterministically is the design and is frozen for parity with + the TypeScript implementation -- both must reach the same vault from the + same inputs. Reporting it is not part of that contract, and the silence is + the part that makes it dangerous. + """ + + id: str + kept: dict[str, str] + discarded: dict[str, str] + different_kind: bool + + +@dataclass +class MergeReport: + """What a merge resolved silently, for callers that want to surface it.""" + + conflicts: list[MergeConflict] = field(default_factory=list) + #: Tombstones dropped because the retention cap was reached. + #: + #: Past the cap the oldest deletions are forgotten, and merging a stale + #: replica or an old relay snapshot then RESURRECTS entries the user + #: deleted. A documented protocol limit -- but a user has no way to know + #: the cap has actually started trimming their history unless told. + tombstones_evicted: int = 0 + + +def _describe_entry(entry: dict[str, Any]) -> dict[str, str]: + return { + "kind": str(entry.get("kind") or entry.get("type") or ""), + "label": str(entry.get("label") or ""), + } + + +def _record_conflict( + report: "MergeReport | None", + idx: str, + kept: dict[str, Any], + discarded: dict[str, Any], +) -> None: + """Note that one side's entry replaced a genuinely different one. + + Only a real divergence is worth reporting. Two replicas holding the same + entry with different timestamps is the normal case -- an edit -- and + flagging it would bury the case that matters in noise. The signal is that + the two sides describe DIFFERENT things: a different kind, or a different + label. + """ + if report is None: + return + kept_desc = _describe_entry(kept) + discarded_desc = _describe_entry(discarded) + if ( + kept_desc["kind"] == discarded_desc["kind"] + and kept_desc["label"] == discarded_desc["label"] + ): + return + report.conflicts.append( + MergeConflict( + id=str(idx), + kept=kept_desc, + discarded=discarded_desc, + different_kind=kept_desc["kind"] != discarded_desc["kind"], + ) + ) + + def merge_index_payloads( - current: dict[str, Any], incoming: dict[str, Any], *, source_tag: str = "" + current: dict[str, Any], + incoming: dict[str, Any], + *, + source_tag: str = "", + report: "MergeReport | None" = None, ) -> dict[str, Any]: - """Merge two index payloads using deterministic conflict resolution.""" - out = ensure_index0_payload(dict(current) if isinstance(current, dict) else {}) - incoming = ensure_index0_payload(incoming if isinstance(incoming, dict) else {}) + """Merge two index payloads using deterministic conflict resolution. + + ``report`` is an optional, write-only collector for what was resolved + silently. Supplying it cannot change the merged result -- the outcome must + not depend on whether anyone was watching, or the two implementations + could reach different vaults from the same inputs. + """ + # Deep copies, not shallow ones. `dict(current)` shares the nested + # `entries` dict with the caller, so the merge wrote its result straight + # into the argument: after merging, the caller's "current" payload had + # already become the merged one. Anything comparing before-and-after, or + # merging the same payload twice, silently saw the wrong input. The + # TypeScript implementation structuredClone()s both sides; this matches. + out = ensure_index0_payload( + copy.deepcopy(current) if isinstance(current, dict) else {} + ) + incoming = ensure_index0_payload( + copy.deepcopy(incoming) if isinstance(incoming, dict) else {} + ) cur_entries = out.get("entries", {}) if not isinstance(cur_entries, dict): cur_entries = {} @@ -278,7 +380,10 @@ def merge_index_payloads( ) continue if _prefer_entry(cur_entry, inc_entry): + _record_conflict(report, key, inc_entry, cur_entry) cur_entries[key] = inc_entry + else: + _record_conflict(report, key, cur_entry, inc_entry) # Apply tombstones deterministically after entry merge. for idx, rec in list(tombstones.items()): @@ -323,6 +428,10 @@ def merge_index_payloads( ), ) if len(tomb_items) > TOMBSTONE_RETENTION_CAP: + # Forgetting a deletion is how a deleted entry comes back: a stale + # replica still carrying it will reinstate it at the next merge. + if report is not None: + report.tombstones_evicted += len(tomb_items) - TOMBSTONE_RETENTION_CAP tomb_items = tomb_items[-TOMBSTONE_RETENTION_CAP:] tombstones = {k: v for k, v in tomb_items} last_merge_ts = max( diff --git a/src/tests/test_delta_merge.py b/src/tests/test_delta_merge.py index 9524ee1e..10f9de7b 100644 --- a/src/tests/test_delta_merge.py +++ b/src/tests/test_delta_merge.py @@ -357,7 +357,34 @@ def test_merge_replay_same_payload_is_idempotent(): } once = merge_index_payloads(base, incoming, source_tag="peer-a") twice = merge_index_payloads(once, incoming, source_tag="peer-a") - assert once == twice + thrice = merge_index_payloads(twice, incoming, source_tag="peer-a") + + # Replaying the same payload does not change any entry's CONTENT: the + # surviving values and timestamps are identical. + once_entry = once["entries"]["1"] + twice_entry = twice["entries"]["1"] + for key, value in once_entry.items(): + assert twice_entry[key] == value + + # It does normalize shape. On the second pass the timestamps are equal, so + # the equal-timestamp path unions the two field sets and writes explicit + # nulls for fields that were simply absent before (tags, custom_fields, + # links). That is not a Python quirk -- the TypeScript implementation + # produces byte-identical output here, which is the parity that matters. + # + # This assertion used to be a strict `once == twice`, and it passed only + # because merge_index_payloads shallow-copied its argument: `once` was the + # same object the merge had already written into, so the two names pointed + # at one dict. Deep-copying the inputs (so a merge no longer edits its + # caller's payload) made the real behaviour visible. + added = set(twice_entry) - set(once_entry) + assert added == {"tags", "custom_fields", "links"} + assert all(twice_entry[k] is None for k in added) + + # The property that actually matters for sync: it settles. A merge that + # kept producing a different payload every time would republish snapshots + # forever. + assert twice == thrice def test_merge_replay_older_payload_does_not_override_newer(): diff --git a/src/tests/test_merge_report.py b/src/tests/test_merge_report.py new file mode 100644 index 00000000..f214b821 --- /dev/null +++ b/src/tests/test_merge_report.py @@ -0,0 +1,212 @@ +"""What a merge resolves silently. + +The merge is a deterministic CRDT and its outcome is frozen for parity with +the TypeScript implementation -- both must reach the same vault from the same +inputs, so the resolution rules are not up for negotiation. What was missing +is that the user is never told a resolution happened: an entry created on one +device is simply absent afterwards, with no error and nothing in the summary. + +The load-bearing test here is the last one: passing a report must not change +the merged result. If observing could alter the outcome, it would be a parity +divergence rather than an observation. + +Mirrors js/packages/core/test/mergeReport.test.ts. +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from seedpass.core.sync_conflict import ( # noqa: E402 + TOMBSTONE_RETENTION_CAP, + MergeReport, + merge_index_payloads, +) + + +def entry(kind: str, label: str, ts: int, **extra): + base = { + "kind": kind, + "type": kind, + "label": label, + "notes": "", + "tags": [], + "archived": False, + "modified_ts": ts, + } + base.update(extra) + return base + + +def index(entries, **extra): + payload = {"schema_version": 4, "entries": entries} + payload.update(extra) + return payload + + +def test_same_id_conflict_names_kept_and_discarded(): + # The realistic case: two devices offline, both allocate id 5. + local = index({"5": entry("password", "bank.example", 1700000100, length=16)}) + remote = index({"5": entry("ssh", "deploy-key", 1700000200, index=5)}) + + report = MergeReport() + merged = merge_index_payloads(local, remote, source_tag="t", report=report) + + assert len(report.conflicts) == 1 + conflict = report.conflicts[0] + assert conflict.id == "5" + assert conflict.different_kind is True + # Describe the actual outcome rather than assuming which side wins. + survivor = merged["entries"]["5"]["label"] + assert conflict.kept["label"] == survivor + assert conflict.discarded["label"] != survivor + assert sorted([conflict.kept["label"], conflict.discarded["label"]]) == [ + "bank.example", + "deploy-key", + ] + + +def test_a_conflict_is_reported_whichever_side_wins(): + # A report that only fired when the incoming side won would miss half of + # all real losses. + older = index({"5": entry("password", "bank.example", 1700000100, length=16)}) + newer = index({"5": entry("ssh", "deploy-key", 1700000200, index=5)}) + + forward = MergeReport() + merge_index_payloads(older, newer, source_tag="t", report=forward) + backward = MergeReport() + merge_index_payloads(newer, older, source_tag="t", report=backward) + + assert len(forward.conflicts) == 1 + assert len(backward.conflicts) == 1 + + +def test_an_ordinary_edit_is_not_reported(): + # The overwhelmingly common case. Reporting it would bury the case that + # matters in noise and train the user to ignore the warning. + local = index({"5": entry("password", "bank.example", 1700000100, length=16)}) + remote = index({"5": entry("password", "bank.example", 1700000200, length=24)}) + report = MergeReport() + merge_index_payloads(local, remote, source_tag="t", report=report) + assert report.conflicts == [] + + +def test_same_kind_different_label_is_reported(): + local = index({"5": entry("password", "bank.example", 1700000100, length=16)}) + remote = index({"5": entry("password", "forum.example", 1700000200, length=16)}) + report = MergeReport() + merge_index_payloads(local, remote, source_tag="t", report=report) + assert len(report.conflicts) == 1 + assert report.conflicts[0].different_kind is False + + +def test_no_overlap_reports_nothing(): + local = index({"1": entry("password", "a", 1700000100, length=16)}) + remote = index({"2": entry("password", "b", 1700000200, length=16)}) + report = MergeReport() + merged = merge_index_payloads(local, remote, source_tag="t", report=report) + assert report.conflicts == [] + assert sorted(merged["entries"].keys()) == ["1", "2"] + + +def test_tombstone_eviction_is_counted(): + # Past the cap the oldest deletions are dropped, and merging a stale + # replica then resurrects those entries. + overflow = 5 + tombstones = { + str(i): { + "deleted_ts": 1700000000 + i, + "entry_hash": "", + "event_hash": "", + "source": "t", + } + for i in range(TOMBSTONE_RETENTION_CAP + overflow) + } + local = index({}, _sync_meta={"tombstones": tombstones}) + report = MergeReport() + merge_index_payloads(local, index({}), source_tag="t", report=report) + assert report.tombstones_evicted == overflow + + +def test_no_eviction_below_the_cap(): + local = index( + {}, + _sync_meta={ + "tombstones": { + "1": { + "deleted_ts": 1700000000, + "entry_hash": "", + "event_hash": "", + "source": "t", + } + } + }, + ) + report = MergeReport() + merge_index_payloads(local, index({}), source_tag="t", report=report) + assert report.tombstones_evicted == 0 + + +def test_observing_the_merge_does_not_change_it(): + """The parity guarantee. + + The merged vault must not depend on whether anyone was watching -- + otherwise the two implementations could reach different vaults from the + same inputs, which is the one thing the deterministic merge exists to + prevent. + """ + local = index( + { + "1": entry("password", "kept", 1700000100, length=16), + "5": entry("password", "loser", 1700000100, length=16), + }, + _sync_meta={ + "next_index": 6, + "tombstones": { + "9": { + "deleted_ts": 1700000050, + "entry_hash": "", + "event_hash": "", + "source": "t", + } + }, + }, + ) + remote = index( + { + "5": entry("ssh", "winner", 1700000200, index=5), + "7": entry("totp", "extra", 1700000300, index=7, period=30, digits=6), + }, + _sync_meta={"next_index": 8, "tombstones": {}}, + ) + + with_report = merge_index_payloads( + local, remote, source_tag="t", report=MergeReport() + ) + without_report = merge_index_payloads(local, remote, source_tag="t") + assert json.dumps(with_report, sort_keys=True) == json.dumps( + without_report, sort_keys=True + ) + + +def test_merging_does_not_mutate_the_caller_s_payloads(): + """The merge reads its inputs; it does not edit them. + + ``dict(current)`` shares the nested ``entries`` dict with the caller, so + the merge used to write its result straight into the argument: after + merging, the caller's "current" payload had already become the merged one. + Anything comparing before-and-after, or merging the same payload twice, + silently saw the wrong input -- which is exactly how + ``test_a_conflict_is_reported_whichever_side_wins`` first failed. + """ + local = index({"5": entry("password", "bank.example", 1700000100, length=16)}) + remote = index({"5": entry("ssh", "deploy-key", 1700000200, index=5)}) + local_before = json.dumps(local, sort_keys=True) + remote_before = json.dumps(remote, sort_keys=True) + + merge_index_payloads(local, remote, source_tag="t") + + assert json.dumps(local, sort_keys=True) == local_before + assert json.dumps(remote, sort_keys=True) == remote_before