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);
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(base: &Vault, ours: &Vault, theirs: &Vault) -> Vec<String> {
87 let base_set: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
88 let ours_set: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
89 let theirs_set: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
90
91 let ours_added: BTreeSet<&str> = ours_set.difference(&base_set).copied().collect();
92 let theirs_added: BTreeSet<&str> = theirs_set.difference(&base_set).copied().collect();
93 let ours_removed: BTreeSet<&str> = base_set.difference(&ours_set).copied().collect();
94 let theirs_removed: BTreeSet<&str> = base_set.difference(&theirs_set).copied().collect();
95
96 let mut result: BTreeSet<&str> = base_set;
97
98 for pk in ours_added {
100 result.insert(pk);
101 }
102 for pk in theirs_added {
103 result.insert(pk);
104 }
105
106 for pk in &ours_removed {
108 result.remove(pk);
109 }
110 for pk in &theirs_removed {
111 result.remove(pk);
112 }
113
114 result.into_iter().map(String::from).collect()
115}
116
117fn merge_btree<V: PartialEq + Clone>(
119 base: &BTreeMap<String, V>,
120 ours: &BTreeMap<String, V>,
121 theirs: &BTreeMap<String, V>,
122 field_name: &str,
123 conflicts: &mut Vec<MergeConflict>,
124) -> BTreeMap<String, V> {
125 let all_keys: BTreeSet<&str> = base
126 .keys()
127 .chain(ours.keys())
128 .chain(theirs.keys())
129 .map(String::as_str)
130 .collect();
131
132 let mut result = BTreeMap::new();
133
134 for key in all_keys {
135 let in_base = base.get(key);
136 let in_ours = ours.get(key);
137 let in_theirs = theirs.get(key);
138
139 match (in_base, in_ours, in_theirs) {
140 (None, None, Some(t)) => {
141 result.insert(key.to_string(), t.clone());
142 }
143 (None, Some(o), None) => {
144 result.insert(key.to_string(), o.clone());
145 }
146 (None, Some(o), Some(t)) => {
147 if o == t {
148 result.insert(key.to_string(), o.clone());
149 } else {
150 conflicts.push(MergeConflict {
151 field: format!("{field_name}.{key}"),
152 reason: "added on both sides with different values".into(),
153 });
154 result.insert(key.to_string(), o.clone());
155 }
156 }
157
158 (Some(_), None | Some(_), None) | (Some(_), None, Some(_)) | (None, None, None) => {}
160
161 (Some(b), Some(o), Some(t)) => {
162 let ours_changed = o != b;
163 let theirs_changed = t != b;
164
165 match (ours_changed, theirs_changed) {
166 (false, true) => {
167 result.insert(key.to_string(), t.clone());
168 }
169 (true, true) if o != t => {
170 conflicts.push(MergeConflict {
171 field: format!("{field_name}.{key}"),
172 reason: "modified on both sides with different values".into(),
173 });
174 result.insert(key.to_string(), o.clone());
175 }
176 _ => {
177 result.insert(key.to_string(), o.clone());
178 }
179 }
180 }
181 }
182 }
183
184 result
185}
186
187fn merge_secrets(
193 base: &Vault,
194 ours: &Vault,
195 theirs: &Vault,
196 ours_changed_recipients: bool,
197 theirs_changed_recipients: bool,
198 conflicts: &mut Vec<MergeConflict>,
199) -> BTreeMap<String, SecretEntry> {
200 if ours_changed_recipients && !theirs_changed_recipients {
203 return merge_secrets_with_reencrypted_side(base, ours, theirs, "theirs", conflicts);
204 }
205 if theirs_changed_recipients && !ours_changed_recipients {
206 return merge_secrets_with_reencrypted_side(base, theirs, ours, "ours", conflicts);
207 }
208 if ours_changed_recipients && theirs_changed_recipients {
209 return merge_secrets_both_reencrypted(base, ours, theirs, conflicts);
210 }
211
212 merge_secrets_normal(base, ours, theirs, conflicts)
214}
215
216fn merge_secrets_normal(
218 base: &Vault,
219 ours: &Vault,
220 theirs: &Vault,
221 conflicts: &mut Vec<MergeConflict>,
222) -> BTreeMap<String, SecretEntry> {
223 let all_keys: BTreeSet<&str> = base
224 .secrets
225 .keys()
226 .chain(ours.secrets.keys())
227 .chain(theirs.secrets.keys())
228 .map(String::as_str)
229 .collect();
230
231 let mut result = BTreeMap::new();
232
233 for key in all_keys {
234 let in_base = base.secrets.get(key);
235 let in_ours = ours.secrets.get(key);
236 let in_theirs = theirs.secrets.get(key);
237
238 match (in_base, in_ours, in_theirs) {
239 (None, None, Some(t)) => {
240 result.insert(key.to_string(), t.clone());
241 }
242 (None, Some(o), None) => {
243 result.insert(key.to_string(), o.clone());
244 }
245 (None, Some(o), Some(t)) => {
246 if o.shared == t.shared {
247 result.insert(key.to_string(), o.clone());
248 } else {
249 conflicts.push(MergeConflict {
250 field: format!("secrets.{key}"),
251 reason: "added on both sides (values may differ)".into(),
252 });
253 result.insert(key.to_string(), o.clone());
254 }
255 }
256
257 (Some(_) | None, None, None) => {}
259
260 (Some(b), Some(o), None) => {
261 if o.shared != b.shared {
262 conflicts.push(MergeConflict {
263 field: format!("secrets.{key}"),
264 reason: "modified on our side but removed on theirs".into(),
265 });
266 result.insert(key.to_string(), o.clone());
267 }
268 }
269 (Some(b), None, Some(t)) => {
270 if t.shared != b.shared {
271 conflicts.push(MergeConflict {
272 field: format!("secrets.{key}"),
273 reason: "removed on our side but modified on theirs".into(),
274 });
275 result.insert(key.to_string(), t.clone());
276 }
277 }
278
279 (Some(b), Some(o), Some(t)) => {
280 let ours_changed = o.shared != b.shared;
281 let theirs_changed = t.shared != b.shared;
282
283 let shared = match (ours_changed, theirs_changed) {
284 (false, true) => t.shared.clone(),
285 (true, true) => {
286 conflicts.push(MergeConflict {
287 field: format!("secrets.{key}"),
288 reason: "shared value modified on both sides".into(),
289 });
290 o.shared.clone()
291 }
292 _ => o.shared.clone(),
293 };
294
295 let scoped = merge_scoped(&b.scoped, &o.scoped, &t.scoped, key, conflicts);
296 result.insert(key.to_string(), SecretEntry { shared, scoped });
297 }
298 }
299 }
300
301 result
302}
303
304fn merge_scoped(
306 base: &BTreeMap<String, String>,
307 ours: &BTreeMap<String, String>,
308 theirs: &BTreeMap<String, String>,
309 secret_key: &str,
310 conflicts: &mut Vec<MergeConflict>,
311) -> BTreeMap<String, String> {
312 let all_pks: BTreeSet<&str> = base
313 .keys()
314 .chain(ours.keys())
315 .chain(theirs.keys())
316 .map(String::as_str)
317 .collect();
318
319 let mut result = BTreeMap::new();
320
321 for pk in all_pks {
322 let in_base = base.get(pk);
323 let in_ours = ours.get(pk);
324 let in_theirs = theirs.get(pk);
325
326 match (in_base, in_ours, in_theirs) {
327 (None, None, Some(t)) => {
328 result.insert(pk.to_string(), t.clone());
329 }
330 (None, Some(o), None) => {
331 result.insert(pk.to_string(), o.clone());
332 }
333 (None, Some(o), Some(t)) => {
334 if o == t {
335 result.insert(pk.to_string(), o.clone());
336 } else {
337 conflicts.push(MergeConflict {
338 field: format!("secrets.{secret_key}.scoped.{pk}"),
339 reason: "scoped override added on both sides".into(),
340 });
341 result.insert(pk.to_string(), o.clone());
342 }
343 }
344 (Some(_) | None, None, None) => {}
345 (Some(b), Some(o), None) => {
346 if o != b {
347 conflicts.push(MergeConflict {
348 field: format!("secrets.{secret_key}.scoped.{pk}"),
349 reason: "scoped override modified on our side but removed on theirs".into(),
350 });
351 result.insert(pk.to_string(), o.clone());
352 }
353 }
354 (Some(b), None, Some(t)) => {
355 if t != b {
356 conflicts.push(MergeConflict {
357 field: format!("secrets.{secret_key}.scoped.{pk}"),
358 reason: "scoped override removed on our side but modified on theirs".into(),
359 });
360 result.insert(pk.to_string(), t.clone());
361 }
362 }
363 (Some(b), Some(o), Some(t)) => {
364 let ours_changed = o != b;
365 let theirs_changed = t != b;
366
367 match (ours_changed, theirs_changed) {
368 (false, true) => {
369 result.insert(pk.to_string(), t.clone());
370 }
371 (true, true) if o != t => {
372 conflicts.push(MergeConflict {
373 field: format!("secrets.{secret_key}.scoped.{pk}"),
374 reason: "scoped override modified on both sides".into(),
375 });
376 result.insert(pk.to_string(), o.clone());
377 }
378 _ => {
379 result.insert(pk.to_string(), o.clone());
380 }
381 }
382 }
383 }
384 }
385
386 result
387}
388
389fn merge_secrets_with_reencrypted_side(
395 base: &Vault,
396 reencrypted: &Vault,
397 other: &Vault,
398 other_label: &str,
399 conflicts: &mut Vec<MergeConflict>,
400) -> BTreeMap<String, SecretEntry> {
401 let mut result = reencrypted.secrets.clone();
403
404 let all_keys: BTreeSet<&str> = base
406 .secrets
407 .keys()
408 .chain(other.secrets.keys())
409 .map(String::as_str)
410 .collect();
411
412 for key in all_keys {
413 let in_base = base.secrets.get(key);
414 let in_other = other.secrets.get(key);
415
416 match (in_base, in_other) {
417 (None, Some(entry)) => {
418 if result.contains_key(key) {
419 conflicts.push(MergeConflict {
420 field: format!("secrets.{key}"),
421 reason: format!(
422 "added on {other_label} side and on the side that changed recipients"
423 ),
424 });
425 } else {
426 result.insert(key.to_string(), entry.clone());
427 }
428 }
429 (Some(_), None) => {
430 result.remove(key);
432 }
433 (Some(b), Some(entry)) => {
434 if entry.shared != b.shared {
435 conflicts.push(MergeConflict {
436 field: format!("secrets.{key}"),
437 reason: format!(
438 "modified on {other_label} side while recipients changed on the other"
439 ),
440 });
441 }
442 }
444 (None, None) => {}
445 }
446 }
447
448 result
449}
450
451fn merge_secrets_both_reencrypted(
454 base: &Vault,
455 ours: &Vault,
456 theirs: &Vault,
457 conflicts: &mut Vec<MergeConflict>,
458) -> BTreeMap<String, SecretEntry> {
459 let all_keys: BTreeSet<&str> = base
460 .secrets
461 .keys()
462 .chain(ours.secrets.keys())
463 .chain(theirs.secrets.keys())
464 .map(String::as_str)
465 .collect();
466
467 let mut result = BTreeMap::new();
468
469 for key in all_keys {
470 let in_base = base.secrets.get(key);
471 let in_ours = ours.secrets.get(key);
472 let in_theirs = theirs.secrets.get(key);
473
474 match (in_base, in_ours, in_theirs) {
475 (Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
477 result.insert(key.to_string(), o.clone());
478 }
479 (Some(_), Some(_) | None, None) | (Some(_), None, Some(_)) | (None, None, None) => {}
481 (None, None, Some(t)) => {
482 result.insert(key.to_string(), t.clone());
483 }
484 (None, Some(o), Some(_)) => {
485 conflicts.push(MergeConflict {
486 field: format!("secrets.{key}"),
487 reason: "added on both sides while both changed recipients".into(),
488 });
489 result.insert(key.to_string(), o.clone());
490 }
491 }
492 }
493
494 result
495}
496
497#[derive(Debug)]
499pub struct MergeDriverOutput {
500 pub result: MergeResult,
501 pub meta_regenerated: bool,
502}
503
504pub fn run_merge_driver(base: &str, ours: &str, theirs: &str) -> Result<MergeDriverOutput, String> {
510 use crate::vault;
511
512 let base_vault = vault::parse(base).map_err(|e| format!("parsing base: {e}"))?;
513 let ours_vault = vault::parse(ours).map_err(|e| format!("parsing ours: {e}"))?;
514 let theirs_vault = vault::parse(theirs).map_err(|e| format!("parsing theirs: {e}"))?;
515
516 let mut result = merge_vaults(&base_vault, &ours_vault, &theirs_vault);
517 let meta_regenerated = regenerate_meta(&mut result.vault, &ours_vault, &theirs_vault).is_some();
518
519 Ok(MergeDriverOutput {
520 result,
521 meta_regenerated,
522 })
523}
524
525pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Option<String> {
531 use crate::{compute_mac, crypto, decrypt_meta, encrypt_value, parse_recipients, resolve_key};
532 use age::secrecy::ExposeSecret;
533 use std::collections::HashMap;
534
535 let secret_key = resolve_key().ok()?;
536 let identity = crypto::parse_identity(secret_key.expose_secret()).ok()?;
537
538 let default_meta = || crate::types::Meta {
539 recipients: HashMap::new(),
540 mac: String::new(),
541 hmac_key: None,
542 };
543
544 let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
545 let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
546
547 let mut names = theirs_meta.recipients;
549 for (pk, name) in ours_meta.recipients {
550 names.insert(pk, name);
551 }
552
553 names.retain(|pk, _| merged.recipients.contains(pk));
555
556 let hmac_key_hex = crate::generate_hmac_key();
557 let hmac_key = crate::decode_hmac_key(&hmac_key_hex).unwrap();
558 let mac = compute_mac(merged, Some(&hmac_key));
559 let meta = crate::types::Meta {
560 recipients: names,
561 mac,
562 hmac_key: Some(hmac_key_hex),
563 };
564
565 let recipients = parse_recipients(&merged.recipients).ok()?;
566
567 if recipients.is_empty() {
568 return None;
569 }
570
571 let meta_json = serde_json::to_vec(&meta).ok()?;
572 let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
573 merged.meta = encrypted;
574 Some("meta regenerated".into())
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
581 use std::collections::BTreeMap;
582
583 fn base_vault() -> Vault {
584 let mut schema = BTreeMap::new();
585 schema.insert(
586 "DB_URL".into(),
587 SchemaEntry {
588 description: "database url".into(),
589 example: None,
590 tags: vec![],
591 },
592 );
593
594 let mut secrets = BTreeMap::new();
595 secrets.insert(
596 "DB_URL".into(),
597 SecretEntry {
598 shared: "base-cipher-db".into(),
599 scoped: BTreeMap::new(),
600 },
601 );
602
603 Vault {
604 version: VAULT_VERSION.into(),
605 created: "2026-01-01T00:00:00Z".into(),
606 vault_name: ".murk".into(),
607 repo: String::new(),
608 recipients: vec!["age1alice".into(), "age1bob".into()],
609 schema,
610 secrets,
611 meta: "base-meta".into(),
612 }
613 }
614
615 #[test]
618 fn merge_no_changes() {
619 let base = base_vault();
620 let r = merge_vaults(&base, &base, &base);
621 assert!(r.conflicts.is_empty());
622 assert_eq!(r.vault.secrets.len(), 1);
623 assert_eq!(r.vault.recipients.len(), 2);
624 }
625
626 #[test]
629 fn merge_ours_adds_secret() {
630 let base = base_vault();
631 let mut ours = base.clone();
632 ours.secrets.insert(
633 "API_KEY".into(),
634 SecretEntry {
635 shared: "ours-cipher-api".into(),
636 scoped: BTreeMap::new(),
637 },
638 );
639 ours.schema.insert(
640 "API_KEY".into(),
641 SchemaEntry {
642 description: "api key".into(),
643 example: None,
644 tags: vec![],
645 },
646 );
647
648 let r = merge_vaults(&base, &ours, &base);
649 assert!(r.conflicts.is_empty());
650 assert!(r.vault.secrets.contains_key("API_KEY"));
651 assert!(r.vault.schema.contains_key("API_KEY"));
652 assert_eq!(r.vault.secrets.len(), 2);
653 }
654
655 #[test]
658 fn merge_theirs_adds_secret() {
659 let base = base_vault();
660 let mut theirs = base.clone();
661 theirs.secrets.insert(
662 "STRIPE_KEY".into(),
663 SecretEntry {
664 shared: "theirs-cipher-stripe".into(),
665 scoped: BTreeMap::new(),
666 },
667 );
668
669 let r = merge_vaults(&base, &base, &theirs);
670 assert!(r.conflicts.is_empty());
671 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
672 }
673
674 #[test]
677 fn merge_both_add_different_keys() {
678 let base = base_vault();
679 let mut ours = base.clone();
680 ours.secrets.insert(
681 "API_KEY".into(),
682 SecretEntry {
683 shared: "ours-cipher-api".into(),
684 scoped: BTreeMap::new(),
685 },
686 );
687
688 let mut theirs = base.clone();
689 theirs.secrets.insert(
690 "STRIPE_KEY".into(),
691 SecretEntry {
692 shared: "theirs-cipher-stripe".into(),
693 scoped: BTreeMap::new(),
694 },
695 );
696
697 let r = merge_vaults(&base, &ours, &theirs);
698 assert!(r.conflicts.is_empty());
699 assert!(r.vault.secrets.contains_key("API_KEY"));
700 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
701 assert!(r.vault.secrets.contains_key("DB_URL"));
702 assert_eq!(r.vault.secrets.len(), 3);
703 }
704
705 #[test]
708 fn merge_both_remove_same_key() {
709 let base = base_vault();
710 let mut ours = base.clone();
711 ours.secrets.remove("DB_URL");
712 let mut theirs = base.clone();
713 theirs.secrets.remove("DB_URL");
714
715 let r = merge_vaults(&base, &ours, &theirs);
716 assert!(r.conflicts.is_empty());
717 assert!(!r.vault.secrets.contains_key("DB_URL"));
718 }
719
720 #[test]
723 fn merge_ours_modifies_theirs_unchanged() {
724 let base = base_vault();
725 let mut ours = base.clone();
726 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
727
728 let r = merge_vaults(&base, &ours, &base);
729 assert!(r.conflicts.is_empty());
730 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
731 }
732
733 #[test]
736 fn merge_theirs_modifies_ours_unchanged() {
737 let base = base_vault();
738 let mut theirs = base.clone();
739 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
740
741 let r = merge_vaults(&base, &base, &theirs);
742 assert!(r.conflicts.is_empty());
743 assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
744 }
745
746 #[test]
749 fn merge_both_modify_same_secret() {
750 let base = base_vault();
751 let mut ours = base.clone();
752 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
753 let mut theirs = base.clone();
754 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
755
756 let r = merge_vaults(&base, &ours, &theirs);
757 assert_eq!(r.conflicts.len(), 1);
758 assert!(r.conflicts[0].field.contains("DB_URL"));
759 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
761 }
762
763 #[test]
764 fn merge_both_add_same_key() {
765 let base = base_vault();
766 let mut ours = base.clone();
767 ours.secrets.insert(
768 "NEW_KEY".into(),
769 SecretEntry {
770 shared: "ours-cipher".into(),
771 scoped: BTreeMap::new(),
772 },
773 );
774 let mut theirs = base.clone();
775 theirs.secrets.insert(
776 "NEW_KEY".into(),
777 SecretEntry {
778 shared: "theirs-cipher".into(),
779 scoped: BTreeMap::new(),
780 },
781 );
782
783 let r = merge_vaults(&base, &ours, &theirs);
784 assert_eq!(r.conflicts.len(), 1);
785 assert!(r.conflicts[0].field.contains("NEW_KEY"));
786 }
787
788 #[test]
789 fn merge_remove_vs_modify() {
790 let base = base_vault();
791 let mut ours = base.clone();
792 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
793 let mut theirs = base.clone();
794 theirs.secrets.remove("DB_URL");
795
796 let r = merge_vaults(&base, &ours, &theirs);
797 assert_eq!(r.conflicts.len(), 1);
798 assert!(
799 r.conflicts[0]
800 .reason
801 .contains("modified on our side but removed on theirs")
802 );
803 }
804
805 #[test]
808 fn merge_recipient_added_ours() {
809 let base = base_vault();
810 let mut ours = base.clone();
811 ours.recipients.push("age1charlie".into());
812
813 let r = merge_vaults(&base, &ours, &base);
814 assert!(r.conflicts.is_empty());
815 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
816 assert_eq!(r.vault.recipients.len(), 3);
817 }
818
819 #[test]
820 fn merge_recipient_added_both_same() {
821 let base = base_vault();
822 let mut ours = base.clone();
823 ours.recipients.push("age1charlie".into());
824 let mut theirs = base.clone();
825 theirs.recipients.push("age1charlie".into());
826
827 let r = merge_vaults(&base, &ours, &theirs);
828 assert!(r.conflicts.is_empty());
829 assert_eq!(
830 r.vault
831 .recipients
832 .iter()
833 .filter(|r| *r == "age1charlie")
834 .count(),
835 1
836 );
837 }
838
839 #[test]
840 fn merge_recipient_removed() {
841 let base = base_vault();
842 let mut ours = base.clone();
843 ours.recipients.retain(|r| r != "age1bob");
844
845 let r = merge_vaults(&base, &ours, &base);
846 assert!(r.conflicts.is_empty());
847 assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
848 assert_eq!(r.vault.recipients.len(), 1);
849 }
850
851 #[test]
854 fn merge_schema_different_keys() {
855 let base = base_vault();
856 let mut ours = base.clone();
857 ours.schema.insert(
858 "API_KEY".into(),
859 SchemaEntry {
860 description: "api".into(),
861 example: None,
862 tags: vec![],
863 },
864 );
865 let mut theirs = base.clone();
866 theirs.schema.insert(
867 "STRIPE".into(),
868 SchemaEntry {
869 description: "stripe".into(),
870 example: None,
871 tags: vec![],
872 },
873 );
874
875 let r = merge_vaults(&base, &ours, &theirs);
876 assert!(r.conflicts.is_empty());
877 assert!(r.vault.schema.contains_key("API_KEY"));
878 assert!(r.vault.schema.contains_key("STRIPE"));
879 }
880
881 #[test]
882 fn merge_schema_same_key_conflict() {
883 let base = base_vault();
884 let mut ours = base.clone();
885 ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
886 let mut theirs = base.clone();
887 theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
888
889 let r = merge_vaults(&base, &ours, &theirs);
890 assert_eq!(r.conflicts.len(), 1);
891 assert!(r.conflicts[0].field.contains("schema.DB_URL"));
892 }
893
894 #[test]
897 fn merge_scoped_different_pubkeys() {
898 let base = base_vault();
899 let mut ours = base.clone();
900 ours.secrets
901 .get_mut("DB_URL")
902 .unwrap()
903 .scoped
904 .insert("age1alice".into(), "alice-scope".into());
905 let mut theirs = base.clone();
906 theirs
907 .secrets
908 .get_mut("DB_URL")
909 .unwrap()
910 .scoped
911 .insert("age1bob".into(), "bob-scope".into());
912
913 let r = merge_vaults(&base, &ours, &theirs);
914 assert!(r.conflicts.is_empty());
915 let entry = &r.vault.secrets["DB_URL"];
916 assert_eq!(entry.scoped["age1alice"], "alice-scope");
917 assert_eq!(entry.scoped["age1bob"], "bob-scope");
918 }
919
920 #[test]
921 fn merge_scoped_both_modify_same() {
922 let mut base = base_vault();
923 base.secrets
924 .get_mut("DB_URL")
925 .unwrap()
926 .scoped
927 .insert("age1alice".into(), "base-scope".into());
928
929 let mut ours = base.clone();
930 ours.secrets
931 .get_mut("DB_URL")
932 .unwrap()
933 .scoped
934 .insert("age1alice".into(), "ours-scope".into());
935 let mut theirs = base.clone();
936 theirs
937 .secrets
938 .get_mut("DB_URL")
939 .unwrap()
940 .scoped
941 .insert("age1alice".into(), "theirs-scope".into());
942
943 let r = merge_vaults(&base, &ours, &theirs);
944 assert_eq!(r.conflicts.len(), 1);
945 assert!(r.conflicts[0].field.contains("scoped"));
946 }
947
948 #[test]
949 fn merge_scoped_add_vs_base_key_removal() {
950 let base = base_vault();
951
952 let mut ours = base.clone();
954 ours.secrets.remove("DB_URL");
955 ours.schema.remove("DB_URL");
956
957 let mut theirs = base.clone();
959 theirs
960 .secrets
961 .get_mut("DB_URL")
962 .unwrap()
963 .scoped
964 .insert("age1alice".into(), "alice-scoped".into());
965
966 let r = merge_vaults(&base, &ours, &theirs);
967 assert!(r.conflicts.is_empty());
970 assert!(!r.vault.secrets.contains_key("DB_URL"));
971 }
972
973 #[test]
974 fn merge_scoped_add_vs_base_key_modification() {
975 let base = base_vault();
976
977 let mut ours = base.clone();
979 ours.secrets.remove("DB_URL");
980 ours.schema.remove("DB_URL");
981
982 let mut theirs = base.clone();
984 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
985 theirs
986 .secrets
987 .get_mut("DB_URL")
988 .unwrap()
989 .scoped
990 .insert("age1alice".into(), "alice-scoped".into());
991
992 let r = merge_vaults(&base, &ours, &theirs);
993 assert_eq!(r.conflicts.len(), 1);
995 assert!(r.conflicts[0].reason.contains("removed on our side"));
996 }
997
998 #[test]
1001 fn merge_ours_changes_recipients_theirs_adds_key() {
1002 let base = base_vault();
1003 let mut ours = base.clone();
1004 ours.recipients.push("age1charlie".into());
1005 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1006
1007 let mut theirs = base.clone();
1008 theirs.secrets.insert(
1009 "NEW_KEY".into(),
1010 SecretEntry {
1011 shared: "theirs-new".into(),
1012 scoped: BTreeMap::new(),
1013 },
1014 );
1015
1016 let r = merge_vaults(&base, &ours, &theirs);
1017 assert!(r.conflicts.is_empty());
1018 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1019 assert!(r.vault.secrets.contains_key("NEW_KEY"));
1020 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1021 }
1022}