1use std::collections::{BTreeMap, BTreeSet};
8
9use crate::types::{Policy, SecretEntry, Vault};
10
11#[derive(Debug)]
13pub struct MergeConflict {
14 pub field: String,
15 pub reason: String,
16}
17
18#[derive(Debug)]
20pub struct MergeResult {
21 pub vault: Vault,
22 pub conflicts: Vec<MergeConflict>,
23}
24
25pub fn merge_vaults(base: &Vault, ours: &Vault, theirs: &Vault) -> MergeResult {
31 let mut conflicts = Vec::new();
32
33 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 let recipients = merge_recipients(base, ours, theirs, &mut conflicts);
41
42 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 let schema = merge_btree(
51 &base.schema,
52 &ours.schema,
53 &theirs.schema,
54 "schema",
55 &mut conflicts,
56 );
57
58 let secrets = merge_secrets(
60 base,
61 ours,
62 theirs,
63 ours_changed_recipients,
64 theirs_changed_recipients,
65 &mut conflicts,
66 );
67
68 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
91fn 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(); }
108 if theirs == base {
109 return ours.cloned(); }
111 conflicts.push(MergeConflict {
112 field: "policy".into(),
113 reason: "agent policy changed on both sides".into(),
114 });
115 ours.cloned()
116}
117
118fn 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 for pk in &ours_added {
139 if theirs_added.contains(pk) {
140 result.insert(pk);
142 } else {
143 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 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 for pk in &ours_removed {
164 if theirs_removed.contains(pk) {
165 result.remove(pk);
167 } else {
168 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 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
188fn 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 (Some(_) | None, None, None) => {}
231 (Some(b), Some(o), None) => {
233 if o == b {
234 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 }
243 (Some(b), None, Some(t)) => {
244 if t == b {
245 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 }
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
281fn 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 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 merge_secrets_normal(base, ours, theirs, conflicts)
308}
309
310fn 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 (Some(_) | None, None, None) => {}
353
354 (Some(b), Some(o), None) => {
355 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 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
416fn 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
505fn 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 let mut result = reencrypted.secrets.clone();
519
520 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 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 }
560 (None, None) => {}
561 }
562 }
563
564 result
565}
566
567fn 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 (Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
593 result.insert(key.to_string(), o.clone());
594 }
595 (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#[derive(Debug)]
615pub struct MergeDriverOutput {
616 pub result: MergeResult,
617 pub meta_regenerated: bool,
618}
619
620pub 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
641pub 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 let mut names = theirs_meta.recipients;
668 for (pk, name) in ours_meta.recipients {
669 names.insert(pk, name);
670 }
671
672 names.retain(|pk, _| merged.recipients.contains(pk));
674
675 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 #[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 #[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 let mut ours = base.clone();
1127 ours.secrets.remove("DB_URL");
1128 ours.schema.remove("DB_URL");
1129
1130 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 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 let mut ours = base.clone();
1152 ours.secrets.remove("DB_URL");
1153 ours.schema.remove("DB_URL");
1154
1155 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 assert!(!r.conflicts.is_empty());
1168 assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1169 }
1170
1171 #[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 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 #[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 #[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 }
1248
1249 #[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 #[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 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 #[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 #[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 assert_eq!(r.vault.schema["DB_URL"].description, "ours desc");
1328 }
1329
1330 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 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 assert!(r.conflicts.iter().any(|c| c.field == "policy"));
1359 assert_eq!(r.vault.policy, Some(policy(&["agents"])));
1360 }
1361}