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