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        github_pins: HashMap::new(),
610    };
611
612    let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
613    let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
614
615    // Merge name maps: union, ours wins on conflict.
616    let mut names = theirs_meta.recipients;
617    for (pk, name) in ours_meta.recipients {
618        names.insert(pk, name);
619    }
620
621    // Only keep names for recipients still in the merged vault.
622    names.retain(|pk, _| merged.recipients.contains(pk));
623
624    let mac_key_hex = crate::generate_mac_key();
625    let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
626    let mac = compute_mac(merged, Some(&mac_key));
627    // Merge github pins: union, ours wins on conflict.
628    let mut github_pins = theirs_meta.github_pins;
629    for (user, pins) in ours_meta.github_pins {
630        github_pins.insert(user, pins);
631    }
632
633    let meta = crate::types::Meta {
634        recipients: names,
635        mac,
636        mac_key: Some(mac_key_hex),
637        github_pins,
638    };
639
640    let recipients = parse_recipients(&merged.recipients).ok()?;
641
642    if recipients.is_empty() {
643        return None;
644    }
645
646    let meta_json = serde_json::to_vec(&meta).ok()?;
647    let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
648    merged.meta = encrypted;
649    Some("meta regenerated".into())
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
656    use std::collections::BTreeMap;
657
658    fn base_vault() -> Vault {
659        let mut schema = BTreeMap::new();
660        schema.insert(
661            "DB_URL".into(),
662            SchemaEntry {
663                description: "database url".into(),
664                example: None,
665                tags: vec![],
666                ..Default::default()
667            },
668        );
669
670        let mut secrets = BTreeMap::new();
671        secrets.insert(
672            "DB_URL".into(),
673            SecretEntry {
674                shared: "base-cipher-db".into(),
675                scoped: BTreeMap::new(),
676            },
677        );
678
679        Vault {
680            version: VAULT_VERSION.into(),
681            created: "2026-01-01T00:00:00Z".into(),
682            vault_name: ".murk".into(),
683            repo: String::new(),
684            recipients: vec!["age1alice".into(), "age1bob".into()],
685            schema,
686            secrets,
687            meta: "base-meta".into(),
688        }
689    }
690
691    // -- No-change merge --
692
693    #[test]
694    fn merge_no_changes() {
695        let base = base_vault();
696        let r = merge_vaults(&base, &base, &base);
697        assert!(r.conflicts.is_empty());
698        assert_eq!(r.vault.secrets.len(), 1);
699        assert_eq!(r.vault.recipients.len(), 2);
700    }
701
702    // -- Ours-only changes --
703
704    #[test]
705    fn merge_ours_adds_secret() {
706        let base = base_vault();
707        let mut ours = base.clone();
708        ours.secrets.insert(
709            "API_KEY".into(),
710            SecretEntry {
711                shared: "ours-cipher-api".into(),
712                scoped: BTreeMap::new(),
713            },
714        );
715        ours.schema.insert(
716            "API_KEY".into(),
717            SchemaEntry {
718                description: "api key".into(),
719                example: None,
720                tags: vec![],
721                ..Default::default()
722            },
723        );
724
725        let r = merge_vaults(&base, &ours, &base);
726        assert!(r.conflicts.is_empty());
727        assert!(r.vault.secrets.contains_key("API_KEY"));
728        assert!(r.vault.schema.contains_key("API_KEY"));
729        assert_eq!(r.vault.secrets.len(), 2);
730    }
731
732    // -- Theirs-only changes --
733
734    #[test]
735    fn merge_theirs_adds_secret() {
736        let base = base_vault();
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, &base, &theirs);
747        assert!(r.conflicts.is_empty());
748        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
749    }
750
751    // -- Both add different keys --
752
753    #[test]
754    fn merge_both_add_different_keys() {
755        let base = base_vault();
756        let mut ours = base.clone();
757        ours.secrets.insert(
758            "API_KEY".into(),
759            SecretEntry {
760                shared: "ours-cipher-api".into(),
761                scoped: BTreeMap::new(),
762            },
763        );
764
765        let mut theirs = base.clone();
766        theirs.secrets.insert(
767            "STRIPE_KEY".into(),
768            SecretEntry {
769                shared: "theirs-cipher-stripe".into(),
770                scoped: BTreeMap::new(),
771            },
772        );
773
774        let r = merge_vaults(&base, &ours, &theirs);
775        assert!(r.conflicts.is_empty());
776        assert!(r.vault.secrets.contains_key("API_KEY"));
777        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
778        assert!(r.vault.secrets.contains_key("DB_URL"));
779        assert_eq!(r.vault.secrets.len(), 3);
780    }
781
782    // -- Both remove same key --
783
784    #[test]
785    fn merge_both_remove_same_key() {
786        let base = base_vault();
787        let mut ours = base.clone();
788        ours.secrets.remove("DB_URL");
789        let mut theirs = base.clone();
790        theirs.secrets.remove("DB_URL");
791
792        let r = merge_vaults(&base, &ours, &theirs);
793        assert!(r.conflicts.is_empty());
794        assert!(!r.vault.secrets.contains_key("DB_URL"));
795    }
796
797    // -- Ours modifies, theirs unchanged --
798
799    #[test]
800    fn merge_ours_modifies_theirs_unchanged() {
801        let base = base_vault();
802        let mut ours = base.clone();
803        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
804
805        let r = merge_vaults(&base, &ours, &base);
806        assert!(r.conflicts.is_empty());
807        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
808    }
809
810    // -- Theirs modifies, ours unchanged --
811
812    #[test]
813    fn merge_theirs_modifies_ours_unchanged() {
814        let base = base_vault();
815        let mut theirs = base.clone();
816        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
817
818        let r = merge_vaults(&base, &base, &theirs);
819        assert!(r.conflicts.is_empty());
820        assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
821    }
822
823    // -- Conflicts --
824
825    #[test]
826    fn merge_both_modify_same_secret() {
827        let base = base_vault();
828        let mut ours = base.clone();
829        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
830        let mut theirs = base.clone();
831        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
832
833        let r = merge_vaults(&base, &ours, &theirs);
834        assert_eq!(r.conflicts.len(), 1);
835        assert!(r.conflicts[0].field.contains("DB_URL"));
836        // Takes ours on conflict.
837        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
838    }
839
840    #[test]
841    fn merge_both_add_same_key() {
842        let base = base_vault();
843        let mut ours = base.clone();
844        ours.secrets.insert(
845            "NEW_KEY".into(),
846            SecretEntry {
847                shared: "ours-cipher".into(),
848                scoped: BTreeMap::new(),
849            },
850        );
851        let mut theirs = base.clone();
852        theirs.secrets.insert(
853            "NEW_KEY".into(),
854            SecretEntry {
855                shared: "theirs-cipher".into(),
856                scoped: BTreeMap::new(),
857            },
858        );
859
860        let r = merge_vaults(&base, &ours, &theirs);
861        assert_eq!(r.conflicts.len(), 1);
862        assert!(r.conflicts[0].field.contains("NEW_KEY"));
863    }
864
865    #[test]
866    fn merge_remove_vs_modify() {
867        let base = base_vault();
868        let mut ours = base.clone();
869        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
870        let mut theirs = base.clone();
871        theirs.secrets.remove("DB_URL");
872
873        let r = merge_vaults(&base, &ours, &theirs);
874        assert_eq!(r.conflicts.len(), 1);
875        assert!(
876            r.conflicts[0]
877                .reason
878                .contains("modified on our side but removed on theirs")
879        );
880    }
881
882    // -- Recipients --
883
884    #[test]
885    fn merge_recipient_added_one_side_conflicts() {
886        let base = base_vault();
887        let mut ours = base.clone();
888        ours.recipients.push("age1charlie".into());
889
890        let r = merge_vaults(&base, &ours, &base);
891        assert_eq!(r.conflicts.len(), 1);
892        assert!(r.conflicts[0].reason.contains("added on one side"));
893        // Recipient is still included (safer to keep than drop).
894        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
895    }
896
897    #[test]
898    fn merge_recipient_added_both_same() {
899        let base = base_vault();
900        let mut ours = base.clone();
901        ours.recipients.push("age1charlie".into());
902        let mut theirs = base.clone();
903        theirs.recipients.push("age1charlie".into());
904
905        let r = merge_vaults(&base, &ours, &theirs);
906        assert!(r.conflicts.is_empty());
907        assert_eq!(
908            r.vault
909                .recipients
910                .iter()
911                .filter(|r| *r == "age1charlie")
912                .count(),
913            1
914        );
915    }
916
917    #[test]
918    fn merge_recipient_removed_one_side_conflicts() {
919        let base = base_vault();
920        let mut ours = base.clone();
921        ours.recipients.retain(|r| r != "age1bob");
922
923        let r = merge_vaults(&base, &ours, &base);
924        // One-sided removal should conflict — recipient kept for safety.
925        assert!(!r.conflicts.is_empty());
926        assert!(r.vault.recipients.contains(&"age1bob".to_string()));
927    }
928
929    #[test]
930    fn merge_recipient_removed_both_sides_ok() {
931        let base = base_vault();
932        let mut ours = base.clone();
933        let mut theirs = base.clone();
934        ours.recipients.retain(|r| r != "age1bob");
935        theirs.recipients.retain(|r| r != "age1bob");
936
937        let r = merge_vaults(&base, &ours, &theirs);
938        assert!(r.conflicts.is_empty());
939        assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
940    }
941
942    // -- Schema --
943
944    #[test]
945    fn merge_schema_different_keys() {
946        let base = base_vault();
947        let mut ours = base.clone();
948        ours.schema.insert(
949            "API_KEY".into(),
950            SchemaEntry {
951                description: "api".into(),
952                example: None,
953                tags: vec![],
954                ..Default::default()
955            },
956        );
957        let mut theirs = base.clone();
958        theirs.schema.insert(
959            "STRIPE".into(),
960            SchemaEntry {
961                description: "stripe".into(),
962                example: None,
963                tags: vec![],
964                ..Default::default()
965            },
966        );
967
968        let r = merge_vaults(&base, &ours, &theirs);
969        assert!(r.conflicts.is_empty());
970        assert!(r.vault.schema.contains_key("API_KEY"));
971        assert!(r.vault.schema.contains_key("STRIPE"));
972    }
973
974    #[test]
975    fn merge_schema_same_key_conflict() {
976        let base = base_vault();
977        let mut ours = base.clone();
978        ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
979        let mut theirs = base.clone();
980        theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
981
982        let r = merge_vaults(&base, &ours, &theirs);
983        assert_eq!(r.conflicts.len(), 1);
984        assert!(r.conflicts[0].field.contains("schema.DB_URL"));
985    }
986
987    // -- Scoped --
988
989    #[test]
990    fn merge_scoped_different_pubkeys() {
991        let base = base_vault();
992        let mut ours = base.clone();
993        ours.secrets
994            .get_mut("DB_URL")
995            .unwrap()
996            .scoped
997            .insert("age1alice".into(), "alice-scope".into());
998        let mut theirs = base.clone();
999        theirs
1000            .secrets
1001            .get_mut("DB_URL")
1002            .unwrap()
1003            .scoped
1004            .insert("age1bob".into(), "bob-scope".into());
1005
1006        let r = merge_vaults(&base, &ours, &theirs);
1007        assert!(r.conflicts.is_empty());
1008        let entry = &r.vault.secrets["DB_URL"];
1009        assert_eq!(entry.scoped["age1alice"], "alice-scope");
1010        assert_eq!(entry.scoped["age1bob"], "bob-scope");
1011    }
1012
1013    #[test]
1014    fn merge_scoped_both_modify_same() {
1015        let mut base = base_vault();
1016        base.secrets
1017            .get_mut("DB_URL")
1018            .unwrap()
1019            .scoped
1020            .insert("age1alice".into(), "base-scope".into());
1021
1022        let mut ours = base.clone();
1023        ours.secrets
1024            .get_mut("DB_URL")
1025            .unwrap()
1026            .scoped
1027            .insert("age1alice".into(), "ours-scope".into());
1028        let mut theirs = base.clone();
1029        theirs
1030            .secrets
1031            .get_mut("DB_URL")
1032            .unwrap()
1033            .scoped
1034            .insert("age1alice".into(), "theirs-scope".into());
1035
1036        let r = merge_vaults(&base, &ours, &theirs);
1037        assert_eq!(r.conflicts.len(), 1);
1038        assert!(r.conflicts[0].field.contains("scoped"));
1039    }
1040
1041    #[test]
1042    fn merge_scoped_add_vs_base_key_removal() {
1043        let base = base_vault();
1044
1045        // Ours: remove the base key entirely.
1046        let mut ours = base.clone();
1047        ours.secrets.remove("DB_URL");
1048        ours.schema.remove("DB_URL");
1049
1050        // Theirs: add a scoped entry on the same key (shared unchanged).
1051        let mut theirs = base.clone();
1052        theirs
1053            .secrets
1054            .get_mut("DB_URL")
1055            .unwrap()
1056            .scoped
1057            .insert("age1alice".into(), "alice-scoped".into());
1058
1059        let r = merge_vaults(&base, &ours, &theirs);
1060        // Ours removed the key, theirs kept it — conflict.
1061        // Schema removal conflicts, secret kept because theirs modified (added scoped).
1062        assert!(!r.conflicts.is_empty());
1063        assert!(r.vault.secrets.contains_key("DB_URL"));
1064    }
1065
1066    #[test]
1067    fn merge_scoped_add_vs_base_key_modification() {
1068        let base = base_vault();
1069
1070        // Ours: remove the base key entirely.
1071        let mut ours = base.clone();
1072        ours.secrets.remove("DB_URL");
1073        ours.schema.remove("DB_URL");
1074
1075        // Theirs: modify the shared value AND add scoped.
1076        let mut theirs = base.clone();
1077        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
1078        theirs
1079            .secrets
1080            .get_mut("DB_URL")
1081            .unwrap()
1082            .scoped
1083            .insert("age1alice".into(), "alice-scoped".into());
1084
1085        let r = merge_vaults(&base, &ours, &theirs);
1086        // Theirs modified shared, ours removed — conflicts for both secrets and schema.
1087        assert!(r.conflicts.len() >= 1);
1088        assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1089    }
1090
1091    // -- Recipient change + secret addition --
1092
1093    #[test]
1094    fn merge_ours_changes_recipients_theirs_adds_key() {
1095        let base = base_vault();
1096        let mut ours = base.clone();
1097        ours.recipients.push("age1charlie".into());
1098        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1099
1100        let mut theirs = base.clone();
1101        theirs.secrets.insert(
1102            "NEW_KEY".into(),
1103            SecretEntry {
1104                shared: "theirs-new".into(),
1105                scoped: BTreeMap::new(),
1106            },
1107        );
1108
1109        let r = merge_vaults(&base, &ours, &theirs);
1110        // One-sided recipient addition now conflicts.
1111        assert!(
1112            r.conflicts
1113                .iter()
1114                .any(|c| c.reason.contains("added on one side"))
1115        );
1116        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1117        assert!(r.vault.secrets.contains_key("NEW_KEY"));
1118        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1119    }
1120
1121    // -- Meta handling --
1122
1123    #[test]
1124    fn merge_takes_ours_meta() {
1125        let base = base_vault();
1126        let mut ours = base.clone();
1127        ours.meta = "ours-meta".into();
1128        let mut theirs = base.clone();
1129        theirs.meta = "theirs-meta".into();
1130
1131        let r = merge_vaults(&base, &ours, &theirs);
1132        assert_eq!(r.vault.meta, "ours-meta");
1133    }
1134
1135    // -- run_merge_driver parses and delegates --
1136
1137    #[test]
1138    fn run_merge_driver_invalid_base() {
1139        let result = run_merge_driver("not json", "{}", "{}");
1140        assert!(result.is_err());
1141        assert!(result.unwrap_err().contains("parsing base"));
1142    }
1143
1144    #[test]
1145    fn run_merge_driver_invalid_ours() {
1146        let base = serde_json::to_string(&base_vault()).unwrap();
1147        let result = run_merge_driver(&base, "not json", &base);
1148        assert!(result.is_err());
1149        assert!(result.unwrap_err().contains("parsing ours"));
1150    }
1151
1152    #[test]
1153    fn run_merge_driver_invalid_theirs() {
1154        let base = serde_json::to_string(&base_vault()).unwrap();
1155        let result = run_merge_driver(&base, &base, "not json");
1156        assert!(result.is_err());
1157        assert!(result.unwrap_err().contains("parsing theirs"));
1158    }
1159
1160    #[test]
1161    fn run_merge_driver_clean_no_changes() {
1162        let base = serde_json::to_string(&base_vault()).unwrap();
1163        let output = run_merge_driver(&base, &base, &base).unwrap();
1164        assert!(output.result.conflicts.is_empty());
1165        // meta_regenerated depends on MURK_KEY availability — don't assert it.
1166    }
1167
1168    // -- Static field preservation --
1169
1170    #[test]
1171    fn merge_preserves_ours_static_fields() {
1172        let base = base_vault();
1173        let mut ours = base.clone();
1174        ours.vault_name = "custom.murk".into();
1175        ours.repo = "https://github.com/test/repo".into();
1176
1177        let r = merge_vaults(&base, &ours, &base);
1178        assert_eq!(r.vault.vault_name, "custom.murk");
1179        assert_eq!(r.vault.repo, "https://github.com/test/repo");
1180        assert_eq!(r.vault.version, VAULT_VERSION);
1181    }
1182
1183    // -- Both sides remove same recipient --
1184
1185    #[test]
1186    fn merge_both_remove_same_recipient() {
1187        let base = base_vault();
1188        let mut ours = base.clone();
1189        ours.recipients.retain(|r| r != "age1bob");
1190        let mut theirs = base.clone();
1191        theirs.recipients.retain(|r| r != "age1bob");
1192
1193        let r = merge_vaults(&base, &ours, &theirs);
1194        assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
1195        // Both removed same recipient — should not conflict.
1196        assert!(
1197            !r.conflicts.iter().any(|c| c.reason.contains("recipient")),
1198            "removing same recipient from both sides should not conflict"
1199        );
1200    }
1201
1202    // -- Empty vault merge --
1203
1204    #[test]
1205    fn merge_empty_vaults() {
1206        let empty = Vault {
1207            version: VAULT_VERSION.into(),
1208            created: "2026-01-01T00:00:00Z".into(),
1209            vault_name: ".murk".into(),
1210            repo: String::new(),
1211            recipients: vec!["age1alice".into()],
1212            schema: BTreeMap::new(),
1213            secrets: BTreeMap::new(),
1214            meta: String::new(),
1215        };
1216        let r = merge_vaults(&empty, &empty, &empty);
1217        assert!(r.conflicts.is_empty());
1218        assert!(r.vault.secrets.is_empty());
1219    }
1220
1221    // -- Schema merge: description changes --
1222
1223    #[test]
1224    fn merge_schema_ours_changes_description() {
1225        let base = base_vault();
1226        let mut ours = base.clone();
1227        ours.schema.get_mut("DB_URL").unwrap().description = "updated desc".into();
1228
1229        let r = merge_vaults(&base, &ours, &base);
1230        assert_eq!(r.vault.schema["DB_URL"].description, "updated desc");
1231        assert!(r.conflicts.is_empty());
1232    }
1233
1234    #[test]
1235    fn merge_schema_both_change_description_takes_ours() {
1236        let base = base_vault();
1237        let mut ours = base.clone();
1238        ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
1239        let mut theirs = base.clone();
1240        theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
1241
1242        let r = merge_vaults(&base, &ours, &theirs);
1243        // Both changed the same schema entry — ours wins (schema conflicts are
1244        // reported but the merge still produces a result).
1245        assert_eq!(r.vault.schema["DB_URL"].description, "ours desc");
1246    }
1247}