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 };
542
543 let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
544 let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
545
546 let mut names = theirs_meta.recipients;
548 for (pk, name) in ours_meta.recipients {
549 names.insert(pk, name);
550 }
551
552 names.retain(|pk, _| merged.recipients.contains(pk));
554
555 let mac = compute_mac(merged);
556 let meta = crate::types::Meta {
557 recipients: names,
558 mac,
559 };
560
561 let recipients = parse_recipients(&merged.recipients).ok()?;
562
563 if recipients.is_empty() {
564 return None;
565 }
566
567 let meta_json = serde_json::to_vec(&meta).ok()?;
568 let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
569 merged.meta = encrypted;
570 Some("meta regenerated".into())
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
577 use std::collections::BTreeMap;
578
579 fn base_vault() -> Vault {
580 let mut schema = BTreeMap::new();
581 schema.insert(
582 "DB_URL".into(),
583 SchemaEntry {
584 description: "database url".into(),
585 example: None,
586 tags: vec![],
587 },
588 );
589
590 let mut secrets = BTreeMap::new();
591 secrets.insert(
592 "DB_URL".into(),
593 SecretEntry {
594 shared: "base-cipher-db".into(),
595 scoped: BTreeMap::new(),
596 },
597 );
598
599 Vault {
600 version: VAULT_VERSION.into(),
601 created: "2026-01-01T00:00:00Z".into(),
602 vault_name: ".murk".into(),
603 repo: String::new(),
604 recipients: vec!["age1alice".into(), "age1bob".into()],
605 schema,
606 secrets,
607 meta: "base-meta".into(),
608 }
609 }
610
611 #[test]
614 fn merge_no_changes() {
615 let base = base_vault();
616 let r = merge_vaults(&base, &base, &base);
617 assert!(r.conflicts.is_empty());
618 assert_eq!(r.vault.secrets.len(), 1);
619 assert_eq!(r.vault.recipients.len(), 2);
620 }
621
622 #[test]
625 fn merge_ours_adds_secret() {
626 let base = base_vault();
627 let mut ours = base.clone();
628 ours.secrets.insert(
629 "API_KEY".into(),
630 SecretEntry {
631 shared: "ours-cipher-api".into(),
632 scoped: BTreeMap::new(),
633 },
634 );
635 ours.schema.insert(
636 "API_KEY".into(),
637 SchemaEntry {
638 description: "api key".into(),
639 example: None,
640 tags: vec![],
641 },
642 );
643
644 let r = merge_vaults(&base, &ours, &base);
645 assert!(r.conflicts.is_empty());
646 assert!(r.vault.secrets.contains_key("API_KEY"));
647 assert!(r.vault.schema.contains_key("API_KEY"));
648 assert_eq!(r.vault.secrets.len(), 2);
649 }
650
651 #[test]
654 fn merge_theirs_adds_secret() {
655 let base = base_vault();
656 let mut theirs = base.clone();
657 theirs.secrets.insert(
658 "STRIPE_KEY".into(),
659 SecretEntry {
660 shared: "theirs-cipher-stripe".into(),
661 scoped: BTreeMap::new(),
662 },
663 );
664
665 let r = merge_vaults(&base, &base, &theirs);
666 assert!(r.conflicts.is_empty());
667 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
668 }
669
670 #[test]
673 fn merge_both_add_different_keys() {
674 let base = base_vault();
675 let mut ours = base.clone();
676 ours.secrets.insert(
677 "API_KEY".into(),
678 SecretEntry {
679 shared: "ours-cipher-api".into(),
680 scoped: BTreeMap::new(),
681 },
682 );
683
684 let mut theirs = base.clone();
685 theirs.secrets.insert(
686 "STRIPE_KEY".into(),
687 SecretEntry {
688 shared: "theirs-cipher-stripe".into(),
689 scoped: BTreeMap::new(),
690 },
691 );
692
693 let r = merge_vaults(&base, &ours, &theirs);
694 assert!(r.conflicts.is_empty());
695 assert!(r.vault.secrets.contains_key("API_KEY"));
696 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
697 assert!(r.vault.secrets.contains_key("DB_URL"));
698 assert_eq!(r.vault.secrets.len(), 3);
699 }
700
701 #[test]
704 fn merge_both_remove_same_key() {
705 let base = base_vault();
706 let mut ours = base.clone();
707 ours.secrets.remove("DB_URL");
708 let mut theirs = base.clone();
709 theirs.secrets.remove("DB_URL");
710
711 let r = merge_vaults(&base, &ours, &theirs);
712 assert!(r.conflicts.is_empty());
713 assert!(!r.vault.secrets.contains_key("DB_URL"));
714 }
715
716 #[test]
719 fn merge_ours_modifies_theirs_unchanged() {
720 let base = base_vault();
721 let mut ours = base.clone();
722 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
723
724 let r = merge_vaults(&base, &ours, &base);
725 assert!(r.conflicts.is_empty());
726 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
727 }
728
729 #[test]
732 fn merge_theirs_modifies_ours_unchanged() {
733 let base = base_vault();
734 let mut theirs = base.clone();
735 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
736
737 let r = merge_vaults(&base, &base, &theirs);
738 assert!(r.conflicts.is_empty());
739 assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
740 }
741
742 #[test]
745 fn merge_both_modify_same_secret() {
746 let base = base_vault();
747 let mut ours = base.clone();
748 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
749 let mut theirs = base.clone();
750 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
751
752 let r = merge_vaults(&base, &ours, &theirs);
753 assert_eq!(r.conflicts.len(), 1);
754 assert!(r.conflicts[0].field.contains("DB_URL"));
755 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
757 }
758
759 #[test]
760 fn merge_both_add_same_key() {
761 let base = base_vault();
762 let mut ours = base.clone();
763 ours.secrets.insert(
764 "NEW_KEY".into(),
765 SecretEntry {
766 shared: "ours-cipher".into(),
767 scoped: BTreeMap::new(),
768 },
769 );
770 let mut theirs = base.clone();
771 theirs.secrets.insert(
772 "NEW_KEY".into(),
773 SecretEntry {
774 shared: "theirs-cipher".into(),
775 scoped: BTreeMap::new(),
776 },
777 );
778
779 let r = merge_vaults(&base, &ours, &theirs);
780 assert_eq!(r.conflicts.len(), 1);
781 assert!(r.conflicts[0].field.contains("NEW_KEY"));
782 }
783
784 #[test]
785 fn merge_remove_vs_modify() {
786 let base = base_vault();
787 let mut ours = base.clone();
788 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
789 let mut theirs = base.clone();
790 theirs.secrets.remove("DB_URL");
791
792 let r = merge_vaults(&base, &ours, &theirs);
793 assert_eq!(r.conflicts.len(), 1);
794 assert!(
795 r.conflicts[0]
796 .reason
797 .contains("modified on our side but removed on theirs")
798 );
799 }
800
801 #[test]
804 fn merge_recipient_added_ours() {
805 let base = base_vault();
806 let mut ours = base.clone();
807 ours.recipients.push("age1charlie".into());
808
809 let r = merge_vaults(&base, &ours, &base);
810 assert!(r.conflicts.is_empty());
811 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
812 assert_eq!(r.vault.recipients.len(), 3);
813 }
814
815 #[test]
816 fn merge_recipient_added_both_same() {
817 let base = base_vault();
818 let mut ours = base.clone();
819 ours.recipients.push("age1charlie".into());
820 let mut theirs = base.clone();
821 theirs.recipients.push("age1charlie".into());
822
823 let r = merge_vaults(&base, &ours, &theirs);
824 assert!(r.conflicts.is_empty());
825 assert_eq!(
826 r.vault
827 .recipients
828 .iter()
829 .filter(|r| *r == "age1charlie")
830 .count(),
831 1
832 );
833 }
834
835 #[test]
836 fn merge_recipient_removed() {
837 let base = base_vault();
838 let mut ours = base.clone();
839 ours.recipients.retain(|r| r != "age1bob");
840
841 let r = merge_vaults(&base, &ours, &base);
842 assert!(r.conflicts.is_empty());
843 assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
844 assert_eq!(r.vault.recipients.len(), 1);
845 }
846
847 #[test]
850 fn merge_schema_different_keys() {
851 let base = base_vault();
852 let mut ours = base.clone();
853 ours.schema.insert(
854 "API_KEY".into(),
855 SchemaEntry {
856 description: "api".into(),
857 example: None,
858 tags: vec![],
859 },
860 );
861 let mut theirs = base.clone();
862 theirs.schema.insert(
863 "STRIPE".into(),
864 SchemaEntry {
865 description: "stripe".into(),
866 example: None,
867 tags: vec![],
868 },
869 );
870
871 let r = merge_vaults(&base, &ours, &theirs);
872 assert!(r.conflicts.is_empty());
873 assert!(r.vault.schema.contains_key("API_KEY"));
874 assert!(r.vault.schema.contains_key("STRIPE"));
875 }
876
877 #[test]
878 fn merge_schema_same_key_conflict() {
879 let base = base_vault();
880 let mut ours = base.clone();
881 ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
882 let mut theirs = base.clone();
883 theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
884
885 let r = merge_vaults(&base, &ours, &theirs);
886 assert_eq!(r.conflicts.len(), 1);
887 assert!(r.conflicts[0].field.contains("schema.DB_URL"));
888 }
889
890 #[test]
893 fn merge_scoped_different_pubkeys() {
894 let base = base_vault();
895 let mut ours = base.clone();
896 ours.secrets
897 .get_mut("DB_URL")
898 .unwrap()
899 .scoped
900 .insert("age1alice".into(), "alice-scope".into());
901 let mut theirs = base.clone();
902 theirs
903 .secrets
904 .get_mut("DB_URL")
905 .unwrap()
906 .scoped
907 .insert("age1bob".into(), "bob-scope".into());
908
909 let r = merge_vaults(&base, &ours, &theirs);
910 assert!(r.conflicts.is_empty());
911 let entry = &r.vault.secrets["DB_URL"];
912 assert_eq!(entry.scoped["age1alice"], "alice-scope");
913 assert_eq!(entry.scoped["age1bob"], "bob-scope");
914 }
915
916 #[test]
917 fn merge_scoped_both_modify_same() {
918 let mut base = base_vault();
919 base.secrets
920 .get_mut("DB_URL")
921 .unwrap()
922 .scoped
923 .insert("age1alice".into(), "base-scope".into());
924
925 let mut ours = base.clone();
926 ours.secrets
927 .get_mut("DB_URL")
928 .unwrap()
929 .scoped
930 .insert("age1alice".into(), "ours-scope".into());
931 let mut theirs = base.clone();
932 theirs
933 .secrets
934 .get_mut("DB_URL")
935 .unwrap()
936 .scoped
937 .insert("age1alice".into(), "theirs-scope".into());
938
939 let r = merge_vaults(&base, &ours, &theirs);
940 assert_eq!(r.conflicts.len(), 1);
941 assert!(r.conflicts[0].field.contains("scoped"));
942 }
943
944 #[test]
945 fn merge_scoped_add_vs_base_key_removal() {
946 let base = base_vault();
947
948 let mut ours = base.clone();
950 ours.secrets.remove("DB_URL");
951 ours.schema.remove("DB_URL");
952
953 let mut theirs = base.clone();
955 theirs
956 .secrets
957 .get_mut("DB_URL")
958 .unwrap()
959 .scoped
960 .insert("age1alice".into(), "alice-scoped".into());
961
962 let r = merge_vaults(&base, &ours, &theirs);
963 assert!(r.conflicts.is_empty());
966 assert!(!r.vault.secrets.contains_key("DB_URL"));
967 }
968
969 #[test]
970 fn merge_scoped_add_vs_base_key_modification() {
971 let base = base_vault();
972
973 let mut ours = base.clone();
975 ours.secrets.remove("DB_URL");
976 ours.schema.remove("DB_URL");
977
978 let mut theirs = base.clone();
980 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
981 theirs
982 .secrets
983 .get_mut("DB_URL")
984 .unwrap()
985 .scoped
986 .insert("age1alice".into(), "alice-scoped".into());
987
988 let r = merge_vaults(&base, &ours, &theirs);
989 assert_eq!(r.conflicts.len(), 1);
991 assert!(r.conflicts[0].reason.contains("removed on our side"));
992 }
993
994 #[test]
997 fn merge_ours_changes_recipients_theirs_adds_key() {
998 let base = base_vault();
999 let mut ours = base.clone();
1000 ours.recipients.push("age1charlie".into());
1001 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1002
1003 let mut theirs = base.clone();
1004 theirs.secrets.insert(
1005 "NEW_KEY".into(),
1006 SecretEntry {
1007 shared: "theirs-new".into(),
1008 scoped: BTreeMap::new(),
1009 },
1010 );
1011
1012 let r = merge_vaults(&base, &ours, &theirs);
1013 assert!(r.conflicts.is_empty());
1014 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1015 assert!(r.vault.secrets.contains_key("NEW_KEY"));
1016 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1017 }
1018}