1use std::collections::{HashMap, HashSet};
9
10use crate::errors::SafeResult;
11use crate::vault::{SecretEntry, VaultFile};
12
13#[derive(Debug)]
15pub struct MergeResult {
16 pub merged: VaultFile,
18 pub added_from_theirs: Vec<String>,
20 pub updated_from_theirs: Vec<String>,
22 pub added_from_ours: Vec<String>,
24 pub conflicts: Vec<String>,
26 pub deleted: Vec<String>,
28 pub is_noop: bool,
30 pub decisions: Vec<MergeDecision>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct MergeDecision {
37 pub key: String,
38 pub state: MergeState,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MergeState {
44 Unchanged,
45 AddedFromOurs,
46 AddedFromTheirs,
47 AddedConcurrentlyIdentical,
48 UpdatedFromOurs,
49 UpdatedFromTheirs,
50 DeletedByOurs,
51 DeletedByTheirs,
52 DeletedByBoth,
53 ConflictResolvedToOurs,
54 ConflictResolvedToTheirs,
55 DeleteConflictResolvedToOurs,
56 DeleteConflictResolvedToTheirs,
57}
58
59#[derive(Debug, Clone)]
60struct PlannedMergeDecision {
61 state: MergeState,
62 merged_entry: Option<SecretEntry>,
63}
64
65fn entry_eq(a: &SecretEntry, b: &SecretEntry) -> bool {
68 a.nonce == b.nonce && a.ciphertext == b.ciphertext
69}
70
71pub fn three_way_merge(
79 base: &VaultFile,
80 ours: &VaultFile,
81 theirs: &VaultFile,
82) -> SafeResult<MergeResult> {
83 let mut merged_secrets: HashMap<String, SecretEntry> = HashMap::new();
84 let mut added_from_theirs = Vec::new();
85 let mut updated_from_theirs = Vec::new();
86 let mut added_from_ours = Vec::new();
87 let mut conflicts = Vec::new();
88 let mut deleted = Vec::new();
89 let mut decisions = Vec::new();
90
91 let all_keys: HashSet<&str> = base
93 .secrets
94 .keys()
95 .chain(ours.secrets.keys())
96 .chain(theirs.secrets.keys())
97 .map(String::as_str)
98 .collect();
99 let mut all_keys: Vec<&str> = all_keys.into_iter().collect();
100 all_keys.sort_unstable();
101
102 for key in all_keys {
103 let planned = plan_merge_decision(
104 base.secrets.get(key),
105 ours.secrets.get(key),
106 theirs.secrets.get(key),
107 )?;
108 match planned.state {
109 MergeState::AddedFromOurs => {
110 added_from_ours.push(key.to_string());
111 }
112 MergeState::AddedFromTheirs => {
113 added_from_theirs.push(key.to_string());
114 }
115 MergeState::UpdatedFromTheirs => {
116 updated_from_theirs.push(key.to_string());
117 }
118 MergeState::DeletedByOurs | MergeState::DeletedByTheirs | MergeState::DeletedByBoth => {
119 deleted.push(key.to_string());
120 }
121 MergeState::ConflictResolvedToOurs
122 | MergeState::ConflictResolvedToTheirs
123 | MergeState::DeleteConflictResolvedToOurs
124 | MergeState::DeleteConflictResolvedToTheirs => {
125 conflicts.push(key.to_string());
126 }
127 MergeState::Unchanged
128 | MergeState::AddedConcurrentlyIdentical
129 | MergeState::UpdatedFromOurs => {}
130 }
131 if let Some(entry) = planned.merged_entry {
132 merged_secrets.insert(key.to_string(), entry);
133 }
134 decisions.push(MergeDecision {
135 key: key.to_string(),
136 state: planned.state,
137 });
138 }
139
140 let mut merged_recipients: Vec<String> = ours.age_recipients.clone();
142 for r in &theirs.age_recipients {
143 if !merged_recipients.contains(r) {
144 merged_recipients.push(r.clone());
145 }
146 }
147
148 let is_noop = decisions
149 .iter()
150 .all(|decision| decision.state == MergeState::Unchanged);
151
152 let merged = VaultFile {
153 schema: ours.schema.clone(),
154 kdf: ours.kdf.clone(),
155 cipher: ours.cipher.clone(),
156 vault_challenge: ours.vault_challenge.clone(),
157 created_at: ours.created_at,
158 updated_at: std::cmp::max(ours.updated_at, theirs.updated_at),
159 secrets: merged_secrets,
160 age_recipients: merged_recipients,
161 wrapped_dek: ours.wrapped_dek.clone(),
164 };
165
166 Ok(MergeResult {
167 merged,
168 added_from_theirs,
169 updated_from_theirs,
170 added_from_ours,
171 conflicts,
172 deleted,
173 is_noop,
174 decisions,
175 })
176}
177
178fn plan_merge_decision(
179 base: Option<&SecretEntry>,
180 ours: Option<&SecretEntry>,
181 theirs: Option<&SecretEntry>,
182) -> SafeResult<PlannedMergeDecision> {
183 match (base, ours, theirs) {
184 (None, Some(o), None) => Ok(PlannedMergeDecision {
186 state: MergeState::AddedFromOurs,
187 merged_entry: Some(o.clone()),
188 }),
189
190 (None, None, Some(t)) => Ok(PlannedMergeDecision {
192 state: MergeState::AddedFromTheirs,
193 merged_entry: Some(t.clone()),
194 }),
195
196 (None, Some(o), Some(t)) => {
198 if entry_eq(o, t) {
199 Ok(PlannedMergeDecision {
200 state: MergeState::AddedConcurrentlyIdentical,
201 merged_entry: Some(o.clone()),
202 })
203 } else if t.updated_at >= o.updated_at {
204 Ok(PlannedMergeDecision {
205 state: MergeState::ConflictResolvedToTheirs,
206 merged_entry: Some(t.clone()),
207 })
208 } else {
209 Ok(PlannedMergeDecision {
210 state: MergeState::ConflictResolvedToOurs,
211 merged_entry: Some(o.clone()),
212 })
213 }
214 }
215
216 (Some(_), None, None) => Ok(PlannedMergeDecision {
218 state: MergeState::DeletedByBoth,
219 merged_entry: None,
220 }),
221
222 (Some(b), None, Some(t)) => {
224 if entry_eq(b, t) {
225 Ok(PlannedMergeDecision {
226 state: MergeState::DeletedByOurs,
227 merged_entry: None,
228 })
229 } else {
230 Ok(PlannedMergeDecision {
231 state: MergeState::DeleteConflictResolvedToTheirs,
232 merged_entry: Some(t.clone()),
233 })
234 }
235 }
236
237 (Some(b), Some(o), None) => {
239 if entry_eq(b, o) {
240 Ok(PlannedMergeDecision {
241 state: MergeState::DeletedByTheirs,
242 merged_entry: None,
243 })
244 } else {
245 Ok(PlannedMergeDecision {
246 state: MergeState::DeleteConflictResolvedToOurs,
247 merged_entry: Some(o.clone()),
248 })
249 }
250 }
251
252 (Some(b), Some(o), Some(t)) => {
254 let ours_changed = !entry_eq(b, o);
255 let theirs_changed = !entry_eq(b, t);
256
257 match (ours_changed, theirs_changed) {
258 (false, false) => Ok(PlannedMergeDecision {
259 state: MergeState::Unchanged,
260 merged_entry: Some(o.clone()),
261 }),
262 (true, false) => Ok(PlannedMergeDecision {
263 state: MergeState::UpdatedFromOurs,
264 merged_entry: Some(o.clone()),
265 }),
266 (false, true) => Ok(PlannedMergeDecision {
267 state: MergeState::UpdatedFromTheirs,
268 merged_entry: Some(t.clone()),
269 }),
270 (true, true) => {
271 if entry_eq(o, t) {
272 Ok(PlannedMergeDecision {
273 state: MergeState::UpdatedFromOurs,
274 merged_entry: Some(o.clone()),
275 })
276 } else if t.updated_at >= o.updated_at {
277 Ok(PlannedMergeDecision {
278 state: MergeState::ConflictResolvedToTheirs,
279 merged_entry: Some(t.clone()),
280 })
281 } else {
282 Ok(PlannedMergeDecision {
283 state: MergeState::ConflictResolvedToOurs,
284 merged_entry: Some(o.clone()),
285 })
286 }
287 }
288 }
289 }
290
291 (None, None, None) => Err(crate::errors::SafeError::InvalidVault {
293 reason: "merge invariant violated: key absent from all three vaults".into(),
294 }),
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::vault::{KdfParams, VaultChallenge};
302 use chrono::{Duration, Utc};
303 use std::collections::HashMap;
304
305 fn empty_vault() -> VaultFile {
306 VaultFile {
307 schema: "tsafe/vault/v1".into(),
308 kdf: KdfParams {
309 algorithm: "argon2id".into(),
310 m_cost: 65536,
311 t_cost: 3,
312 p_cost: 4,
313 salt: "AAAA".into(),
314 },
315 cipher: "xchacha20poly1305".into(),
316 vault_challenge: VaultChallenge {
317 nonce: "AAAA".into(),
318 ciphertext: "AAAA".into(),
319 },
320 created_at: Utc::now(),
321 updated_at: Utc::now(),
322 secrets: HashMap::new(),
323 age_recipients: Vec::new(),
324 wrapped_dek: None,
325 }
326 }
327
328 fn make_entry(nonce: &str, ct: &str, age_days: i64) -> SecretEntry {
329 let now = Utc::now();
330 SecretEntry {
331 nonce: nonce.into(),
332 ciphertext: ct.into(),
333 created_at: now - Duration::days(age_days + 1),
334 updated_at: now - Duration::days(age_days),
335 tags: HashMap::new(),
336 history: Vec::new(),
337 }
338 }
339
340 #[test]
341 fn merge_no_changes_is_noop() {
342 let base = empty_vault();
343 let ours = base.clone();
344 let theirs = base.clone();
345 let result = three_way_merge(&base, &ours, &theirs).unwrap();
346 assert!(result.is_noop);
347 assert!(result.conflicts.is_empty());
348 }
349
350 #[test]
351 fn merge_disjoint_additions() {
352 let base = empty_vault();
353 let mut ours = base.clone();
354 ours.secrets
355 .insert("KEY_A".into(), make_entry("n1", "ct1", 0));
356 let mut theirs = base.clone();
357 theirs
358 .secrets
359 .insert("KEY_B".into(), make_entry("n2", "ct2", 0));
360
361 let result = three_way_merge(&base, &ours, &theirs).unwrap();
362 assert!(!result.is_noop);
363 assert!(result.merged.secrets.contains_key("KEY_A"));
364 assert!(result.merged.secrets.contains_key("KEY_B"));
365 assert_eq!(result.added_from_ours, vec!["KEY_A"]);
366 assert_eq!(result.added_from_theirs, vec!["KEY_B"]);
367 assert!(result.conflicts.is_empty());
368 assert_eq!(
369 result.decisions,
370 vec![
371 MergeDecision {
372 key: "KEY_A".into(),
373 state: MergeState::AddedFromOurs,
374 },
375 MergeDecision {
376 key: "KEY_B".into(),
377 state: MergeState::AddedFromTheirs,
378 },
379 ]
380 );
381 }
382
383 #[test]
384 fn merge_ours_deletes_theirs_unchanged() {
385 let mut base = empty_vault();
386 base.secrets
387 .insert("DEL".into(), make_entry("n1", "ct1", 5));
388 let mut ours = base.clone();
389 ours.secrets.remove("DEL");
390 let theirs = base.clone(); let result = three_way_merge(&base, &ours, &theirs).unwrap();
393 assert!(!result.merged.secrets.contains_key("DEL"));
394 assert_eq!(result.deleted, vec!["DEL"]);
395 }
396
397 #[test]
398 fn merge_both_edit_same_key_lww() {
399 let mut base = empty_vault();
400 base.secrets
401 .insert("SHARED".into(), make_entry("n0", "ct0", 10));
402
403 let mut ours = base.clone();
404 ours.secrets
405 .insert("SHARED".into(), make_entry("n1", "ct1", 2)); let mut theirs = base.clone();
408 theirs
409 .secrets
410 .insert("SHARED".into(), make_entry("n2", "ct2", 0)); let result = three_way_merge(&base, &ours, &theirs).unwrap();
413 assert_eq!(result.conflicts, vec!["SHARED"]);
414 assert_eq!(result.merged.secrets["SHARED"].ciphertext, "ct2");
416 }
417
418 #[test]
419 fn merge_both_edit_same_value_no_conflict() {
420 let mut base = empty_vault();
421 base.secrets
422 .insert("SAME".into(), make_entry("n0", "ct0", 5));
423
424 let mut ours = base.clone();
425 ours.secrets
426 .insert("SAME".into(), make_entry("n1", "ct1", 0));
427
428 let mut theirs = base.clone();
429 theirs
430 .secrets
431 .insert("SAME".into(), make_entry("n1", "ct1", 0)); let result = three_way_merge(&base, &ours, &theirs).unwrap();
434 assert!(result.conflicts.is_empty());
435 }
436
437 #[test]
438 fn merge_only_theirs_changed() {
439 let mut base = empty_vault();
440 base.secrets.insert("K".into(), make_entry("n0", "ct0", 5));
441 let ours = base.clone();
442 let mut theirs = base.clone();
443 theirs
444 .secrets
445 .insert("K".into(), make_entry("n1", "ct1", 0));
446
447 let result = three_way_merge(&base, &ours, &theirs).unwrap();
448 assert_eq!(result.updated_from_theirs, vec!["K"]);
449 assert_eq!(result.merged.secrets["K"].ciphertext, "ct1");
450 }
451
452 #[test]
453 fn merge_recipients_union() {
454 let mut base = empty_vault();
455 base.age_recipients = vec!["age1aaa".into()];
456
457 let mut ours = base.clone();
458 ours.age_recipients.push("age1bbb".into());
459
460 let mut theirs = base.clone();
461 theirs.age_recipients.push("age1ccc".into());
462
463 let result = three_way_merge(&base, &ours, &theirs).unwrap();
464 assert!(result
465 .merged
466 .age_recipients
467 .contains(&"age1aaa".to_string()));
468 assert!(result
469 .merged
470 .age_recipients
471 .contains(&"age1bbb".to_string()));
472 assert!(result
473 .merged
474 .age_recipients
475 .contains(&"age1ccc".to_string()));
476 }
477
478 #[test]
479 fn merge_delete_vs_edit_conflict_prefers_live_value() {
480 let mut base = empty_vault();
481 base.secrets
482 .insert("KEY".into(), make_entry("n0", "ct0", 5));
483
484 let mut ours = base.clone();
486 ours.secrets.remove("KEY");
487
488 let mut theirs = base.clone();
490 theirs
491 .secrets
492 .insert("KEY".into(), make_entry("n1", "ct1", 0));
493
494 let result = three_way_merge(&base, &ours, &theirs).unwrap();
495 assert!(result.merged.secrets.contains_key("KEY"));
497 assert!(result.conflicts.contains(&"KEY".to_string()));
498 assert_eq!(
499 result.decisions,
500 vec![MergeDecision {
501 key: "KEY".into(),
502 state: MergeState::DeleteConflictResolvedToTheirs,
503 }]
504 );
505 }
506
507 #[test]
508 fn merge_both_delete_records_deleted_by_both() {
509 let mut base = empty_vault();
510 base.secrets
511 .insert("GONE".into(), make_entry("n0", "ct0", 5));
512
513 let mut ours = base.clone();
514 ours.secrets.remove("GONE");
515
516 let mut theirs = base.clone();
517 theirs.secrets.remove("GONE");
518
519 let result = three_way_merge(&base, &ours, &theirs).unwrap();
520 assert_eq!(result.deleted, vec!["GONE"]);
521 assert_eq!(
522 result.decisions,
523 vec![MergeDecision {
524 key: "GONE".into(),
525 state: MergeState::DeletedByBoth,
526 }]
527 );
528 }
529}