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::{Policy, 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        policy: merge_policy(
79            base.policy.as_ref(),
80            ours.policy.as_ref(),
81            theirs.policy.as_ref(),
82            &mut conflicts,
83        ),
84        secrets,
85        meta,
86    };
87
88    MergeResult { vault, conflicts }
89}
90
91/// Merge the header policy three-way. The policy is a security guardrail, so a
92/// change on either side must not be silently dropped (taking "ours" blindly
93/// would discard a tightening from the other branch and re-MAC it as valid).
94/// Take the side that changed from base; if both changed differently, keep ours
95/// and flag a conflict for a human to resolve.
96fn merge_policy(
97    base: Option<&Policy>,
98    ours: Option<&Policy>,
99    theirs: Option<&Policy>,
100    conflicts: &mut Vec<MergeConflict>,
101) -> Option<Policy> {
102    if ours == theirs {
103        return ours.cloned();
104    }
105    if ours == base {
106        return theirs.cloned(); // only theirs changed — take it
107    }
108    if theirs == base {
109        return ours.cloned(); // only ours changed — take it
110    }
111    conflicts.push(MergeConflict {
112        field: "policy".into(),
113        reason: "agent policy changed on both sides".into(),
114    });
115    ours.cloned()
116}
117
118/// The first 12 *characters* of a recipient string, for a conflict label.
119/// Recipients on the `theirs` side are unvalidated, attacker-controlled data,
120/// so byte slicing (`&pk[..12]`) can panic mid-codepoint on multibyte input;
121/// take chars to stay panic-free.
122fn recipient_label(pk: &str) -> String {
123    pk.chars().take(12).collect()
124}
125
126/// Merge recipient lists as sets: union additions, honor removals.
127fn merge_recipients(
128    base: &Vault,
129    ours: &Vault,
130    theirs: &Vault,
131    conflicts: &mut Vec<MergeConflict>,
132) -> Vec<String> {
133    let base_set: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
134    let ours_set: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
135    let theirs_set: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
136
137    let ours_added: BTreeSet<&str> = ours_set.difference(&base_set).copied().collect();
138    let theirs_added: BTreeSet<&str> = theirs_set.difference(&base_set).copied().collect();
139    let ours_removed: BTreeSet<&str> = base_set.difference(&ours_set).copied().collect();
140    let theirs_removed: BTreeSet<&str> = base_set.difference(&theirs_set).copied().collect();
141
142    let mut result: BTreeSet<&str> = base_set;
143
144    // Recipient addition requires both sides to agree, or it's a conflict.
145    // Blind set-union would let a malicious branch silently grant access.
146    for pk in &ours_added {
147        if theirs_added.contains(pk) {
148            // Both sides added the same recipient — safe.
149            result.insert(pk);
150        } else {
151            // Only ours added — conflict. Include the recipient but flag it.
152            result.insert(pk);
153            conflicts.push(MergeConflict {
154                field: format!("recipients.{}", recipient_label(pk)),
155                reason: "added on one side but not the other".into(),
156            });
157        }
158    }
159    for pk in &theirs_added {
160        if !ours_added.contains(pk) {
161            // Only theirs added — conflict.
162            result.insert(pk);
163            conflicts.push(MergeConflict {
164                field: format!("recipients.{}", recipient_label(pk)),
165                reason: "added on one side but not the other".into(),
166            });
167        }
168    }
169
170    // Recipient removal requires both sides to agree, or it's a conflict.
171    for pk in &ours_removed {
172        if theirs_removed.contains(pk) {
173            // Both sides removed — safe.
174            result.remove(pk);
175        } else {
176            // Only ours removed — conflict. Keep the recipient (safer default).
177            conflicts.push(MergeConflict {
178                field: format!("recipients.{}", recipient_label(pk)),
179                reason: "removed on one side but not the other".into(),
180            });
181        }
182    }
183    for pk in &theirs_removed {
184        if !ours_removed.contains(pk) {
185            // Only theirs removed — conflict. Keep the recipient.
186            conflicts.push(MergeConflict {
187                field: format!("recipients.{}", recipient_label(pk)),
188                reason: "removed on one side but not the other".into(),
189            });
190        }
191    }
192
193    result.into_iter().map(String::from).collect()
194}
195
196/// Generic three-way merge for BTreeMap where values implement PartialEq + Clone.
197fn merge_btree<V: PartialEq + Clone>(
198    base: &BTreeMap<String, V>,
199    ours: &BTreeMap<String, V>,
200    theirs: &BTreeMap<String, V>,
201    field_name: &str,
202    conflicts: &mut Vec<MergeConflict>,
203) -> BTreeMap<String, V> {
204    let all_keys: BTreeSet<&str> = base
205        .keys()
206        .chain(ours.keys())
207        .chain(theirs.keys())
208        .map(String::as_str)
209        .collect();
210
211    let mut result = BTreeMap::new();
212
213    for key in all_keys {
214        let in_base = base.get(key);
215        let in_ours = ours.get(key);
216        let in_theirs = theirs.get(key);
217
218        match (in_base, in_ours, in_theirs) {
219            (None, None, Some(t)) => {
220                result.insert(key.to_string(), t.clone());
221            }
222            (None, Some(o), None) => {
223                result.insert(key.to_string(), o.clone());
224            }
225            (None, Some(o), Some(t)) => {
226                if o == t {
227                    result.insert(key.to_string(), o.clone());
228                } else {
229                    conflicts.push(MergeConflict {
230                        field: format!("{field_name}.{key}"),
231                        reason: "added on both sides with different values".into(),
232                    });
233                    result.insert(key.to_string(), o.clone());
234                }
235            }
236
237            // Both sides removed — safe to omit.
238            (Some(_) | None, None, None) => {}
239            // One side removed, other kept unchanged — conflict.
240            (Some(b), Some(o), None) => {
241                if o == b {
242                    // Ours didn't touch it, theirs removed — conflict.
243                    conflicts.push(MergeConflict {
244                        field: format!("{field_name}.{key}"),
245                        reason: "removed on one side, unchanged on the other".into(),
246                    });
247                    result.insert(key.to_string(), o.clone());
248                }
249                // else: ours modified AND theirs removed — ours wins (modified takes priority)
250            }
251            (Some(b), None, Some(t)) => {
252                if t == b {
253                    // Theirs didn't touch it, ours removed — conflict.
254                    conflicts.push(MergeConflict {
255                        field: format!("{field_name}.{key}"),
256                        reason: "removed on one side, unchanged on the other".into(),
257                    });
258                    result.insert(key.to_string(), t.clone());
259                }
260                // else: theirs modified AND ours removed — theirs wins
261            }
262
263            (Some(b), Some(o), Some(t)) => {
264                let ours_changed = o != b;
265                let theirs_changed = t != b;
266
267                match (ours_changed, theirs_changed) {
268                    (false, true) => {
269                        result.insert(key.to_string(), t.clone());
270                    }
271                    (true, true) if o != t => {
272                        conflicts.push(MergeConflict {
273                            field: format!("{field_name}.{key}"),
274                            reason: "modified on both sides with different values".into(),
275                        });
276                        result.insert(key.to_string(), o.clone());
277                    }
278                    _ => {
279                        result.insert(key.to_string(), o.clone());
280                    }
281                }
282            }
283        }
284    }
285
286    result
287}
288
289/// Merge secrets with ciphertext-equality-against-base comparison.
290///
291/// When one side changed recipients (triggering full re-encryption), that side's
292/// ciphertext all differs from base. We detect this and use the re-encrypted side
293/// as the baseline, applying the other side's additions/removals.
294fn merge_secrets(
295    base: &Vault,
296    ours: &Vault,
297    theirs: &Vault,
298    ours_changed_recipients: bool,
299    theirs_changed_recipients: bool,
300    conflicts: &mut Vec<MergeConflict>,
301) -> BTreeMap<String, SecretEntry> {
302    // If one side changed recipients, all its ciphertext differs from base.
303    // Use the re-encrypted side as the "new base" and apply the other side's diffs.
304    if ours_changed_recipients && !theirs_changed_recipients {
305        return merge_secrets_with_reencrypted_side(base, ours, theirs, "theirs", conflicts);
306    }
307    if theirs_changed_recipients && !ours_changed_recipients {
308        return merge_secrets_with_reencrypted_side(base, theirs, ours, "ours", conflicts);
309    }
310    if ours_changed_recipients && theirs_changed_recipients {
311        return merge_secrets_both_reencrypted(base, ours, theirs, conflicts);
312    }
313
314    // Normal case: neither side changed recipients. Ciphertext comparison works.
315    merge_secrets_normal(base, ours, theirs, conflicts)
316}
317
318/// Normal secret merge: compare ciphertext against base to detect changes.
319fn merge_secrets_normal(
320    base: &Vault,
321    ours: &Vault,
322    theirs: &Vault,
323    conflicts: &mut Vec<MergeConflict>,
324) -> BTreeMap<String, SecretEntry> {
325    let all_keys: BTreeSet<&str> = base
326        .secrets
327        .keys()
328        .chain(ours.secrets.keys())
329        .chain(theirs.secrets.keys())
330        .map(String::as_str)
331        .collect();
332
333    let mut result = BTreeMap::new();
334
335    for key in all_keys {
336        let in_base = base.secrets.get(key);
337        let in_ours = ours.secrets.get(key);
338        let in_theirs = theirs.secrets.get(key);
339
340        match (in_base, in_ours, in_theirs) {
341            (None, None, Some(t)) => {
342                result.insert(key.to_string(), t.clone());
343            }
344            (None, Some(o), None) => {
345                result.insert(key.to_string(), o.clone());
346            }
347            (None, Some(o), Some(t)) => {
348                if o.shared == t.shared {
349                    result.insert(key.to_string(), o.clone());
350                } else {
351                    conflicts.push(MergeConflict {
352                        field: format!("secrets.{key}"),
353                        reason: "added on both sides (values may differ)".into(),
354                    });
355                    result.insert(key.to_string(), o.clone());
356                }
357            }
358
359            // Both removed or impossible key.
360            (Some(_) | None, None, None) => {}
361
362            (Some(b), Some(o), None) => {
363                // Theirs removed, ours kept — always conflict.
364                conflicts.push(MergeConflict {
365                    field: format!("secrets.{key}"),
366                    reason: if o.shared == b.shared {
367                        "removed on one side, unchanged on the other".into()
368                    } else {
369                        "modified on our side but removed on theirs".into()
370                    },
371                });
372                result.insert(key.to_string(), o.clone());
373            }
374            (Some(b), None, Some(t)) => {
375                // Ours removed, theirs kept — always conflict.
376                conflicts.push(MergeConflict {
377                    field: format!("secrets.{key}"),
378                    reason: if t.shared == b.shared {
379                        "removed on one side, unchanged on the other".into()
380                    } else {
381                        "removed on our side but modified on theirs".into()
382                    },
383                });
384                result.insert(key.to_string(), t.clone());
385            }
386
387            (Some(b), Some(o), Some(t)) => {
388                let ours_changed = o.shared != b.shared;
389                let theirs_changed = t.shared != b.shared;
390
391                let shared = match (ours_changed, theirs_changed) {
392                    (false, true) => t.shared.clone(),
393                    (true, true) => {
394                        conflicts.push(MergeConflict {
395                            field: format!("secrets.{key}"),
396                            reason: "shared value modified on both sides".into(),
397                        });
398                        o.shared.clone()
399                    }
400                    _ => o.shared.clone(),
401                };
402
403                let private = merge_scoped(
404                    &b.private, &o.private, &t.private, key, "private", conflicts,
405                );
406                let grouped = merge_scoped(
407                    &b.grouped, &o.grouped, &t.grouped, key, "grouped", conflicts,
408                );
409                result.insert(
410                    key.to_string(),
411                    SecretEntry {
412                        shared,
413                        private,
414                        grouped,
415                    },
416                );
417            }
418        }
419    }
420
421    result
422}
423
424/// Merge scoped (mote) entries within a single secret key.
425/// Three-way merge of a per-name ciphertext map. Used for both `scoped`
426/// (keyed by pubkey) and `grouped` (keyed by group name) — `kind` is the field
427/// name used in conflict messages.
428fn merge_scoped(
429    base: &BTreeMap<String, String>,
430    ours: &BTreeMap<String, String>,
431    theirs: &BTreeMap<String, String>,
432    secret_key: &str,
433    kind: &str,
434    conflicts: &mut Vec<MergeConflict>,
435) -> BTreeMap<String, String> {
436    let all_pks: BTreeSet<&str> = base
437        .keys()
438        .chain(ours.keys())
439        .chain(theirs.keys())
440        .map(String::as_str)
441        .collect();
442
443    let mut result = BTreeMap::new();
444
445    for pk in all_pks {
446        let in_base = base.get(pk);
447        let in_ours = ours.get(pk);
448        let in_theirs = theirs.get(pk);
449
450        match (in_base, in_ours, in_theirs) {
451            (None, None, Some(t)) => {
452                result.insert(pk.to_string(), t.clone());
453            }
454            (None, Some(o), None) => {
455                result.insert(pk.to_string(), o.clone());
456            }
457            (None, Some(o), Some(t)) => {
458                if o == t {
459                    result.insert(pk.to_string(), o.clone());
460                } else {
461                    conflicts.push(MergeConflict {
462                        field: format!("secrets.{secret_key}.{kind}.{pk}"),
463                        reason: format!("{kind} entry added on both sides"),
464                    });
465                    result.insert(pk.to_string(), o.clone());
466                }
467            }
468            (Some(_) | None, None, None) => {}
469            (Some(b), Some(o), None) => {
470                if o != b {
471                    conflicts.push(MergeConflict {
472                        field: format!("secrets.{secret_key}.{kind}.{pk}"),
473                        reason: format!("{kind} entry modified on our side but removed on theirs"),
474                    });
475                    result.insert(pk.to_string(), o.clone());
476                }
477            }
478            (Some(b), None, Some(t)) => {
479                if t != b {
480                    conflicts.push(MergeConflict {
481                        field: format!("secrets.{secret_key}.{kind}.{pk}"),
482                        reason: format!("{kind} entry removed on our side but modified on theirs"),
483                    });
484                    result.insert(pk.to_string(), t.clone());
485                }
486            }
487            (Some(b), Some(o), Some(t)) => {
488                let ours_changed = o != b;
489                let theirs_changed = t != b;
490
491                match (ours_changed, theirs_changed) {
492                    (false, true) => {
493                        result.insert(pk.to_string(), t.clone());
494                    }
495                    (true, true) if o != t => {
496                        conflicts.push(MergeConflict {
497                            field: format!("secrets.{secret_key}.{kind}.{pk}"),
498                            reason: format!("{kind} entry modified on both sides"),
499                        });
500                        result.insert(pk.to_string(), o.clone());
501                    }
502                    _ => {
503                        result.insert(pk.to_string(), o.clone());
504                    }
505                }
506            }
507        }
508    }
509
510    result
511}
512
513/// When one side re-encrypted (changed recipients), use it as the new baseline
514/// and apply the other side's key-level additions/removals.
515///
516/// `reencrypted` is the side that changed recipients (all ciphertext differs from base).
517/// `other` is the side with stable ciphertext. `other_label` is "ours" or "theirs" for messages.
518fn merge_secrets_with_reencrypted_side(
519    base: &Vault,
520    reencrypted: &Vault,
521    other: &Vault,
522    other_label: &str,
523    conflicts: &mut Vec<MergeConflict>,
524) -> BTreeMap<String, SecretEntry> {
525    // Start with the re-encrypted side's secrets (they have the new recipient set).
526    let mut result = reencrypted.secrets.clone();
527
528    // Detect what the other side added/removed/modified relative to base.
529    let all_keys: BTreeSet<&str> = base
530        .secrets
531        .keys()
532        .chain(other.secrets.keys())
533        .map(String::as_str)
534        .collect();
535
536    for key in all_keys {
537        let in_base = base.secrets.get(key);
538        let in_other = other.secrets.get(key);
539
540        match (in_base, in_other) {
541            (None, Some(entry)) => {
542                if result.contains_key(key) {
543                    conflicts.push(MergeConflict {
544                        field: format!("secrets.{key}"),
545                        reason: format!(
546                            "added on {other_label} side and on the side that changed recipients"
547                        ),
548                    });
549                } else {
550                    result.insert(key.to_string(), entry.clone());
551                }
552            }
553            (Some(_), None) => {
554                // Other side removed this key. Honor the removal.
555                result.remove(key);
556            }
557            (Some(b), Some(entry)) => {
558                if entry.shared != b.shared {
559                    conflicts.push(MergeConflict {
560                        field: format!("secrets.{key}"),
561                        reason: format!(
562                            "modified on {other_label} side while recipients changed on the other"
563                        ),
564                    });
565                }
566                // If other side didn't modify, keep re-encrypted version.
567            }
568            (None, None) => {}
569        }
570    }
571
572    result
573}
574
575/// Both sides changed recipients — all ciphertext on both sides differs from base.
576/// Without decryption we can only merge keys that were added/removed (not modified).
577fn merge_secrets_both_reencrypted(
578    base: &Vault,
579    ours: &Vault,
580    theirs: &Vault,
581    conflicts: &mut Vec<MergeConflict>,
582) -> BTreeMap<String, SecretEntry> {
583    let all_keys: BTreeSet<&str> = base
584        .secrets
585        .keys()
586        .chain(ours.secrets.keys())
587        .chain(theirs.secrets.keys())
588        .map(String::as_str)
589        .collect();
590
591    let mut result = BTreeMap::new();
592
593    for key in all_keys {
594        let in_base = base.secrets.get(key);
595        let in_ours = ours.secrets.get(key);
596        let in_theirs = theirs.secrets.get(key);
597
598        match (in_base, in_ours, in_theirs) {
599            // Both have it and it was in base — take ours.
600            (Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
601                result.insert(key.to_string(), o.clone());
602            }
603            // Removals — honor them.
604            (Some(_), Some(_) | None, None) | (Some(_), None, Some(_)) | (None, None, None) => {}
605            (None, None, Some(t)) => {
606                result.insert(key.to_string(), t.clone());
607            }
608            (None, Some(o), Some(_)) => {
609                conflicts.push(MergeConflict {
610                    field: format!("secrets.{key}"),
611                    reason: "added on both sides while both changed recipients".into(),
612                });
613                result.insert(key.to_string(), o.clone());
614            }
615        }
616    }
617
618    result
619}
620
621/// Output of the merge driver: the merge result and whether meta was regenerated.
622#[derive(Debug)]
623pub struct MergeDriverOutput {
624    pub result: MergeResult,
625    pub meta_regenerated: bool,
626}
627
628/// Run the three-way merge driver on vault contents (as strings).
629///
630/// Parses all three versions, merges, and attempts meta regeneration.
631/// Returns the merged vault and conflict list. The caller is responsible for
632/// writing the result to disk.
633pub fn run_merge_driver(base: &str, ours: &str, theirs: &str) -> Result<MergeDriverOutput, String> {
634    use crate::vault;
635
636    let base_vault = vault::parse(base).map_err(|e| format!("parsing base: {e}"))?;
637    let ours_vault = vault::parse(ours).map_err(|e| format!("parsing ours: {e}"))?;
638    let theirs_vault = vault::parse(theirs).map_err(|e| format!("parsing theirs: {e}"))?;
639
640    let mut result = merge_vaults(&base_vault, &ours_vault, &theirs_vault);
641    let meta_regenerated = regenerate_meta(&mut result.vault, &ours_vault, &theirs_vault).is_some();
642
643    Ok(MergeDriverOutput {
644        result,
645        meta_regenerated,
646    })
647}
648
649/// Attempt to regenerate the meta blob for a merged vault.
650///
651/// Decrypts meta from `ours` and `theirs` to merge recipient name maps,
652/// recomputes the MAC, and re-encrypts. Falls back to `ours.meta` if
653/// MURK_KEY is unavailable.
654pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Option<String> {
655    use crate::{compute_mac, crypto, decrypt_meta, encrypt_value, parse_recipients, resolve_key};
656    use age::secrecy::ExposeSecret;
657    use std::collections::HashMap;
658
659    let secret_key = resolve_key().ok()?;
660    let identity = crypto::parse_identity(secret_key.expose_secret()).ok()?;
661
662    let default_meta = || crate::types::Meta {
663        recipients: HashMap::new(),
664        mac: String::new(),
665        mac_key: None,
666        github_pins: HashMap::new(),
667        groups: BTreeMap::new(),
668        grants: BTreeMap::new(),
669        signers: BTreeMap::new(),
670        sig: None,
671    };
672
673    let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
674    let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
675
676    // Merge name maps: union, ours wins on conflict.
677    let mut names = theirs_meta.recipients;
678    for (pk, name) in ours_meta.recipients {
679        names.insert(pk, name);
680    }
681
682    // Only keep names for recipients still in the merged vault.
683    names.retain(|pk, _| merged.recipients.contains(pk));
684
685    // Merge group membership: union, ours wins on conflict. Drop members no
686    // longer in the merged recipient set, and drop now-empty groups.
687    let mut groups = theirs_meta.groups;
688    for (name, members) in ours_meta.groups {
689        groups.insert(name, members);
690    }
691    for members in groups.values_mut() {
692        members.retain(|pk| merged.recipients.contains(pk));
693    }
694    groups.retain(|_, members| !members.is_empty());
695
696    // Merge agent grants: union, ours wins on conflict. Drop grants whose
697    // ephemeral pubkey is no longer in the merged recipient set.
698    let mut grants = theirs_meta.grants;
699    for (name, grant) in ours_meta.grants {
700        grants.insert(name, grant);
701    }
702    grants.retain(|_, grant| merged.recipients.contains(&grant.pubkey));
703
704    let mac_key_hex = crate::generate_mac_key();
705    let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
706    let mac = compute_mac(merged, &groups, &grants, Some(&mac_key));
707    // Merge github pins: union, ours wins on conflict.
708    let mut github_pins = theirs_meta.github_pins;
709    for (user, pins) in ours_meta.github_pins {
710        github_pins.insert(user, pins);
711    }
712
713    // Merge the signer registry (union, ours wins), retained to current
714    // recipients. The merged vault is deliberately left UNSIGNED: the driver runs
715    // non-interactively and must not vouch for content a human hasn't reviewed —
716    // auto-signing here would re-bless one-sided value injection just as
717    // auto-MACing did. `sig: None` makes the next load warn "unsigned"; any
718    // keyholder write (after reviewing `murk diff`) re-signs it.
719    let mut signers = theirs_meta.signers;
720    for (pk, vk) in ours_meta.signers {
721        signers.insert(pk, vk);
722    }
723    signers.retain(|pk, _| merged.recipients.contains(pk));
724
725    let meta = crate::types::Meta {
726        recipients: names,
727        mac,
728        mac_key: Some(mac_key_hex),
729        github_pins,
730        groups,
731        grants,
732        signers,
733        sig: None,
734    };
735
736    let recipients = parse_recipients(&merged.recipients).ok()?;
737
738    if recipients.is_empty() {
739        return None;
740    }
741
742    let meta_json = serde_json::to_vec(&meta).ok()?;
743    let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
744    merged.meta = encrypted;
745    Some("meta regenerated".into())
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
752    use std::collections::BTreeMap;
753
754    fn base_vault() -> Vault {
755        let mut schema = BTreeMap::new();
756        schema.insert(
757            "DB_URL".into(),
758            SchemaEntry {
759                description: "database url".into(),
760                example: None,
761                tags: vec![],
762                ..Default::default()
763            },
764        );
765
766        let mut secrets = BTreeMap::new();
767        secrets.insert(
768            "DB_URL".into(),
769            SecretEntry {
770                shared: "base-cipher-db".into(),
771                private: BTreeMap::new(),
772                grouped: std::collections::BTreeMap::default(),
773            },
774        );
775
776        Vault {
777            version: VAULT_VERSION.into(),
778            created: "2026-01-01T00:00:00Z".into(),
779            vault_name: ".murk".into(),
780            repo: String::new(),
781            recipients: vec!["age1alice".into(), "age1bob".into()],
782            schema,
783            policy: None,
784            secrets,
785            meta: "base-meta".into(),
786        }
787    }
788
789    // -- No-change merge --
790
791    #[test]
792    fn merge_no_changes() {
793        let base = base_vault();
794        let r = merge_vaults(&base, &base, &base);
795        assert!(r.conflicts.is_empty());
796        assert_eq!(r.vault.secrets.len(), 1);
797        assert_eq!(r.vault.recipients.len(), 2);
798    }
799
800    // -- Ours-only changes --
801
802    #[test]
803    fn merge_ours_adds_secret() {
804        let base = base_vault();
805        let mut ours = base.clone();
806        ours.secrets.insert(
807            "API_KEY".into(),
808            SecretEntry {
809                shared: "ours-cipher-api".into(),
810                private: BTreeMap::new(),
811                grouped: std::collections::BTreeMap::default(),
812            },
813        );
814        ours.schema.insert(
815            "API_KEY".into(),
816            SchemaEntry {
817                description: "api key".into(),
818                example: None,
819                tags: vec![],
820                ..Default::default()
821            },
822        );
823
824        let r = merge_vaults(&base, &ours, &base);
825        assert!(r.conflicts.is_empty());
826        assert!(r.vault.secrets.contains_key("API_KEY"));
827        assert!(r.vault.schema.contains_key("API_KEY"));
828        assert_eq!(r.vault.secrets.len(), 2);
829    }
830
831    // -- Theirs-only changes --
832
833    #[test]
834    fn merge_theirs_adds_secret() {
835        let base = base_vault();
836        let mut theirs = base.clone();
837        theirs.secrets.insert(
838            "STRIPE_KEY".into(),
839            SecretEntry {
840                shared: "theirs-cipher-stripe".into(),
841                private: BTreeMap::new(),
842                grouped: std::collections::BTreeMap::default(),
843            },
844        );
845
846        let r = merge_vaults(&base, &base, &theirs);
847        assert!(r.conflicts.is_empty());
848        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
849    }
850
851    // -- Both add different keys --
852
853    #[test]
854    fn merge_both_add_different_keys() {
855        let base = base_vault();
856        let mut ours = base.clone();
857        ours.secrets.insert(
858            "API_KEY".into(),
859            SecretEntry {
860                shared: "ours-cipher-api".into(),
861                private: BTreeMap::new(),
862                grouped: std::collections::BTreeMap::default(),
863            },
864        );
865
866        let mut theirs = base.clone();
867        theirs.secrets.insert(
868            "STRIPE_KEY".into(),
869            SecretEntry {
870                shared: "theirs-cipher-stripe".into(),
871                private: BTreeMap::new(),
872                grouped: std::collections::BTreeMap::default(),
873            },
874        );
875
876        let r = merge_vaults(&base, &ours, &theirs);
877        assert!(r.conflicts.is_empty());
878        assert!(r.vault.secrets.contains_key("API_KEY"));
879        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
880        assert!(r.vault.secrets.contains_key("DB_URL"));
881        assert_eq!(r.vault.secrets.len(), 3);
882    }
883
884    // -- Both remove same key --
885
886    #[test]
887    fn merge_both_remove_same_key() {
888        let base = base_vault();
889        let mut ours = base.clone();
890        ours.secrets.remove("DB_URL");
891        let mut theirs = base.clone();
892        theirs.secrets.remove("DB_URL");
893
894        let r = merge_vaults(&base, &ours, &theirs);
895        assert!(r.conflicts.is_empty());
896        assert!(!r.vault.secrets.contains_key("DB_URL"));
897    }
898
899    // -- Ours modifies, theirs unchanged --
900
901    #[test]
902    fn merge_ours_modifies_theirs_unchanged() {
903        let base = base_vault();
904        let mut ours = base.clone();
905        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
906
907        let r = merge_vaults(&base, &ours, &base);
908        assert!(r.conflicts.is_empty());
909        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
910    }
911
912    // -- Theirs modifies, ours unchanged --
913
914    #[test]
915    fn merge_theirs_modifies_ours_unchanged() {
916        let base = base_vault();
917        let mut theirs = base.clone();
918        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
919
920        let r = merge_vaults(&base, &base, &theirs);
921        assert!(r.conflicts.is_empty());
922        assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
923    }
924
925    // -- Conflicts --
926
927    #[test]
928    fn merge_both_modify_same_secret() {
929        let base = base_vault();
930        let mut ours = base.clone();
931        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
932        let mut theirs = base.clone();
933        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
934
935        let r = merge_vaults(&base, &ours, &theirs);
936        assert_eq!(r.conflicts.len(), 1);
937        assert!(r.conflicts[0].field.contains("DB_URL"));
938        // Takes ours on conflict.
939        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
940    }
941
942    #[test]
943    fn merge_both_add_same_key() {
944        let base = base_vault();
945        let mut ours = base.clone();
946        ours.secrets.insert(
947            "NEW_KEY".into(),
948            SecretEntry {
949                shared: "ours-cipher".into(),
950                private: BTreeMap::new(),
951                grouped: std::collections::BTreeMap::default(),
952            },
953        );
954        let mut theirs = base.clone();
955        theirs.secrets.insert(
956            "NEW_KEY".into(),
957            SecretEntry {
958                shared: "theirs-cipher".into(),
959                private: BTreeMap::new(),
960                grouped: std::collections::BTreeMap::default(),
961            },
962        );
963
964        let r = merge_vaults(&base, &ours, &theirs);
965        assert_eq!(r.conflicts.len(), 1);
966        assert!(r.conflicts[0].field.contains("NEW_KEY"));
967    }
968
969    #[test]
970    fn merge_remove_vs_modify() {
971        let base = base_vault();
972        let mut ours = base.clone();
973        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
974        let mut theirs = base.clone();
975        theirs.secrets.remove("DB_URL");
976
977        let r = merge_vaults(&base, &ours, &theirs);
978        assert_eq!(r.conflicts.len(), 1);
979        assert!(
980            r.conflicts[0]
981                .reason
982                .contains("modified on our side but removed on theirs")
983        );
984    }
985
986    // -- Recipients --
987
988    #[test]
989    fn merge_recipient_added_one_side_conflicts() {
990        let base = base_vault();
991        let mut ours = base.clone();
992        ours.recipients.push("age1charlie".into());
993
994        let r = merge_vaults(&base, &ours, &base);
995        assert_eq!(r.conflicts.len(), 1);
996        assert!(r.conflicts[0].reason.contains("added on one side"));
997        // Recipient is still included (safer to keep than drop).
998        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
999    }
1000
1001    #[test]
1002    fn merge_recipient_label_handles_multibyte() {
1003        // A recipient whose 12th byte lands mid-codepoint must not panic the
1004        // conflict-label formatter. `theirs` recipients are unvalidated, so this
1005        // is adversarial input reachable through the merge driver.
1006        let base = base_vault();
1007        let mut ours = base.clone();
1008        ours.recipients.push("age1aaaaaaañ".into());
1009
1010        let r = merge_vaults(&base, &ours, &base);
1011        assert_eq!(r.conflicts.len(), 1);
1012        assert!(r.conflicts[0].field.starts_with("recipients."));
1013    }
1014
1015    #[test]
1016    fn merge_recipient_added_both_same() {
1017        let base = base_vault();
1018        let mut ours = base.clone();
1019        ours.recipients.push("age1charlie".into());
1020        let mut theirs = base.clone();
1021        theirs.recipients.push("age1charlie".into());
1022
1023        let r = merge_vaults(&base, &ours, &theirs);
1024        assert!(r.conflicts.is_empty());
1025        assert_eq!(
1026            r.vault
1027                .recipients
1028                .iter()
1029                .filter(|r| *r == "age1charlie")
1030                .count(),
1031            1
1032        );
1033    }
1034
1035    #[test]
1036    fn merge_recipient_removed_one_side_conflicts() {
1037        let base = base_vault();
1038        let mut ours = base.clone();
1039        ours.recipients.retain(|r| r != "age1bob");
1040
1041        let r = merge_vaults(&base, &ours, &base);
1042        // One-sided removal should conflict — recipient kept for safety.
1043        assert!(!r.conflicts.is_empty());
1044        assert!(r.vault.recipients.contains(&"age1bob".to_string()));
1045    }
1046
1047    #[test]
1048    fn merge_recipient_removed_both_sides_ok() {
1049        let base = base_vault();
1050        let mut ours = base.clone();
1051        let mut theirs = base.clone();
1052        ours.recipients.retain(|r| r != "age1bob");
1053        theirs.recipients.retain(|r| r != "age1bob");
1054
1055        let r = merge_vaults(&base, &ours, &theirs);
1056        assert!(r.conflicts.is_empty());
1057        assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
1058    }
1059
1060    // -- Schema --
1061
1062    #[test]
1063    fn merge_schema_different_keys() {
1064        let base = base_vault();
1065        let mut ours = base.clone();
1066        ours.schema.insert(
1067            "API_KEY".into(),
1068            SchemaEntry {
1069                description: "api".into(),
1070                example: None,
1071                tags: vec![],
1072                ..Default::default()
1073            },
1074        );
1075        let mut theirs = base.clone();
1076        theirs.schema.insert(
1077            "STRIPE".into(),
1078            SchemaEntry {
1079                description: "stripe".into(),
1080                example: None,
1081                tags: vec![],
1082                ..Default::default()
1083            },
1084        );
1085
1086        let r = merge_vaults(&base, &ours, &theirs);
1087        assert!(r.conflicts.is_empty());
1088        assert!(r.vault.schema.contains_key("API_KEY"));
1089        assert!(r.vault.schema.contains_key("STRIPE"));
1090    }
1091
1092    #[test]
1093    fn merge_schema_same_key_conflict() {
1094        let base = base_vault();
1095        let mut ours = base.clone();
1096        ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
1097        let mut theirs = base.clone();
1098        theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
1099
1100        let r = merge_vaults(&base, &ours, &theirs);
1101        assert_eq!(r.conflicts.len(), 1);
1102        assert!(r.conflicts[0].field.contains("schema.DB_URL"));
1103    }
1104
1105    // -- Scoped --
1106
1107    #[test]
1108    fn merge_scoped_different_pubkeys() {
1109        let base = base_vault();
1110        let mut ours = base.clone();
1111        ours.secrets
1112            .get_mut("DB_URL")
1113            .unwrap()
1114            .private
1115            .insert("age1alice".into(), "alice-scope".into());
1116        let mut theirs = base.clone();
1117        theirs
1118            .secrets
1119            .get_mut("DB_URL")
1120            .unwrap()
1121            .private
1122            .insert("age1bob".into(), "bob-scope".into());
1123
1124        let r = merge_vaults(&base, &ours, &theirs);
1125        assert!(r.conflicts.is_empty());
1126        let entry = &r.vault.secrets["DB_URL"];
1127        assert_eq!(entry.private["age1alice"], "alice-scope");
1128        assert_eq!(entry.private["age1bob"], "bob-scope");
1129    }
1130
1131    #[test]
1132    fn merge_scoped_both_modify_same() {
1133        let mut base = base_vault();
1134        base.secrets
1135            .get_mut("DB_URL")
1136            .unwrap()
1137            .private
1138            .insert("age1alice".into(), "base-scope".into());
1139
1140        let mut ours = base.clone();
1141        ours.secrets
1142            .get_mut("DB_URL")
1143            .unwrap()
1144            .private
1145            .insert("age1alice".into(), "ours-scope".into());
1146        let mut theirs = base.clone();
1147        theirs
1148            .secrets
1149            .get_mut("DB_URL")
1150            .unwrap()
1151            .private
1152            .insert("age1alice".into(), "theirs-scope".into());
1153
1154        let r = merge_vaults(&base, &ours, &theirs);
1155        assert_eq!(r.conflicts.len(), 1);
1156        assert!(r.conflicts[0].field.contains("private"));
1157        assert!(r.conflicts[0].reason.contains("private entry"));
1158    }
1159
1160    #[test]
1161    fn merge_scoped_add_vs_base_key_removal() {
1162        let base = base_vault();
1163
1164        // Ours: remove the base key entirely.
1165        let mut ours = base.clone();
1166        ours.secrets.remove("DB_URL");
1167        ours.schema.remove("DB_URL");
1168
1169        // Theirs: add a scoped entry on the same key (shared unchanged).
1170        let mut theirs = base.clone();
1171        theirs
1172            .secrets
1173            .get_mut("DB_URL")
1174            .unwrap()
1175            .private
1176            .insert("age1alice".into(), "alice-scoped".into());
1177
1178        let r = merge_vaults(&base, &ours, &theirs);
1179        // Ours removed the key, theirs kept it — conflict.
1180        // Schema removal conflicts, secret kept because theirs modified (added scoped).
1181        assert!(!r.conflicts.is_empty());
1182        assert!(r.vault.secrets.contains_key("DB_URL"));
1183    }
1184
1185    #[test]
1186    fn merge_scoped_add_vs_base_key_modification() {
1187        let base = base_vault();
1188
1189        // Ours: remove the base key entirely.
1190        let mut ours = base.clone();
1191        ours.secrets.remove("DB_URL");
1192        ours.schema.remove("DB_URL");
1193
1194        // Theirs: modify the shared value AND add scoped.
1195        let mut theirs = base.clone();
1196        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
1197        theirs
1198            .secrets
1199            .get_mut("DB_URL")
1200            .unwrap()
1201            .private
1202            .insert("age1alice".into(), "alice-scoped".into());
1203
1204        let r = merge_vaults(&base, &ours, &theirs);
1205        // Theirs modified shared, ours removed — conflicts for both secrets and schema.
1206        assert!(!r.conflicts.is_empty());
1207        assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1208    }
1209
1210    // -- Recipient change + secret addition --
1211
1212    #[test]
1213    fn merge_ours_changes_recipients_theirs_adds_key() {
1214        let base = base_vault();
1215        let mut ours = base.clone();
1216        ours.recipients.push("age1charlie".into());
1217        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1218
1219        let mut theirs = base.clone();
1220        theirs.secrets.insert(
1221            "NEW_KEY".into(),
1222            SecretEntry {
1223                shared: "theirs-new".into(),
1224                private: BTreeMap::new(),
1225                grouped: std::collections::BTreeMap::default(),
1226            },
1227        );
1228
1229        let r = merge_vaults(&base, &ours, &theirs);
1230        // One-sided recipient addition now conflicts.
1231        assert!(
1232            r.conflicts
1233                .iter()
1234                .any(|c| c.reason.contains("added on one side"))
1235        );
1236        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1237        assert!(r.vault.secrets.contains_key("NEW_KEY"));
1238        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1239    }
1240
1241    // -- Meta handling --
1242
1243    #[test]
1244    fn merge_takes_ours_meta() {
1245        let base = base_vault();
1246        let mut ours = base.clone();
1247        ours.meta = "ours-meta".into();
1248        let mut theirs = base.clone();
1249        theirs.meta = "theirs-meta".into();
1250
1251        let r = merge_vaults(&base, &ours, &theirs);
1252        assert_eq!(r.vault.meta, "ours-meta");
1253    }
1254
1255    // -- run_merge_driver parses and delegates --
1256
1257    #[test]
1258    fn run_merge_driver_invalid_base() {
1259        let result = run_merge_driver("not json", "{}", "{}");
1260        assert!(result.is_err());
1261        assert!(result.unwrap_err().contains("parsing base"));
1262    }
1263
1264    #[test]
1265    fn run_merge_driver_invalid_ours() {
1266        let base = serde_json::to_string(&base_vault()).unwrap();
1267        let result = run_merge_driver(&base, "not json", &base);
1268        assert!(result.is_err());
1269        assert!(result.unwrap_err().contains("parsing ours"));
1270    }
1271
1272    #[test]
1273    fn run_merge_driver_invalid_theirs() {
1274        let base = serde_json::to_string(&base_vault()).unwrap();
1275        let result = run_merge_driver(&base, &base, "not json");
1276        assert!(result.is_err());
1277        assert!(result.unwrap_err().contains("parsing theirs"));
1278    }
1279
1280    #[test]
1281    fn run_merge_driver_clean_no_changes() {
1282        let base = serde_json::to_string(&base_vault()).unwrap();
1283        let output = run_merge_driver(&base, &base, &base).unwrap();
1284        assert!(output.result.conflicts.is_empty());
1285        // meta_regenerated depends on MURK_KEY availability — don't assert it.
1286    }
1287
1288    // -- Static field preservation --
1289
1290    #[test]
1291    fn merge_preserves_ours_static_fields() {
1292        let base = base_vault();
1293        let mut ours = base.clone();
1294        ours.vault_name = "custom.murk".into();
1295        ours.repo = "https://github.com/test/repo".into();
1296
1297        let r = merge_vaults(&base, &ours, &base);
1298        assert_eq!(r.vault.vault_name, "custom.murk");
1299        assert_eq!(r.vault.repo, "https://github.com/test/repo");
1300        assert_eq!(r.vault.version, VAULT_VERSION);
1301    }
1302
1303    // -- Both sides remove same recipient --
1304
1305    #[test]
1306    fn merge_both_remove_same_recipient() {
1307        let base = base_vault();
1308        let mut ours = base.clone();
1309        ours.recipients.retain(|r| r != "age1bob");
1310        let mut theirs = base.clone();
1311        theirs.recipients.retain(|r| r != "age1bob");
1312
1313        let r = merge_vaults(&base, &ours, &theirs);
1314        assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
1315        // Both removed same recipient — should not conflict.
1316        assert!(
1317            !r.conflicts.iter().any(|c| c.reason.contains("recipient")),
1318            "removing same recipient from both sides should not conflict"
1319        );
1320    }
1321
1322    // -- Empty vault merge --
1323
1324    #[test]
1325    fn merge_empty_vaults() {
1326        let empty = Vault {
1327            version: VAULT_VERSION.into(),
1328            created: "2026-01-01T00:00:00Z".into(),
1329            vault_name: ".murk".into(),
1330            repo: String::new(),
1331            recipients: vec!["age1alice".into()],
1332            schema: BTreeMap::new(),
1333            policy: None,
1334            secrets: BTreeMap::new(),
1335            meta: String::new(),
1336        };
1337        let r = merge_vaults(&empty, &empty, &empty);
1338        assert!(r.conflicts.is_empty());
1339        assert!(r.vault.secrets.is_empty());
1340    }
1341
1342    // -- Schema merge: description changes --
1343
1344    #[test]
1345    fn merge_schema_ours_changes_description() {
1346        let base = base_vault();
1347        let mut ours = base.clone();
1348        ours.schema.get_mut("DB_URL").unwrap().description = "updated desc".into();
1349
1350        let r = merge_vaults(&base, &ours, &base);
1351        assert_eq!(r.vault.schema["DB_URL"].description, "updated desc");
1352        assert!(r.conflicts.is_empty());
1353    }
1354
1355    #[test]
1356    fn merge_schema_both_change_description_takes_ours() {
1357        let base = base_vault();
1358        let mut ours = base.clone();
1359        ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
1360        let mut theirs = base.clone();
1361        theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
1362
1363        let r = merge_vaults(&base, &ours, &theirs);
1364        // Both changed the same schema entry — ours wins (schema conflicts are
1365        // reported but the merge still produces a result).
1366        assert_eq!(r.vault.schema["DB_URL"].description, "ours desc");
1367    }
1368
1369    // -- Policy merge --
1370
1371    fn policy(tags: &[&str]) -> Policy {
1372        Policy {
1373            agent_allow_tags: tags.iter().map(|t| (*t).to_string()).collect(),
1374        }
1375    }
1376
1377    #[test]
1378    fn merge_policy_takes_the_side_that_changed() {
1379        // Only theirs set a policy — it must be kept, not silently dropped.
1380        let base = base_vault();
1381        let mut theirs = base_vault();
1382        theirs.policy = Some(policy(&["agents"]));
1383        let r = merge_vaults(&base, &base, &theirs);
1384        assert_eq!(r.vault.policy, Some(policy(&["agents"])));
1385        assert!(!r.conflicts.iter().any(|c| c.field == "policy"));
1386    }
1387
1388    #[test]
1389    fn merge_policy_conflict_when_both_change() {
1390        let base = base_vault();
1391        let mut ours = base_vault();
1392        ours.policy = Some(policy(&["agents"]));
1393        let mut theirs = base_vault();
1394        theirs.policy = Some(policy(&["dev"]));
1395        let r = merge_vaults(&base, &ours, &theirs);
1396        // Divergent change is flagged, not silently resolved; ours is kept.
1397        assert!(r.conflicts.iter().any(|c| c.field == "policy"));
1398        assert_eq!(r.vault.policy, Some(policy(&["agents"])));
1399    }
1400}