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
497pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Option<String> {
503 use crate::{compute_mac, crypto, decrypt_value, encrypt_value, resolve_key, types};
504 use age::secrecy::ExposeSecret;
505 use std::collections::HashMap;
506
507 let secret_key = resolve_key().ok()?;
508 let identity = crypto::parse_identity(secret_key.expose_secret()).ok()?;
509
510 let ours_meta: types::Meta = decrypt_value(&ours.meta, &identity)
512 .ok()
513 .and_then(|p| serde_json::from_slice(&p).ok())
514 .unwrap_or_else(|| types::Meta {
515 recipients: HashMap::new(),
516 mac: String::new(),
517 });
518
519 let theirs_meta: types::Meta = decrypt_value(&theirs.meta, &identity)
520 .ok()
521 .and_then(|p| serde_json::from_slice(&p).ok())
522 .unwrap_or_else(|| types::Meta {
523 recipients: HashMap::new(),
524 mac: String::new(),
525 });
526
527 let mut names = theirs_meta.recipients;
529 for (pk, name) in ours_meta.recipients {
530 names.insert(pk, name);
531 }
532
533 names.retain(|pk, _| merged.recipients.contains(pk));
535
536 let mac = compute_mac(merged);
537 let meta = types::Meta {
538 recipients: names,
539 mac,
540 };
541
542 let recipients: Vec<age::x25519::Recipient> = merged
543 .recipients
544 .iter()
545 .filter_map(|pk| crypto::parse_recipient(pk).ok())
546 .collect();
547
548 if recipients.is_empty() {
549 return None;
550 }
551
552 let meta_json = serde_json::to_vec(&meta).ok()?;
553 let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
554 merged.meta = encrypted;
555 Some("meta regenerated".into())
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::types::{SchemaEntry, SecretEntry, Vault};
562 use std::collections::BTreeMap;
563
564 fn base_vault() -> Vault {
565 let mut schema = BTreeMap::new();
566 schema.insert(
567 "DB_URL".into(),
568 SchemaEntry {
569 description: "database url".into(),
570 example: None,
571 tags: vec![],
572 },
573 );
574
575 let mut secrets = BTreeMap::new();
576 secrets.insert(
577 "DB_URL".into(),
578 SecretEntry {
579 shared: "base-cipher-db".into(),
580 scoped: BTreeMap::new(),
581 },
582 );
583
584 Vault {
585 version: "2.0".into(),
586 created: "2026-01-01T00:00:00Z".into(),
587 vault_name: ".murk".into(),
588 repo: String::new(),
589 recipients: vec!["age1alice".into(), "age1bob".into()],
590 schema,
591 secrets,
592 meta: "base-meta".into(),
593 }
594 }
595
596 #[test]
599 fn merge_no_changes() {
600 let base = base_vault();
601 let r = merge_vaults(&base, &base, &base);
602 assert!(r.conflicts.is_empty());
603 assert_eq!(r.vault.secrets.len(), 1);
604 assert_eq!(r.vault.recipients.len(), 2);
605 }
606
607 #[test]
610 fn merge_ours_adds_secret() {
611 let base = base_vault();
612 let mut ours = base.clone();
613 ours.secrets.insert(
614 "API_KEY".into(),
615 SecretEntry {
616 shared: "ours-cipher-api".into(),
617 scoped: BTreeMap::new(),
618 },
619 );
620 ours.schema.insert(
621 "API_KEY".into(),
622 SchemaEntry {
623 description: "api key".into(),
624 example: None,
625 tags: vec![],
626 },
627 );
628
629 let r = merge_vaults(&base, &ours, &base);
630 assert!(r.conflicts.is_empty());
631 assert!(r.vault.secrets.contains_key("API_KEY"));
632 assert!(r.vault.schema.contains_key("API_KEY"));
633 assert_eq!(r.vault.secrets.len(), 2);
634 }
635
636 #[test]
639 fn merge_theirs_adds_secret() {
640 let base = base_vault();
641 let mut theirs = base.clone();
642 theirs.secrets.insert(
643 "STRIPE_KEY".into(),
644 SecretEntry {
645 shared: "theirs-cipher-stripe".into(),
646 scoped: BTreeMap::new(),
647 },
648 );
649
650 let r = merge_vaults(&base, &base, &theirs);
651 assert!(r.conflicts.is_empty());
652 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
653 }
654
655 #[test]
658 fn merge_both_add_different_keys() {
659 let base = base_vault();
660 let mut ours = base.clone();
661 ours.secrets.insert(
662 "API_KEY".into(),
663 SecretEntry {
664 shared: "ours-cipher-api".into(),
665 scoped: BTreeMap::new(),
666 },
667 );
668
669 let mut theirs = base.clone();
670 theirs.secrets.insert(
671 "STRIPE_KEY".into(),
672 SecretEntry {
673 shared: "theirs-cipher-stripe".into(),
674 scoped: BTreeMap::new(),
675 },
676 );
677
678 let r = merge_vaults(&base, &ours, &theirs);
679 assert!(r.conflicts.is_empty());
680 assert!(r.vault.secrets.contains_key("API_KEY"));
681 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
682 assert!(r.vault.secrets.contains_key("DB_URL"));
683 assert_eq!(r.vault.secrets.len(), 3);
684 }
685
686 #[test]
689 fn merge_both_remove_same_key() {
690 let base = base_vault();
691 let mut ours = base.clone();
692 ours.secrets.remove("DB_URL");
693 let mut theirs = base.clone();
694 theirs.secrets.remove("DB_URL");
695
696 let r = merge_vaults(&base, &ours, &theirs);
697 assert!(r.conflicts.is_empty());
698 assert!(!r.vault.secrets.contains_key("DB_URL"));
699 }
700
701 #[test]
704 fn merge_ours_modifies_theirs_unchanged() {
705 let base = base_vault();
706 let mut ours = base.clone();
707 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
708
709 let r = merge_vaults(&base, &ours, &base);
710 assert!(r.conflicts.is_empty());
711 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
712 }
713
714 #[test]
717 fn merge_theirs_modifies_ours_unchanged() {
718 let base = base_vault();
719 let mut theirs = base.clone();
720 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
721
722 let r = merge_vaults(&base, &base, &theirs);
723 assert!(r.conflicts.is_empty());
724 assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
725 }
726
727 #[test]
730 fn merge_both_modify_same_secret() {
731 let base = base_vault();
732 let mut ours = base.clone();
733 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
734 let mut theirs = base.clone();
735 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
736
737 let r = merge_vaults(&base, &ours, &theirs);
738 assert_eq!(r.conflicts.len(), 1);
739 assert!(r.conflicts[0].field.contains("DB_URL"));
740 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
742 }
743
744 #[test]
745 fn merge_both_add_same_key() {
746 let base = base_vault();
747 let mut ours = base.clone();
748 ours.secrets.insert(
749 "NEW_KEY".into(),
750 SecretEntry {
751 shared: "ours-cipher".into(),
752 scoped: BTreeMap::new(),
753 },
754 );
755 let mut theirs = base.clone();
756 theirs.secrets.insert(
757 "NEW_KEY".into(),
758 SecretEntry {
759 shared: "theirs-cipher".into(),
760 scoped: BTreeMap::new(),
761 },
762 );
763
764 let r = merge_vaults(&base, &ours, &theirs);
765 assert_eq!(r.conflicts.len(), 1);
766 assert!(r.conflicts[0].field.contains("NEW_KEY"));
767 }
768
769 #[test]
770 fn merge_remove_vs_modify() {
771 let base = base_vault();
772 let mut ours = base.clone();
773 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
774 let mut theirs = base.clone();
775 theirs.secrets.remove("DB_URL");
776
777 let r = merge_vaults(&base, &ours, &theirs);
778 assert_eq!(r.conflicts.len(), 1);
779 assert!(
780 r.conflicts[0]
781 .reason
782 .contains("modified on our side but removed on theirs")
783 );
784 }
785
786 #[test]
789 fn merge_recipient_added_ours() {
790 let base = base_vault();
791 let mut ours = base.clone();
792 ours.recipients.push("age1charlie".into());
793
794 let r = merge_vaults(&base, &ours, &base);
795 assert!(r.conflicts.is_empty());
796 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
797 assert_eq!(r.vault.recipients.len(), 3);
798 }
799
800 #[test]
801 fn merge_recipient_added_both_same() {
802 let base = base_vault();
803 let mut ours = base.clone();
804 ours.recipients.push("age1charlie".into());
805 let mut theirs = base.clone();
806 theirs.recipients.push("age1charlie".into());
807
808 let r = merge_vaults(&base, &ours, &theirs);
809 assert!(r.conflicts.is_empty());
810 assert_eq!(
811 r.vault
812 .recipients
813 .iter()
814 .filter(|r| *r == "age1charlie")
815 .count(),
816 1
817 );
818 }
819
820 #[test]
821 fn merge_recipient_removed() {
822 let base = base_vault();
823 let mut ours = base.clone();
824 ours.recipients.retain(|r| r != "age1bob");
825
826 let r = merge_vaults(&base, &ours, &base);
827 assert!(r.conflicts.is_empty());
828 assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
829 assert_eq!(r.vault.recipients.len(), 1);
830 }
831
832 #[test]
835 fn merge_schema_different_keys() {
836 let base = base_vault();
837 let mut ours = base.clone();
838 ours.schema.insert(
839 "API_KEY".into(),
840 SchemaEntry {
841 description: "api".into(),
842 example: None,
843 tags: vec![],
844 },
845 );
846 let mut theirs = base.clone();
847 theirs.schema.insert(
848 "STRIPE".into(),
849 SchemaEntry {
850 description: "stripe".into(),
851 example: None,
852 tags: vec![],
853 },
854 );
855
856 let r = merge_vaults(&base, &ours, &theirs);
857 assert!(r.conflicts.is_empty());
858 assert!(r.vault.schema.contains_key("API_KEY"));
859 assert!(r.vault.schema.contains_key("STRIPE"));
860 }
861
862 #[test]
863 fn merge_schema_same_key_conflict() {
864 let base = base_vault();
865 let mut ours = base.clone();
866 ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
867 let mut theirs = base.clone();
868 theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
869
870 let r = merge_vaults(&base, &ours, &theirs);
871 assert_eq!(r.conflicts.len(), 1);
872 assert!(r.conflicts[0].field.contains("schema.DB_URL"));
873 }
874
875 #[test]
878 fn merge_scoped_different_pubkeys() {
879 let base = base_vault();
880 let mut ours = base.clone();
881 ours.secrets
882 .get_mut("DB_URL")
883 .unwrap()
884 .scoped
885 .insert("age1alice".into(), "alice-scope".into());
886 let mut theirs = base.clone();
887 theirs
888 .secrets
889 .get_mut("DB_URL")
890 .unwrap()
891 .scoped
892 .insert("age1bob".into(), "bob-scope".into());
893
894 let r = merge_vaults(&base, &ours, &theirs);
895 assert!(r.conflicts.is_empty());
896 let entry = &r.vault.secrets["DB_URL"];
897 assert_eq!(entry.scoped["age1alice"], "alice-scope");
898 assert_eq!(entry.scoped["age1bob"], "bob-scope");
899 }
900
901 #[test]
902 fn merge_scoped_both_modify_same() {
903 let mut base = base_vault();
904 base.secrets
905 .get_mut("DB_URL")
906 .unwrap()
907 .scoped
908 .insert("age1alice".into(), "base-scope".into());
909
910 let mut ours = base.clone();
911 ours.secrets
912 .get_mut("DB_URL")
913 .unwrap()
914 .scoped
915 .insert("age1alice".into(), "ours-scope".into());
916 let mut theirs = base.clone();
917 theirs
918 .secrets
919 .get_mut("DB_URL")
920 .unwrap()
921 .scoped
922 .insert("age1alice".into(), "theirs-scope".into());
923
924 let r = merge_vaults(&base, &ours, &theirs);
925 assert_eq!(r.conflicts.len(), 1);
926 assert!(r.conflicts[0].field.contains("scoped"));
927 }
928
929 #[test]
932 fn merge_ours_changes_recipients_theirs_adds_key() {
933 let base = base_vault();
934 let mut ours = base.clone();
935 ours.recipients.push("age1charlie".into());
936 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
937
938 let mut theirs = base.clone();
939 theirs.secrets.insert(
940 "NEW_KEY".into(),
941 SecretEntry {
942 shared: "theirs-new".into(),
943 scoped: BTreeMap::new(),
944 },
945 );
946
947 let r = merge_vaults(&base, &ours, &theirs);
948 assert!(r.conflicts.is_empty());
949 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
950 assert!(r.vault.secrets.contains_key("NEW_KEY"));
951 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
952 }
953}