1use std::collections::{BTreeMap, BTreeSet};
8
9use crate::types::{SecretEntry, Vault};
10
11#[derive(Debug)]
13pub struct MergeConflict {
14 pub field: String,
15 pub reason: String,
16}
17
18#[derive(Debug)]
20pub struct MergeResult {
21 pub vault: Vault,
22 pub conflicts: Vec<MergeConflict>,
23}
24
25pub fn merge_vaults(base: &Vault, ours: &Vault, theirs: &Vault) -> MergeResult {
31 let mut conflicts = Vec::new();
32
33 let version = ours.version.clone();
35 let created = ours.created.clone();
36 let vault_name = ours.vault_name.clone();
37 let repo = ours.repo.clone();
38
39 let recipients = merge_recipients(base, ours, theirs, &mut conflicts);
41
42 let base_recip: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
44 let ours_recip: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
45 let theirs_recip: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
46 let ours_changed_recipients = ours_recip != base_recip;
47 let theirs_changed_recipients = theirs_recip != base_recip;
48
49 let schema = merge_btree(
51 &base.schema,
52 &ours.schema,
53 &theirs.schema,
54 "schema",
55 &mut conflicts,
56 );
57
58 let secrets = merge_secrets(
60 base,
61 ours,
62 theirs,
63 ours_changed_recipients,
64 theirs_changed_recipients,
65 &mut conflicts,
66 );
67
68 let meta = ours.meta.clone();
70
71 let vault = Vault {
72 version,
73 created,
74 vault_name,
75 repo,
76 recipients,
77 schema,
78 secrets,
79 meta,
80 };
81
82 MergeResult { vault, conflicts }
83}
84
85fn merge_recipients(
87 base: &Vault,
88 ours: &Vault,
89 theirs: &Vault,
90 conflicts: &mut Vec<MergeConflict>,
91) -> Vec<String> {
92 let base_set: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
93 let ours_set: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
94 let theirs_set: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
95
96 let ours_added: BTreeSet<&str> = ours_set.difference(&base_set).copied().collect();
97 let theirs_added: BTreeSet<&str> = theirs_set.difference(&base_set).copied().collect();
98 let ours_removed: BTreeSet<&str> = base_set.difference(&ours_set).copied().collect();
99 let theirs_removed: BTreeSet<&str> = base_set.difference(&theirs_set).copied().collect();
100
101 let mut result: BTreeSet<&str> = base_set;
102
103 for pk in &ours_added {
106 if theirs_added.contains(pk) {
107 result.insert(pk);
109 } else {
110 result.insert(pk);
112 conflicts.push(MergeConflict {
113 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
114 reason: "added on one side but not the other".into(),
115 });
116 }
117 }
118 for pk in &theirs_added {
119 if !ours_added.contains(pk) {
120 result.insert(pk);
122 conflicts.push(MergeConflict {
123 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
124 reason: "added on one side but not the other".into(),
125 });
126 }
127 }
128
129 for pk in &ours_removed {
131 if theirs_removed.contains(pk) {
132 result.remove(pk);
134 } else {
135 conflicts.push(MergeConflict {
137 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
138 reason: "removed on one side but not the other".into(),
139 });
140 }
141 }
142 for pk in &theirs_removed {
143 if !ours_removed.contains(pk) {
144 conflicts.push(MergeConflict {
146 field: format!("recipients.{}", &pk[..12.min(pk.len())]),
147 reason: "removed on one side but not the other".into(),
148 });
149 }
150 }
151
152 result.into_iter().map(String::from).collect()
153}
154
155fn merge_btree<V: PartialEq + Clone>(
157 base: &BTreeMap<String, V>,
158 ours: &BTreeMap<String, V>,
159 theirs: &BTreeMap<String, V>,
160 field_name: &str,
161 conflicts: &mut Vec<MergeConflict>,
162) -> BTreeMap<String, V> {
163 let all_keys: BTreeSet<&str> = base
164 .keys()
165 .chain(ours.keys())
166 .chain(theirs.keys())
167 .map(String::as_str)
168 .collect();
169
170 let mut result = BTreeMap::new();
171
172 for key in all_keys {
173 let in_base = base.get(key);
174 let in_ours = ours.get(key);
175 let in_theirs = theirs.get(key);
176
177 match (in_base, in_ours, in_theirs) {
178 (None, None, Some(t)) => {
179 result.insert(key.to_string(), t.clone());
180 }
181 (None, Some(o), None) => {
182 result.insert(key.to_string(), o.clone());
183 }
184 (None, Some(o), Some(t)) => {
185 if o == t {
186 result.insert(key.to_string(), o.clone());
187 } else {
188 conflicts.push(MergeConflict {
189 field: format!("{field_name}.{key}"),
190 reason: "added on both sides with different values".into(),
191 });
192 result.insert(key.to_string(), o.clone());
193 }
194 }
195
196 (Some(_) | None, None, None) => {}
198 (Some(b), Some(o), None) => {
200 if o == b {
201 conflicts.push(MergeConflict {
203 field: format!("{field_name}.{key}"),
204 reason: "removed on one side, unchanged on the other".into(),
205 });
206 result.insert(key.to_string(), o.clone());
207 }
208 }
210 (Some(b), None, Some(t)) => {
211 if t == b {
212 conflicts.push(MergeConflict {
214 field: format!("{field_name}.{key}"),
215 reason: "removed on one side, unchanged on the other".into(),
216 });
217 result.insert(key.to_string(), t.clone());
218 }
219 }
221
222 (Some(b), Some(o), Some(t)) => {
223 let ours_changed = o != b;
224 let theirs_changed = t != b;
225
226 match (ours_changed, theirs_changed) {
227 (false, true) => {
228 result.insert(key.to_string(), t.clone());
229 }
230 (true, true) if o != t => {
231 conflicts.push(MergeConflict {
232 field: format!("{field_name}.{key}"),
233 reason: "modified on both sides with different values".into(),
234 });
235 result.insert(key.to_string(), o.clone());
236 }
237 _ => {
238 result.insert(key.to_string(), o.clone());
239 }
240 }
241 }
242 }
243 }
244
245 result
246}
247
248fn merge_secrets(
254 base: &Vault,
255 ours: &Vault,
256 theirs: &Vault,
257 ours_changed_recipients: bool,
258 theirs_changed_recipients: bool,
259 conflicts: &mut Vec<MergeConflict>,
260) -> BTreeMap<String, SecretEntry> {
261 if ours_changed_recipients && !theirs_changed_recipients {
264 return merge_secrets_with_reencrypted_side(base, ours, theirs, "theirs", conflicts);
265 }
266 if theirs_changed_recipients && !ours_changed_recipients {
267 return merge_secrets_with_reencrypted_side(base, theirs, ours, "ours", conflicts);
268 }
269 if ours_changed_recipients && theirs_changed_recipients {
270 return merge_secrets_both_reencrypted(base, ours, theirs, conflicts);
271 }
272
273 merge_secrets_normal(base, ours, theirs, conflicts)
275}
276
277fn merge_secrets_normal(
279 base: &Vault,
280 ours: &Vault,
281 theirs: &Vault,
282 conflicts: &mut Vec<MergeConflict>,
283) -> BTreeMap<String, SecretEntry> {
284 let all_keys: BTreeSet<&str> = base
285 .secrets
286 .keys()
287 .chain(ours.secrets.keys())
288 .chain(theirs.secrets.keys())
289 .map(String::as_str)
290 .collect();
291
292 let mut result = BTreeMap::new();
293
294 for key in all_keys {
295 let in_base = base.secrets.get(key);
296 let in_ours = ours.secrets.get(key);
297 let in_theirs = theirs.secrets.get(key);
298
299 match (in_base, in_ours, in_theirs) {
300 (None, None, Some(t)) => {
301 result.insert(key.to_string(), t.clone());
302 }
303 (None, Some(o), None) => {
304 result.insert(key.to_string(), o.clone());
305 }
306 (None, Some(o), Some(t)) => {
307 if o.shared == t.shared {
308 result.insert(key.to_string(), o.clone());
309 } else {
310 conflicts.push(MergeConflict {
311 field: format!("secrets.{key}"),
312 reason: "added on both sides (values may differ)".into(),
313 });
314 result.insert(key.to_string(), o.clone());
315 }
316 }
317
318 (Some(_) | None, None, None) => {}
320
321 (Some(b), Some(o), None) => {
322 conflicts.push(MergeConflict {
324 field: format!("secrets.{key}"),
325 reason: if o.shared == b.shared {
326 "removed on one side, unchanged on the other".into()
327 } else {
328 "modified on our side but removed on theirs".into()
329 },
330 });
331 result.insert(key.to_string(), o.clone());
332 }
333 (Some(b), None, Some(t)) => {
334 conflicts.push(MergeConflict {
336 field: format!("secrets.{key}"),
337 reason: if t.shared == b.shared {
338 "removed on one side, unchanged on the other".into()
339 } else {
340 "removed on our side but modified on theirs".into()
341 },
342 });
343 result.insert(key.to_string(), t.clone());
344 }
345
346 (Some(b), Some(o), Some(t)) => {
347 let ours_changed = o.shared != b.shared;
348 let theirs_changed = t.shared != b.shared;
349
350 let shared = match (ours_changed, theirs_changed) {
351 (false, true) => t.shared.clone(),
352 (true, true) => {
353 conflicts.push(MergeConflict {
354 field: format!("secrets.{key}"),
355 reason: "shared value modified on both sides".into(),
356 });
357 o.shared.clone()
358 }
359 _ => o.shared.clone(),
360 };
361
362 let scoped = merge_scoped(&b.scoped, &o.scoped, &t.scoped, key, conflicts);
363 result.insert(key.to_string(), SecretEntry { shared, scoped });
364 }
365 }
366 }
367
368 result
369}
370
371fn merge_scoped(
373 base: &BTreeMap<String, String>,
374 ours: &BTreeMap<String, String>,
375 theirs: &BTreeMap<String, String>,
376 secret_key: &str,
377 conflicts: &mut Vec<MergeConflict>,
378) -> BTreeMap<String, String> {
379 let all_pks: BTreeSet<&str> = base
380 .keys()
381 .chain(ours.keys())
382 .chain(theirs.keys())
383 .map(String::as_str)
384 .collect();
385
386 let mut result = BTreeMap::new();
387
388 for pk in all_pks {
389 let in_base = base.get(pk);
390 let in_ours = ours.get(pk);
391 let in_theirs = theirs.get(pk);
392
393 match (in_base, in_ours, in_theirs) {
394 (None, None, Some(t)) => {
395 result.insert(pk.to_string(), t.clone());
396 }
397 (None, Some(o), None) => {
398 result.insert(pk.to_string(), o.clone());
399 }
400 (None, Some(o), Some(t)) => {
401 if o == t {
402 result.insert(pk.to_string(), o.clone());
403 } else {
404 conflicts.push(MergeConflict {
405 field: format!("secrets.{secret_key}.scoped.{pk}"),
406 reason: "scoped override added on both sides".into(),
407 });
408 result.insert(pk.to_string(), o.clone());
409 }
410 }
411 (Some(_) | None, None, None) => {}
412 (Some(b), Some(o), None) => {
413 if o != b {
414 conflicts.push(MergeConflict {
415 field: format!("secrets.{secret_key}.scoped.{pk}"),
416 reason: "scoped override modified on our side but removed on theirs".into(),
417 });
418 result.insert(pk.to_string(), o.clone());
419 }
420 }
421 (Some(b), None, Some(t)) => {
422 if t != b {
423 conflicts.push(MergeConflict {
424 field: format!("secrets.{secret_key}.scoped.{pk}"),
425 reason: "scoped override removed on our side but modified on theirs".into(),
426 });
427 result.insert(pk.to_string(), t.clone());
428 }
429 }
430 (Some(b), Some(o), Some(t)) => {
431 let ours_changed = o != b;
432 let theirs_changed = t != b;
433
434 match (ours_changed, theirs_changed) {
435 (false, true) => {
436 result.insert(pk.to_string(), t.clone());
437 }
438 (true, true) if o != t => {
439 conflicts.push(MergeConflict {
440 field: format!("secrets.{secret_key}.scoped.{pk}"),
441 reason: "scoped override modified on both sides".into(),
442 });
443 result.insert(pk.to_string(), o.clone());
444 }
445 _ => {
446 result.insert(pk.to_string(), o.clone());
447 }
448 }
449 }
450 }
451 }
452
453 result
454}
455
456fn merge_secrets_with_reencrypted_side(
462 base: &Vault,
463 reencrypted: &Vault,
464 other: &Vault,
465 other_label: &str,
466 conflicts: &mut Vec<MergeConflict>,
467) -> BTreeMap<String, SecretEntry> {
468 let mut result = reencrypted.secrets.clone();
470
471 let all_keys: BTreeSet<&str> = base
473 .secrets
474 .keys()
475 .chain(other.secrets.keys())
476 .map(String::as_str)
477 .collect();
478
479 for key in all_keys {
480 let in_base = base.secrets.get(key);
481 let in_other = other.secrets.get(key);
482
483 match (in_base, in_other) {
484 (None, Some(entry)) => {
485 if result.contains_key(key) {
486 conflicts.push(MergeConflict {
487 field: format!("secrets.{key}"),
488 reason: format!(
489 "added on {other_label} side and on the side that changed recipients"
490 ),
491 });
492 } else {
493 result.insert(key.to_string(), entry.clone());
494 }
495 }
496 (Some(_), None) => {
497 result.remove(key);
499 }
500 (Some(b), Some(entry)) => {
501 if entry.shared != b.shared {
502 conflicts.push(MergeConflict {
503 field: format!("secrets.{key}"),
504 reason: format!(
505 "modified on {other_label} side while recipients changed on the other"
506 ),
507 });
508 }
509 }
511 (None, None) => {}
512 }
513 }
514
515 result
516}
517
518fn merge_secrets_both_reencrypted(
521 base: &Vault,
522 ours: &Vault,
523 theirs: &Vault,
524 conflicts: &mut Vec<MergeConflict>,
525) -> BTreeMap<String, SecretEntry> {
526 let all_keys: BTreeSet<&str> = base
527 .secrets
528 .keys()
529 .chain(ours.secrets.keys())
530 .chain(theirs.secrets.keys())
531 .map(String::as_str)
532 .collect();
533
534 let mut result = BTreeMap::new();
535
536 for key in all_keys {
537 let in_base = base.secrets.get(key);
538 let in_ours = ours.secrets.get(key);
539 let in_theirs = theirs.secrets.get(key);
540
541 match (in_base, in_ours, in_theirs) {
542 (Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
544 result.insert(key.to_string(), o.clone());
545 }
546 (Some(_), Some(_) | None, None) | (Some(_), None, Some(_)) | (None, None, None) => {}
548 (None, None, Some(t)) => {
549 result.insert(key.to_string(), t.clone());
550 }
551 (None, Some(o), Some(_)) => {
552 conflicts.push(MergeConflict {
553 field: format!("secrets.{key}"),
554 reason: "added on both sides while both changed recipients".into(),
555 });
556 result.insert(key.to_string(), o.clone());
557 }
558 }
559 }
560
561 result
562}
563
564#[derive(Debug)]
566pub struct MergeDriverOutput {
567 pub result: MergeResult,
568 pub meta_regenerated: bool,
569}
570
571pub fn run_merge_driver(base: &str, ours: &str, theirs: &str) -> Result<MergeDriverOutput, String> {
577 use crate::vault;
578
579 let base_vault = vault::parse(base).map_err(|e| format!("parsing base: {e}"))?;
580 let ours_vault = vault::parse(ours).map_err(|e| format!("parsing ours: {e}"))?;
581 let theirs_vault = vault::parse(theirs).map_err(|e| format!("parsing theirs: {e}"))?;
582
583 let mut result = merge_vaults(&base_vault, &ours_vault, &theirs_vault);
584 let meta_regenerated = regenerate_meta(&mut result.vault, &ours_vault, &theirs_vault).is_some();
585
586 Ok(MergeDriverOutput {
587 result,
588 meta_regenerated,
589 })
590}
591
592pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Option<String> {
598 use crate::{compute_mac, crypto, decrypt_meta, encrypt_value, parse_recipients, resolve_key};
599 use age::secrecy::ExposeSecret;
600 use std::collections::HashMap;
601
602 let secret_key = resolve_key().ok()?;
603 let identity = crypto::parse_identity(secret_key.expose_secret()).ok()?;
604
605 let default_meta = || crate::types::Meta {
606 recipients: HashMap::new(),
607 mac: String::new(),
608 hmac_key: None,
609 };
610
611 let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
612 let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
613
614 let mut names = theirs_meta.recipients;
616 for (pk, name) in ours_meta.recipients {
617 names.insert(pk, name);
618 }
619
620 names.retain(|pk, _| merged.recipients.contains(pk));
622
623 let hmac_key_hex = crate::generate_hmac_key();
624 let hmac_key = crate::decode_hmac_key(&hmac_key_hex).unwrap();
625 let mac = compute_mac(merged, Some(&hmac_key));
626 let meta = crate::types::Meta {
627 recipients: names,
628 mac,
629 hmac_key: Some(hmac_key_hex),
630 };
631
632 let recipients = parse_recipients(&merged.recipients).ok()?;
633
634 if recipients.is_empty() {
635 return None;
636 }
637
638 let meta_json = serde_json::to_vec(&meta).ok()?;
639 let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
640 merged.meta = encrypted;
641 Some("meta regenerated".into())
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
648 use std::collections::BTreeMap;
649
650 fn base_vault() -> Vault {
651 let mut schema = BTreeMap::new();
652 schema.insert(
653 "DB_URL".into(),
654 SchemaEntry {
655 description: "database url".into(),
656 example: None,
657 tags: vec![],
658 },
659 );
660
661 let mut secrets = BTreeMap::new();
662 secrets.insert(
663 "DB_URL".into(),
664 SecretEntry {
665 shared: "base-cipher-db".into(),
666 scoped: BTreeMap::new(),
667 },
668 );
669
670 Vault {
671 version: VAULT_VERSION.into(),
672 created: "2026-01-01T00:00:00Z".into(),
673 vault_name: ".murk".into(),
674 repo: String::new(),
675 recipients: vec!["age1alice".into(), "age1bob".into()],
676 schema,
677 secrets,
678 meta: "base-meta".into(),
679 }
680 }
681
682 #[test]
685 fn merge_no_changes() {
686 let base = base_vault();
687 let r = merge_vaults(&base, &base, &base);
688 assert!(r.conflicts.is_empty());
689 assert_eq!(r.vault.secrets.len(), 1);
690 assert_eq!(r.vault.recipients.len(), 2);
691 }
692
693 #[test]
696 fn merge_ours_adds_secret() {
697 let base = base_vault();
698 let mut ours = base.clone();
699 ours.secrets.insert(
700 "API_KEY".into(),
701 SecretEntry {
702 shared: "ours-cipher-api".into(),
703 scoped: BTreeMap::new(),
704 },
705 );
706 ours.schema.insert(
707 "API_KEY".into(),
708 SchemaEntry {
709 description: "api key".into(),
710 example: None,
711 tags: vec![],
712 },
713 );
714
715 let r = merge_vaults(&base, &ours, &base);
716 assert!(r.conflicts.is_empty());
717 assert!(r.vault.secrets.contains_key("API_KEY"));
718 assert!(r.vault.schema.contains_key("API_KEY"));
719 assert_eq!(r.vault.secrets.len(), 2);
720 }
721
722 #[test]
725 fn merge_theirs_adds_secret() {
726 let base = base_vault();
727 let mut theirs = base.clone();
728 theirs.secrets.insert(
729 "STRIPE_KEY".into(),
730 SecretEntry {
731 shared: "theirs-cipher-stripe".into(),
732 scoped: BTreeMap::new(),
733 },
734 );
735
736 let r = merge_vaults(&base, &base, &theirs);
737 assert!(r.conflicts.is_empty());
738 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
739 }
740
741 #[test]
744 fn merge_both_add_different_keys() {
745 let base = base_vault();
746 let mut ours = base.clone();
747 ours.secrets.insert(
748 "API_KEY".into(),
749 SecretEntry {
750 shared: "ours-cipher-api".into(),
751 scoped: BTreeMap::new(),
752 },
753 );
754
755 let mut theirs = base.clone();
756 theirs.secrets.insert(
757 "STRIPE_KEY".into(),
758 SecretEntry {
759 shared: "theirs-cipher-stripe".into(),
760 scoped: BTreeMap::new(),
761 },
762 );
763
764 let r = merge_vaults(&base, &ours, &theirs);
765 assert!(r.conflicts.is_empty());
766 assert!(r.vault.secrets.contains_key("API_KEY"));
767 assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
768 assert!(r.vault.secrets.contains_key("DB_URL"));
769 assert_eq!(r.vault.secrets.len(), 3);
770 }
771
772 #[test]
775 fn merge_both_remove_same_key() {
776 let base = base_vault();
777 let mut ours = base.clone();
778 ours.secrets.remove("DB_URL");
779 let mut theirs = base.clone();
780 theirs.secrets.remove("DB_URL");
781
782 let r = merge_vaults(&base, &ours, &theirs);
783 assert!(r.conflicts.is_empty());
784 assert!(!r.vault.secrets.contains_key("DB_URL"));
785 }
786
787 #[test]
790 fn merge_ours_modifies_theirs_unchanged() {
791 let base = base_vault();
792 let mut ours = base.clone();
793 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
794
795 let r = merge_vaults(&base, &ours, &base);
796 assert!(r.conflicts.is_empty());
797 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
798 }
799
800 #[test]
803 fn merge_theirs_modifies_ours_unchanged() {
804 let base = base_vault();
805 let mut theirs = base.clone();
806 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
807
808 let r = merge_vaults(&base, &base, &theirs);
809 assert!(r.conflicts.is_empty());
810 assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
811 }
812
813 #[test]
816 fn merge_both_modify_same_secret() {
817 let base = base_vault();
818 let mut ours = base.clone();
819 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
820 let mut theirs = base.clone();
821 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
822
823 let r = merge_vaults(&base, &ours, &theirs);
824 assert_eq!(r.conflicts.len(), 1);
825 assert!(r.conflicts[0].field.contains("DB_URL"));
826 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
828 }
829
830 #[test]
831 fn merge_both_add_same_key() {
832 let base = base_vault();
833 let mut ours = base.clone();
834 ours.secrets.insert(
835 "NEW_KEY".into(),
836 SecretEntry {
837 shared: "ours-cipher".into(),
838 scoped: BTreeMap::new(),
839 },
840 );
841 let mut theirs = base.clone();
842 theirs.secrets.insert(
843 "NEW_KEY".into(),
844 SecretEntry {
845 shared: "theirs-cipher".into(),
846 scoped: BTreeMap::new(),
847 },
848 );
849
850 let r = merge_vaults(&base, &ours, &theirs);
851 assert_eq!(r.conflicts.len(), 1);
852 assert!(r.conflicts[0].field.contains("NEW_KEY"));
853 }
854
855 #[test]
856 fn merge_remove_vs_modify() {
857 let base = base_vault();
858 let mut ours = base.clone();
859 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
860 let mut theirs = base.clone();
861 theirs.secrets.remove("DB_URL");
862
863 let r = merge_vaults(&base, &ours, &theirs);
864 assert_eq!(r.conflicts.len(), 1);
865 assert!(
866 r.conflicts[0]
867 .reason
868 .contains("modified on our side but removed on theirs")
869 );
870 }
871
872 #[test]
875 fn merge_recipient_added_one_side_conflicts() {
876 let base = base_vault();
877 let mut ours = base.clone();
878 ours.recipients.push("age1charlie".into());
879
880 let r = merge_vaults(&base, &ours, &base);
881 assert_eq!(r.conflicts.len(), 1);
882 assert!(r.conflicts[0].reason.contains("added on one side"));
883 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
885 }
886
887 #[test]
888 fn merge_recipient_added_both_same() {
889 let base = base_vault();
890 let mut ours = base.clone();
891 ours.recipients.push("age1charlie".into());
892 let mut theirs = base.clone();
893 theirs.recipients.push("age1charlie".into());
894
895 let r = merge_vaults(&base, &ours, &theirs);
896 assert!(r.conflicts.is_empty());
897 assert_eq!(
898 r.vault
899 .recipients
900 .iter()
901 .filter(|r| *r == "age1charlie")
902 .count(),
903 1
904 );
905 }
906
907 #[test]
908 fn merge_recipient_removed_one_side_conflicts() {
909 let base = base_vault();
910 let mut ours = base.clone();
911 ours.recipients.retain(|r| r != "age1bob");
912
913 let r = merge_vaults(&base, &ours, &base);
914 assert!(!r.conflicts.is_empty());
916 assert!(r.vault.recipients.contains(&"age1bob".to_string()));
917 }
918
919 #[test]
920 fn merge_recipient_removed_both_sides_ok() {
921 let base = base_vault();
922 let mut ours = base.clone();
923 let mut theirs = base.clone();
924 ours.recipients.retain(|r| r != "age1bob");
925 theirs.recipients.retain(|r| r != "age1bob");
926
927 let r = merge_vaults(&base, &ours, &theirs);
928 assert!(r.conflicts.is_empty());
929 assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
930 }
931
932 #[test]
935 fn merge_schema_different_keys() {
936 let base = base_vault();
937 let mut ours = base.clone();
938 ours.schema.insert(
939 "API_KEY".into(),
940 SchemaEntry {
941 description: "api".into(),
942 example: None,
943 tags: vec![],
944 },
945 );
946 let mut theirs = base.clone();
947 theirs.schema.insert(
948 "STRIPE".into(),
949 SchemaEntry {
950 description: "stripe".into(),
951 example: None,
952 tags: vec![],
953 },
954 );
955
956 let r = merge_vaults(&base, &ours, &theirs);
957 assert!(r.conflicts.is_empty());
958 assert!(r.vault.schema.contains_key("API_KEY"));
959 assert!(r.vault.schema.contains_key("STRIPE"));
960 }
961
962 #[test]
963 fn merge_schema_same_key_conflict() {
964 let base = base_vault();
965 let mut ours = base.clone();
966 ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
967 let mut theirs = base.clone();
968 theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
969
970 let r = merge_vaults(&base, &ours, &theirs);
971 assert_eq!(r.conflicts.len(), 1);
972 assert!(r.conflicts[0].field.contains("schema.DB_URL"));
973 }
974
975 #[test]
978 fn merge_scoped_different_pubkeys() {
979 let base = base_vault();
980 let mut ours = base.clone();
981 ours.secrets
982 .get_mut("DB_URL")
983 .unwrap()
984 .scoped
985 .insert("age1alice".into(), "alice-scope".into());
986 let mut theirs = base.clone();
987 theirs
988 .secrets
989 .get_mut("DB_URL")
990 .unwrap()
991 .scoped
992 .insert("age1bob".into(), "bob-scope".into());
993
994 let r = merge_vaults(&base, &ours, &theirs);
995 assert!(r.conflicts.is_empty());
996 let entry = &r.vault.secrets["DB_URL"];
997 assert_eq!(entry.scoped["age1alice"], "alice-scope");
998 assert_eq!(entry.scoped["age1bob"], "bob-scope");
999 }
1000
1001 #[test]
1002 fn merge_scoped_both_modify_same() {
1003 let mut base = base_vault();
1004 base.secrets
1005 .get_mut("DB_URL")
1006 .unwrap()
1007 .scoped
1008 .insert("age1alice".into(), "base-scope".into());
1009
1010 let mut ours = base.clone();
1011 ours.secrets
1012 .get_mut("DB_URL")
1013 .unwrap()
1014 .scoped
1015 .insert("age1alice".into(), "ours-scope".into());
1016 let mut theirs = base.clone();
1017 theirs
1018 .secrets
1019 .get_mut("DB_URL")
1020 .unwrap()
1021 .scoped
1022 .insert("age1alice".into(), "theirs-scope".into());
1023
1024 let r = merge_vaults(&base, &ours, &theirs);
1025 assert_eq!(r.conflicts.len(), 1);
1026 assert!(r.conflicts[0].field.contains("scoped"));
1027 }
1028
1029 #[test]
1030 fn merge_scoped_add_vs_base_key_removal() {
1031 let base = base_vault();
1032
1033 let mut ours = base.clone();
1035 ours.secrets.remove("DB_URL");
1036 ours.schema.remove("DB_URL");
1037
1038 let mut theirs = base.clone();
1040 theirs
1041 .secrets
1042 .get_mut("DB_URL")
1043 .unwrap()
1044 .scoped
1045 .insert("age1alice".into(), "alice-scoped".into());
1046
1047 let r = merge_vaults(&base, &ours, &theirs);
1048 assert!(!r.conflicts.is_empty());
1051 assert!(r.vault.secrets.contains_key("DB_URL"));
1052 }
1053
1054 #[test]
1055 fn merge_scoped_add_vs_base_key_modification() {
1056 let base = base_vault();
1057
1058 let mut ours = base.clone();
1060 ours.secrets.remove("DB_URL");
1061 ours.schema.remove("DB_URL");
1062
1063 let mut theirs = base.clone();
1065 theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
1066 theirs
1067 .secrets
1068 .get_mut("DB_URL")
1069 .unwrap()
1070 .scoped
1071 .insert("age1alice".into(), "alice-scoped".into());
1072
1073 let r = merge_vaults(&base, &ours, &theirs);
1074 assert!(r.conflicts.len() >= 1);
1076 assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1077 }
1078
1079 #[test]
1082 fn merge_ours_changes_recipients_theirs_adds_key() {
1083 let base = base_vault();
1084 let mut ours = base.clone();
1085 ours.recipients.push("age1charlie".into());
1086 ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
1087
1088 let mut theirs = base.clone();
1089 theirs.secrets.insert(
1090 "NEW_KEY".into(),
1091 SecretEntry {
1092 shared: "theirs-new".into(),
1093 scoped: BTreeMap::new(),
1094 },
1095 );
1096
1097 let r = merge_vaults(&base, &ours, &theirs);
1098 assert!(
1100 r.conflicts
1101 .iter()
1102 .any(|c| c.reason.contains("added on one side"))
1103 );
1104 assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
1105 assert!(r.vault.secrets.contains_key("NEW_KEY"));
1106 assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
1107 }
1108}