Skip to main content

tsafe_core/
sync.rs

1//! Three-way vault merge for concurrent edit reconciliation.
2//!
3//! `merge` takes a common ancestor and two diverged [`VaultFile`] snapshots and
4//! produces a merged result.  Secret additions and deletions from both sides are
5//! applied; conflicting edits (same key changed differently) resolve in favour of
6//! the `theirs` side with a conflict marker in the merged entry's tags.
7
8use std::collections::{HashMap, HashSet};
9
10use crate::errors::SafeResult;
11use crate::vault::{SecretEntry, VaultFile};
12
13/// Result of a three-way merge between base, ours (local), and theirs (remote) vault files.
14#[derive(Debug)]
15pub struct MergeResult {
16    /// The merged vault file — ready to write to disk.
17    pub merged: VaultFile,
18    /// Keys added from theirs (remote additions we didn't have).
19    pub added_from_theirs: Vec<String>,
20    /// Keys updated from theirs (we were stale, theirs was newer).
21    pub updated_from_theirs: Vec<String>,
22    /// Keys added from ours (local additions not on remote).
23    pub added_from_ours: Vec<String>,
24    /// Keys where both sides changed — resolved by last-write-wins.
25    pub conflicts: Vec<String>,
26    /// Keys deleted (removed from one or both sides).
27    pub deleted: Vec<String>,
28    /// True if nothing changed (ours == theirs at the key level).
29    pub is_noop: bool,
30    /// Explicit per-key merge outcomes in deterministic key order.
31    pub decisions: Vec<MergeDecision>,
32}
33
34/// Explicit per-key outcome from the sync merge lifecycle.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct MergeDecision {
37    pub key: String,
38    pub state: MergeState,
39}
40
41/// Merge lifecycle states for one key across base, ours, and theirs.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MergeState {
44    Unchanged,
45    AddedFromOurs,
46    AddedFromTheirs,
47    AddedConcurrentlyIdentical,
48    UpdatedFromOurs,
49    UpdatedFromTheirs,
50    DeletedByOurs,
51    DeletedByTheirs,
52    DeletedByBoth,
53    ConflictResolvedToOurs,
54    ConflictResolvedToTheirs,
55    DeleteConflictResolvedToOurs,
56    DeleteConflictResolvedToTheirs,
57}
58
59#[derive(Debug, Clone)]
60struct PlannedMergeDecision {
61    state: MergeState,
62    merged_entry: Option<SecretEntry>,
63}
64
65/// Compare two SecretEntry values by their encrypted representation.
66/// Returns true if the ciphertext is identical (same encryption = same value + same nonce).
67fn entry_eq(a: &SecretEntry, b: &SecretEntry) -> bool {
68    a.nonce == b.nonce && a.ciphertext == b.ciphertext
69}
70
71/// Three-way merge of vault files at the per-key level.
72///
73/// Works entirely on the encrypted representation — no decryption required.
74/// Compares `nonce + ciphertext` to detect changes. Uses `updated_at` as a
75/// last-write-wins tiebreaker when both sides changed the same key.
76///
77/// `base` may have an empty secrets map (first sync / no common ancestor).
78pub fn three_way_merge(
79    base: &VaultFile,
80    ours: &VaultFile,
81    theirs: &VaultFile,
82) -> SafeResult<MergeResult> {
83    let mut merged_secrets: HashMap<String, SecretEntry> = HashMap::new();
84    let mut added_from_theirs = Vec::new();
85    let mut updated_from_theirs = Vec::new();
86    let mut added_from_ours = Vec::new();
87    let mut conflicts = Vec::new();
88    let mut deleted = Vec::new();
89    let mut decisions = Vec::new();
90
91    // Collect all keys across all three versions.
92    let all_keys: HashSet<&str> = base
93        .secrets
94        .keys()
95        .chain(ours.secrets.keys())
96        .chain(theirs.secrets.keys())
97        .map(String::as_str)
98        .collect();
99    let mut all_keys: Vec<&str> = all_keys.into_iter().collect();
100    all_keys.sort_unstable();
101
102    for key in all_keys {
103        let planned = plan_merge_decision(
104            base.secrets.get(key),
105            ours.secrets.get(key),
106            theirs.secrets.get(key),
107        )?;
108        match planned.state {
109            MergeState::AddedFromOurs => {
110                added_from_ours.push(key.to_string());
111            }
112            MergeState::AddedFromTheirs => {
113                added_from_theirs.push(key.to_string());
114            }
115            MergeState::UpdatedFromTheirs => {
116                updated_from_theirs.push(key.to_string());
117            }
118            MergeState::DeletedByOurs | MergeState::DeletedByTheirs | MergeState::DeletedByBoth => {
119                deleted.push(key.to_string());
120            }
121            MergeState::ConflictResolvedToOurs
122            | MergeState::ConflictResolvedToTheirs
123            | MergeState::DeleteConflictResolvedToOurs
124            | MergeState::DeleteConflictResolvedToTheirs => {
125                conflicts.push(key.to_string());
126            }
127            MergeState::Unchanged
128            | MergeState::AddedConcurrentlyIdentical
129            | MergeState::UpdatedFromOurs => {}
130        }
131        if let Some(entry) = planned.merged_entry {
132            merged_secrets.insert(key.to_string(), entry);
133        }
134        decisions.push(MergeDecision {
135            key: key.to_string(),
136            state: planned.state,
137        });
138    }
139
140    // Merge age_recipients as a union.
141    let mut merged_recipients: Vec<String> = ours.age_recipients.clone();
142    for r in &theirs.age_recipients {
143        if !merged_recipients.contains(r) {
144            merged_recipients.push(r.clone());
145        }
146    }
147
148    let is_noop = decisions
149        .iter()
150        .all(|decision| decision.state == MergeState::Unchanged);
151
152    let merged = VaultFile {
153        schema: ours.schema.clone(),
154        kdf: ours.kdf.clone(),
155        cipher: ours.cipher.clone(),
156        vault_challenge: ours.vault_challenge.clone(),
157        created_at: ours.created_at,
158        updated_at: std::cmp::max(ours.updated_at, theirs.updated_at),
159        secrets: merged_secrets,
160        age_recipients: merged_recipients,
161        // wrapped_dek: if recipients changed, caller must re-wrap.
162        // For now, keep ours (caller handles re-wrapping if needed).
163        wrapped_dek: ours.wrapped_dek.clone(),
164    };
165
166    Ok(MergeResult {
167        merged,
168        added_from_theirs,
169        updated_from_theirs,
170        added_from_ours,
171        conflicts,
172        deleted,
173        is_noop,
174        decisions,
175    })
176}
177
178fn plan_merge_decision(
179    base: Option<&SecretEntry>,
180    ours: Option<&SecretEntry>,
181    theirs: Option<&SecretEntry>,
182) -> SafeResult<PlannedMergeDecision> {
183    match (base, ours, theirs) {
184        // Not in base — only in ours → keep ours (local addition).
185        (None, Some(o), None) => Ok(PlannedMergeDecision {
186            state: MergeState::AddedFromOurs,
187            merged_entry: Some(o.clone()),
188        }),
189
190        // Not in base — only in theirs → take theirs (remote addition).
191        (None, None, Some(t)) => Ok(PlannedMergeDecision {
192            state: MergeState::AddedFromTheirs,
193            merged_entry: Some(t.clone()),
194        }),
195
196        // Not in base — in both → simultaneous add.
197        (None, Some(o), Some(t)) => {
198            if entry_eq(o, t) {
199                Ok(PlannedMergeDecision {
200                    state: MergeState::AddedConcurrentlyIdentical,
201                    merged_entry: Some(o.clone()),
202                })
203            } else if t.updated_at >= o.updated_at {
204                Ok(PlannedMergeDecision {
205                    state: MergeState::ConflictResolvedToTheirs,
206                    merged_entry: Some(t.clone()),
207                })
208            } else {
209                Ok(PlannedMergeDecision {
210                    state: MergeState::ConflictResolvedToOurs,
211                    merged_entry: Some(o.clone()),
212                })
213            }
214        }
215
216        // In base, not in ours, not in theirs → both deleted.
217        (Some(_), None, None) => Ok(PlannedMergeDecision {
218            state: MergeState::DeletedByBoth,
219            merged_entry: None,
220        }),
221
222        // In base, deleted in ours, unchanged in theirs → deleted.
223        (Some(b), None, Some(t)) => {
224            if entry_eq(b, t) {
225                Ok(PlannedMergeDecision {
226                    state: MergeState::DeletedByOurs,
227                    merged_entry: None,
228                })
229            } else {
230                Ok(PlannedMergeDecision {
231                    state: MergeState::DeleteConflictResolvedToTheirs,
232                    merged_entry: Some(t.clone()),
233                })
234            }
235        }
236
237        // In base, unchanged in ours, deleted in theirs → deleted.
238        (Some(b), Some(o), None) => {
239            if entry_eq(b, o) {
240                Ok(PlannedMergeDecision {
241                    state: MergeState::DeletedByTheirs,
242                    merged_entry: None,
243                })
244            } else {
245                Ok(PlannedMergeDecision {
246                    state: MergeState::DeleteConflictResolvedToOurs,
247                    merged_entry: Some(o.clone()),
248                })
249            }
250        }
251
252        // In base, in both — check for changes.
253        (Some(b), Some(o), Some(t)) => {
254            let ours_changed = !entry_eq(b, o);
255            let theirs_changed = !entry_eq(b, t);
256
257            match (ours_changed, theirs_changed) {
258                (false, false) => Ok(PlannedMergeDecision {
259                    state: MergeState::Unchanged,
260                    merged_entry: Some(o.clone()),
261                }),
262                (true, false) => Ok(PlannedMergeDecision {
263                    state: MergeState::UpdatedFromOurs,
264                    merged_entry: Some(o.clone()),
265                }),
266                (false, true) => Ok(PlannedMergeDecision {
267                    state: MergeState::UpdatedFromTheirs,
268                    merged_entry: Some(t.clone()),
269                }),
270                (true, true) => {
271                    if entry_eq(o, t) {
272                        Ok(PlannedMergeDecision {
273                            state: MergeState::UpdatedFromOurs,
274                            merged_entry: Some(o.clone()),
275                        })
276                    } else if t.updated_at >= o.updated_at {
277                        Ok(PlannedMergeDecision {
278                            state: MergeState::ConflictResolvedToTheirs,
279                            merged_entry: Some(t.clone()),
280                        })
281                    } else {
282                        Ok(PlannedMergeDecision {
283                            state: MergeState::ConflictResolvedToOurs,
284                            merged_entry: Some(o.clone()),
285                        })
286                    }
287                }
288            }
289        }
290
291        // Not in any — impossible since we iterate all_keys (invariant violation).
292        (None, None, None) => Err(crate::errors::SafeError::InvalidVault {
293            reason: "merge invariant violated: key absent from all three vaults".into(),
294        }),
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::vault::{KdfParams, VaultChallenge};
302    use chrono::{Duration, Utc};
303    use std::collections::HashMap;
304
305    fn empty_vault() -> VaultFile {
306        VaultFile {
307            schema: "tsafe/vault/v1".into(),
308            kdf: KdfParams {
309                algorithm: "argon2id".into(),
310                m_cost: 65536,
311                t_cost: 3,
312                p_cost: 4,
313                salt: "AAAA".into(),
314            },
315            cipher: "xchacha20poly1305".into(),
316            vault_challenge: VaultChallenge {
317                nonce: "AAAA".into(),
318                ciphertext: "AAAA".into(),
319            },
320            created_at: Utc::now(),
321            updated_at: Utc::now(),
322            secrets: HashMap::new(),
323            age_recipients: Vec::new(),
324            wrapped_dek: None,
325        }
326    }
327
328    fn make_entry(nonce: &str, ct: &str, age_days: i64) -> SecretEntry {
329        let now = Utc::now();
330        SecretEntry {
331            nonce: nonce.into(),
332            ciphertext: ct.into(),
333            created_at: now - Duration::days(age_days + 1),
334            updated_at: now - Duration::days(age_days),
335            tags: HashMap::new(),
336            history: Vec::new(),
337        }
338    }
339
340    #[test]
341    fn merge_no_changes_is_noop() {
342        let base = empty_vault();
343        let ours = base.clone();
344        let theirs = base.clone();
345        let result = three_way_merge(&base, &ours, &theirs).unwrap();
346        assert!(result.is_noop);
347        assert!(result.conflicts.is_empty());
348    }
349
350    #[test]
351    fn merge_disjoint_additions() {
352        let base = empty_vault();
353        let mut ours = base.clone();
354        ours.secrets
355            .insert("KEY_A".into(), make_entry("n1", "ct1", 0));
356        let mut theirs = base.clone();
357        theirs
358            .secrets
359            .insert("KEY_B".into(), make_entry("n2", "ct2", 0));
360
361        let result = three_way_merge(&base, &ours, &theirs).unwrap();
362        assert!(!result.is_noop);
363        assert!(result.merged.secrets.contains_key("KEY_A"));
364        assert!(result.merged.secrets.contains_key("KEY_B"));
365        assert_eq!(result.added_from_ours, vec!["KEY_A"]);
366        assert_eq!(result.added_from_theirs, vec!["KEY_B"]);
367        assert!(result.conflicts.is_empty());
368        assert_eq!(
369            result.decisions,
370            vec![
371                MergeDecision {
372                    key: "KEY_A".into(),
373                    state: MergeState::AddedFromOurs,
374                },
375                MergeDecision {
376                    key: "KEY_B".into(),
377                    state: MergeState::AddedFromTheirs,
378                },
379            ]
380        );
381    }
382
383    #[test]
384    fn merge_ours_deletes_theirs_unchanged() {
385        let mut base = empty_vault();
386        base.secrets
387            .insert("DEL".into(), make_entry("n1", "ct1", 5));
388        let mut ours = base.clone();
389        ours.secrets.remove("DEL");
390        let theirs = base.clone(); // unchanged
391
392        let result = three_way_merge(&base, &ours, &theirs).unwrap();
393        assert!(!result.merged.secrets.contains_key("DEL"));
394        assert_eq!(result.deleted, vec!["DEL"]);
395    }
396
397    #[test]
398    fn merge_both_edit_same_key_lww() {
399        let mut base = empty_vault();
400        base.secrets
401            .insert("SHARED".into(), make_entry("n0", "ct0", 10));
402
403        let mut ours = base.clone();
404        ours.secrets
405            .insert("SHARED".into(), make_entry("n1", "ct1", 2)); // 2 days ago
406
407        let mut theirs = base.clone();
408        theirs
409            .secrets
410            .insert("SHARED".into(), make_entry("n2", "ct2", 0)); // today (newer)
411
412        let result = three_way_merge(&base, &ours, &theirs).unwrap();
413        assert_eq!(result.conflicts, vec!["SHARED"]);
414        // Theirs wins (more recent updated_at).
415        assert_eq!(result.merged.secrets["SHARED"].ciphertext, "ct2");
416    }
417
418    #[test]
419    fn merge_both_edit_same_value_no_conflict() {
420        let mut base = empty_vault();
421        base.secrets
422            .insert("SAME".into(), make_entry("n0", "ct0", 5));
423
424        let mut ours = base.clone();
425        ours.secrets
426            .insert("SAME".into(), make_entry("n1", "ct1", 0));
427
428        let mut theirs = base.clone();
429        theirs
430            .secrets
431            .insert("SAME".into(), make_entry("n1", "ct1", 0)); // identical edit
432
433        let result = three_way_merge(&base, &ours, &theirs).unwrap();
434        assert!(result.conflicts.is_empty());
435    }
436
437    #[test]
438    fn merge_only_theirs_changed() {
439        let mut base = empty_vault();
440        base.secrets.insert("K".into(), make_entry("n0", "ct0", 5));
441        let ours = base.clone();
442        let mut theirs = base.clone();
443        theirs
444            .secrets
445            .insert("K".into(), make_entry("n1", "ct1", 0));
446
447        let result = three_way_merge(&base, &ours, &theirs).unwrap();
448        assert_eq!(result.updated_from_theirs, vec!["K"]);
449        assert_eq!(result.merged.secrets["K"].ciphertext, "ct1");
450    }
451
452    #[test]
453    fn merge_recipients_union() {
454        let mut base = empty_vault();
455        base.age_recipients = vec!["age1aaa".into()];
456
457        let mut ours = base.clone();
458        ours.age_recipients.push("age1bbb".into());
459
460        let mut theirs = base.clone();
461        theirs.age_recipients.push("age1ccc".into());
462
463        let result = three_way_merge(&base, &ours, &theirs).unwrap();
464        assert!(result
465            .merged
466            .age_recipients
467            .contains(&"age1aaa".to_string()));
468        assert!(result
469            .merged
470            .age_recipients
471            .contains(&"age1bbb".to_string()));
472        assert!(result
473            .merged
474            .age_recipients
475            .contains(&"age1ccc".to_string()));
476    }
477
478    #[test]
479    fn merge_delete_vs_edit_conflict_prefers_live_value() {
480        let mut base = empty_vault();
481        base.secrets
482            .insert("KEY".into(), make_entry("n0", "ct0", 5));
483
484        // Ours deletes it.
485        let mut ours = base.clone();
486        ours.secrets.remove("KEY");
487
488        // Theirs edits it (changed ciphertext).
489        let mut theirs = base.clone();
490        theirs
491            .secrets
492            .insert("KEY".into(), make_entry("n1", "ct1", 0));
493
494        let result = three_way_merge(&base, &ours, &theirs).unwrap();
495        // Should prefer the live value over deletion.
496        assert!(result.merged.secrets.contains_key("KEY"));
497        assert!(result.conflicts.contains(&"KEY".to_string()));
498        assert_eq!(
499            result.decisions,
500            vec![MergeDecision {
501                key: "KEY".into(),
502                state: MergeState::DeleteConflictResolvedToTheirs,
503            }]
504        );
505    }
506
507    #[test]
508    fn merge_both_delete_records_deleted_by_both() {
509        let mut base = empty_vault();
510        base.secrets
511            .insert("GONE".into(), make_entry("n0", "ct0", 5));
512
513        let mut ours = base.clone();
514        ours.secrets.remove("GONE");
515
516        let mut theirs = base.clone();
517        theirs.secrets.remove("GONE");
518
519        let result = three_way_merge(&base, &ours, &theirs).unwrap();
520        assert_eq!(result.deleted, vec!["GONE"]);
521        assert_eq!(
522            result.decisions,
523            vec![MergeDecision {
524                key: "GONE".into(),
525                state: MergeState::DeletedByBoth,
526            }]
527        );
528    }
529}