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        signers: BTreeMap::new(),
662        sig: None,
663    };
664
665    let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
666    let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
667
668    // Merge name maps: union, ours wins on conflict.
669    let mut names = theirs_meta.recipients;
670    for (pk, name) in ours_meta.recipients {
671        names.insert(pk, name);
672    }
673
674    // Only keep names for recipients still in the merged vault.
675    names.retain(|pk, _| merged.recipients.contains(pk));
676
677    // Merge group membership: union, ours wins on conflict. Drop members no
678    // longer in the merged recipient set, and drop now-empty groups.
679    let mut groups = theirs_meta.groups;
680    for (name, members) in ours_meta.groups {
681        groups.insert(name, members);
682    }
683    for members in groups.values_mut() {
684        members.retain(|pk| merged.recipients.contains(pk));
685    }
686    groups.retain(|_, members| !members.is_empty());
687
688    // Merge agent grants: union, ours wins on conflict. Drop grants whose
689    // ephemeral pubkey is no longer in the merged recipient set.
690    let mut grants = theirs_meta.grants;
691    for (name, grant) in ours_meta.grants {
692        grants.insert(name, grant);
693    }
694    grants.retain(|_, grant| merged.recipients.contains(&grant.pubkey));
695
696    let mac_key_hex = crate::generate_mac_key();
697    let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
698    let mac = compute_mac(merged, &groups, &grants, Some(&mac_key));
699    // Merge github pins: union, ours wins on conflict.
700    let mut github_pins = theirs_meta.github_pins;
701    for (user, pins) in ours_meta.github_pins {
702        github_pins.insert(user, pins);
703    }
704
705    // Merge the signer registry (union, ours wins), retained to current
706    // recipients. The merged vault is deliberately left UNSIGNED: the driver runs
707    // non-interactively and must not vouch for content a human hasn't reviewed —
708    // auto-signing here would re-bless one-sided value injection just as
709    // auto-MACing did. `sig: None` makes the next load warn "unsigned"; any
710    // keyholder write (after reviewing `murk diff`) re-signs it.
711    let mut signers = theirs_meta.signers;
712    for (pk, vk) in ours_meta.signers {
713        signers.insert(pk, vk);
714    }
715    signers.retain(|pk, _| merged.recipients.contains(pk));
716
717    let meta = crate::types::Meta {
718        recipients: names,
719        mac,
720        mac_key: Some(mac_key_hex),
721        github_pins,
722        groups,
723        grants,
724        signers,
725        sig: None,
726    };
727
728    let recipients = parse_recipients(&merged.recipients).ok()?;
729
730    if recipients.is_empty() {
731        return None;
732    }
733
734    let meta_json = serde_json::to_vec(&meta).ok()?;
735    let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
736    merged.meta = encrypted;
737    Some("meta regenerated".into())
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
744    use std::collections::BTreeMap;
745
746    fn base_vault() -> Vault {
747        let mut schema = BTreeMap::new();
748        schema.insert(
749            "DB_URL".into(),
750            SchemaEntry {
751                description: "database url".into(),
752                example: None,
753                tags: vec![],
754                ..Default::default()
755            },
756        );
757
758        let mut secrets = BTreeMap::new();
759        secrets.insert(
760            "DB_URL".into(),
761            SecretEntry {
762                shared: "base-cipher-db".into(),
763                private: BTreeMap::new(),
764                grouped: std::collections::BTreeMap::default(),
765            },
766        );
767
768        Vault {
769            version: VAULT_VERSION.into(),
770            created: "2026-01-01T00:00:00Z".into(),
771            vault_name: ".murk".into(),
772            repo: String::new(),
773            recipients: vec!["age1alice".into(), "age1bob".into()],
774            schema,
775            policy: None,
776            secrets,
777            meta: "base-meta".into(),
778        }
779    }
780
781    // -- No-change merge --
782
783    #[test]
784    fn merge_no_changes() {
785        let base = base_vault();
786        let r = merge_vaults(&base, &base, &base);
787        assert!(r.conflicts.is_empty());
788        assert_eq!(r.vault.secrets.len(), 1);
789        assert_eq!(r.vault.recipients.len(), 2);
790    }
791
792    // -- Ours-only changes --
793
794    #[test]
795    fn merge_ours_adds_secret() {
796        let base = base_vault();
797        let mut ours = base.clone();
798        ours.secrets.insert(
799            "API_KEY".into(),
800            SecretEntry {
801                shared: "ours-cipher-api".into(),
802                private: BTreeMap::new(),
803                grouped: std::collections::BTreeMap::default(),
804            },
805        );
806        ours.schema.insert(
807            "API_KEY".into(),
808            SchemaEntry {
809                description: "api key".into(),
810                example: None,
811                tags: vec![],
812                ..Default::default()
813            },
814        );
815
816        let r = merge_vaults(&base, &ours, &base);
817        assert!(r.conflicts.is_empty());
818        assert!(r.vault.secrets.contains_key("API_KEY"));
819        assert!(r.vault.schema.contains_key("API_KEY"));
820        assert_eq!(r.vault.secrets.len(), 2);
821    }
822
823    // -- Theirs-only changes --
824
825    #[test]
826    fn merge_theirs_adds_secret() {
827        let base = base_vault();
828        let mut theirs = base.clone();
829        theirs.secrets.insert(
830            "STRIPE_KEY".into(),
831            SecretEntry {
832                shared: "theirs-cipher-stripe".into(),
833                private: BTreeMap::new(),
834                grouped: std::collections::BTreeMap::default(),
835            },
836        );
837
838        let r = merge_vaults(&base, &base, &theirs);
839        assert!(r.conflicts.is_empty());
840        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
841    }
842
843    // -- Both add different keys --
844
845    #[test]
846    fn merge_both_add_different_keys() {
847        let base = base_vault();
848        let mut ours = base.clone();
849        ours.secrets.insert(
850            "API_KEY".into(),
851            SecretEntry {
852                shared: "ours-cipher-api".into(),
853                private: BTreeMap::new(),
854                grouped: std::collections::BTreeMap::default(),
855            },
856        );
857
858        let mut theirs = base.clone();
859        theirs.secrets.insert(
860            "STRIPE_KEY".into(),
861            SecretEntry {
862                shared: "theirs-cipher-stripe".into(),
863                private: BTreeMap::new(),
864                grouped: std::collections::BTreeMap::default(),
865            },
866        );
867
868        let r = merge_vaults(&base, &ours, &theirs);
869        assert!(r.conflicts.is_empty());
870        assert!(r.vault.secrets.contains_key("API_KEY"));
871        assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
872        assert!(r.vault.secrets.contains_key("DB_URL"));
873        assert_eq!(r.vault.secrets.len(), 3);
874    }
875
876    // -- Both remove same key --
877
878    #[test]
879    fn merge_both_remove_same_key() {
880        let base = base_vault();
881        let mut ours = base.clone();
882        ours.secrets.remove("DB_URL");
883        let mut theirs = base.clone();
884        theirs.secrets.remove("DB_URL");
885
886        let r = merge_vaults(&base, &ours, &theirs);
887        assert!(r.conflicts.is_empty());
888        assert!(!r.vault.secrets.contains_key("DB_URL"));
889    }
890
891    // -- Ours modifies, theirs unchanged --
892
893    #[test]
894    fn merge_ours_modifies_theirs_unchanged() {
895        let base = base_vault();
896        let mut ours = base.clone();
897        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
898
899        let r = merge_vaults(&base, &ours, &base);
900        assert!(r.conflicts.is_empty());
901        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
902    }
903
904    // -- Theirs modifies, ours unchanged --
905
906    #[test]
907    fn merge_theirs_modifies_ours_unchanged() {
908        let base = base_vault();
909        let mut theirs = base.clone();
910        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
911
912        let r = merge_vaults(&base, &base, &theirs);
913        assert!(r.conflicts.is_empty());
914        assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
915    }
916
917    // -- Conflicts --
918
919    #[test]
920    fn merge_both_modify_same_secret() {
921        let base = base_vault();
922        let mut ours = base.clone();
923        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
924        let mut theirs = base.clone();
925        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
926
927        let r = merge_vaults(&base, &ours, &theirs);
928        assert_eq!(r.conflicts.len(), 1);
929        assert!(r.conflicts[0].field.contains("DB_URL"));
930        // Takes ours on conflict.
931        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
932    }
933
934    #[test]
935    fn merge_both_add_same_key() {
936        let base = base_vault();
937        let mut ours = base.clone();
938        ours.secrets.insert(
939            "NEW_KEY".into(),
940            SecretEntry {
941                shared: "ours-cipher".into(),
942                private: BTreeMap::new(),
943                grouped: std::collections::BTreeMap::default(),
944            },
945        );
946        let mut theirs = base.clone();
947        theirs.secrets.insert(
948            "NEW_KEY".into(),
949            SecretEntry {
950                shared: "theirs-cipher".into(),
951                private: BTreeMap::new(),
952                grouped: std::collections::BTreeMap::default(),
953            },
954        );
955
956        let r = merge_vaults(&base, &ours, &theirs);
957        assert_eq!(r.conflicts.len(), 1);
958        assert!(r.conflicts[0].field.contains("NEW_KEY"));
959    }
960
961    #[test]
962    fn merge_remove_vs_modify() {
963        let base = base_vault();
964        let mut ours = base.clone();
965        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
966        let mut theirs = base.clone();
967        theirs.secrets.remove("DB_URL");
968
969        let r = merge_vaults(&base, &ours, &theirs);
970        assert_eq!(r.conflicts.len(), 1);
971        assert!(
972            r.conflicts[0]
973                .reason
974                .contains("modified on our side but removed on theirs")
975        );
976    }
977
978    // -- Recipients --
979
980    #[test]
981    fn merge_recipient_added_one_side_conflicts() {
982        let base = base_vault();
983        let mut ours = base.clone();
984        ours.recipients.push("age1charlie".into());
985
986        let r = merge_vaults(&base, &ours, &base);
987        assert_eq!(r.conflicts.len(), 1);
988        assert!(r.conflicts[0].reason.contains("added on one side"));
989        // Recipient is still included (safer to keep than drop).
990        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
991    }
992
993    #[test]
994    fn merge_recipient_added_both_same() {
995        let base = base_vault();
996        let mut ours = base.clone();
997        ours.recipients.push("age1charlie".into());
998        let mut theirs = base.clone();
999        theirs.recipients.push("age1charlie".into());
1000
1001        let r = merge_vaults(&base, &ours, &theirs);
1002        assert!(r.conflicts.is_empty());
1003        assert_eq!(
1004            r.vault
1005                .recipients
1006                .iter()
1007                .filter(|r| *r == "age1charlie")
1008                .count(),
1009            1
1010        );
1011    }
1012
1013    #[test]
1014    fn merge_recipient_removed_one_side_conflicts() {
1015        let base = base_vault();
1016        let mut ours = base.clone();
1017        ours.recipients.retain(|r| r != "age1bob");
1018
1019        let r = merge_vaults(&base, &ours, &base);
1020        // One-sided removal should conflict — recipient kept for safety.
1021        assert!(!r.conflicts.is_empty());
1022        assert!(r.vault.recipients.contains(&"age1bob".to_string()));
1023    }
1024
1025    #[test]
1026    fn merge_recipient_removed_both_sides_ok() {
1027        let base = base_vault();
1028        let mut ours = base.clone();
1029        let mut theirs = base.clone();
1030        ours.recipients.retain(|r| r != "age1bob");
1031        theirs.recipients.retain(|r| r != "age1bob");
1032
1033        let r = merge_vaults(&base, &ours, &theirs);
1034        assert!(r.conflicts.is_empty());
1035        assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
1036    }
1037
1038    // -- Schema --
1039
1040    #[test]
1041    fn merge_schema_different_keys() {
1042        let base = base_vault();
1043        let mut ours = base.clone();
1044        ours.schema.insert(
1045            "API_KEY".into(),
1046            SchemaEntry {
1047                description: "api".into(),
1048                example: None,
1049                tags: vec![],
1050                ..Default::default()
1051            },
1052        );
1053        let mut theirs = base.clone();
1054        theirs.schema.insert(
1055            "STRIPE".into(),
1056            SchemaEntry {
1057                description: "stripe".into(),
1058                example: None,
1059                tags: vec![],
1060                ..Default::default()
1061            },
1062        );
1063
1064        let r = merge_vaults(&base, &ours, &theirs);
1065        assert!(r.conflicts.is_empty());
1066        assert!(r.vault.schema.contains_key("API_KEY"));
1067        assert!(r.vault.schema.contains_key("STRIPE"));
1068    }
1069
1070    #[test]
1071    fn merge_schema_same_key_conflict() {
1072        let base = base_vault();
1073        let mut ours = base.clone();
1074        ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
1075        let mut theirs = base.clone();
1076        theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
1077
1078        let r = merge_vaults(&base, &ours, &theirs);
1079        assert_eq!(r.conflicts.len(), 1);
1080        assert!(r.conflicts[0].field.contains("schema.DB_URL"));
1081    }
1082
1083    // -- Scoped --
1084
1085    #[test]
1086    fn merge_scoped_different_pubkeys() {
1087        let base = base_vault();
1088        let mut ours = base.clone();
1089        ours.secrets
1090            .get_mut("DB_URL")
1091            .unwrap()
1092            .private
1093            .insert("age1alice".into(), "alice-scope".into());
1094        let mut theirs = base.clone();
1095        theirs
1096            .secrets
1097            .get_mut("DB_URL")
1098            .unwrap()
1099            .private
1100            .insert("age1bob".into(), "bob-scope".into());
1101
1102        let r = merge_vaults(&base, &ours, &theirs);
1103        assert!(r.conflicts.is_empty());
1104        let entry = &r.vault.secrets["DB_URL"];
1105        assert_eq!(entry.private["age1alice"], "alice-scope");
1106        assert_eq!(entry.private["age1bob"], "bob-scope");
1107    }
1108
1109    #[test]
1110    fn merge_scoped_both_modify_same() {
1111        let mut base = base_vault();
1112        base.secrets
1113            .get_mut("DB_URL")
1114            .unwrap()
1115            .private
1116            .insert("age1alice".into(), "base-scope".into());
1117
1118        let mut ours = base.clone();
1119        ours.secrets
1120            .get_mut("DB_URL")
1121            .unwrap()
1122            .private
1123            .insert("age1alice".into(), "ours-scope".into());
1124        let mut theirs = base.clone();
1125        theirs
1126            .secrets
1127            .get_mut("DB_URL")
1128            .unwrap()
1129            .private
1130            .insert("age1alice".into(), "theirs-scope".into());
1131
1132        let r = merge_vaults(&base, &ours, &theirs);
1133        assert_eq!(r.conflicts.len(), 1);
1134        assert!(r.conflicts[0].field.contains("private"));
1135    }
1136
1137    #[test]
1138    fn merge_scoped_add_vs_base_key_removal() {
1139        let base = base_vault();
1140
1141        // Ours: remove the base key entirely.
1142        let mut ours = base.clone();
1143        ours.secrets.remove("DB_URL");
1144        ours.schema.remove("DB_URL");
1145
1146        // Theirs: add a scoped entry on the same key (shared unchanged).
1147        let mut theirs = base.clone();
1148        theirs
1149            .secrets
1150            .get_mut("DB_URL")
1151            .unwrap()
1152            .private
1153            .insert("age1alice".into(), "alice-scoped".into());
1154
1155        let r = merge_vaults(&base, &ours, &theirs);
1156        // Ours removed the key, theirs kept it — conflict.
1157        // Schema removal conflicts, secret kept because theirs modified (added scoped).
1158        assert!(!r.conflicts.is_empty());
1159        assert!(r.vault.secrets.contains_key("DB_URL"));
1160    }
1161
1162    #[test]
1163    fn merge_scoped_add_vs_base_key_modification() {
1164        let base = base_vault();
1165
1166        // Ours: remove the base key entirely.
1167        let mut ours = base.clone();
1168        ours.secrets.remove("DB_URL");
1169        ours.schema.remove("DB_URL");
1170
1171        // Theirs: modify the shared value AND add scoped.
1172        let mut theirs = base.clone();
1173        theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
1174        theirs
1175            .secrets
1176            .get_mut("DB_URL")
1177            .unwrap()
1178            .private
1179            .insert("age1alice".into(), "alice-scoped".into());
1180
1181        let r = merge_vaults(&base, &ours, &theirs);
1182        // Theirs modified shared, ours removed — conflicts for both secrets and schema.
1183        assert!(!r.conflicts.is_empty());
1184        assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1185    }
1186
1187    // -- Recipient change + secret addition --
1188
1189    #[test]
1190    fn merge_ours_changes_recipients_theirs_adds_key() {
1191        let base = base_vault();
1192        let mut ours = base.clone();
1193        ours.recipients.push("age1charlie".into());
1194        ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1195
1196        let mut theirs = base.clone();
1197        theirs.secrets.insert(
1198            "NEW_KEY".into(),
1199            SecretEntry {
1200                shared: "theirs-new".into(),
1201                private: BTreeMap::new(),
1202                grouped: std::collections::BTreeMap::default(),
1203            },
1204        );
1205
1206        let r = merge_vaults(&base, &ours, &theirs);
1207        // One-sided recipient addition now conflicts.
1208        assert!(
1209            r.conflicts
1210                .iter()
1211                .any(|c| c.reason.contains("added on one side"))
1212        );
1213        assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1214        assert!(r.vault.secrets.contains_key("NEW_KEY"));
1215        assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1216    }
1217
1218    // -- Meta handling --
1219
1220    #[test]
1221    fn merge_takes_ours_meta() {
1222        let base = base_vault();
1223        let mut ours = base.clone();
1224        ours.meta = "ours-meta".into();
1225        let mut theirs = base.clone();
1226        theirs.meta = "theirs-meta".into();
1227
1228        let r = merge_vaults(&base, &ours, &theirs);
1229        assert_eq!(r.vault.meta, "ours-meta");
1230    }
1231
1232    // -- run_merge_driver parses and delegates --
1233
1234    #[test]
1235    fn run_merge_driver_invalid_base() {
1236        let result = run_merge_driver("not json", "{}", "{}");
1237        assert!(result.is_err());
1238        assert!(result.unwrap_err().contains("parsing base"));
1239    }
1240
1241    #[test]
1242    fn run_merge_driver_invalid_ours() {
1243        let base = serde_json::to_string(&base_vault()).unwrap();
1244        let result = run_merge_driver(&base, "not json", &base);
1245        assert!(result.is_err());
1246        assert!(result.unwrap_err().contains("parsing ours"));
1247    }
1248
1249    #[test]
1250    fn run_merge_driver_invalid_theirs() {
1251        let base = serde_json::to_string(&base_vault()).unwrap();
1252        let result = run_merge_driver(&base, &base, "not json");
1253        assert!(result.is_err());
1254        assert!(result.unwrap_err().contains("parsing theirs"));
1255    }
1256
1257    #[test]
1258    fn run_merge_driver_clean_no_changes() {
1259        let base = serde_json::to_string(&base_vault()).unwrap();
1260        let output = run_merge_driver(&base, &base, &base).unwrap();
1261        assert!(output.result.conflicts.is_empty());
1262        // meta_regenerated depends on MURK_KEY availability — don't assert it.
1263    }
1264
1265    // -- Static field preservation --
1266
1267    #[test]
1268    fn merge_preserves_ours_static_fields() {
1269        let base = base_vault();
1270        let mut ours = base.clone();
1271        ours.vault_name = "custom.murk".into();
1272        ours.repo = "https://github.com/test/repo".into();
1273
1274        let r = merge_vaults(&base, &ours, &base);
1275        assert_eq!(r.vault.vault_name, "custom.murk");
1276        assert_eq!(r.vault.repo, "https://github.com/test/repo");
1277        assert_eq!(r.vault.version, VAULT_VERSION);
1278    }
1279
1280    // -- Both sides remove same recipient --
1281
1282    #[test]
1283    fn merge_both_remove_same_recipient() {
1284        let base = base_vault();
1285        let mut ours = base.clone();
1286        ours.recipients.retain(|r| r != "age1bob");
1287        let mut theirs = base.clone();
1288        theirs.recipients.retain(|r| r != "age1bob");
1289
1290        let r = merge_vaults(&base, &ours, &theirs);
1291        assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
1292        // Both removed same recipient — should not conflict.
1293        assert!(
1294            !r.conflicts.iter().any(|c| c.reason.contains("recipient")),
1295            "removing same recipient from both sides should not conflict"
1296        );
1297    }
1298
1299    // -- Empty vault merge --
1300
1301    #[test]
1302    fn merge_empty_vaults() {
1303        let empty = Vault {
1304            version: VAULT_VERSION.into(),
1305            created: "2026-01-01T00:00:00Z".into(),
1306            vault_name: ".murk".into(),
1307            repo: String::new(),
1308            recipients: vec!["age1alice".into()],
1309            schema: BTreeMap::new(),
1310            policy: None,
1311            secrets: BTreeMap::new(),
1312            meta: String::new(),
1313        };
1314        let r = merge_vaults(&empty, &empty, &empty);
1315        assert!(r.conflicts.is_empty());
1316        assert!(r.vault.secrets.is_empty());
1317    }
1318
1319    // -- Schema merge: description changes --
1320
1321    #[test]
1322    fn merge_schema_ours_changes_description() {
1323        let base = base_vault();
1324        let mut ours = base.clone();
1325        ours.schema.get_mut("DB_URL").unwrap().description = "updated desc".into();
1326
1327        let r = merge_vaults(&base, &ours, &base);
1328        assert_eq!(r.vault.schema["DB_URL"].description, "updated desc");
1329        assert!(r.conflicts.is_empty());
1330    }
1331
1332    #[test]
1333    fn merge_schema_both_change_description_takes_ours() {
1334        let base = base_vault();
1335        let mut ours = base.clone();
1336        ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
1337        let mut theirs = base.clone();
1338        theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
1339
1340        let r = merge_vaults(&base, &ours, &theirs);
1341        // Both changed the same schema entry — ours wins (schema conflicts are
1342        // reported but the merge still produces a result).
1343        assert_eq!(r.vault.schema["DB_URL"].description, "ours desc");
1344    }
1345
1346    // -- Policy merge --
1347
1348    fn policy(tags: &[&str]) -> Policy {
1349        Policy {
1350            agent_allow_tags: tags.iter().map(|t| (*t).to_string()).collect(),
1351        }
1352    }
1353
1354    #[test]
1355    fn merge_policy_takes_the_side_that_changed() {
1356        // Only theirs set a policy — it must be kept, not silently dropped.
1357        let base = base_vault();
1358        let mut theirs = base_vault();
1359        theirs.policy = Some(policy(&["agents"]));
1360        let r = merge_vaults(&base, &base, &theirs);
1361        assert_eq!(r.vault.policy, Some(policy(&["agents"])));
1362        assert!(!r.conflicts.iter().any(|c| c.field == "policy"));
1363    }
1364
1365    #[test]
1366    fn merge_policy_conflict_when_both_change() {
1367        let base = base_vault();
1368        let mut ours = base_vault();
1369        ours.policy = Some(policy(&["agents"]));
1370        let mut theirs = base_vault();
1371        theirs.policy = Some(policy(&["dev"]));
1372        let r = merge_vaults(&base, &ours, &theirs);
1373        // Divergent change is flagged, not silently resolved; ours is kept.
1374        assert!(r.conflicts.iter().any(|c| c.field == "policy"));
1375        assert_eq!(r.vault.policy, Some(policy(&["agents"])));
1376    }
1377}