Skip to main content

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(&current) {
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}