Skip to main content

murk_cli/
merge.rs

1//! Three-way merge driver for `.murk` vault files.
2//!
3//! Operates at the Vault struct level: recipients as a set, schema and secrets
4//! as key-level maps. Ciphertext equality against the base determines whether
5//! a side modified a value (murk preserves ciphertext for unchanged values).
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use crate::types::{SecretEntry, Vault};
10
11/// A single conflict discovered during merge.
12#[derive(Debug)]
13pub struct MergeConflict {
14    pub field: String,
15    pub reason: String,
16}
17
18/// Result of a three-way vault merge.
19#[derive(Debug)]
20pub struct MergeResult {
21    pub vault: Vault,
22    pub conflicts: Vec<MergeConflict>,
23}
24
25/// Three-way merge of vault files at the struct level.
26///
27/// `base` is the common ancestor, `ours` is the current branch,
28/// `theirs` is the incoming branch. Returns the merged vault and any conflicts.
29/// On conflict, the conflicting field keeps the "ours" value.
30pub fn merge_vaults(base: &Vault, ours: &Vault, theirs: &Vault) -> MergeResult {
31    let mut conflicts = Vec::new();
32
33    // -- Static fields: take ours --
34    let version = ours.version.clone();
35    let created = ours.created.clone();
36    let vault_name = ours.vault_name.clone();
37    let repo = ours.repo.clone();
38
39    // -- Recipients: set union/removal --
40    let recipients = merge_recipients(base, ours, theirs);
41
42    // Detect recipient-change sides (triggers full re-encryption).
43    let base_recip: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
44    let ours_recip: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
45    let theirs_recip: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
46    let ours_changed_recipients = ours_recip != base_recip;
47    let theirs_changed_recipients = theirs_recip != base_recip;
48
49    // -- Schema: key-level merge --
50    let schema = merge_btree(
51        &base.schema,
52        &ours.schema,
53        &theirs.schema,
54        "schema",
55        &mut conflicts,
56    );
57
58    // -- Secrets: key-level merge with ciphertext comparison --
59    let secrets = merge_secrets(
60        base,
61        ours,
62        theirs,
63        ours_changed_recipients,
64        theirs_changed_recipients,
65        &mut conflicts,
66    );
67
68    // -- Meta: take ours for now; the CLI command handles regeneration --
69    let meta = ours.meta.clone();
70
71    let vault = Vault {
72        version,
73        created,
74        vault_name,
75        repo,
76        recipients,
77        schema,
78        secrets,
79        meta,
80    };
81
82    MergeResult { vault, conflicts }
83}
84
85/// Merge recipient lists as sets: union additions, honor removals.
86fn merge_recipients(base: &Vault, ours: &Vault, theirs: &Vault) -> Vec<String> {
87    let base_set: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
88    let ours_set: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
89    let theirs_set: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
90
91    let ours_added: BTreeSet<&str> = ours_set.difference(&base_set).copied().collect();
92    let theirs_added: BTreeSet<&str> = theirs_set.difference(&base_set).copied().collect();
93    let ours_removed: BTreeSet<&str> = base_set.difference(&ours_set).copied().collect();
94    let theirs_removed: BTreeSet<&str> = base_set.difference(&theirs_set).copied().collect();
95
96    let mut result: BTreeSet<&str> = base_set;
97
98    // Add new recipients from both sides.
99    for pk in ours_added {
100        result.insert(pk);
101    }
102    for pk in theirs_added {
103        result.insert(pk);
104    }
105
106    // Remove recipients removed by either side.
107    for pk in &ours_removed {
108        result.remove(pk);
109    }
110    for pk in &theirs_removed {
111        result.remove(pk);
112    }
113
114    result.into_iter().map(String::from).collect()
115}
116
117/// Generic three-way merge for BTreeMap where values implement PartialEq + Clone.
118fn merge_btree<V: PartialEq + Clone>(
119    base: &BTreeMap<String, V>,
120    ours: &BTreeMap<String, V>,
121    theirs: &BTreeMap<String, V>,
122    field_name: &str,
123    conflicts: &mut Vec<MergeConflict>,
124) -> BTreeMap<String, V> {
125    let all_keys: BTreeSet<&str> = base
126        .keys()
127        .chain(ours.keys())
128        .chain(theirs.keys())
129        .map(String::as_str)
130        .collect();
131
132    let mut result = BTreeMap::new();
133
134    for key in all_keys {
135        let in_base = base.get(key);
136        let in_ours = ours.get(key);
137        let in_theirs = theirs.get(key);
138
139        match (in_base, in_ours, in_theirs) {
140            (None, None, Some(t)) => {
141                result.insert(key.to_string(), t.clone());
142            }
143            (None, Some(o), None) => {
144                result.insert(key.to_string(), o.clone());
145            }
146            (None, Some(o), Some(t)) => {
147                if o == t {
148                    result.insert(key.to_string(), o.clone());
149                } else {
150                    conflicts.push(MergeConflict {
151                        field: format!("{field_name}.{key}"),
152                        reason: "added on both sides with different values".into(),
153                    });
154                    result.insert(key.to_string(), o.clone());
155                }
156            }
157
158            // Removed by one or both sides — omit from result.
159            (Some(_), None | Some(_), None) | (Some(_), None, Some(_)) | (None, None, None) => {}
160
161            (Some(b), Some(o), Some(t)) => {
162                let ours_changed = o != b;
163                let theirs_changed = t != b;
164
165                match (ours_changed, theirs_changed) {
166                    (false, true) => {
167                        result.insert(key.to_string(), t.clone());
168                    }
169                    (true, true) if o != t => {
170                        conflicts.push(MergeConflict {
171                            field: format!("{field_name}.{key}"),
172                            reason: "modified on both sides with different values".into(),
173                        });
174                        result.insert(key.to_string(), o.clone());
175                    }
176                    _ => {
177                        result.insert(key.to_string(), o.clone());
178                    }
179                }
180            }
181        }
182    }
183
184    result
185}
186
187/// Merge secrets with ciphertext-equality-against-base comparison.
188///
189/// When one side changed recipients (triggering full re-encryption), that side's
190/// ciphertext all differs from base. We detect this and use the re-encrypted side
191/// as the baseline, applying the other side's additions/removals.
192fn merge_secrets(
193    base: &Vault,
194    ours: &Vault,
195    theirs: &Vault,
196    ours_changed_recipients: bool,
197    theirs_changed_recipients: bool,
198    conflicts: &mut Vec<MergeConflict>,
199) -> BTreeMap<String, SecretEntry> {
200    // If one side changed recipients, all its ciphertext differs from base.
201    // Use the re-encrypted side as the "new base" and apply the other side's diffs.
202    if ours_changed_recipients && !theirs_changed_recipients {
203        return merge_secrets_with_reencrypted_side(base, ours, theirs, "theirs", conflicts);
204    }
205    if theirs_changed_recipients && !ours_changed_recipients {
206        return merge_secrets_with_reencrypted_side(base, theirs, ours, "ours", conflicts);
207    }
208    if ours_changed_recipients && theirs_changed_recipients {
209        return merge_secrets_both_reencrypted(base, ours, theirs, conflicts);
210    }
211
212    // Normal case: neither side changed recipients. Ciphertext comparison works.
213    merge_secrets_normal(base, ours, theirs, conflicts)
214}
215
216/// Normal secret merge: compare ciphertext against base to detect changes.
217fn merge_secrets_normal(
218    base: &Vault,
219    ours: &Vault,
220    theirs: &Vault,
221    conflicts: &mut Vec<MergeConflict>,
222) -> BTreeMap<String, SecretEntry> {
223    let all_keys: BTreeSet<&str> = base
224        .secrets
225        .keys()
226        .chain(ours.secrets.keys())
227        .chain(theirs.secrets.keys())
228        .map(String::as_str)
229        .collect();
230
231    let mut result = BTreeMap::new();
232
233    for key in all_keys {
234        let in_base = base.secrets.get(key);
235        let in_ours = ours.secrets.get(key);
236        let in_theirs = theirs.secrets.get(key);
237
238        match (in_base, in_ours, in_theirs) {
239            (None, None, Some(t)) => {
240                result.insert(key.to_string(), t.clone());
241            }
242            (None, Some(o), None) => {
243                result.insert(key.to_string(), o.clone());
244            }
245            (None, Some(o), Some(t)) => {
246                if o.shared == t.shared {
247                    result.insert(key.to_string(), o.clone());
248                } else {
249                    conflicts.push(MergeConflict {
250                        field: format!("secrets.{key}"),
251                        reason: "added on both sides (values may differ)".into(),
252                    });
253                    result.insert(key.to_string(), o.clone());
254                }
255            }
256
257            // Both removed or impossible key.
258            (Some(_) | None, None, None) => {}
259
260            (Some(b), Some(o), None) => {
261                if o.shared != b.shared {
262                    conflicts.push(MergeConflict {
263                        field: format!("secrets.{key}"),
264                        reason: "modified on our side but removed on theirs".into(),
265                    });
266                    result.insert(key.to_string(), o.clone());
267                }
268            }
269            (Some(b), None, Some(t)) => {
270                if t.shared != b.shared {
271                    conflicts.push(MergeConflict {
272                        field: format!("secrets.{key}"),
273                        reason: "removed on our side but modified on theirs".into(),
274                    });
275                    result.insert(key.to_string(), t.clone());
276                }
277            }
278
279            (Some(b), Some(o), Some(t)) => {
280                let ours_changed = o.shared != b.shared;
281                let theirs_changed = t.shared != b.shared;
282
283                let shared = match (ours_changed, theirs_changed) {
284                    (false, true) => t.shared.clone(),
285                    (true, true) => {
286                        conflicts.push(MergeConflict {
287                            field: format!("secrets.{key}"),
288                            reason: "shared value modified on both sides".into(),
289                        });
290                        o.shared.clone()
291                    }
292                    _ => o.shared.clone(),
293                };
294
295                let scoped = merge_scoped(&b.scoped, &o.scoped, &t.scoped, key, conflicts);
296                result.insert(key.to_string(), SecretEntry { shared, scoped });
297            }
298        }
299    }
300
301    result
302}
303
304/// Merge scoped (mote) entries within a single secret key.
305fn merge_scoped(
306    base: &BTreeMap<String, String>,
307    ours: &BTreeMap<String, String>,
308    theirs: &BTreeMap<String, String>,
309    secret_key: &str,
310    conflicts: &mut Vec<MergeConflict>,
311) -> BTreeMap<String, String> {
312    let all_pks: BTreeSet<&str> = base
313        .keys()
314        .chain(ours.keys())
315        .chain(theirs.keys())
316        .map(String::as_str)
317        .collect();
318
319    let mut result = BTreeMap::new();
320
321    for pk in all_pks {
322        let in_base = base.get(pk);
323        let in_ours = ours.get(pk);
324        let in_theirs = theirs.get(pk);
325
326        match (in_base, in_ours, in_theirs) {
327            (None, None, Some(t)) => {
328                result.insert(pk.to_string(), t.clone());
329            }
330            (None, Some(o), None) => {
331                result.insert(pk.to_string(), o.clone());
332            }
333            (None, Some(o), Some(t)) => {
334                if o == t {
335                    result.insert(pk.to_string(), o.clone());
336                } else {
337                    conflicts.push(MergeConflict {
338                        field: format!("secrets.{secret_key}.scoped.{pk}"),
339                        reason: "scoped override added on both sides".into(),
340                    });
341                    result.insert(pk.to_string(), o.clone());
342                }
343            }
344            (Some(_) | None, None, None) => {}
345            (Some(b), Some(o), None) => {
346                if o != b {
347                    conflicts.push(MergeConflict {
348                        field: format!("secrets.{secret_key}.scoped.{pk}"),
349                        reason: "scoped override modified on our side but removed on theirs".into(),
350                    });
351                    result.insert(pk.to_string(), o.clone());
352                }
353            }
354            (Some(b), None, Some(t)) => {
355                if t != b {
356                    conflicts.push(MergeConflict {
357                        field: format!("secrets.{secret_key}.scoped.{pk}"),
358                        reason: "scoped override removed on our side but modified on theirs".into(),
359                    });
360                    result.insert(pk.to_string(), t.clone());
361                }
362            }
363            (Some(b), Some(o), Some(t)) => {
364                let ours_changed = o != b;
365                let theirs_changed = t != b;
366
367                match (ours_changed, theirs_changed) {
368                    (false, true) => {
369                        result.insert(pk.to_string(), t.clone());
370                    }
371                    (true, true) if o != t => {
372                        conflicts.push(MergeConflict {
373                            field: format!("secrets.{secret_key}.scoped.{pk}"),
374                            reason: "scoped override modified on both sides".into(),
375                        });
376                        result.insert(pk.to_string(), o.clone());
377                    }
378                    _ => {
379                        result.insert(pk.to_string(), o.clone());
380                    }
381                }
382            }
383        }
384    }
385
386    result
387}
388
389/// When one side re-encrypted (changed recipients), use it as the new baseline
390/// and apply the other side's key-level additions/removals.
391///
392/// `reencrypted` is the side that changed recipients (all ciphertext differs from base).
393/// `other` is the side with stable ciphertext. `other_label` is "ours" or "theirs" for messages.
394fn merge_secrets_with_reencrypted_side(
395    base: &Vault,
396    reencrypted: &Vault,
397    other: &Vault,
398    other_label: &str,
399    conflicts: &mut Vec<MergeConflict>,
400) -> BTreeMap<String, SecretEntry> {
401    // Start with the re-encrypted side's secrets (they have the new recipient set).
402    let mut result = reencrypted.secrets.clone();
403
404    // Detect what the other side added/removed/modified relative to base.
405    let all_keys: BTreeSet<&str> = base
406        .secrets
407        .keys()
408        .chain(other.secrets.keys())
409        .map(String::as_str)
410        .collect();
411
412    for key in all_keys {
413        let in_base = base.secrets.get(key);
414        let in_other = other.secrets.get(key);
415
416        match (in_base, in_other) {
417            (None, Some(entry)) => {
418                if result.contains_key(key) {
419                    conflicts.push(MergeConflict {
420                        field: format!("secrets.{key}"),
421                        reason: format!(
422                            "added on {other_label} side and on the side that changed recipients"
423                        ),
424                    });
425                } else {
426                    result.insert(key.to_string(), entry.clone());
427                }
428            }
429            (Some(_), None) => {
430                // Other side removed this key. Honor the removal.
431                result.remove(key);
432            }
433            (Some(b), Some(entry)) => {
434                if entry.shared != b.shared {
435                    conflicts.push(MergeConflict {
436                        field: format!("secrets.{key}"),
437                        reason: format!(
438                            "modified on {other_label} side while recipients changed on the other"
439                        ),
440                    });
441                }
442                // If other side didn't modify, keep re-encrypted version.
443            }
444            (None, None) => {}
445        }
446    }
447
448    result
449}
450
451/// Both sides changed recipients — all ciphertext on both sides differs from base.
452/// Without decryption we can only merge keys that were added/removed (not modified).
453fn merge_secrets_both_reencrypted(
454    base: &Vault,
455    ours: &Vault,
456    theirs: &Vault,
457    conflicts: &mut Vec<MergeConflict>,
458) -> BTreeMap<String, SecretEntry> {
459    let all_keys: BTreeSet<&str> = base
460        .secrets
461        .keys()
462        .chain(ours.secrets.keys())
463        .chain(theirs.secrets.keys())
464        .map(String::as_str)
465        .collect();
466
467    let mut result = BTreeMap::new();
468
469    for key in all_keys {
470        let in_base = base.secrets.get(key);
471        let in_ours = ours.secrets.get(key);
472        let in_theirs = theirs.secrets.get(key);
473
474        match (in_base, in_ours, in_theirs) {
475            // Both have it and it was in base — take ours.
476            (Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
477                result.insert(key.to_string(), o.clone());
478            }
479            // Removals — honor them.
480            (Some(_), Some(_) | None, None) | (Some(_), None, Some(_)) | (None, None, None) => {}
481            (None, None, Some(t)) => {
482                result.insert(key.to_string(), t.clone());
483            }
484            (None, Some(o), Some(_)) => {
485                conflicts.push(MergeConflict {
486                    field: format!("secrets.{key}"),
487                    reason: "added on both sides while both changed recipients".into(),
488                });
489                result.insert(key.to_string(), o.clone());
490            }
491        }
492    }
493
494    result
495}
496
497/// Output of the merge driver: the merge result and whether meta was regenerated.
498#[derive(Debug)]
499pub struct MergeDriverOutput {
500    pub result: MergeResult,
501    pub meta_regenerated: bool,
502}
503
504/// Run the three-way merge driver on vault contents (as strings).
505///
506/// Parses all three versions, merges, and attempts meta regeneration.
507/// Returns the merged vault and conflict list. The caller is responsible for
508/// writing the result to disk.
509pub fn run_merge_driver(base: &str, ours: &str, theirs: &str) -> Result<MergeDriverOutput, String> {
510    use crate::vault;
511
512    let base_vault = vault::parse(base).map_err(|e| format!("parsing base: {e}"))?;
513    let ours_vault = vault::parse(ours).map_err(|e| format!("parsing ours: {e}"))?;
514    let theirs_vault = vault::parse(theirs).map_err(|e| format!("parsing theirs: {e}"))?;
515
516    let mut result = merge_vaults(&base_vault, &ours_vault, &theirs_vault);
517    let meta_regenerated = regenerate_meta(&mut result.vault, &ours_vault, &theirs_vault).is_some();
518
519    Ok(MergeDriverOutput {
520        result,
521        meta_regenerated,
522    })
523}
524
525/// Attempt to regenerate the meta blob for a merged vault.
526///
527/// Decrypts meta from `ours` and `theirs` to merge recipient name maps,
528/// recomputes the MAC, and re-encrypts. Falls back to `ours.meta` if
529/// MURK_KEY is unavailable.
530pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Option<String> {
531    use crate::{compute_mac, crypto, decrypt_meta, encrypt_value, parse_recipients, resolve_key};
532    use age::secrecy::ExposeSecret;
533    use std::collections::HashMap;
534
535    let secret_key = resolve_key().ok()?;
536    let identity = crypto::parse_identity(secret_key.expose_secret()).ok()?;
537
538    let default_meta = || crate::types::Meta {
539        recipients: HashMap::new(),
540        mac: String::new(),
541        hmac_key: None,
542    };
543
544    let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
545    let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
546
547    // Merge name maps: union, ours wins on conflict.
548    let mut names = theirs_meta.recipients;
549    for (pk, name) in ours_meta.recipients {
550        names.insert(pk, name);
551    }
552
553    // Only keep names for recipients still in the merged vault.
554    names.retain(|pk, _| merged.recipients.contains(pk));
555
556    let hmac_key_hex = crate::generate_hmac_key();
557    let hmac_key = crate::decode_hmac_key(&hmac_key_hex).unwrap();
558    let mac = compute_mac(merged, Some(&hmac_key));
559    let meta = crate::types::Meta {
560        recipients: names,
561        mac,
562        hmac_key: Some(hmac_key_hex),
563    };
564
565    let recipients = parse_recipients(&merged.recipients).ok()?;
566
567    if recipients.is_empty() {
568        return None;
569    }
570
571    let meta_json = serde_json::to_vec(&meta).ok()?;
572    let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
573    merged.meta = encrypted;
574    Some("meta regenerated".into())
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
581    use std::collections::BTreeMap;
582
583    fn base_vault() -> Vault {
584        let mut schema = BTreeMap::new();
585        schema.insert(
586            "DB_URL".into(),
587            SchemaEntry {
588                description: "database url".into(),
589                example: None,
590                tags: vec![],
591            },
592        );
593
594        let mut secrets = BTreeMap::new();
595        secrets.insert(
596            "DB_URL".into(),
597            SecretEntry {
598                shared: "base-cipher-db".into(),
599                scoped: BTreeMap::new(),
600            },
601        );
602
603        Vault {
604            version: VAULT_VERSION.into(),
605            created: "2026-01-01T00:00:00Z".into(),
606            vault_name: ".murk".into(),
607            repo: String::new(),
608            recipients: vec!["age1alice".into(), "age1bob".into()],
609            schema,
610            secrets,
611            meta: "base-meta".into(),
612        }
613    }
614
615    // -- No-change merge --
616
617    #[test]
618    fn merge_no_changes() {
619        let base = base_vault();
620        let r = merge_vaults(&base, &base, &base);
621        assert!(r.conflicts.is_empty());
622        assert_eq!(r.vault.secrets.len(), 1);
623        assert_eq!(r.vault.recipients.len(), 2);
624    }
625
626    // -- Ours-only changes --
627
628    #[test]
629    fn merge_ours_adds_secret() {
630        let base = base_vault();
631        let mut ours = base.clone();
632        ours.secrets.insert(
633            "API_KEY".into(),
634            SecretEntry {
635                shared: "ours-cipher-api".into(),
636                scoped: BTreeMap::new(),
637            },
638        );
639        ours.schema.insert(
640            "API_KEY".into(),
641            SchemaEntry {
642                description: "api key".into(),
643                example: None,
644                tags: vec![],
645            },
646        );
647
648        let r = merge_vaults(&base, &ours, &base);
649        assert!(r.conflicts.is_empty());
650        assert!(r.vault.secrets.contains_key("API_KEY"));
651        assert!(r.vault.schema.contains_key("API_KEY"));
652        assert_eq!(r.vault.secrets.len(), 2);
653    }
654
655    // -- Theirs-only changes --
656
657    #[test]
658    fn merge_theirs_adds_secret() {
659        let base = base_vault();
660        let mut theirs = base.clone();
661        theirs.secrets.insert(
662            "STRIPE_KEY".into(),
663            SecretEntry {
664                shared: "theirs-cipher-stripe".into(),
665                scoped: BTreeMap::new(),
666            },
667        );
668
669        let r = merge_vaults(&base, &base, &theirs);
670        assert!(r.conflicts.is_empty());
671        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
672    }
673
674    // -- Both add different keys --
675
676    #[test]
677    fn merge_both_add_different_keys() {
678        let base = base_vault();
679        let mut ours = base.clone();
680        ours.secrets.insert(
681            "API_KEY".into(),
682            SecretEntry {
683                shared: "ours-cipher-api".into(),
684                scoped: BTreeMap::new(),
685            },
686        );
687
688        let mut theirs = base.clone();
689        theirs.secrets.insert(
690            "STRIPE_KEY".into(),
691            SecretEntry {
692                shared: "theirs-cipher-stripe".into(),
693                scoped: BTreeMap::new(),
694            },
695        );
696
697        let r = merge_vaults(&base, &ours, &theirs);
698        assert!(r.conflicts.is_empty());
699        assert!(r.vault.secrets.contains_key("API_KEY"));
700        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
701        assert!(r.vault.secrets.contains_key("DB_URL"));
702        assert_eq!(r.vault.secrets.len(), 3);
703    }
704
705    // -- Both remove same key --
706
707    #[test]
708    fn merge_both_remove_same_key() {
709        let base = base_vault();
710        let mut ours = base.clone();
711        ours.secrets.remove("DB_URL");
712        let mut theirs = base.clone();
713        theirs.secrets.remove("DB_URL");
714
715        let r = merge_vaults(&base, &ours, &theirs);
716        assert!(r.conflicts.is_empty());
717        assert!(!r.vault.secrets.contains_key("DB_URL"));
718    }
719
720    // -- Ours modifies, theirs unchanged --
721
722    #[test]
723    fn merge_ours_modifies_theirs_unchanged() {
724        let base = base_vault();
725        let mut ours = base.clone();
726        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
727
728        let r = merge_vaults(&base, &ours, &base);
729        assert!(r.conflicts.is_empty());
730        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
731    }
732
733    // -- Theirs modifies, ours unchanged --
734
735    #[test]
736    fn merge_theirs_modifies_ours_unchanged() {
737        let base = base_vault();
738        let mut theirs = base.clone();
739        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
740
741        let r = merge_vaults(&base, &base, &theirs);
742        assert!(r.conflicts.is_empty());
743        assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
744    }
745
746    // -- Conflicts --
747
748    #[test]
749    fn merge_both_modify_same_secret() {
750        let base = base_vault();
751        let mut ours = base.clone();
752        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
753        let mut theirs = base.clone();
754        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
755
756        let r = merge_vaults(&base, &ours, &theirs);
757        assert_eq!(r.conflicts.len(), 1);
758        assert!(r.conflicts[0].field.contains("DB_URL"));
759        // Takes ours on conflict.
760        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
761    }
762
763    #[test]
764    fn merge_both_add_same_key() {
765        let base = base_vault();
766        let mut ours = base.clone();
767        ours.secrets.insert(
768            "NEW_KEY".into(),
769            SecretEntry {
770                shared: "ours-cipher".into(),
771                scoped: BTreeMap::new(),
772            },
773        );
774        let mut theirs = base.clone();
775        theirs.secrets.insert(
776            "NEW_KEY".into(),
777            SecretEntry {
778                shared: "theirs-cipher".into(),
779                scoped: BTreeMap::new(),
780            },
781        );
782
783        let r = merge_vaults(&base, &ours, &theirs);
784        assert_eq!(r.conflicts.len(), 1);
785        assert!(r.conflicts[0].field.contains("NEW_KEY"));
786    }
787
788    #[test]
789    fn merge_remove_vs_modify() {
790        let base = base_vault();
791        let mut ours = base.clone();
792        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
793        let mut theirs = base.clone();
794        theirs.secrets.remove("DB_URL");
795
796        let r = merge_vaults(&base, &ours, &theirs);
797        assert_eq!(r.conflicts.len(), 1);
798        assert!(
799            r.conflicts[0]
800                .reason
801                .contains("modified on our side but removed on theirs")
802        );
803    }
804
805    // -- Recipients --
806
807    #[test]
808    fn merge_recipient_added_ours() {
809        let base = base_vault();
810        let mut ours = base.clone();
811        ours.recipients.push("age1charlie".into());
812
813        let r = merge_vaults(&base, &ours, &base);
814        assert!(r.conflicts.is_empty());
815        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
816        assert_eq!(r.vault.recipients.len(), 3);
817    }
818
819    #[test]
820    fn merge_recipient_added_both_same() {
821        let base = base_vault();
822        let mut ours = base.clone();
823        ours.recipients.push("age1charlie".into());
824        let mut theirs = base.clone();
825        theirs.recipients.push("age1charlie".into());
826
827        let r = merge_vaults(&base, &ours, &theirs);
828        assert!(r.conflicts.is_empty());
829        assert_eq!(
830            r.vault
831                .recipients
832                .iter()
833                .filter(|r| *r == "age1charlie")
834                .count(),
835            1
836        );
837    }
838
839    #[test]
840    fn merge_recipient_removed() {
841        let base = base_vault();
842        let mut ours = base.clone();
843        ours.recipients.retain(|r| r != "age1bob");
844
845        let r = merge_vaults(&base, &ours, &base);
846        assert!(r.conflicts.is_empty());
847        assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
848        assert_eq!(r.vault.recipients.len(), 1);
849    }
850
851    // -- Schema --
852
853    #[test]
854    fn merge_schema_different_keys() {
855        let base = base_vault();
856        let mut ours = base.clone();
857        ours.schema.insert(
858            "API_KEY".into(),
859            SchemaEntry {
860                description: "api".into(),
861                example: None,
862                tags: vec![],
863            },
864        );
865        let mut theirs = base.clone();
866        theirs.schema.insert(
867            "STRIPE".into(),
868            SchemaEntry {
869                description: "stripe".into(),
870                example: None,
871                tags: vec![],
872            },
873        );
874
875        let r = merge_vaults(&base, &ours, &theirs);
876        assert!(r.conflicts.is_empty());
877        assert!(r.vault.schema.contains_key("API_KEY"));
878        assert!(r.vault.schema.contains_key("STRIPE"));
879    }
880
881    #[test]
882    fn merge_schema_same_key_conflict() {
883        let base = base_vault();
884        let mut ours = base.clone();
885        ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
886        let mut theirs = base.clone();
887        theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
888
889        let r = merge_vaults(&base, &ours, &theirs);
890        assert_eq!(r.conflicts.len(), 1);
891        assert!(r.conflicts[0].field.contains("schema.DB_URL"));
892    }
893
894    // -- Scoped --
895
896    #[test]
897    fn merge_scoped_different_pubkeys() {
898        let base = base_vault();
899        let mut ours = base.clone();
900        ours.secrets
901            .get_mut("DB_URL")
902            .unwrap()
903            .scoped
904            .insert("age1alice".into(), "alice-scope".into());
905        let mut theirs = base.clone();
906        theirs
907            .secrets
908            .get_mut("DB_URL")
909            .unwrap()
910            .scoped
911            .insert("age1bob".into(), "bob-scope".into());
912
913        let r = merge_vaults(&base, &ours, &theirs);
914        assert!(r.conflicts.is_empty());
915        let entry = &r.vault.secrets["DB_URL"];
916        assert_eq!(entry.scoped["age1alice"], "alice-scope");
917        assert_eq!(entry.scoped["age1bob"], "bob-scope");
918    }
919
920    #[test]
921    fn merge_scoped_both_modify_same() {
922        let mut base = base_vault();
923        base.secrets
924            .get_mut("DB_URL")
925            .unwrap()
926            .scoped
927            .insert("age1alice".into(), "base-scope".into());
928
929        let mut ours = base.clone();
930        ours.secrets
931            .get_mut("DB_URL")
932            .unwrap()
933            .scoped
934            .insert("age1alice".into(), "ours-scope".into());
935        let mut theirs = base.clone();
936        theirs
937            .secrets
938            .get_mut("DB_URL")
939            .unwrap()
940            .scoped
941            .insert("age1alice".into(), "theirs-scope".into());
942
943        let r = merge_vaults(&base, &ours, &theirs);
944        assert_eq!(r.conflicts.len(), 1);
945        assert!(r.conflicts[0].field.contains("scoped"));
946    }
947
948    #[test]
949    fn merge_scoped_add_vs_base_key_removal() {
950        let base = base_vault();
951
952        // Ours: remove the base key entirely.
953        let mut ours = base.clone();
954        ours.secrets.remove("DB_URL");
955        ours.schema.remove("DB_URL");
956
957        // Theirs: add a scoped entry on the same key (shared unchanged).
958        let mut theirs = base.clone();
959        theirs
960            .secrets
961            .get_mut("DB_URL")
962            .unwrap()
963            .scoped
964            .insert("age1alice".into(), "alice-scoped".into());
965
966        let r = merge_vaults(&base, &ours, &theirs);
967        // Theirs only added scoped (shared unchanged), so ours' removal wins
968        // without conflict — the scoped addition is silently dropped.
969        assert!(r.conflicts.is_empty());
970        assert!(!r.vault.secrets.contains_key("DB_URL"));
971    }
972
973    #[test]
974    fn merge_scoped_add_vs_base_key_modification() {
975        let base = base_vault();
976
977        // Ours: remove the base key entirely.
978        let mut ours = base.clone();
979        ours.secrets.remove("DB_URL");
980        ours.schema.remove("DB_URL");
981
982        // Theirs: modify the shared value AND add scoped.
983        let mut theirs = base.clone();
984        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
985        theirs
986            .secrets
987            .get_mut("DB_URL")
988            .unwrap()
989            .scoped
990            .insert("age1alice".into(), "alice-scoped".into());
991
992        let r = merge_vaults(&base, &ours, &theirs);
993        // Theirs modified shared, ours removed — this IS a conflict.
994        assert_eq!(r.conflicts.len(), 1);
995        assert!(r.conflicts[0].reason.contains("removed on our side"));
996    }
997
998    // -- Recipient change + secret addition --
999
1000    #[test]
1001    fn merge_ours_changes_recipients_theirs_adds_key() {
1002        let base = base_vault();
1003        let mut ours = base.clone();
1004        ours.recipients.push("age1charlie".into());
1005        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1006
1007        let mut theirs = base.clone();
1008        theirs.secrets.insert(
1009            "NEW_KEY".into(),
1010            SecretEntry {
1011                shared: "theirs-new".into(),
1012                scoped: BTreeMap::new(),
1013            },
1014        );
1015
1016        let r = merge_vaults(&base, &ours, &theirs);
1017        assert!(r.conflicts.is_empty());
1018        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1019        assert!(r.vault.secrets.contains_key("NEW_KEY"));
1020        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1021    }
1022}