1use std::collections::{HashMap, HashSet, VecDeque};
7
8use crate::crypto::KeyVault;
9use crate::metadata::{parse_commit, Commit};
10use crate::store::{FsStore, ObjectStoreExt};
11use crate::cid::{ToVoidCid, VoidCid};
12use crate::{cid, Result};
13use void_crypto::EncryptedCommit;
14
15fn load_commit(store: &FsStore, vault: &KeyVault, commit_cid: &VoidCid) -> Result<Commit> {
19 let cid_str = commit_cid.to_string();
20 let blob: EncryptedCommit = store.get_blob(commit_cid).map_err(|e| {
21 crate::VoidError::NotFound(format!("commit {} not found: {}", cid_str, e))
22 })?;
23 let (decrypted, _reader) =
24 vault.open_commit(&blob)
25 .map_err(|e| {
26 crate::VoidError::Serialization(format!(
27 "failed to decrypt commit {} (encrypted_size={} bytes): {}",
28 cid_str,
29 blob.as_bytes().len(),
30 e
31 ))
32 })?;
33 parse_commit(&decrypted).map_err(|e| {
35 crate::VoidError::Serialization(format!(
36 "failed to parse commit {} (decrypted_size={} bytes): {}",
37 cid_str,
38 decrypted.len(),
39 e
40 ))
41 })
42}
43
44pub fn find_merge_base(
61 store: &FsStore,
62 vault: &KeyVault,
63 commit_a: &VoidCid,
64 commit_b: &VoidCid,
65) -> Result<Option<VoidCid>> {
66 if commit_a == commit_b {
68 return Ok(Some(*commit_a));
69 }
70
71 let mut visited_a: HashMap<String, u64> = HashMap::new();
74 let mut visited_b: HashMap<String, u64> = HashMap::new();
75
76 let mut queue_a: VecDeque<VoidCid> = VecDeque::new();
78 let mut queue_b: VecDeque<VoidCid> = VecDeque::new();
79
80 queue_a.push_back(*commit_a);
81 queue_b.push_back(*commit_b);
82
83 let mut candidates: Vec<(String, u64)> = Vec::new();
86
87 while !queue_a.is_empty() || !queue_b.is_empty() {
89 let level_size_a = queue_a.len();
91 for _ in 0..level_size_a {
92 if let Some(current) = queue_a.pop_front() {
93 let current_str = current.to_string();
94
95 if visited_a.contains_key(¤t_str) {
97 continue;
98 }
99
100 let commit = load_commit(store, vault, ¤t)?;
101 visited_a.insert(current_str.clone(), commit.timestamp);
102
103 if visited_b.contains_key(¤t_str) {
105 candidates.push((current_str.clone(), commit.timestamp));
106 }
107
108 for parent in &commit.parents {
110 if !parent.as_bytes().is_empty() {
111 let parent_cid = parent.to_void_cid()?;
112 let parent_str = parent_cid.to_string();
113 if !visited_a.contains_key(&parent_str) {
114 queue_a.push_back(parent_cid);
115 }
116 }
117 }
118 }
119 }
120
121 let level_size_b = queue_b.len();
123 for _ in 0..level_size_b {
124 if let Some(current) = queue_b.pop_front() {
125 let current_str = current.to_string();
126
127 if visited_b.contains_key(¤t_str) {
129 continue;
130 }
131
132 let commit = load_commit(store, vault, ¤t)?;
133 visited_b.insert(current_str.clone(), commit.timestamp);
134
135 if visited_a.contains_key(¤t_str) {
137 candidates.push((current_str.clone(), commit.timestamp));
138 }
139
140 for parent in &commit.parents {
142 if !parent.as_bytes().is_empty() {
143 let parent_cid = parent.to_void_cid()?;
144 let parent_str = parent_cid.to_string();
145 if !visited_b.contains_key(&parent_str) {
146 queue_b.push_back(parent_cid);
147 }
148 }
149 }
150 }
151 }
152
153 if !candidates.is_empty() {
155 let mut seen: HashSet<String> = HashSet::new();
157 candidates.retain(|(cid_str, _)| seen.insert(cid_str.clone()));
158 candidates.sort_by_key(|(_, ts)| *ts);
159
160 let (best_cid_str, _) = &candidates[0];
161 return Ok(Some(cid::parse(best_cid_str)?));
162 }
163 }
164
165 Ok(None)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::crypto::{self, encrypt_with_envelope, generate_key_nonce, KeyVault};
173 use crate::metadata::Commit;
174 use crate::store::ObjectStoreExt;
175 use camino::Utf8PathBuf;
176 use tempfile::TempDir;
177 use void_crypto::{CommitCid, EncryptedCommit, MetadataCid};
178
179 fn temp_store() -> (FsStore, TempDir) {
180 let dir = TempDir::new().unwrap();
181 let store = FsStore::new(Utf8PathBuf::try_from(dir.path().to_path_buf()).unwrap()).unwrap();
182 (store, dir)
183 }
184
185 fn store_commit(store: &FsStore, key: &[u8; 32], commit: &Commit) -> VoidCid {
186 let bytes = crate::support::cbor_to_vec(commit).unwrap();
187 let nonce = generate_key_nonce();
188 let encrypted = encrypt_with_envelope(key, &nonce, &bytes, crypto::AAD_COMMIT).unwrap();
189 store.put_blob(&EncryptedCommit::from_bytes(encrypted)).unwrap()
190 }
191
192 #[test]
193 fn same_commit_returns_itself() {
194 let (store, _dir) = temp_store();
195 let key = [0x42u8; 32];
196 let vault = KeyVault::new(key).expect("key derivation should not fail");
197
198 let commit = Commit::new(None, MetadataCid::from_bytes(vec![1, 2, 3]), 1000, "initial".into());
199 let cid = store_commit(&store, &key, &commit);
200
201 let result = find_merge_base(&store, &vault, &cid, &cid).unwrap();
202 assert_eq!(result, Some(cid));
203 }
204
205 #[test]
206 fn linear_history_base_is_older_commit() {
207 let (store, _dir) = temp_store();
211 let key = [0x42u8; 32];
212 let vault = KeyVault::new(key).expect("key derivation should not fail");
213
214 let commit_a = Commit::new(None, MetadataCid::from_bytes(vec![1]), 1000, "A".into());
216 let cid_a = store_commit(&store, &key, &commit_a);
217
218 let commit_b = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![2]), 2000, "B".into());
220 let cid_b = store_commit(&store, &key, &commit_b);
221
222 let commit_c = Commit::new(Some(CommitCid::from_bytes(cid_b.to_bytes())), MetadataCid::from_bytes(vec![3]), 3000, "C".into());
224 let cid_c = store_commit(&store, &key, &commit_c);
225
226 let result = find_merge_base(&store, &vault, &cid_a, &cid_c).unwrap();
228 assert_eq!(result, Some(cid_a));
229
230 let result = find_merge_base(&store, &vault, &cid_b, &cid_c).unwrap();
232 assert_eq!(result, Some(cid_b));
233
234 let result = find_merge_base(&store, &vault, &cid_c, &cid_a).unwrap();
236 assert_eq!(result, Some(cid_a));
237 }
238
239 #[test]
240 fn diverged_branches_single_merge_base() {
241 let (store, _dir) = temp_store();
249 let key = [0x42u8; 32];
250 let vault = KeyVault::new(key).expect("key derivation should not fail");
251
252 let commit_a = Commit::new(None, MetadataCid::from_bytes(vec![1]), 1000, "A".into());
254 let cid_a = store_commit(&store, &key, &commit_a);
255
256 let commit_b = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![2]), 2000, "B".into());
258 let cid_b = store_commit(&store, &key, &commit_b);
259
260 let commit_c = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![3]), 2500, "C".into());
262 let cid_c = store_commit(&store, &key, &commit_c);
263
264 let result = find_merge_base(&store, &vault, &cid_b, &cid_c).unwrap();
266 assert_eq!(result, Some(cid_a));
267
268 let result = find_merge_base(&store, &vault, &cid_c, &cid_b).unwrap();
270 assert_eq!(result, Some(cid_a));
271 }
272
273 #[test]
274 fn deeper_divergence() {
275 let (store, _dir) = temp_store();
283 let key = [0x42u8; 32];
284 let vault = KeyVault::new(key).expect("key derivation should not fail");
285
286 let commit_a = Commit::new(None, MetadataCid::from_bytes(vec![1]), 1000, "A".into());
288 let cid_a = store_commit(&store, &key, &commit_a);
289
290 let commit_b = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![2]), 2000, "B".into());
292 let cid_b = store_commit(&store, &key, &commit_b);
293
294 let commit_c = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![3]), 2500, "C".into());
296 let cid_c = store_commit(&store, &key, &commit_c);
297
298 let commit_d = Commit::new(Some(CommitCid::from_bytes(cid_b.to_bytes())), MetadataCid::from_bytes(vec![4]), 3000, "D".into());
300 let cid_d = store_commit(&store, &key, &commit_d);
301
302 let commit_e = Commit::new(Some(CommitCid::from_bytes(cid_c.to_bytes())), MetadataCid::from_bytes(vec![5]), 3500, "E".into());
304 let cid_e = store_commit(&store, &key, &commit_e);
305
306 let result = find_merge_base(&store, &vault, &cid_d, &cid_e).unwrap();
308 assert_eq!(result, Some(cid_a));
309 }
310
311 #[test]
312 fn unrelated_histories_returns_none() {
313 let (store, _dir) = temp_store();
315 let key = [0x42u8; 32];
316 let vault = KeyVault::new(key).expect("key derivation should not fail");
317
318 let commit_a = Commit::new(None, MetadataCid::from_bytes(vec![1]), 1000, "A".into());
320 let cid_a = store_commit(&store, &key, &commit_a);
321
322 let commit_b = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![2]), 2000, "B".into());
323 let cid_b = store_commit(&store, &key, &commit_b);
324
325 let commit_x = Commit::new(None, MetadataCid::from_bytes(vec![10]), 1500, "X".into());
327 let cid_x = store_commit(&store, &key, &commit_x);
328
329 let commit_y = Commit::new(Some(CommitCid::from_bytes(cid_x.to_bytes())), MetadataCid::from_bytes(vec![11]), 2500, "Y".into());
330 let cid_y = store_commit(&store, &key, &commit_y);
331
332 let result = find_merge_base(&store, &vault, &cid_b, &cid_y).unwrap();
334 assert_eq!(result, None);
335 }
336
337 #[test]
338 fn multiple_lcas_picks_earliest_timestamp() {
339 let (store, _dir) = temp_store();
355 let key = [0x42u8; 32];
356 let vault = KeyVault::new(key).expect("key derivation should not fail");
357
358 let commit_a = Commit::new(None, MetadataCid::from_bytes(vec![1]), 1000, "A".into());
360 let cid_a = store_commit(&store, &key, &commit_a);
361
362 let commit_b = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![2]), 2000, "B".into());
364 let cid_b = store_commit(&store, &key, &commit_b);
365
366 let commit_c = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![3]), 2500, "C".into());
368 let cid_c = store_commit(&store, &key, &commit_c);
369
370 let commit_d = Commit::new_merge(
372 vec![CommitCid::from_bytes(cid_b.to_bytes()), CommitCid::from_bytes(cid_c.to_bytes())],
373 MetadataCid::from_bytes(vec![4]),
374 3000,
375 "D (merge)".into(),
376 );
377 let cid_d = store_commit(&store, &key, &commit_d);
378
379 let commit_e = Commit::new(Some(CommitCid::from_bytes(cid_d.to_bytes())), MetadataCid::from_bytes(vec![5]), 4000, "E".into());
381 let cid_e = store_commit(&store, &key, &commit_e);
382
383 let commit_f = Commit::new(Some(CommitCid::from_bytes(cid_d.to_bytes())), MetadataCid::from_bytes(vec![6]), 4500, "F".into());
385 let cid_f = store_commit(&store, &key, &commit_f);
386
387 let result = find_merge_base(&store, &vault, &cid_e, &cid_f).unwrap();
389 assert_eq!(result, Some(cid_d));
390 }
391
392 #[test]
393 fn criss_cross_merge_picks_earliest() {
394 let (store, _dir) = temp_store();
411 let key = [0x42u8; 32];
412 let vault = KeyVault::new(key).expect("key derivation should not fail");
413
414 let commit_a = Commit::new(None, MetadataCid::from_bytes(vec![1]), 1000, "A".into());
416 let cid_a = store_commit(&store, &key, &commit_a);
417
418 let commit_b = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![2]), 2000, "B".into());
420 let cid_b = store_commit(&store, &key, &commit_b);
421
422 let commit_c = Commit::new(Some(CommitCid::from_bytes(cid_a.to_bytes())), MetadataCid::from_bytes(vec![3]), 2500, "C".into());
424 let cid_c = store_commit(&store, &key, &commit_c);
425
426 let commit_x = Commit::new_merge(
428 vec![CommitCid::from_bytes(cid_b.to_bytes()), CommitCid::from_bytes(cid_c.to_bytes())],
429 MetadataCid::from_bytes(vec![4]),
430 3000,
431 "X (merge B+C)".into(),
432 );
433 let cid_x = store_commit(&store, &key, &commit_x);
434
435 let commit_y = Commit::new_merge(
437 vec![CommitCid::from_bytes(cid_c.to_bytes()), CommitCid::from_bytes(cid_b.to_bytes())],
438 MetadataCid::from_bytes(vec![5]),
439 3500,
440 "Y (merge C+B)".into(),
441 );
442 let cid_y = store_commit(&store, &key, &commit_y);
443
444 let commit_p = Commit::new(Some(CommitCid::from_bytes(cid_x.to_bytes())), MetadataCid::from_bytes(vec![6]), 4000, "P".into());
446 let cid_p = store_commit(&store, &key, &commit_p);
447
448 let commit_q = Commit::new(Some(CommitCid::from_bytes(cid_y.to_bytes())), MetadataCid::from_bytes(vec![7]), 4500, "Q".into());
450 let cid_q = store_commit(&store, &key, &commit_q);
451
452 let result = find_merge_base(&store, &vault, &cid_p, &cid_q).unwrap();
455 assert!(result.is_some());
456 let result_cid = result.unwrap();
457 assert_eq!(result_cid, cid_b);
460 }
461}