Skip to main content

qualia2fa_core/sync/
merge.rs

1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! Union merge and adjudication.
17//!
18//! The merge takes the union keyed by [`EntryId`], which is the bulk of synchronization. A
19//! random 128-bit identifier collides with negligible probability, so **the same ID on both
20//! sides denotes the same entry** and no version vector or logical clock is needed to
21//! decide which side is newer.
22//!
23//! The union alone cannot answer two cases, and they become a [`Conflict`]:
24//!
25//! | Local | Peer | Handling |
26//! |---|---|---|
27//! | entry | entry with the same ID, different content | **conflict**: both sides edited it, a human decides |
28//! | entry | tombstone with the same ID, local entry **not modified since** | deletion accepted automatically |
29//! | entry | tombstone with the same ID, local entry **modified since** | **conflict** |
30//! | entry | nothing | local entry kept (the peer simply does not have it) |
31//! | nothing | entry / tombstone | accepted |
32//! | group | nothing | accepted |
33//! | group | group with the same ID, same name and same shared relative order | nothing to do |
34//! | group | group with the same ID that arranges a shared child differently | **conflict**: the user picks one whole arrangement |
35//!
36//! The tombstone is what makes a deletion synchronizable: in a plain union "this entry does
37//! not exist here" and "this entry was deleted" are the **same observation**, both showing
38//! up as "the peer does not have it". Without tombstones an offline device coming back
39//! online would keep a deleted entry as "I have it, the peer does not" and the deletion
40//! would be silently undone. A tombstone turns "deleted" into **a record with content**
41//! that can be synchronized.
42//!
43//! This module never decides which side is newer on its own. Timestamps could be used to
44//! guess, but that would decide for the user and deleting a 2FA entry is irreversible;
45//! timestamps also come from the peer device and can be filled in freely by an attacker.
46//! Apart from the two rules below, which need no human judgement, a merge where both sides
47//! hold content **always** reports a conflict:
48//!
49//! - **Content identical field by field** → no conflict. This is the most common case: the
50//!   entry was edited on one side only and the other side holds an untouched old copy.
51//!   Without this rule every sync would report a pile of conflicts while nothing changed.
52//! - **Local entry not modified after the tombstone's time** → deletion accepted,
53//!   corresponding to "B deleted it, A never touched it": the user's intent is clear, and
54//!   it is what finally lets a deletion propagate.
55//!
56//! A local entry newer than the tombstone **still reports a conflict**, with
57//! `local_is_newer` handed to the adjudication UI as well — the single most useful fact
58//! when the user decides which side to keep, but the library never picks a side from it.
59
60use std::collections::BTreeMap;
61
62use crate::Result;
63use crate::vault::{Entry as VaultEntry, EntryId, Group, GroupChild, GroupId, Tombstone, Vault};
64
65/// A record received from a peer, together with its identifier.
66///
67/// The identifier lives here rather than in [`VaultEntry`] because [`VaultEntry`]
68/// deliberately carries no ID: an entry that brought its own identifier would allow two
69/// contradictory identifiers within the same data. Every record in a sync payload **must**
70/// carry its identifier — it is one of the AEAD associated data items and the key of the
71/// union. Sealing the pair in an enum makes the state "there is an entry but its identity
72/// is unknown" unrepresentable, so [`merge_into`] needs no `Result` to report a
73/// self-contradictory record.
74///
75/// # No `Clone`
76///
77/// [`VaultEntry`] deliberately does not implement `Clone` (see [`crate::otp::OtpSecret`]),
78/// so this enum cannot derive it either. Copying goes through
79/// [`VaultEntry::duplicate`], an explicit entry point: the call site has to spell out
80/// `duplicate()`, which makes "another copy of key material is made here" visible in
81/// review.
82#[derive(Debug, PartialEq, Eq)]
83pub enum MergedRecord {
84    /// A live entry.
85    Entry {
86        /// The identifier of the entry.
87        id: EntryId,
88        /// The content of the entry.
89        entry: Box<VaultEntry>,
90    },
91    /// A tombstone (deletion record).
92    Tombstone(Box<Tombstone>),
93    /// A group with its ordering.
94    ///
95    /// The granularity is the **whole group**: its name, its position and the order of its
96    /// entries travel as one value, because that is what the user arranged and what the
97    /// adjudication screen shows side by side.
98    Group {
99        /// The identifier of the group.
100        id: GroupId,
101        /// The group itself.
102        group: Box<Group>,
103    },
104}
105
106impl MergedRecord {
107    /// The 16-byte key this record is filed under.
108    ///
109    /// For a group that is its identifier reinterpreted, since the payload keys its records by 16
110    /// opaque bytes and does not distinguish the kinds (see [`RecordSet`]).
111    #[must_use]
112    pub fn entry_id(&self) -> EntryId {
113        match self {
114            Self::Entry { id, .. } => *id,
115            Self::Tombstone(tombstone) => tombstone.entry_id,
116            Self::Group { id, .. } => EntryId::from_bytes(*id.as_bytes()),
117        }
118    }
119}
120/// A disagreement found in one merge that a human has to adjudicate.
121///
122/// The entries are copied here because a conflict is by definition "there are two different
123/// versions" and the adjudication UI must see **both** sides at once. The local version is
124/// copied in, and the peer's version (already in hand) is moved in.
125///
126/// Copying goes through [`VaultEntry::duplicate`], which is explicit and can fail. This is
127/// more than a matter of style: a second copy of the same key material in memory is a
128/// second exposure surface, and the copy happens **only when a disagreement really
129/// exists** — in the vast majority of syncs `conflicts` is empty.
130#[derive(Debug, PartialEq, Eq)]
131pub struct Conflict {
132    /// The entry in disagreement.
133    pub entry_id: EntryId,
134    /// The kind of disagreement.
135    pub kind: ConflictKind,
136}
137
138impl Conflict {
139    /// The group this conflict is about.
140    ///
141    /// An ordering conflict carries its identifier in [`Conflict::entry_id`] - the same 16-byte
142    /// field every payload record is filed under, since the payload does not distinguish the
143    /// kinds - and this recovers it with its meaning attached.
144    #[must_use]
145    pub fn group_id(&self) -> Option<GroupId> {
146        match self.kind {
147            ConflictKind::OrderingMismatch { .. } => {
148                Some(GroupId::from_bytes(*self.entry_id.as_bytes()))
149            }
150            _ => None,
151        }
152    }
153}
154
155/// The kind of disagreement.
156#[derive(Debug, PartialEq, Eq)]
157pub enum ConflictKind {
158    /// Both sides have this entry, with different content.
159    ///
160    /// The content of **both** sides is kept (rather than only a "conflict" marker): the
161    /// adjudication UI has to show the account names and notes of both sides, otherwise
162    /// the user cannot decide.
163    EntryMismatch {
164        /// The local version.
165        local: Box<VaultEntry>,
166        /// The version received from the peer. **Not yet** written to the local vault.
167        incoming: Box<VaultEntry>,
168    },
169    /// Both sides have the group, and they differ in the name, the position or the ordering.
170    ///
171    /// **The ordering is not merged element by element.** Two devices that arranged the same
172    /// entries differently hold two different sequences, and interleaving them would produce a
173    /// third arrangement that neither user asked for; the user picks one of the two whole
174    /// orderings (see [`crate::sync::adjudicate`]).
175    OrderingMismatch {
176        /// The local group.
177        local: Box<Group>,
178        /// The version received from the peer. **Not yet** written to the local vault.
179        incoming: Box<Group>,
180    },
181    /// One side deleted the entry while the other still has it and it **was modified**.
182    ///
183    /// If the local entry was not touched after the deletion, this does not become a
184    /// conflict — the deletion is accepted automatically.
185    DeletionConflict {
186        /// The local version.
187        ///
188        /// This conflict has **two directions**, and only one of them carries local content
189        /// here:
190        ///
191        /// - **The peer sent a tombstone** (the local side has a newer live entry): the
192        ///   local content is already in the vault and the adjudication UI reads it
193        ///   directly, so this is `None`, saving one copy of key material.
194        /// - **The peer sent a live entry** (the local side has a tombstone): the local
195        ///   content is a **tombstone**, while the peer's version of the entry is already
196        ///   in hand and simply moved in, so this holds the peer's version, which tells the
197        ///   adjudication UI that the peer wants to restore the entry.
198        ///
199        /// Neither direction copies key material for display, except the one where
200        /// `incoming` below is a live entry — that one has no local live entry to read.
201        local: Option<Box<VaultEntry>>,
202        /// The tombstone received from the peer. **Not yet** written to the local vault.
203        incoming: Box<Tombstone>,
204        /// The local version is later than the tombstone.
205        ///
206        /// This is a **hint**, not a basis for the decision: it tells the adjudication UI
207        /// that this side was edited after the deletion. The library does not pick a side
208        /// from it — the peer fills in the peer's own timestamps.
209        local_is_newer: bool,
210    },
211}
212
213/// The result of one merge.
214///
215/// No `Clone`: it can hold conflicts, and conflicts hold key material.
216#[derive(Debug, Default, PartialEq, Eq)]
217pub struct MergeOutcome {
218    /// Number of entries added.
219    pub added: usize,
220    /// Number of tombstones added. **Not** including the case where the local vault
221    /// already had that tombstone.
222    pub tombstones_added: usize,
223    /// Number of deletions accepted (a local entry replaced by a tombstone).
224    pub deletions_applied: usize,
225    /// Number of groups added. **Not** including the case where the local vault already had that
226    /// group with the same content.
227    pub groups_added: usize,
228    /// Number of records whose content was completely identical, so nothing was done.
229    pub unchanged: usize,
230    /// Disagreements that need a human to adjudicate.
231    pub conflicts: Vec<Conflict>,
232}
233
234impl MergeOutcome {
235    /// Whether this merge changed nothing and left no disagreement to adjudicate.
236    ///
237    /// Lets the UI skip "sync complete"-style messages — it is `true` when the same batch
238    /// of data is synced again, which is the observable form of idempotence.
239    #[must_use]
240    pub fn made_no_change(&self) -> bool {
241        self.added == 0
242            && self.tombstones_added == 0
243            && self.deletions_applied == 0
244            && self.conflicts.is_empty()
245    }
246
247    /// Whether there is a disagreement waiting for adjudication.
248    #[must_use]
249    pub fn needs_adjudication(&self) -> bool {
250        !self.conflicts.is_empty()
251    }
252}
253
254/// Merges a batch of records from a peer into the local vault.
255///
256/// # Idempotence
257///
258/// Merging the same batch twice leaves `added`, `tombstones_added` and
259/// `deletions_applied` at `0` on the second pass, `unchanged` at the full count, and the
260/// vault contents byte-for-byte identical. The idempotence is **observable**, not merely
261/// "idempotent by argument".
262///
263/// # Commutativity holds for the vault contents only
264///
265/// `merge(merge(v, a), b)` and `merge(merge(v, b), a)` leave the **same vault contents**
266/// but can report **different conflict lists** (the order decides which side is treated as
267/// `local`). The library does not pretend the two are equivalent: conflicts are precisely
268/// the things a human has to decide, and hiding the order difference would only make the
269/// adjudication UI show something other than what the user just saw.
270///
271/// # Errors
272///
273/// Structural errors other than [`crate::Error::VaultDecryptionFailed`], propagated from
274/// [`VaultEntry::duplicate`], which fails only when the vault holds an entry that violates
275/// the format constraints. Duplicating happens for the local side of a conflict; apart
276/// from that the merge only operates on in-memory maps and cannot fail.
277///
278/// The `incoming` side of a conflict is **not** written: the caller has to let the user
279/// adjudicate first. Writing it and letting the user revert would overwrite local data
280/// before the user can react.
281pub fn merge_into<'a>(
282    vault: &mut Vault,
283    records: impl IntoIterator<Item = &'a MergedRecord>,
284) -> Result<MergeOutcome> {
285    let mut outcome = MergeOutcome::default();
286
287    // Entries and tombstones first, groups after: a group's ordering is compared against the
288    // entries this device holds **once the batch has been applied**, so a group that names an
289    // entry arriving in the same payload is not reported as a difference just because the two
290    // records happened to be ordered that way.
291    let all_records: Vec<&MergedRecord> = records.into_iter().collect();
292    let mut groups: Vec<&MergedRecord> = Vec::new();
293    let mut arrived: Vec<EntryId> = Vec::new();
294    for record in &all_records {
295        match record {
296            MergedRecord::Entry { id, entry } => {
297                let existed = vault.get(id).is_some() || vault.get_tombstone(id).is_some();
298                merge_entry(vault, id, entry, &mut outcome)?;
299                if !existed {
300                    arrived.push(*id);
301                }
302            }
303            MergedRecord::Tombstone(tombstone) => {
304                merge_tombstone(vault, tombstone, &mut outcome);
305            }
306            MergedRecord::Group { .. } => groups.push(*record),
307        }
308    }
309    for record in groups {
310        if let MergedRecord::Group { id, group } = record {
311            merge_group(vault, id, group, &mut outcome);
312        }
313    }
314    // The same rule the file parser applies: a child no group claims belongs to the top group.
315    // Without it an entry arriving in a payload that carries no group record - or whose group
316    // record lost the id - would exist in the vault and appear nowhere, and an inserted group
317    // would have no parent list to hang from.
318    //
319    // The order is the peer's: its top group lists the entries it sent in the arrangement it
320    // wants, and appending them in the order the *records* happened to appear would put them
321    // somewhere else - which the next sync would then report as a disagreement this merge itself
322    // created. Whatever the top group does not mention follows in payload order.
323    let mut claim_order: Vec<EntryId> = Vec::new();
324    for record in &all_records {
325        if let MergedRecord::Group { id, group } = record
326            && id.is_top()
327        {
328            claim_order.extend(group.entry_ids());
329        }
330    }
331    for entry_id in &arrived {
332        if !claim_order.contains(entry_id) {
333            claim_order.push(*entry_id);
334        }
335    }
336    // One pass over the whole tree: it repairs duplicate references, attaches a group that
337    // arrived without the parent list that names it, and places unclaimed entries - the same
338    // rules the file parser applies, so a merged vault is indistinguishable from one read from
339    // disk.
340    vault.rebuild_tree(&claim_order, &[]);
341
342    Ok(outcome)
343}
344
345/// Merges one group.
346///
347/// Three cases: the local vault has no such group, so it is inserted; it has one with the same
348/// **visible** content, so nothing is done; or the two differ, which is a
349/// [`ConflictKind::OrderingMismatch`] for the user.
350///
351/// # What counts as a disagreement
352///
353/// The names must match, and so must the **relative order of the children both sides list** -
354/// entries and child groups alike. A child only one side lists says nothing about the other's
355/// arrangement - it cannot have been placed there by the other device - so it is not a
356/// disagreement: the merge keeps the local arrangement and the arriving child is placed by the
357/// tree repair that follows (see [`crate::vault::Vault::rebuild_tree`]).
358///
359/// The stricter rule "the lists must be identical" was tried first and made **every** first sync
360/// between two devices a conflict: each device has appended its own entries to the top group, so
361/// the two lists differ by exactly the children the other side had never seen. What the user can
362/// actually disagree about is where a child goes **relative to the others they both have**, and
363/// that is what is compared here.
364///
365/// The consequence, stated rather than hidden: two devices that each had private entries keep
366/// their own placement of those. They converge on the children they share, which is the part
367/// either of them could have an opinion about.
368///
369/// # Moving a group can produce two conflicts
370///
371/// Moving a group between parents rewrites two lists - the old parent loses it and the new parent
372/// gains it - and each list is a separate record, so each can be reported. That is a known cost
373/// of keeping the merge at one record per group; the interface groups conflicts of the same
374/// group together. Whatever order the two are adjudicated in, the tree is the same afterwards,
375/// because [`crate::vault::Vault::set_children`] allows one parent per child.
376///
377/// Returns no `Result`: a group holds no secret material, so nothing here can fail.
378fn merge_group(vault: &mut Vault, id: &GroupId, incoming: &Group, outcome: &mut MergeOutcome) {
379    let Some(local) = vault.group(id) else {
380        // A failure here means the identifier is already taken, which the `get` above ruled out.
381        if vault.insert_group_with_id(*id, incoming.clone()).is_ok() {
382            outcome.groups_added += 1;
383        }
384        return;
385    };
386
387    if local.name == incoming.name && !children_disagree(&local.children, &incoming.children) {
388        outcome.unchanged += 1;
389        return;
390    }
391    outcome.conflicts.push(Conflict {
392        entry_id: EntryId::from_bytes(*id.as_bytes()),
393        kind: ConflictKind::OrderingMismatch {
394            local: Box::new(local.clone()),
395            incoming: Box::new(incoming.clone()),
396        },
397    });
398}
399
400/// Whether two child lists place the children they **both** list differently.
401///
402/// Both lists are reduced to the references they have in common - the only ones either side can
403/// have an opinion about - and the two reductions are compared. One child alone is always "in the
404/// same order as itself", so a difference in the set of children is never reported here.
405fn children_disagree(local: &[GroupChild], incoming: &[GroupChild]) -> bool {
406    let shared = |list: &[GroupChild]| -> Vec<GroupChild> {
407        list.iter()
408            .filter(|child| incoming.contains(child))
409            .copied()
410            .collect()
411    };
412    shared(local) != shared(incoming)
413}
414
415/// Merges one live entry.
416///
417/// Three cases: the local vault already has a live entry with this ID (identical content →
418/// `unchanged`, otherwise a [`ConflictKind::EntryMismatch`] conflict); the local vault has a
419/// tombstone and no live entry, which means the entry comes from a device that has **not
420/// received the deletion yet**, so it is reported as a [`ConflictKind::DeletionConflict`]
421/// instead of being inserted and reviving the adjudicated deletion; and no local record at
422/// all, in which case the entry is inserted. `insert_with_id` fails only when the ID already
423/// exists, and both such cases are handled above, so a failure there is unreachable — the
424/// result is still not `expect`-ed, so that no panic branch exists on user data.
425///
426/// Fails **only** because [`VaultEntry::duplicate`] failed (the local version is copied when
427/// a conflict is reported).
428fn merge_entry(
429    vault: &mut Vault,
430    id: &EntryId,
431    entry: &VaultEntry,
432    outcome: &mut MergeOutcome,
433) -> Result<()> {
434    if let Some(existing) = vault.get(id) {
435        if entries_identical(existing, entry) {
436            outcome.unchanged += 1;
437            return Ok(());
438        }
439        outcome.conflicts.push(Conflict {
440            entry_id: *id,
441            kind: ConflictKind::EntryMismatch {
442                local: Box::new(existing.duplicate()?),
443                incoming: Box::new(entry.duplicate()?),
444            },
445        });
446        return Ok(());
447    }
448
449    if let Some(tombstone) = vault.get_tombstone(id) {
450        outcome.conflicts.push(Conflict {
451            entry_id: *id,
452            kind: ConflictKind::DeletionConflict {
453                local: Some(Box::new(entry.duplicate()?)),
454                incoming: Box::new(tombstone.clone()),
455                local_is_newer: entry.updated_at > tombstone.deleted_at,
456            },
457        });
458        return Ok(());
459    }
460
461    // Inserted **without** touching an ordering: the payload's group records decide the
462    // arrangement, and an entry appended to the top group here would make the local top group
463    // differ from the peer's copy of it, reporting a conflict that is really this insertion.
464    // `merge_into` adopts whatever no group claims once everything has been applied.
465    if vault.insert_parsed(*id, entry.duplicate()?).is_ok() {
466        outcome.added += 1;
467    }
468    Ok(())
469}
470
471/// Merges one tombstone.
472///
473/// If the local vault already knows the tombstone, the **earlier** of the two deletion
474/// times is kept: both tombstones prove that the deletion happened, and taking the earlier
475/// one makes the two sides converge on the same value without refreshing the deletion time,
476/// so a device deleting repeatedly cannot change an adjudicated result.
477///
478/// If the local side holds a live entry whose `updated_at` is not later than the deletion
479/// time, the deletion is accepted: the entry is removed, the tombstone recorded and
480/// `deletions_applied` incremented. `remove` runs before `record_tombstone` because
481/// `Vault::record_tombstone` does **not** remove a live entry, and a live entry coexisting
482/// with a tombstone is the state of an adjudication that has not finished. If the live
483/// entry is newer the result is a [`ConflictKind::DeletionConflict`] with `local` set to
484/// `None`: the local content stays in the vault and the adjudication UI reads it there, so
485/// no key material is copied — `merge_tombstone` returns no `Result`, and copying can fail.
486/// What that conflict has to display is "the local vault has a newer entry, the peer says
487/// it was deleted", unlike the `EntryMismatch` branch, where both sides must be shown
488/// together.
489///
490/// Returns no `Result`: it copies no key material (a tombstone holds no secret) and
491/// therefore has nothing that can fail.
492fn merge_tombstone(vault: &mut Vault, tombstone: &Tombstone, outcome: &mut MergeOutcome) {
493    let id = tombstone.entry_id;
494
495    if let Some(existing) = vault.get_tombstone(&id) {
496        if tombstone.deleted_at < existing.deleted_at {
497            vault.record_tombstone(tombstone.clone());
498        }
499        outcome.unchanged += 1;
500        return;
501    }
502
503    if let Some(local) = vault.get(&id) {
504        if local.updated_at <= tombstone.deleted_at {
505            vault.remove(&id, tombstone.deleted_at);
506            vault.record_tombstone(tombstone.clone());
507            outcome.deletions_applied += 1;
508            return;
509        }
510        outcome.conflicts.push(Conflict {
511            entry_id: id,
512            kind: ConflictKind::DeletionConflict {
513                local: None,
514                incoming: Box::new(tombstone.clone()),
515                local_is_newer: true,
516            },
517        });
518        return;
519    }
520
521    vault.record_tombstone(tombstone.clone());
522    outcome.tombstones_added += 1;
523}
524
525/// Whether two entries are completely identical.
526///
527/// Calls [`VaultEntry`]'s `PartialEq` directly: that implementation deliberately compares
528/// the secret field (the seed) **last**, so a pair of entries whose names already differ is
529/// excluded at the first field and pays no constant-time comparison. See the `PartialEq` of
530/// [`crate::vault::Entry`].
531///
532/// The wrapper exists rather than a plain `a == b` so that the call site has one place to
533/// state why comparing secrets is acceptable here: whoever can perform the comparison
534/// already holds both plaintexts, so an ordinary comparison is no downgrade.
535fn entries_identical(a: &VaultEntry, b: &VaultEntry) -> bool {
536    a == b
537}
538
539/// A container that lets callers organize records by identifier.
540///
541/// # Deduplication by identifier
542///
543/// The same `EntryId` appearing twice in one payload is **legal**: it can carry one version
544/// of the entry per device. A merge can only apply one result, so which one is used has to
545/// be fixed first: first-inserted wins, and whoever builds the payload is responsible for
546/// putting the version it wants adopted first.
547///
548/// Last-inserted-wins is **not** used: that would make the order inside a payload a
549/// security-relevant property (the last record decides), while the peer controls that
550/// order.
551///
552/// # Genericity
553///
554/// Two places need a "record set deduplicated by identifier", with different element
555/// types:
556///
557/// - [`MergedRecord`]: the semantic record **after decryption**, used by the merge layer.
558/// - [`crate::sync::Record`]: the wire-format record that is **still encrypted**, used by
559///   payload packing and parsing.
560///
561/// One generic container instead of two implementations is not just less code: the two
562/// deduplication rules **must be identical** (both are first-inserted wins), and two
563/// implementations would silently drift apart, showing up as a payload deduplicated on the
564/// sending side and not on the receiving side — a failure that reproduces only on the peer.
565#[derive(Debug)]
566pub struct RecordSet<T> {
567    /// Records indexed by identifier. A `BTreeMap` rather than a `HashMap`: the iteration
568    /// order has to be deterministic.
569    by_id: BTreeMap<EntryId, T>,
570}
571
572impl<T> Default for RecordSet<T> {
573    fn default() -> Self {
574        Self {
575            by_id: BTreeMap::new(),
576        }
577    }
578}
579
580impl<T: HasEntryId> RecordSet<T> {
581    /// An empty set.
582    #[must_use]
583    pub fn new() -> Self {
584        Self::default()
585    }
586
587    /// Inserts a record; **does not overwrite** when the same ID already exists.
588    ///
589    /// Returns whether the record was actually inserted (`false` means this ID is already
590    /// present).
591    pub fn insert(&mut self, record: T) -> bool {
592        let id = record.entry_id();
593        if self.by_id.contains_key(&id) {
594            return false;
595        }
596        self.by_id.insert(id, record);
597        true
598    }
599
600    /// How many records there are.
601    #[must_use]
602    pub fn len(&self) -> usize {
603        self.by_id.len()
604    }
605
606    /// Whether the set is empty.
607    #[must_use]
608    pub fn is_empty(&self) -> bool {
609        self.by_id.is_empty()
610    }
611
612    /// Borrows the record for one ID.
613    #[must_use]
614    pub fn get(&self, id: &EntryId) -> Option<&T> {
615        self.by_id.get(id)
616    }
617
618    /// Iterates in ID order.
619    ///
620    /// A `BTreeMap` rather than a `HashMap`: the iteration order has to be deterministic,
621    /// otherwise the same payload would produce a different merge order on different
622    /// devices and the order of the conflict list would follow; for the wire format it
623    /// would also make two payloads with identical content produce different plaintext.
624    pub fn iter(&self) -> impl Iterator<Item = (&EntryId, &T)> {
625        self.by_id.iter()
626    }
627}
628
629impl<'a, T> IntoIterator for &'a RecordSet<T> {
630    type Item = &'a T;
631    type IntoIter = std::collections::btree_map::Values<'a, EntryId, T>;
632
633    /// Borrows every record in ID order.
634    ///
635    /// Borrowing rather than a `records() -> Vec<T>`: the latter requires every record to
636    /// be copyable, and both element types deliberately have no `Clone` (they hold key
637    /// material). Borrowing lets [`merge_into`] take a `&RecordSet` directly, without
638    /// copying the key material of a whole payload just to pass it around — one sync would
639    /// copy hundreds of seeds for no reason at all.
640    fn into_iter(self) -> Self::IntoIter {
641        self.by_id.values()
642    }
643}
644
645/// A type that knows which record it is.
646///
647/// [`RecordSet`] uses this trait for the key of deduplication. It has this one method and
648/// no more because the container needs this one thing — a wider trait would tempt the
649/// container into doing what it should not (such as ordering by content, which requires
650/// reading key material).
651pub trait HasEntryId {
652    /// Which entry this record is about.
653    fn entry_id(&self) -> EntryId;
654}
655
656impl HasEntryId for MergedRecord {
657    fn entry_id(&self) -> EntryId {
658        match self {
659            Self::Entry { id, .. } => *id,
660            Self::Tombstone(tombstone) => tombstone.entry_id,
661            Self::Group { id, .. } => EntryId::from_bytes(*id.as_bytes()),
662        }
663    }
664}