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