1use std::collections::{BTreeMap, BTreeSet};
8
9use crate::types::{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 secrets,
79 meta,
80 };
81
82 MergeResult { vault, conflicts }
83}
84
85fn merge_recipients(
87 base: &Vault,
88 ours: &Vault,
89 theirs: &Vault,
90 conflicts: &mut Vec<MergeConflict>,
91) -> Vec<String> {
92 let base_set: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
93 let ours_set: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
94 let theirs_set: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
95
96 let ours_added: BTreeSet<&str> = ours_set.difference(&base_set).copied().collect();
97 let theirs_added: BTreeSet<&str> = theirs_set.difference(&base_set).copied().collect();
98 let ours_removed: BTreeSet<&str> = base_set.difference(&ours_set).copied().collect();
99 let theirs_removed: BTreeSet<&str> = base_set.difference(&theirs_set).copied().collect();
100
101 let mut result: BTreeSet<&str> = base_set;
102
103 for pk in &ours_added {
106 if theirs_added.contains(pk) {
107 result.insert(pk);
109 } else {
110 result.insert(pk);
112 conflicts.push(MergeConflict {
113 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
114 reason: "added on one side but not the other".into(),
115 });
116 }
117 }
118 for pk in &theirs_added {
119 if !ours_added.contains(pk) {
120 result.insert(pk);
122 conflicts.push(MergeConflict {
123 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
124 reason: "added on one side but not the other".into(),
125 });
126 }
127 }
128
129 for pk in &ours_removed {
131 if theirs_removed.contains(pk) {
132 result.remove(pk);
134 } else {
135 conflicts.push(MergeConflict {
137 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
138 reason: "removed on one side but not the other".into(),
139 });
140 }
141 }
142 for pk in &theirs_removed {
143 if !ours_removed.contains(pk) {
144 conflicts.push(MergeConflict {
146 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
147 reason: "removed on one side but not the other".into(),
148 });
149 }
150 }
151
152 result.into_iter().map(String::from).collect()
153}
154
155fn merge_btree<V: PartialEq + Clone>(
157 base: &BTreeMap<String, V>,
158 ours: &BTreeMap<String, V>,
159 theirs: &BTreeMap<String, V>,
160 field_name: &str,
161 conflicts: &mut Vec<MergeConflict>,
162) -> BTreeMap<String, V> {
163 let all_keys: BTreeSet<&str> = base
164 .keys()
165 .chain(ours.keys())
166 .chain(theirs.keys())
167 .map(String::as_str)
168 .collect();
169
170 let mut result = BTreeMap::new();
171
172 for key in all_keys {
173 let in_base = base.get(key);
174 let in_ours = ours.get(key);
175 let in_theirs = theirs.get(key);
176
177 match (in_base, in_ours, in_theirs) {
178 (None, None, Some(t)) => {
179 result.insert(key.to_string(), t.clone());
180 }
181 (None, Some(o), None) => {
182 result.insert(key.to_string(), o.clone());
183 }
184 (None, Some(o), Some(t)) => {
185 if o == t {
186 result.insert(key.to_string(), o.clone());
187 } else {
188 conflicts.push(MergeConflict {
189 field: format!("{field_name}.{key}"),
190 reason: "added on both sides with different values".into(),
191 });
192 result.insert(key.to_string(), o.clone());
193 }
194 }
195
196 (Some(_) | None, None, None) => {}
198 (Some(b), Some(o), None) => {
200 if o == b {
201 conflicts.push(MergeConflict {
203 field: format!("{field_name}.{key}"),
204 reason: "removed on one side, unchanged on the other".into(),
205 });
206 result.insert(key.to_string(), o.clone());
207 }
208 }
210 (Some(b), None, Some(t)) => {
211 if t == b {
212 conflicts.push(MergeConflict {
214 field: format!("{field_name}.{key}"),
215 reason: "removed on one side, unchanged on the other".into(),
216 });
217 result.insert(key.to_string(), t.clone());
218 }
219 }
221
222 (Some(b), Some(o), Some(t)) => {
223 let ours_changed = o != b;
224 let theirs_changed = t != b;
225
226 match (ours_changed, theirs_changed) {
227 (false, true) => {
228 result.insert(key.to_string(), t.clone());
229 }
230 (true, true) if o != t => {
231 conflicts.push(MergeConflict {
232 field: format!("{field_name}.{key}"),
233 reason: "modified on both sides with different values".into(),
234 });
235 result.insert(key.to_string(), o.clone());
236 }
237 _ => {
238 result.insert(key.to_string(), o.clone());
239 }
240 }
241 }
242 }
243 }
244
245 result
246}
247
248fn merge_secrets(
254 base: &Vault,
255 ours: &Vault,
256 theirs: &Vault,
257 ours_changed_recipients: bool,
258 theirs_changed_recipients: bool,
259 conflicts: &mut Vec<MergeConflict>,
260) -> BTreeMap<String, SecretEntry> {
261 if ours_changed_recipients && !theirs_changed_recipients {
264 return merge_secrets_with_reencrypted_side(base, ours, theirs, "theirs", conflicts);
265 }
266 if theirs_changed_recipients && !ours_changed_recipients {
267 return merge_secrets_with_reencrypted_side(base, theirs, ours, "ours", conflicts);
268 }
269 if ours_changed_recipients && theirs_changed_recipients {
270 return merge_secrets_both_reencrypted(base, ours, theirs, conflicts);
271 }
272
273 merge_secrets_normal(base, ours, theirs, conflicts)
275}
276
277fn merge_secrets_normal(
279 base: &Vault,
280 ours: &Vault,
281 theirs: &Vault,
282 conflicts: &mut Vec<MergeConflict>,
283) -> BTreeMap<String, SecretEntry> {
284 let all_keys: BTreeSet<&str> = base
285 .secrets
286 .keys()
287 .chain(ours.secrets.keys())
288 .chain(theirs.secrets.keys())
289 .map(String::as_str)
290 .collect();
291
292 let mut result = BTreeMap::new();
293
294 for key in all_keys {
295 let in_base = base.secrets.get(key);
296 let in_ours = ours.secrets.get(key);
297 let in_theirs = theirs.secrets.get(key);
298
299 match (in_base, in_ours, in_theirs) {
300 (None, None, Some(t)) => {
301 result.insert(key.to_string(), t.clone());
302 }
303 (None, Some(o), None) => {
304 result.insert(key.to_string(), o.clone());
305 }
306 (None, Some(o), Some(t)) => {
307 if o.shared == t.shared {
308 result.insert(key.to_string(), o.clone());
309 } else {
310 conflicts.push(MergeConflict {
311 field: format!("secrets.{key}"),
312 reason: "added on both sides (values may differ)".into(),
313 });
314 result.insert(key.to_string(), o.clone());
315 }
316 }
317
318 (Some(_) | None, None, None) => {}
320
321 (Some(b), Some(o), None) => {
322 conflicts.push(MergeConflict {
324 field: format!("secrets.{key}"),
325 reason: if o.shared == b.shared {
326 "removed on one side, unchanged on the other".into()
327 } else {
328 "modified on our side but removed on theirs".into()
329 },
330 });
331 result.insert(key.to_string(), o.clone());
332 }
333 (Some(b), None, Some(t)) => {
334 conflicts.push(MergeConflict {
336 field: format!("secrets.{key}"),
337 reason: if t.shared == b.shared {
338 "removed on one side, unchanged on the other".into()
339 } else {
340 "removed on our side but modified on theirs".into()
341 },
342 });
343 result.insert(key.to_string(), t.clone());
344 }
345
346 (Some(b), Some(o), Some(t)) => {
347 let ours_changed = o.shared != b.shared;
348 let theirs_changed = t.shared != b.shared;
349
350 let shared = match (ours_changed, theirs_changed) {
351 (false, true) => t.shared.clone(),
352 (true, true) => {
353 conflicts.push(MergeConflict {
354 field: format!("secrets.{key}"),
355 reason: "shared value modified on both sides".into(),
356 });
357 o.shared.clone()
358 }
359 _ => o.shared.clone(),
360 };
361
362 let scoped = merge_scoped(&b.scoped, &o.scoped, &t.scoped, key, conflicts);
363 result.insert(key.to_string(), SecretEntry { shared, scoped });
364 }
365 }
366 }
367
368 result
369}
370
371fn merge_scoped(
373 base: &BTreeMap<String, String>,
374 ours: &BTreeMap<String, String>,
375 theirs: &BTreeMap<String, String>,
376 secret_key: &str,
377 conflicts: &mut Vec<MergeConflict>,
378) -> BTreeMap<String, String> {
379 let all_pks: BTreeSet<&str> = base
380 .keys()
381 .chain(ours.keys())
382 .chain(theirs.keys())
383 .map(String::as_str)
384 .collect();
385
386 let mut result = BTreeMap::new();
387
388 for pk in all_pks {
389 let in_base = base.get(pk);
390 let in_ours = ours.get(pk);
391 let in_theirs = theirs.get(pk);
392
393 match (in_base, in_ours, in_theirs) {
394 (None, None, Some(t)) => {
395 result.insert(pk.to_string(), t.clone());
396 }
397 (None, Some(o), None) => {
398 result.insert(pk.to_string(), o.clone());
399 }
400 (None, Some(o), Some(t)) => {
401 if o == t {
402 result.insert(pk.to_string(), o.clone());
403 } else {
404 conflicts.push(MergeConflict {
405 field: format!("secrets.{secret_key}.scoped.{pk}"),
406 reason: "scoped override added on both sides".into(),
407 });
408 result.insert(pk.to_string(), o.clone());
409 }
410 }
411 (Some(_) | None, None, None) => {}
412 (Some(b), Some(o), None) => {
413 if o != b {
414 conflicts.push(MergeConflict {
415 field: format!("secrets.{secret_key}.scoped.{pk}"),
416 reason: "scoped override modified on our side but removed on theirs".into(),
417 });
418 result.insert(pk.to_string(), o.clone());
419 }
420 }
421 (Some(b), None, Some(t)) => {
422 if t != b {
423 conflicts.push(MergeConflict {
424 field: format!("secrets.{secret_key}.scoped.{pk}"),
425 reason: "scoped override removed on our side but modified on theirs".into(),
426 });
427 result.insert(pk.to_string(), t.clone());
428 }
429 }
430 (Some(b), Some(o), Some(t)) => {
431 let ours_changed = o != b;
432 let theirs_changed = t != b;
433
434 match (ours_changed, theirs_changed) {
435 (false, true) => {
436 result.insert(pk.to_string(), t.clone());
437 }
438 (true, true) if o != t => {
439 conflicts.push(MergeConflict {
440 field: format!("secrets.{secret_key}.scoped.{pk}"),
441 reason: "scoped override modified on both sides".into(),
442 });
443 result.insert(pk.to_string(), o.clone());
444 }
445 _ => {
446 result.insert(pk.to_string(), o.clone());
447 }
448 }
449 }
450 }
451 }
452
453 result
454}
455
456fn merge_secrets_with_reencrypted_side(
462 base: &Vault,
463 reencrypted: &Vault,
464 other: &Vault,
465 other_label: &str,
466 conflicts: &mut Vec<MergeConflict>,
467) -> BTreeMap<String, SecretEntry> {
468 let mut result = reencrypted.secrets.clone();
470
471 let all_keys: BTreeSet<&str> = base
473 .secrets
474 .keys()
475 .chain(other.secrets.keys())
476 .map(String::as_str)
477 .collect();
478
479 for key in all_keys {
480 let in_base = base.secrets.get(key);
481 let in_other = other.secrets.get(key);
482
483 match (in_base, in_other) {
484 (None, Some(entry)) => {
485 if result.contains_key(key) {
486 conflicts.push(MergeConflict {
487 field: format!("secrets.{key}"),
488 reason: format!(
489 "added on {other_label} side and on the side that changed recipients"
490 ),
491 });
492 } else {
493 result.insert(key.to_string(), entry.clone());
494 }
495 }
496 (Some(_), None) => {
497 result.remove(key);
499 }
500 (Some(b), Some(entry)) => {
501 if entry.shared != b.shared {
502 conflicts.push(MergeConflict {
503 field: format!("secrets.{key}"),
504 reason: format!(
505 "modified on {other_label} side while recipients changed on the other"
506 ),
507 });
508 }
509 }
511 (None, None) => {}
512 }
513 }
514
515 result
516}
517
518fn merge_secrets_both_reencrypted(
521 base: &Vault,
522 ours: &Vault,
523 theirs: &Vault,
524 conflicts: &mut Vec<MergeConflict>,
525) -> BTreeMap<String, SecretEntry> {
526 let all_keys: BTreeSet<&str> = base
527 .secrets
528 .keys()
529 .chain(ours.secrets.keys())
530 .chain(theirs.secrets.keys())
531 .map(String::as_str)
532 .collect();
533
534 let mut result = BTreeMap::new();
535
536 for key in all_keys {
537 let in_base = base.secrets.get(key);
538 let in_ours = ours.secrets.get(key);
539 let in_theirs = theirs.secrets.get(key);
540
541 match (in_base, in_ours, in_theirs) {
542 (Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
544 result.insert(key.to_string(), o.clone());
545 }
546 (Some(_), Some(_) | None, None) | (Some(_), None, Some(_)) | (None, None, None) => {}
548 (None, None, Some(t)) => {
549 result.insert(key.to_string(), t.clone());
550 }
551 (None, Some(o), Some(_)) => {
552 conflicts.push(MergeConflict {
553 field: format!("secrets.{key}"),
554 reason: "added on both sides while both changed recipients".into(),
555 });
556 result.insert(key.to_string(), o.clone());
557 }
558 }
559 }
560
561 result
562}
563
564#[derive(Debug)]
566pub struct MergeDriverOutput {
567 pub result: MergeResult,
568 pub meta_regenerated: bool,
569}
570
571pub fn run_merge_driver(base: &str, ours: &str, theirs: &str) -> Result<MergeDriverOutput, String> {
577 use crate::vault;
578
579 let base_vault = vault::parse(base).map_err(|e| format!("parsing base: {e}"))?;
580 let ours_vault = vault::parse(ours).map_err(|e| format!("parsing ours: {e}"))?;
581 let theirs_vault = vault::parse(theirs).map_err(|e| format!("parsing theirs: {e}"))?;
582
583 let mut result = merge_vaults(&base_vault, &ours_vault, &theirs_vault);
584 let meta_regenerated = regenerate_meta(&mut result.vault, &ours_vault, &theirs_vault).is_some();
585
586 Ok(MergeDriverOutput {
587 result,
588 meta_regenerated,
589 })
590}
591
592pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Option<String> {
598 use crate::{compute_mac, crypto, decrypt_meta, encrypt_value, parse_recipients, resolve_key};
599 use age::secrecy::ExposeSecret;
600 use std::collections::HashMap;
601
602 let secret_key = resolve_key().ok()?;
603 let identity = crypto::parse_identity(secret_key.expose_secret()).ok()?;
604
605 let default_meta = || crate::types::Meta {
606 recipients: HashMap::new(),
607 mac: String::new(),
608 mac_key: None,
609 github_pins: HashMap::new(),
610 };
611
612 let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
613 let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
614
615 let mut names = theirs_meta.recipients;
617 for (pk, name) in ours_meta.recipients {
618 names.insert(pk, name);
619 }
620
621 names.retain(|pk, _| merged.recipients.contains(pk));
623
624 let mac_key_hex = crate::generate_mac_key();
625 let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
626 let mac = compute_mac(merged, Some(&mac_key));
627 let mut github_pins = theirs_meta.github_pins;
629 for (user, pins) in ours_meta.github_pins {
630 github_pins.insert(user, pins);
631 }
632
633 let meta = crate::types::Meta {
634 recipients: names,
635 mac,
636 mac_key: Some(mac_key_hex),
637 github_pins,
638 };
639
640 let recipients = parse_recipients(&merged.recipients).ok()?;
641
642 if recipients.is_empty() {
643 return None;
644 }
645
646 let meta_json = serde_json::to_vec(&meta).ok()?;
647 let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
648 merged.meta = encrypted;
649 Some("meta regenerated".into())
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
656 use std::collections::BTreeMap;
657
658 fn base_vault() -> Vault {
659 let mut schema = BTreeMap::new();
660 schema.insert(
661 "DB_URL".into(),
662 SchemaEntry {
663 description: "database url".into(),
664 example: None,
665 tags: vec![],
666 ..Default::default()
667 },
668 );
669
670 let mut secrets = BTreeMap::new();
671 secrets.insert(
672 "DB_URL".into(),
673 SecretEntry {
674 shared: "base-cipher-db".into(),
675 scoped: BTreeMap::new(),
676 },
677 );
678
679 Vault {
680 version: VAULT_VERSION.into(),
681 created: "2026-01-01T00:00:00Z".into(),
682 vault_name: ".murk".into(),
683 repo: String::new(),
684 recipients: vec!["age1alice".into(), "age1bob".into()],
685 schema,
686 secrets,
687 meta: "base-meta".into(),
688 }
689 }
690
691 #[test]
694 fn merge_no_changes() {
695 let base = base_vault();
696 let r = merge_vaults(&base, &base, &base);
697 assert!(r.conflicts.is_empty());
698 assert_eq!(r.vault.secrets.len(), 1);
699 assert_eq!(r.vault.recipients.len(), 2);
700 }
701
702 #[test]
705 fn merge_ours_adds_secret() {
706 let base = base_vault();
707 let mut ours = base.clone();
708 ours.secrets.insert(
709 "API_KEY".into(),
710 SecretEntry {
711 shared: "ours-cipher-api".into(),
712 scoped: BTreeMap::new(),
713 },
714 );
715 ours.schema.insert(
716 "API_KEY".into(),
717 SchemaEntry {
718 description: "api key".into(),
719 example: None,
720 tags: vec![],
721 ..Default::default()
722 },
723 );
724
725 let r = merge_vaults(&base, &ours, &base);
726 assert!(r.conflicts.is_empty());
727 assert!(r.vault.secrets.contains_key("API_KEY"));
728 assert!(r.vault.schema.contains_key("API_KEY"));
729 assert_eq!(r.vault.secrets.len(), 2);
730 }
731
732 #[test]
735 fn merge_theirs_adds_secret() {
736 let base = base_vault();
737 let mut theirs = base.clone();
738 theirs.secrets.insert(
739 "STRIPE_KEY".into(),
740 SecretEntry {
741 shared: "theirs-cipher-stripe".into(),
742 scoped: BTreeMap::new(),
743 },
744 );
745
746 let r = merge_vaults(&base, &base, &theirs);
747 assert!(r.conflicts.is_empty());
748 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
749 }
750
751 #[test]
754 fn merge_both_add_different_keys() {
755 let base = base_vault();
756 let mut ours = base.clone();
757 ours.secrets.insert(
758 "API_KEY".into(),
759 SecretEntry {
760 shared: "ours-cipher-api".into(),
761 scoped: BTreeMap::new(),
762 },
763 );
764
765 let mut theirs = base.clone();
766 theirs.secrets.insert(
767 "STRIPE_KEY".into(),
768 SecretEntry {
769 shared: "theirs-cipher-stripe".into(),
770 scoped: BTreeMap::new(),
771 },
772 );
773
774 let r = merge_vaults(&base, &ours, &theirs);
775 assert!(r.conflicts.is_empty());
776 assert!(r.vault.secrets.contains_key("API_KEY"));
777 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
778 assert!(r.vault.secrets.contains_key("DB_URL"));
779 assert_eq!(r.vault.secrets.len(), 3);
780 }
781
782 #[test]
785 fn merge_both_remove_same_key() {
786 let base = base_vault();
787 let mut ours = base.clone();
788 ours.secrets.remove("DB_URL");
789 let mut theirs = base.clone();
790 theirs.secrets.remove("DB_URL");
791
792 let r = merge_vaults(&base, &ours, &theirs);
793 assert!(r.conflicts.is_empty());
794 assert!(!r.vault.secrets.contains_key("DB_URL"));
795 }
796
797 #[test]
800 fn merge_ours_modifies_theirs_unchanged() {
801 let base = base_vault();
802 let mut ours = base.clone();
803 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
804
805 let r = merge_vaults(&base, &ours, &base);
806 assert!(r.conflicts.is_empty());
807 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
808 }
809
810 #[test]
813 fn merge_theirs_modifies_ours_unchanged() {
814 let base = base_vault();
815 let mut theirs = base.clone();
816 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
817
818 let r = merge_vaults(&base, &base, &theirs);
819 assert!(r.conflicts.is_empty());
820 assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
821 }
822
823 #[test]
826 fn merge_both_modify_same_secret() {
827 let base = base_vault();
828 let mut ours = base.clone();
829 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
830 let mut theirs = base.clone();
831 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
832
833 let r = merge_vaults(&base, &ours, &theirs);
834 assert_eq!(r.conflicts.len(), 1);
835 assert!(r.conflicts[0].field.contains("DB_URL"));
836 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
838 }
839
840 #[test]
841 fn merge_both_add_same_key() {
842 let base = base_vault();
843 let mut ours = base.clone();
844 ours.secrets.insert(
845 "NEW_KEY".into(),
846 SecretEntry {
847 shared: "ours-cipher".into(),
848 scoped: BTreeMap::new(),
849 },
850 );
851 let mut theirs = base.clone();
852 theirs.secrets.insert(
853 "NEW_KEY".into(),
854 SecretEntry {
855 shared: "theirs-cipher".into(),
856 scoped: BTreeMap::new(),
857 },
858 );
859
860 let r = merge_vaults(&base, &ours, &theirs);
861 assert_eq!(r.conflicts.len(), 1);
862 assert!(r.conflicts[0].field.contains("NEW_KEY"));
863 }
864
865 #[test]
866 fn merge_remove_vs_modify() {
867 let base = base_vault();
868 let mut ours = base.clone();
869 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
870 let mut theirs = base.clone();
871 theirs.secrets.remove("DB_URL");
872
873 let r = merge_vaults(&base, &ours, &theirs);
874 assert_eq!(r.conflicts.len(), 1);
875 assert!(
876 r.conflicts[0]
877 .reason
878 .contains("modified on our side but removed on theirs")
879 );
880 }
881
882 #[test]
885 fn merge_recipient_added_one_side_conflicts() {
886 let base = base_vault();
887 let mut ours = base.clone();
888 ours.recipients.push("age1charlie".into());
889
890 let r = merge_vaults(&base, &ours, &base);
891 assert_eq!(r.conflicts.len(), 1);
892 assert!(r.conflicts[0].reason.contains("added on one side"));
893 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
895 }
896
897 #[test]
898 fn merge_recipient_added_both_same() {
899 let base = base_vault();
900 let mut ours = base.clone();
901 ours.recipients.push("age1charlie".into());
902 let mut theirs = base.clone();
903 theirs.recipients.push("age1charlie".into());
904
905 let r = merge_vaults(&base, &ours, &theirs);
906 assert!(r.conflicts.is_empty());
907 assert_eq!(
908 r.vault
909 .recipients
910 .iter()
911 .filter(|r| *r == "age1charlie")
912 .count(),
913 1
914 );
915 }
916
917 #[test]
918 fn merge_recipient_removed_one_side_conflicts() {
919 let base = base_vault();
920 let mut ours = base.clone();
921 ours.recipients.retain(|r| r != "age1bob");
922
923 let r = merge_vaults(&base, &ours, &base);
924 assert!(!r.conflicts.is_empty());
926 assert!(r.vault.recipients.contains(&"age1bob".to_string()));
927 }
928
929 #[test]
930 fn merge_recipient_removed_both_sides_ok() {
931 let base = base_vault();
932 let mut ours = base.clone();
933 let mut theirs = base.clone();
934 ours.recipients.retain(|r| r != "age1bob");
935 theirs.recipients.retain(|r| r != "age1bob");
936
937 let r = merge_vaults(&base, &ours, &theirs);
938 assert!(r.conflicts.is_empty());
939 assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
940 }
941
942 #[test]
945 fn merge_schema_different_keys() {
946 let base = base_vault();
947 let mut ours = base.clone();
948 ours.schema.insert(
949 "API_KEY".into(),
950 SchemaEntry {
951 description: "api".into(),
952 example: None,
953 tags: vec![],
954 ..Default::default()
955 },
956 );
957 let mut theirs = base.clone();
958 theirs.schema.insert(
959 "STRIPE".into(),
960 SchemaEntry {
961 description: "stripe".into(),
962 example: None,
963 tags: vec![],
964 ..Default::default()
965 },
966 );
967
968 let r = merge_vaults(&base, &ours, &theirs);
969 assert!(r.conflicts.is_empty());
970 assert!(r.vault.schema.contains_key("API_KEY"));
971 assert!(r.vault.schema.contains_key("STRIPE"));
972 }
973
974 #[test]
975 fn merge_schema_same_key_conflict() {
976 let base = base_vault();
977 let mut ours = base.clone();
978 ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
979 let mut theirs = base.clone();
980 theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
981
982 let r = merge_vaults(&base, &ours, &theirs);
983 assert_eq!(r.conflicts.len(), 1);
984 assert!(r.conflicts[0].field.contains("schema.DB_URL"));
985 }
986
987 #[test]
990 fn merge_scoped_different_pubkeys() {
991 let base = base_vault();
992 let mut ours = base.clone();
993 ours.secrets
994 .get_mut("DB_URL")
995 .unwrap()
996 .scoped
997 .insert("age1alice".into(), "alice-scope".into());
998 let mut theirs = base.clone();
999 theirs
1000 .secrets
1001 .get_mut("DB_URL")
1002 .unwrap()
1003 .scoped
1004 .insert("age1bob".into(), "bob-scope".into());
1005
1006 let r = merge_vaults(&base, &ours, &theirs);
1007 assert!(r.conflicts.is_empty());
1008 let entry = &r.vault.secrets["DB_URL"];
1009 assert_eq!(entry.scoped["age1alice"], "alice-scope");
1010 assert_eq!(entry.scoped["age1bob"], "bob-scope");
1011 }
1012
1013 #[test]
1014 fn merge_scoped_both_modify_same() {
1015 let mut base = base_vault();
1016 base.secrets
1017 .get_mut("DB_URL")
1018 .unwrap()
1019 .scoped
1020 .insert("age1alice".into(), "base-scope".into());
1021
1022 let mut ours = base.clone();
1023 ours.secrets
1024 .get_mut("DB_URL")
1025 .unwrap()
1026 .scoped
1027 .insert("age1alice".into(), "ours-scope".into());
1028 let mut theirs = base.clone();
1029 theirs
1030 .secrets
1031 .get_mut("DB_URL")
1032 .unwrap()
1033 .scoped
1034 .insert("age1alice".into(), "theirs-scope".into());
1035
1036 let r = merge_vaults(&base, &ours, &theirs);
1037 assert_eq!(r.conflicts.len(), 1);
1038 assert!(r.conflicts[0].field.contains("scoped"));
1039 }
1040
1041 #[test]
1042 fn merge_scoped_add_vs_base_key_removal() {
1043 let base = base_vault();
1044
1045 let mut ours = base.clone();
1047 ours.secrets.remove("DB_URL");
1048 ours.schema.remove("DB_URL");
1049
1050 let mut theirs = base.clone();
1052 theirs
1053 .secrets
1054 .get_mut("DB_URL")
1055 .unwrap()
1056 .scoped
1057 .insert("age1alice".into(), "alice-scoped".into());
1058
1059 let r = merge_vaults(&base, &ours, &theirs);
1060 assert!(!r.conflicts.is_empty());
1063 assert!(r.vault.secrets.contains_key("DB_URL"));
1064 }
1065
1066 #[test]
1067 fn merge_scoped_add_vs_base_key_modification() {
1068 let base = base_vault();
1069
1070 let mut ours = base.clone();
1072 ours.secrets.remove("DB_URL");
1073 ours.schema.remove("DB_URL");
1074
1075 let mut theirs = base.clone();
1077 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
1078 theirs
1079 .secrets
1080 .get_mut("DB_URL")
1081 .unwrap()
1082 .scoped
1083 .insert("age1alice".into(), "alice-scoped".into());
1084
1085 let r = merge_vaults(&base, &ours, &theirs);
1086 assert!(r.conflicts.len() >= 1);
1088 assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1089 }
1090
1091 #[test]
1094 fn merge_ours_changes_recipients_theirs_adds_key() {
1095 let base = base_vault();
1096 let mut ours = base.clone();
1097 ours.recipients.push("age1charlie".into());
1098 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1099
1100 let mut theirs = base.clone();
1101 theirs.secrets.insert(
1102 "NEW_KEY".into(),
1103 SecretEntry {
1104 shared: "theirs-new".into(),
1105 scoped: BTreeMap::new(),
1106 },
1107 );
1108
1109 let r = merge_vaults(&base, &ours, &theirs);
1110 assert!(
1112 r.conflicts
1113 .iter()
1114 .any(|c| c.reason.contains("added on one side"))
1115 );
1116 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1117 assert!(r.vault.secrets.contains_key("NEW_KEY"));
1118 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1119 }
1120
1121 #[test]
1124 fn merge_takes_ours_meta() {
1125 let base = base_vault();
1126 let mut ours = base.clone();
1127 ours.meta = "ours-meta".into();
1128 let mut theirs = base.clone();
1129 theirs.meta = "theirs-meta".into();
1130
1131 let r = merge_vaults(&base, &ours, &theirs);
1132 assert_eq!(r.vault.meta, "ours-meta");
1133 }
1134
1135 #[test]
1138 fn run_merge_driver_invalid_base() {
1139 let result = run_merge_driver("not json", "{}", "{}");
1140 assert!(result.is_err());
1141 assert!(result.unwrap_err().contains("parsing base"));
1142 }
1143
1144 #[test]
1145 fn run_merge_driver_invalid_ours() {
1146 let base = serde_json::to_string(&base_vault()).unwrap();
1147 let result = run_merge_driver(&base, "not json", &base);
1148 assert!(result.is_err());
1149 assert!(result.unwrap_err().contains("parsing ours"));
1150 }
1151
1152 #[test]
1153 fn run_merge_driver_invalid_theirs() {
1154 let base = serde_json::to_string(&base_vault()).unwrap();
1155 let result = run_merge_driver(&base, &base, "not json");
1156 assert!(result.is_err());
1157 assert!(result.unwrap_err().contains("parsing theirs"));
1158 }
1159
1160 #[test]
1161 fn run_merge_driver_clean_no_changes() {
1162 let base = serde_json::to_string(&base_vault()).unwrap();
1163 let output = run_merge_driver(&base, &base, &base).unwrap();
1164 assert!(output.result.conflicts.is_empty());
1165 }
1167
1168 #[test]
1171 fn merge_preserves_ours_static_fields() {
1172 let base = base_vault();
1173 let mut ours = base.clone();
1174 ours.vault_name = "custom.murk".into();
1175 ours.repo = "https://github.com/test/repo".into();
1176
1177 let r = merge_vaults(&base, &ours, &base);
1178 assert_eq!(r.vault.vault_name, "custom.murk");
1179 assert_eq!(r.vault.repo, "https://github.com/test/repo");
1180 assert_eq!(r.vault.version, VAULT_VERSION);
1181 }
1182
1183 #[test]
1186 fn merge_both_remove_same_recipient() {
1187 let base = base_vault();
1188 let mut ours = base.clone();
1189 ours.recipients.retain(|r| r != "age1bob");
1190 let mut theirs = base.clone();
1191 theirs.recipients.retain(|r| r != "age1bob");
1192
1193 let r = merge_vaults(&base, &ours, &theirs);
1194 assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
1195 assert!(
1197 !r.conflicts.iter().any(|c| c.reason.contains("recipient")),
1198 "removing same recipient from both sides should not conflict"
1199 );
1200 }
1201
1202 #[test]
1205 fn merge_empty_vaults() {
1206 let empty = Vault {
1207 version: VAULT_VERSION.into(),
1208 created: "2026-01-01T00:00:00Z".into(),
1209 vault_name: ".murk".into(),
1210 repo: String::new(),
1211 recipients: vec!["age1alice".into()],
1212 schema: BTreeMap::new(),
1213 secrets: BTreeMap::new(),
1214 meta: String::new(),
1215 };
1216 let r = merge_vaults(&empty, &empty, &empty);
1217 assert!(r.conflicts.is_empty());
1218 assert!(r.vault.secrets.is_empty());
1219 }
1220
1221 #[test]
1224 fn merge_schema_ours_changes_description() {
1225 let base = base_vault();
1226 let mut ours = base.clone();
1227 ours.schema.get_mut("DB_URL").unwrap().description = "updated desc".into();
1228
1229 let r = merge_vaults(&base, &ours, &base);
1230 assert_eq!(r.vault.schema["DB_URL"].description, "updated desc");
1231 assert!(r.conflicts.is_empty());
1232 }
1233
1234 #[test]
1235 fn merge_schema_both_change_description_takes_ours() {
1236 let base = base_vault();
1237 let mut ours = base.clone();
1238 ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
1239 let mut theirs = base.clone();
1240 theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
1241
1242 let r = merge_vaults(&base, &ours, &theirs);
1243 assert_eq!(r.vault.schema["DB_URL"].description, "ours desc");
1246 }
1247}