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 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 let mut names = theirs_meta.recipients;
670 for (pk, name) in ours_meta.recipients {
671 names.insert(pk, name);
672 }
673
674 names.retain(|pk, _| merged.recipients.contains(pk));
676
677 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 #[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 #[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 let mut ours = base.clone();
1143 ours.secrets.remove("DB_URL");
1144 ours.schema.remove("DB_URL");
1145
1146 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 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 let mut ours = base.clone();
1168 ours.secrets.remove("DB_URL");
1169 ours.schema.remove("DB_URL");
1170
1171 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 assert!(!r.conflicts.is_empty());
1184 assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1185 }
1186
1187 #[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 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 #[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 #[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 }
1264
1265 #[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 #[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 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 #[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 #[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 assert_eq!(r.vault.schema["DB_URL"].description, "ours desc");
1344 }
1345
1346 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 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 assert!(r.conflicts.iter().any(|c| c.field == "policy"));
1375 assert_eq!(r.vault.policy, Some(policy(&["agents"])));
1376 }
1377}