Skip to main content

murk_cli/
merge.rs

1//! Three-way merge driver for `.murk` vault files.
2//!
3//! Operates at the Vault struct level: recipients as a set, schema and secrets
4//! as key-level maps. Ciphertext equality against the base determines whether
5//! a side modified a value (murk preserves ciphertext for unchanged values).
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use crate::types::{SecretEntry, Vault};
10
11/// A single conflict discovered during merge.
12#[derive(Debug)]
13pub struct MergeConflict {
14    pub field: String,
15    pub reason: String,
16}
17
18/// Result of a three-way vault merge.
19#[derive(Debug)]
20pub struct MergeResult {
21    pub vault: Vault,
22    pub conflicts: Vec<MergeConflict>,
23}
24
25/// Three-way merge of vault files at the struct level.
26///
27/// `base` is the common ancestor, `ours` is the current branch,
28/// `theirs` is the incoming branch. Returns the merged vault and any conflicts.
29/// On conflict, the conflicting field keeps the "ours" value.
30pub fn merge_vaults(base: &Vault, ours: &Vault, theirs: &Vault) -> MergeResult {
31    let mut conflicts = Vec::new();
32
33    // -- Static fields: take ours --
34    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    // -- Recipients: set union/removal --
40    let recipients = merge_recipients(base, ours, theirs, &mut conflicts);
41
42    // Detect recipient-change sides (triggers full re-encryption).
43    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    // -- Schema: key-level merge --
50    let schema = merge_btree(
51        &base.schema,
52        &ours.schema,
53        &theirs.schema,
54        "schema",
55        &mut conflicts,
56    );
57
58    // -- Secrets: key-level merge with ciphertext comparison --
59    let secrets = merge_secrets(
60        base,
61        ours,
62        theirs,
63        ours_changed_recipients,
64        theirs_changed_recipients,
65        &mut conflicts,
66    );
67
68    // -- Meta: take ours for now; the CLI command handles regeneration --
69    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
85/// Merge recipient lists as sets: union additions, honor removals.
86fn 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    // Recipient addition requires both sides to agree, or it's a conflict.
104    // Blind set-union would let a malicious branch silently grant access.
105    for pk in &ours_added {
106        if theirs_added.contains(pk) {
107            // Both sides added the same recipient — safe.
108            result.insert(pk);
109        } else {
110            // Only ours added — conflict. Include the recipient but flag it.
111            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            // Only theirs added — conflict.
121            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    // Recipient removal requires both sides to agree, or it's a conflict.
130    for pk in &ours_removed {
131        if theirs_removed.contains(pk) {
132            // Both sides removed — safe.
133            result.remove(pk);
134        } else {
135            // Only ours removed — conflict. Keep the recipient (safer default).
136            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            // Only theirs removed — conflict. Keep the recipient.
145            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
155/// Generic three-way merge for BTreeMap where values implement PartialEq + Clone.
156fn 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            // Both sides removed — safe to omit.
197            (Some(_) | None, None, None) => {}
198            // One side removed, other kept unchanged — conflict.
199            (Some(b), Some(o), None) => {
200                if o == b {
201                    // Ours didn't touch it, theirs removed — conflict.
202                    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                // else: ours modified AND theirs removed — ours wins (modified takes priority)
209            }
210            (Some(b), None, Some(t)) => {
211                if t == b {
212                    // Theirs didn't touch it, ours removed — conflict.
213                    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                // else: theirs modified AND ours removed — theirs wins
220            }
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
248/// Merge secrets with ciphertext-equality-against-base comparison.
249///
250/// When one side changed recipients (triggering full re-encryption), that side's
251/// ciphertext all differs from base. We detect this and use the re-encrypted side
252/// as the baseline, applying the other side's additions/removals.
253fn 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 one side changed recipients, all its ciphertext differs from base.
262    // Use the re-encrypted side as the "new base" and apply the other side's diffs.
263    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    // Normal case: neither side changed recipients. Ciphertext comparison works.
274    merge_secrets_normal(base, ours, theirs, conflicts)
275}
276
277/// Normal secret merge: compare ciphertext against base to detect changes.
278fn 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            // Both removed or impossible key.
319            (Some(_) | None, None, None) => {}
320
321            (Some(b), Some(o), None) => {
322                // Theirs removed, ours kept — always conflict.
323                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                // Ours removed, theirs kept — always conflict.
335                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
371/// Merge scoped (mote) entries within a single secret key.
372fn 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
456/// When one side re-encrypted (changed recipients), use it as the new baseline
457/// and apply the other side's key-level additions/removals.
458///
459/// `reencrypted` is the side that changed recipients (all ciphertext differs from base).
460/// `other` is the side with stable ciphertext. `other_label` is "ours" or "theirs" for messages.
461fn 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    // Start with the re-encrypted side's secrets (they have the new recipient set).
469    let mut result = reencrypted.secrets.clone();
470
471    // Detect what the other side added/removed/modified relative to base.
472    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                // Other side removed this key. Honor the removal.
498                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                // If other side didn't modify, keep re-encrypted version.
510            }
511            (None, None) => {}
512        }
513    }
514
515    result
516}
517
518/// Both sides changed recipients — all ciphertext on both sides differs from base.
519/// Without decryption we can only merge keys that were added/removed (not modified).
520fn 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            // Both have it and it was in base — take ours.
543            (Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
544                result.insert(key.to_string(), o.clone());
545            }
546            // Removals — honor them.
547            (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/// Output of the merge driver: the merge result and whether meta was regenerated.
565#[derive(Debug)]
566pub struct MergeDriverOutput {
567    pub result: MergeResult,
568    pub meta_regenerated: bool,
569}
570
571/// Run the three-way merge driver on vault contents (as strings).
572///
573/// Parses all three versions, merges, and attempts meta regeneration.
574/// Returns the merged vault and conflict list. The caller is responsible for
575/// writing the result to disk.
576pub 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
592/// Attempt to regenerate the meta blob for a merged vault.
593///
594/// Decrypts meta from `ours` and `theirs` to merge recipient name maps,
595/// recomputes the MAC, and re-encrypts. Falls back to `ours.meta` if
596/// MURK_KEY is unavailable.
597pub 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    // Merge name maps: union, ours wins on conflict.
615    let mut names = theirs_meta.recipients;
616    for (pk, name) in ours_meta.recipients {
617        names.insert(pk, name);
618    }
619
620    // Only keep names for recipients still in the merged vault.
621    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    // -- No-change merge --
683
684    #[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    // -- Ours-only changes --
694
695    #[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    // -- Theirs-only changes --
723
724    #[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    // -- Both add different keys --
742
743    #[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    // -- Both remove same key --
773
774    #[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    // -- Ours modifies, theirs unchanged --
788
789    #[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    // -- Theirs modifies, ours unchanged --
801
802    #[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    // -- Conflicts --
814
815    #[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        // Takes ours on conflict.
827        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    // -- Recipients --
873
874    #[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        // Recipient is still included (safer to keep than drop).
884        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        // One-sided removal should conflict — recipient kept for safety.
915        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    // -- Schema --
933
934    #[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    // -- Scoped --
976
977    #[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        // Ours: remove the base key entirely.
1034        let mut ours = base.clone();
1035        ours.secrets.remove("DB_URL");
1036        ours.schema.remove("DB_URL");
1037
1038        // Theirs: add a scoped entry on the same key (shared unchanged).
1039        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        // Ours removed the key, theirs kept it — conflict.
1049        // Schema removal conflicts, secret kept because theirs modified (added scoped).
1050        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        // Ours: remove the base key entirely.
1059        let mut ours = base.clone();
1060        ours.secrets.remove("DB_URL");
1061        ours.schema.remove("DB_URL");
1062
1063        // Theirs: modify the shared value AND add scoped.
1064        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        // Theirs modified shared, ours removed — conflicts for both secrets and schema.
1075        assert!(r.conflicts.len() >= 1);
1076        assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
1077    }
1078
1079    // -- Recipient change + secret addition --
1080
1081    #[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        // One-sided recipient addition now conflicts.
1099        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}