mkit_core/ops/graph.rs
1//! Commit-graph traversal helpers.
2//!
3//! This module exposes [`collect_ancestor_set`]. Higher-level walks
4//! (`is_ancestor`, `find_merge_base`) live in [`super::merge`] alongside
5//! the merge algorithm that consumes them.
6//!
7//! Bound: at most [`MAX_ANCESTORS`] (`10_000`) commits are visited per
8//! call. Beyond that the walk stops silently — callers asking about
9//! pathologically deep histories get a partial answer rather than an
10//! OOM.
11
12use std::collections::{BTreeSet, HashSet, VecDeque};
13use std::hash::BuildHasher;
14
15use crate::hash::Hash;
16use crate::object::{Object, ObjectType};
17use crate::store::{ObjectStore, StoreError};
18
19/// Hard cap on commits visited per call.
20pub const MAX_ANCESTORS: usize = 10_000;
21
22/// Collect the set of all ancestor commits of `start`, including
23/// `start` itself, by DFS over `Commit::parents`. The walk:
24///
25/// * Adds `start` to `set` even if its object is not in the store
26/// (the hash is recorded, then the missing-object error
27/// short-circuits the parent walk for that node).
28/// * Treats non-commit objects at a hash as terminators (no parents to
29/// follow).
30/// * Stops cleanly after [`MAX_ANCESTORS`] inserts.
31///
32/// # Errors
33///
34/// Only [`StoreError::Io`] / [`StoreError::HashMismatch`] /
35/// [`StoreError::ObjectTooLarge`] / [`StoreError::Decode`] propagate.
36/// `ObjectNotFound` is *swallowed* — see test
37/// `handles_non_existent_parent_gracefully`.
38pub fn collect_ancestor_set<S: BuildHasher>(
39 store: &ObjectStore,
40 start: Hash,
41 set: &mut HashSet<Hash, S>,
42) -> Result<(), StoreError> {
43 let mut stack: Vec<Hash> = Vec::new();
44 stack.push(start);
45
46 let mut count: usize = 0;
47 while let Some(current) = stack.pop() {
48 if count >= MAX_ANCESTORS {
49 break;
50 }
51 if !set.insert(current) {
52 continue;
53 }
54 count += 1;
55
56 match store.read_object(¤t) {
57 Ok(Object::Commit(c)) => {
58 for &parent in &c.parents {
59 stack.push(parent);
60 }
61 }
62 // Non-commit (or unreadable) — stop walking from this node,
63 // but we keep the hash in `set`.
64 Ok(_) | Err(StoreError::ObjectNotFound(_)) => {}
65 Err(e) => return Err(e),
66 }
67 }
68 Ok(())
69}
70
71/// Hard cap on the total number of objects [`reachable_objects`] will
72/// visit per call. Matches the scale of [`MAX_ANCESTORS`] but applies
73/// to the full object closure (commits + trees + blobs + chunks), so it
74/// is intentionally larger. A repo that exceeds this cap has to split
75/// pushes — the push-path is the only caller for now.
76pub const MAX_REACHABLE: usize = 10_000_000;
77
78/// Collect every object reachable from commit `root` — the full closure
79/// needed to reconstruct the commit on a fresh store. Walks, in order:
80///
81/// 1. `root` commit → its `tree_hash` + every `parent`.
82/// 2. each tree → every entry's `object_hash` (blob / tree / chunked-blob).
83/// 3. nested trees → recurse.
84/// 4. chunked-blob manifests → every `chunks[i]` hash.
85///
86/// Deltas are not produced by any path the push code drives, and remix
87/// objects are walked like commits (tree + parents) per SPEC-OBJECTS §6.
88///
89/// Returns a [`BTreeSet`] so iteration order is deterministic — the
90/// push code uses that to build reproducible packfiles. Deduplication
91/// happens naturally via the set.
92///
93/// # Errors
94///
95/// - [`StoreError::ObjectNotFound`] if `root` itself is missing from
96/// the store. Missing *referenced* objects (e.g. a tree listing a
97/// blob that got pruned) are a harder failure and propagate via the
98/// same variant — callers pushing partial repos should fix their
99/// store before pushing.
100/// - Other [`StoreError`] variants propagate as-is.
101pub fn reachable_objects(store: &ObjectStore, root: &Hash) -> Result<BTreeSet<Hash>, StoreError> {
102 reachable_closure(store, std::iter::once(root))
103}
104
105/// Collect every object reachable from **any** of `roots` — the
106/// multi-root generalization of [`reachable_objects`]. This is the
107/// closure `mkit gc` (#233) keeps live: seed it with the full retention
108/// root set from [`super::gc::collect_roots`] and every object NOT in the
109/// result is unreachable.
110///
111/// Identical walk semantics to [`reachable_objects`] (commits/remixes →
112/// tree + parents, trees → entries, chunked-blobs → chunks, tags →
113/// target, blobs/deltas are leaves), deduped via the returned
114/// [`BTreeSet`], and capped at [`MAX_REACHABLE`] total objects. With a
115/// single root the result is byte-identical to [`reachable_objects`].
116///
117/// # Errors
118///
119/// Propagates [`StoreError`] as [`reachable_objects`] does — notably
120/// [`StoreError::ObjectNotFound`] for a missing root or referenced
121/// object, so a caller (gc) fails closed rather than under-counting the
122/// live set.
123///
124/// # Content integrity of leaves (#636)
125///
126/// This walk classifies blob/delta leaves via a cheap type check
127/// ([`ObjectStore::object_type`]) instead of a full verified read, so a
128/// leaf's BLAKE3 content hash is **not** checked here — only its type.
129/// A blob whose stored bytes are corrupted but whose type prologue is
130/// intact is still counted as reachable (and, for `gc`, therefore
131/// survives). This is a deliberate scope change from the walk's
132/// pre-#636 behavior, where every leaf's content was verified
133/// incidentally as a side effect of the full read the old
134/// classification path did. Corruption is still caught — just later,
135/// the first time something actually reads the leaf's bytes (pack
136/// build, checkout, `cat-file`, ...) via `store.read`/`read_object`,
137/// both of which always verify. `gc`'s job is reachability, not
138/// content integrity, so this narrowing is intentional; it just means
139/// `gc`/push-planning are no longer an incidental corruption-detection
140/// pass the way they used to be.
141pub fn reachable_closure<'a, I>(store: &ObjectStore, roots: I) -> Result<BTreeSet<Hash>, StoreError>
142where
143 I: IntoIterator<Item = &'a Hash>,
144{
145 // Push-path callers tolerate cap truncation (they split pushes), so
146 // the truncation flag is dropped here. gc must NOT — it uses
147 // [`reachable_closure_checked`] and fails closed.
148 reachable_closure_checked(store, roots).map(|(out, _truncated)| out)
149}
150
151/// Like [`reachable_closure`] but also reports whether the
152/// [`MAX_REACHABLE`] cap truncated the walk (`true` = incomplete). A
153/// caller that would *delete* unreachable objects (gc) MUST treat
154/// `truncated == true` as fatal: beyond the cap the "unreachable" verdict
155/// is unsound, so pruning would drop live data.
156///
157/// # Errors
158///
159/// Propagates [`StoreError`] as [`reachable_objects`] does.
160pub fn reachable_closure_checked<'a, I>(
161 store: &ObjectStore,
162 roots: I,
163) -> Result<(BTreeSet<Hash>, bool), StoreError>
164where
165 I: IntoIterator<Item = &'a Hash>,
166{
167 reachable_closure_checked_with_cap(store, roots, MAX_REACHABLE)
168}
169
170/// Same walk as [`reachable_closure_checked`], but with a caller-supplied
171/// cap instead of the hardcoded [`MAX_REACHABLE`] (10 million).
172///
173/// Test-only injection point: `gc`'s fail-closed `Truncated` abort has
174/// no other way to exercise a truncation without actually constructing
175/// ten million objects. `pub(crate)`, not part of the public API — real
176/// external callers MUST use [`reachable_closure_checked`] (or
177/// [`reachable_closure`]) instead.
178pub(crate) fn reachable_closure_checked_with_cap<'a, I>(
179 store: &ObjectStore,
180 roots: I,
181 cap: usize,
182) -> Result<(BTreeSet<Hash>, bool), StoreError>
183where
184 I: IntoIterator<Item = &'a Hash>,
185{
186 let mut out: BTreeSet<Hash> = BTreeSet::new();
187 let mut queue: VecDeque<Hash> = VecDeque::new();
188 for root in roots {
189 queue.push_back(*root);
190 }
191
192 let mut truncated = false;
193 while let Some(h) = queue.pop_front() {
194 if out.len() >= cap {
195 // Still had work to do but hit the cap — the closure is
196 // incomplete.
197 truncated = true;
198 break;
199 }
200 if !out.insert(h) {
201 continue;
202 }
203 // Classify the node via the cheap 6-byte prologue check
204 // (`object_type`) instead of a full read+verify+decode
205 // (`read_object`) — INV-14. Missing objects bubble up here
206 // exactly as `read_object` would: a reachable-set walk for a
207 // push must refuse to build a known-broken pack.
208 let ty = store.object_type(&h)?;
209 if matches!(ty, ObjectType::Blob | ObjectType::Delta) {
210 // Leaves — nothing to walk, so there is nothing more to learn
211 // about this node than its type. Skip the full read entirely:
212 // its content (and therefore its BLAKE3 integrity) is never
213 // consulted by reachability. Whoever actually reads this
214 // object's bytes later (pack build, checkout, ...) still gets
215 // the full verified read via `store.read`/`read_object`.
216 continue;
217 }
218
219 // Every other kind needs its decoded body to find its children,
220 // so this still pays for a full read+verify+decode.
221 let obj = store.read_object(&h)?;
222 match obj {
223 Object::Commit(c) => {
224 queue.push_back(c.tree_hash);
225 for p in c.parents {
226 queue.push_back(p);
227 }
228 }
229 Object::Remix(r) => {
230 queue.push_back(r.tree_hash);
231 for p in r.parents {
232 queue.push_back(p);
233 }
234 // Remix `sources` are foreign-repo pointers (SPEC-OBJECTS
235 // §6) — by definition NOT in our store, so don't queue them.
236 }
237 Object::Tree(t) => {
238 for e in t.entries {
239 queue.push_back(e.object_hash);
240 }
241 }
242 Object::ChunkedBlob(cb) => {
243 for c in cb.chunks {
244 queue.push_back(c);
245 }
246 }
247 Object::Tag(t) => {
248 // An annotated/signed tag points at one target object
249 // (SPEC-OBJECTS §6a). Walk it so a tag is a valid pack
250 // root.
251 queue.push_back(t.target);
252 }
253 Object::Blob(_) | Object::Delta(_) => {
254 // Unreachable: `object_type` already filtered these out
255 // above. Kept so the match stays exhaustive if a new
256 // object kind is ever added.
257 unreachable!("blob/delta leaves are short-circuited before read_object")
258 }
259 }
260 }
261 Ok((out, truncated))
262}
263
264// =====================================================================
265// Tests
266// =====================================================================
267
268#[cfg(test)]
269#[allow(clippy::many_single_char_names)] // single-letter commit names keep the test tables compact
270mod tests {
271 use super::*;
272 use crate::hash;
273 use crate::object::EntryMode;
274 use crate::object::{Blob, Commit, Identity, Object, Tree, TreeEntry};
275 use crate::serialize;
276 use tempfile::TempDir;
277
278 fn store() -> (TempDir, ObjectStore) {
279 let d = TempDir::new().unwrap();
280 let s = ObjectStore::init(&crate::layout::RepoLayout::single(d.path())).unwrap();
281 (d, s)
282 }
283
284 fn put_blob(s: &ObjectStore, data: &[u8]) -> Hash {
285 let bytes = serialize::serialize(&Object::Blob(Blob {
286 data: data.to_vec(),
287 }))
288 .unwrap();
289 s.write(&bytes).unwrap()
290 }
291
292 fn put_tree(s: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
293 let bytes = serialize::serialize(&Object::Tree(Tree { entries })).unwrap();
294 s.write(&bytes).unwrap()
295 }
296
297 fn make_single_file_tree(s: &ObjectStore, name: &[u8], data: &[u8]) -> Hash {
298 let blob = put_blob(s, data);
299 put_tree(
300 s,
301 vec![TreeEntry {
302 name: name.to_vec(),
303 mode: EntryMode::Blob,
304 object_hash: blob,
305 }],
306 )
307 }
308
309 fn make_commit(s: &ObjectStore, tree: Hash, parents: &[Hash], message: &str) -> Hash {
310 let c = Commit {
311 tree_hash: tree,
312 parents: parents.to_vec(),
313 author: Identity::ed25519([0; 32]),
314 signer: [0; 32],
315 message: message.as_bytes().to_vec(),
316 timestamp: message.len() as u64, // tiny per-commit divergence avoids store dedup
317 message_hash: [0; 32],
318 content_digest: [0; 32],
319 signature: [0; 64],
320 };
321 let bytes = serialize::serialize(&Object::Commit(c)).unwrap();
322 s.write(&bytes).unwrap()
323 }
324
325 #[test]
326 fn linear_chain_3_commits() {
327 let (_d, s) = store();
328 let tree = make_single_file_tree(&s, b"f", b"data");
329 let c1 = make_commit(&s, tree, &[], "c1");
330 let c2 = make_commit(&s, tree, &[c1], "c2");
331 let c3 = make_commit(&s, tree, &[c2], "c3");
332
333 let mut set = HashSet::new();
334 collect_ancestor_set(&s, c3, &mut set).unwrap();
335 assert_eq!(set.len(), 3);
336 assert!(set.contains(&c1));
337 assert!(set.contains(&c2));
338 assert!(set.contains(&c3));
339 }
340
341 #[test]
342 fn diamond_dag() {
343 let (_d, s) = store();
344 let tree = make_single_file_tree(&s, b"f.txt", b"data");
345 let c1 = make_commit(&s, tree, &[], "c1");
346 let c2 = make_commit(&s, tree, &[c1], "c2");
347 let c3 = make_commit(&s, tree, &[c1], "c3");
348 let c4 = make_commit(&s, tree, &[c2, c3], "c4");
349
350 let mut set = HashSet::new();
351 collect_ancestor_set(&s, c4, &mut set).unwrap();
352 assert_eq!(set.len(), 4);
353 assert!(set.contains(&c1));
354 assert!(set.contains(&c2));
355 assert!(set.contains(&c3));
356 assert!(set.contains(&c4));
357 }
358
359 #[test]
360 fn root_commit_alone() {
361 let (_d, s) = store();
362 let tree = make_single_file_tree(&s, b"f.txt", b"data");
363 let c1 = make_commit(&s, tree, &[], "root");
364
365 let mut set = HashSet::new();
366 collect_ancestor_set(&s, c1, &mut set).unwrap();
367 assert_eq!(set.len(), 1);
368 assert!(set.contains(&c1));
369 }
370
371 #[test]
372 fn handles_non_existent_parent_gracefully() {
373 let (_d, s) = store();
374 let tree = make_single_file_tree(&s, b"f.txt", b"data");
375 let fake_parent = hash::hash(b"nonexistent-parent");
376 let c1 = make_commit(&s, tree, &[fake_parent], "orphan");
377
378 let mut set = HashSet::new();
379 collect_ancestor_set(&s, c1, &mut set).unwrap();
380 // Both c1 and the fake hash end up in the set; the fake hash
381 // doesn't continue the walk because the object lookup fails.
382 assert_eq!(set.len(), 2);
383 assert!(set.contains(&c1));
384 assert!(set.contains(&fake_parent));
385 }
386
387 #[test]
388 fn empty_store_records_starting_hash() {
389 let (_d, s) = store();
390 let fake = hash::hash(b"does-not-exist");
391 let mut set = HashSet::new();
392 collect_ancestor_set(&s, fake, &mut set).unwrap();
393 assert_eq!(set.len(), 1);
394 assert!(set.contains(&fake));
395 }
396
397 // =================================================================
398 // reachable_objects — full closure from a commit.
399 // =================================================================
400
401 #[test]
402 fn reachable_single_commit_includes_tree_and_blob() {
403 let (_d, s) = store();
404 let blob = put_blob(&s, b"hi");
405 let tree = put_tree(
406 &s,
407 vec![TreeEntry {
408 name: b"f".to_vec(),
409 mode: EntryMode::Blob,
410 object_hash: blob,
411 }],
412 );
413 let c1 = make_commit(&s, tree, &[], "c1");
414 let reach = reachable_objects(&s, &c1).unwrap();
415 assert!(reach.contains(&c1));
416 assert!(reach.contains(&tree));
417 assert!(reach.contains(&blob));
418 assert_eq!(reach.len(), 3);
419 }
420
421 #[test]
422 fn reachable_walks_parents_and_dedups() {
423 let (_d, s) = store();
424 let t = make_single_file_tree(&s, b"f", b"x");
425 let c1 = make_commit(&s, t, &[], "c1");
426 let c2 = make_commit(&s, t, &[c1], "c2");
427 let reach = reachable_objects(&s, &c2).unwrap();
428 // c1, c2, one shared tree, one shared blob
429 assert_eq!(reach.len(), 4);
430 assert!(reach.contains(&c1));
431 assert!(reach.contains(&c2));
432 assert!(reach.contains(&t));
433 }
434
435 #[test]
436 fn reachable_walks_nested_trees() {
437 let (_d, s) = store();
438 let leaf = put_blob(&s, b"leaf");
439 let inner = put_tree(
440 &s,
441 vec![TreeEntry {
442 name: b"leaf".to_vec(),
443 mode: EntryMode::Blob,
444 object_hash: leaf,
445 }],
446 );
447 let outer = put_tree(
448 &s,
449 vec![TreeEntry {
450 name: b"sub".to_vec(),
451 mode: EntryMode::Tree,
452 object_hash: inner,
453 }],
454 );
455 let c1 = make_commit(&s, outer, &[], "c");
456 let reach = reachable_objects(&s, &c1).unwrap();
457 assert!(reach.contains(&outer));
458 assert!(reach.contains(&inner));
459 assert!(reach.contains(&leaf));
460 }
461
462 #[test]
463 fn reachable_walks_chunked_blob_chunks() {
464 use crate::object::ChunkedBlob;
465 let (_d, s) = store();
466 let c0 = put_blob(&s, b"A");
467 let c1 = put_blob(&s, b"B");
468 let cb_bytes = serialize::serialize(&Object::ChunkedBlob(ChunkedBlob {
469 total_size: 2,
470 chunk_size: 1,
471 chunks: vec![c0, c1],
472 }))
473 .unwrap();
474 let cb = s.write(&cb_bytes).unwrap();
475 let tree = put_tree(
476 &s,
477 vec![TreeEntry {
478 name: b"big".to_vec(),
479 mode: EntryMode::Blob,
480 object_hash: cb,
481 }],
482 );
483 let commit = make_commit(&s, tree, &[], "c");
484 let reach = reachable_objects(&s, &commit).unwrap();
485 assert!(reach.contains(&cb));
486 assert!(reach.contains(&c0));
487 assert!(reach.contains(&c1));
488 }
489
490 #[test]
491 fn reachable_missing_root_errors() {
492 let (_d, s) = store();
493 let fake = hash::hash(b"nope");
494 let err = reachable_objects(&s, &fake).unwrap_err();
495 assert!(matches!(err, StoreError::ObjectNotFound(_)));
496 }
497
498 // =================================================================
499 // object_type() short-circuit (#636 / INV-14) — the walk must
500 // classify blob/delta leaves via the cheap type-prologue check
501 // instead of a full read+verify+decode, without changing the
502 // reachable/unreachable classification of any object (INV-3).
503 // =================================================================
504
505 /// Flip a byte well past the 6-byte type prologue (offset 0..6, which
506 /// `object_type()` reads) so the object's on-disk *content* no longer
507 /// matches its BLAKE3 hash, while its type tag stays intact.
508 fn corrupt_payload_byte(s: &ObjectStore, h: &Hash) {
509 use std::fs::OpenOptions;
510 use std::io::{Read, Seek, SeekFrom, Write};
511 let path = s.path_for(h);
512 let mut f = OpenOptions::new()
513 .read(true)
514 .write(true)
515 .open(&path)
516 .unwrap();
517 f.seek(SeekFrom::Start(6)).unwrap();
518 let mut byte = [0u8; 1];
519 f.read_exact(&mut byte).unwrap();
520 f.seek(SeekFrom::Start(6)).unwrap();
521 f.write_all(&[byte[0] ^ 0xFF]).unwrap();
522 f.sync_all().unwrap();
523 }
524
525 #[test]
526 fn reachable_closure_matches_expected_set_for_mixed_graph() {
527 // Correctness proxy for "byte-identical to the old full-read path":
528 // a hand-computed expected set over a graph that exercises every
529 // node kind the walk handles (commit chain, tree, nested tree,
530 // chunked-blob manifest, plain blobs).
531 let (_d, s) = store();
532 let leaf1 = put_blob(&s, b"leaf-one");
533 let inner = put_tree(
534 &s,
535 vec![TreeEntry {
536 name: b"leaf".to_vec(),
537 mode: EntryMode::Blob,
538 object_hash: leaf1,
539 }],
540 );
541 let chunk_a = put_blob(&s, b"chunk-a");
542 let chunk_b = put_blob(&s, b"chunk-b");
543 let cb_bytes = serialize::serialize(&Object::ChunkedBlob(crate::object::ChunkedBlob {
544 total_size: 14,
545 chunk_size: 7,
546 chunks: vec![chunk_a, chunk_b],
547 }))
548 .unwrap();
549 let cb = s.write(&cb_bytes).unwrap();
550 let outer = put_tree(
551 &s,
552 vec![
553 TreeEntry {
554 name: b"big".to_vec(),
555 mode: EntryMode::Blob,
556 object_hash: cb,
557 },
558 TreeEntry {
559 name: b"sub".to_vec(),
560 mode: EntryMode::Tree,
561 object_hash: inner,
562 },
563 ],
564 );
565 let c1 = make_commit(&s, outer, &[], "c1");
566 let c2 = make_commit(&s, outer, &[c1], "c2");
567
568 let reach = reachable_objects(&s, &c2).unwrap();
569 let expected: BTreeSet<Hash> = [c1, c2, outer, inner, leaf1, cb, chunk_a, chunk_b]
570 .into_iter()
571 .collect();
572 assert_eq!(reach, expected);
573 }
574
575 #[test]
576 fn reachable_closure_short_circuits_corrupted_blob_leaf() {
577 let (_d, s) = store();
578 let blob = put_blob(&s, &vec![0xABu8; 4096]);
579 let tree = put_tree(
580 &s,
581 vec![TreeEntry {
582 name: b"f".to_vec(),
583 mode: EntryMode::Blob,
584 object_hash: blob,
585 }],
586 );
587 let c1 = make_commit(&s, tree, &[], "c1");
588
589 corrupt_payload_byte(&s, &blob);
590
591 // Sanity: a full read+verify of this object now fails — proves the
592 // corruption is real and would have broken the old `read_object`
593 // path had it still been called for this leaf.
594 assert!(matches!(
595 s.read_object(&blob),
596 Err(StoreError::HashMismatch { .. })
597 ));
598
599 // The walk must still succeed, and the blob must still be
600 // classified reachable: reachability only needs to know this node
601 // has no children, and the intact type prologue already answers
602 // that. Content integrity is a concern for whoever reads the bytes
603 // later, not for this classification.
604 let (reach, truncated) = reachable_closure_checked(&s, [c1].iter()).unwrap();
605 assert!(!truncated);
606 assert!(reach.contains(&c1));
607 assert!(reach.contains(&tree));
608 assert!(reach.contains(&blob));
609 assert_eq!(reach.len(), 3);
610 }
611
612 #[test]
613 fn reachable_closure_still_fails_closed_on_corrupted_tree() {
614 // A non-leaf node (tree) still needs its decoded body to find its
615 // children, so corruption there must still surface as an error —
616 // proving the short-circuit is targeted at true leaves (blob/
617 // delta) only, not a blanket weakening of verification.
618 let (_d, s) = store();
619 let blob = put_blob(&s, b"hi");
620 let tree = put_tree(
621 &s,
622 vec![TreeEntry {
623 name: b"f".to_vec(),
624 mode: EntryMode::Blob,
625 object_hash: blob,
626 }],
627 );
628 let c1 = make_commit(&s, tree, &[], "c1");
629
630 corrupt_payload_byte(&s, &tree);
631
632 let err = reachable_closure_checked(&s, [c1].iter()).unwrap_err();
633 assert!(matches!(err, StoreError::HashMismatch { .. }));
634 }
635}