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