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