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