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}