mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
feat(sync): report what a merge resolves silently; stop it editing its inputs (L-2, L-3)
The deterministic merge is frozen for parity -- both implementations must
reach the same vault from the same inputs -- so how it resolves is not up for
negotiation. What was missing is that nobody is told a resolution happened.
L-2: the allocation watermark is per-replica, so two devices working offline
can both allocate id N. The merge then resolves that id by timestamp then
hash, and the loser is replaced with no trace. An entry the user created is
simply absent afterwards. Worse than a plain overwrite, because an id is a
permanent BIP-85 derivation coordinate: the survivor derives from the same
coordinate the discarded entry did (see the L-1 detector).
L-3: past TOMBSTONE_RETENTION_CAP the oldest deletions are forgotten, and
merging a stale replica or old snapshot then resurrects those entries. A
documented protocol limit -- but a user cannot know the cap has actually begun
trimming their history unless something says so.
Both implementations gain an optional MergeReport collector: same-id conflicts
(with the kept and discarded entries named) and a count of evicted tombstones.
`nostr restore` reports both in its summary and on stderr, covering delta
replay as well as the final local merge.
Only genuine divergences are reported. Two replicas holding the same entry
with different timestamps is an ordinary edit and is ignored; flagging it
would bury the case that matters in noise and train the user past the warning.
The collector is write-only and cannot change the merged result -- asserted in
both implementations, because if observing could alter the outcome it would be
a parity divergence rather than an observation.
Two defects found while writing the tests:
- Python's merge MUTATED its `current` argument. `dict(current)` shares the
nested entries dict, so the merge wrote its result straight into the
caller's payload: after merging, "current" had already become the merged
value. Anything comparing before-and-after, or merging one payload twice,
silently saw the wrong input. Now deep-copied, matching TypeScript's
structuredClone. Found because the "reported whichever side wins" test
failed -- the forward merge had corrupted the input for the backward one.
- test_merge_replay_same_payload_is_idempotent was passing only because of
that aliasing: `once` and `twice` were one object. With inputs no longer
mutated, replaying the same payload is shown to normalize entry SHAPE --
the equal-timestamp path unions field sets and writes explicit nulls for
absent optional fields. TypeScript produces byte-identical output for the
same input, so this is shared designed behaviour rather than a Python
regression, and it converges on the second pass. The test now asserts what
is actually true (content unchanged, shape normalized, settles at two) and
records why the old assertion held.
Mutation-verified: removing the recording turns the reporting tests red.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
1 parent
75e370d098
commit
276aca65b6
7 files changed
+677
-5
No files matched your search
@@ -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<string, unknown>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
return {
|
||||
kind,
|
||||
type: kind,
|
||||
label,
|
||||
notes: "",
|
||||
tags: [],
|
||||
archived: false,
|
||||
modified_ts: ts,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function index(entries: Record<string, unknown>, extra: Record<string, unknown> = {}) {
|
||||
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<string, { label: string }>;
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown> = {};
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user