use std::collections::{BTreeSet, HashSet, VecDeque};
use std::hash::BuildHasher;
use crate::hash::Hash;
use crate::object::{Object, ObjectType};
use crate::store::{ObjectStore, StoreError};
pub const MAX_ANCESTORS: usize = 10_000;
pub fn collect_ancestor_set<S: BuildHasher>(
store: &ObjectStore,
start: Hash,
set: &mut HashSet<Hash, S>,
) -> Result<(), StoreError> {
let mut stack: Vec<Hash> = Vec::new();
stack.push(start);
let mut count: usize = 0;
while let Some(current) = stack.pop() {
if count >= MAX_ANCESTORS {
break;
}
if !set.insert(current) {
continue;
}
count += 1;
match store.read_object(¤t) {
Ok(Object::Commit(c)) => {
for &parent in &c.parents {
stack.push(parent);
}
}
Ok(_) | Err(StoreError::ObjectNotFound(_)) => {}
Err(e) => return Err(e),
}
}
Ok(())
}
pub const MAX_REACHABLE: usize = 10_000_000;
pub fn reachable_objects(store: &ObjectStore, root: &Hash) -> Result<BTreeSet<Hash>, StoreError> {
reachable_closure(store, std::iter::once(root))
}
pub fn reachable_closure<'a, I>(store: &ObjectStore, roots: I) -> Result<BTreeSet<Hash>, StoreError>
where
I: IntoIterator<Item = &'a Hash>,
{
reachable_closure_checked(store, roots).map(|(out, _truncated)| out)
}
pub fn reachable_closure_checked<'a, I>(
store: &ObjectStore,
roots: I,
) -> Result<(BTreeSet<Hash>, bool), StoreError>
where
I: IntoIterator<Item = &'a Hash>,
{
reachable_closure_checked_with_cap(store, roots, MAX_REACHABLE)
}
pub(crate) fn reachable_closure_checked_with_cap<'a, I>(
store: &ObjectStore,
roots: I,
cap: usize,
) -> Result<(BTreeSet<Hash>, bool), StoreError>
where
I: IntoIterator<Item = &'a Hash>,
{
let mut out: BTreeSet<Hash> = BTreeSet::new();
let mut queue: VecDeque<Hash> = VecDeque::new();
for root in roots {
queue.push_back(*root);
}
let mut truncated = false;
while let Some(h) = queue.pop_front() {
if out.len() >= cap {
truncated = true;
break;
}
if !out.insert(h) {
continue;
}
let ty = store.object_type(&h)?;
if matches!(ty, ObjectType::Blob | ObjectType::Delta) {
continue;
}
let obj = store.read_object(&h)?;
match obj {
Object::Commit(c) => {
queue.push_back(c.tree_hash);
for p in c.parents {
queue.push_back(p);
}
}
Object::Remix(r) => {
queue.push_back(r.tree_hash);
for p in r.parents {
queue.push_back(p);
}
}
Object::Tree(t) => {
for e in t.entries {
queue.push_back(e.object_hash);
}
}
Object::ChunkedBlob(cb) => {
for c in cb.chunks {
queue.push_back(c);
}
}
Object::Tag(t) => {
queue.push_back(t.target);
}
Object::Blob(_) | Object::Delta(_) => {
unreachable!("blob/delta leaves are short-circuited before read_object")
}
}
}
Ok((out, truncated))
}
#[cfg(test)]
#[allow(clippy::many_single_char_names)] mod tests {
use super::*;
use crate::hash;
use crate::object::EntryMode;
use crate::object::{Blob, Commit, Identity, Object, Tree, TreeEntry};
use crate::serialize;
use tempfile::TempDir;
fn store() -> (TempDir, ObjectStore) {
let d = TempDir::new().unwrap();
let s = ObjectStore::init(&crate::layout::RepoLayout::single(d.path())).unwrap();
(d, s)
}
fn put_blob(s: &ObjectStore, data: &[u8]) -> Hash {
let bytes = serialize::serialize(&Object::Blob(Blob {
data: data.to_vec(),
}))
.unwrap();
s.write(&bytes).unwrap()
}
fn put_tree(s: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
let bytes = serialize::serialize(&Object::Tree(Tree { entries })).unwrap();
s.write(&bytes).unwrap()
}
fn make_single_file_tree(s: &ObjectStore, name: &[u8], data: &[u8]) -> Hash {
let blob = put_blob(s, data);
put_tree(
s,
vec![TreeEntry {
name: name.to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
)
}
fn make_commit(s: &ObjectStore, tree: Hash, parents: &[Hash], message: &str) -> Hash {
let c = Commit {
tree_hash: tree,
parents: parents.to_vec(),
author: Identity::ed25519([0; 32]),
signer: [0; 32],
message: message.as_bytes().to_vec(),
timestamp: message.len() as u64, message_hash: [0; 32],
content_digest: [0; 32],
signature: [0; 64],
};
let bytes = serialize::serialize(&Object::Commit(c)).unwrap();
s.write(&bytes).unwrap()
}
#[test]
fn linear_chain_3_commits() {
let (_d, s) = store();
let tree = make_single_file_tree(&s, b"f", b"data");
let c1 = make_commit(&s, tree, &[], "c1");
let c2 = make_commit(&s, tree, &[c1], "c2");
let c3 = make_commit(&s, tree, &[c2], "c3");
let mut set = HashSet::new();
collect_ancestor_set(&s, c3, &mut set).unwrap();
assert_eq!(set.len(), 3);
assert!(set.contains(&c1));
assert!(set.contains(&c2));
assert!(set.contains(&c3));
}
#[test]
fn diamond_dag() {
let (_d, s) = store();
let tree = make_single_file_tree(&s, b"f.txt", b"data");
let c1 = make_commit(&s, tree, &[], "c1");
let c2 = make_commit(&s, tree, &[c1], "c2");
let c3 = make_commit(&s, tree, &[c1], "c3");
let c4 = make_commit(&s, tree, &[c2, c3], "c4");
let mut set = HashSet::new();
collect_ancestor_set(&s, c4, &mut set).unwrap();
assert_eq!(set.len(), 4);
assert!(set.contains(&c1));
assert!(set.contains(&c2));
assert!(set.contains(&c3));
assert!(set.contains(&c4));
}
#[test]
fn root_commit_alone() {
let (_d, s) = store();
let tree = make_single_file_tree(&s, b"f.txt", b"data");
let c1 = make_commit(&s, tree, &[], "root");
let mut set = HashSet::new();
collect_ancestor_set(&s, c1, &mut set).unwrap();
assert_eq!(set.len(), 1);
assert!(set.contains(&c1));
}
#[test]
fn handles_non_existent_parent_gracefully() {
let (_d, s) = store();
let tree = make_single_file_tree(&s, b"f.txt", b"data");
let fake_parent = hash::hash(b"nonexistent-parent");
let c1 = make_commit(&s, tree, &[fake_parent], "orphan");
let mut set = HashSet::new();
collect_ancestor_set(&s, c1, &mut set).unwrap();
assert_eq!(set.len(), 2);
assert!(set.contains(&c1));
assert!(set.contains(&fake_parent));
}
#[test]
fn empty_store_records_starting_hash() {
let (_d, s) = store();
let fake = hash::hash(b"does-not-exist");
let mut set = HashSet::new();
collect_ancestor_set(&s, fake, &mut set).unwrap();
assert_eq!(set.len(), 1);
assert!(set.contains(&fake));
}
#[test]
fn reachable_single_commit_includes_tree_and_blob() {
let (_d, s) = store();
let blob = put_blob(&s, b"hi");
let tree = put_tree(
&s,
vec![TreeEntry {
name: b"f".to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
);
let c1 = make_commit(&s, tree, &[], "c1");
let reach = reachable_objects(&s, &c1).unwrap();
assert!(reach.contains(&c1));
assert!(reach.contains(&tree));
assert!(reach.contains(&blob));
assert_eq!(reach.len(), 3);
}
#[test]
fn reachable_walks_parents_and_dedups() {
let (_d, s) = store();
let t = make_single_file_tree(&s, b"f", b"x");
let c1 = make_commit(&s, t, &[], "c1");
let c2 = make_commit(&s, t, &[c1], "c2");
let reach = reachable_objects(&s, &c2).unwrap();
assert_eq!(reach.len(), 4);
assert!(reach.contains(&c1));
assert!(reach.contains(&c2));
assert!(reach.contains(&t));
}
#[test]
fn reachable_walks_nested_trees() {
let (_d, s) = store();
let leaf = put_blob(&s, b"leaf");
let inner = put_tree(
&s,
vec![TreeEntry {
name: b"leaf".to_vec(),
mode: EntryMode::Blob,
object_hash: leaf,
}],
);
let outer = put_tree(
&s,
vec![TreeEntry {
name: b"sub".to_vec(),
mode: EntryMode::Tree,
object_hash: inner,
}],
);
let c1 = make_commit(&s, outer, &[], "c");
let reach = reachable_objects(&s, &c1).unwrap();
assert!(reach.contains(&outer));
assert!(reach.contains(&inner));
assert!(reach.contains(&leaf));
}
#[test]
fn reachable_walks_chunked_blob_chunks() {
use crate::object::ChunkedBlob;
let (_d, s) = store();
let c0 = put_blob(&s, b"A");
let c1 = put_blob(&s, b"B");
let cb_bytes = serialize::serialize(&Object::ChunkedBlob(ChunkedBlob {
total_size: 2,
chunk_size: 1,
chunks: vec![c0, c1],
}))
.unwrap();
let cb = s.write(&cb_bytes).unwrap();
let tree = put_tree(
&s,
vec![TreeEntry {
name: b"big".to_vec(),
mode: EntryMode::Blob,
object_hash: cb,
}],
);
let commit = make_commit(&s, tree, &[], "c");
let reach = reachable_objects(&s, &commit).unwrap();
assert!(reach.contains(&cb));
assert!(reach.contains(&c0));
assert!(reach.contains(&c1));
}
#[test]
fn reachable_missing_root_errors() {
let (_d, s) = store();
let fake = hash::hash(b"nope");
let err = reachable_objects(&s, &fake).unwrap_err();
assert!(matches!(err, StoreError::ObjectNotFound(_)));
}
fn corrupt_payload_byte(s: &ObjectStore, h: &Hash) {
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
let path = s.path_for(h);
let mut f = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
f.seek(SeekFrom::Start(6)).unwrap();
let mut byte = [0u8; 1];
f.read_exact(&mut byte).unwrap();
f.seek(SeekFrom::Start(6)).unwrap();
f.write_all(&[byte[0] ^ 0xFF]).unwrap();
f.sync_all().unwrap();
}
#[test]
fn reachable_closure_matches_expected_set_for_mixed_graph() {
let (_d, s) = store();
let leaf1 = put_blob(&s, b"leaf-one");
let inner = put_tree(
&s,
vec![TreeEntry {
name: b"leaf".to_vec(),
mode: EntryMode::Blob,
object_hash: leaf1,
}],
);
let chunk_a = put_blob(&s, b"chunk-a");
let chunk_b = put_blob(&s, b"chunk-b");
let cb_bytes = serialize::serialize(&Object::ChunkedBlob(crate::object::ChunkedBlob {
total_size: 14,
chunk_size: 7,
chunks: vec![chunk_a, chunk_b],
}))
.unwrap();
let cb = s.write(&cb_bytes).unwrap();
let outer = put_tree(
&s,
vec![
TreeEntry {
name: b"big".to_vec(),
mode: EntryMode::Blob,
object_hash: cb,
},
TreeEntry {
name: b"sub".to_vec(),
mode: EntryMode::Tree,
object_hash: inner,
},
],
);
let c1 = make_commit(&s, outer, &[], "c1");
let c2 = make_commit(&s, outer, &[c1], "c2");
let reach = reachable_objects(&s, &c2).unwrap();
let expected: BTreeSet<Hash> = [c1, c2, outer, inner, leaf1, cb, chunk_a, chunk_b]
.into_iter()
.collect();
assert_eq!(reach, expected);
}
#[test]
fn reachable_closure_short_circuits_corrupted_blob_leaf() {
let (_d, s) = store();
let blob = put_blob(&s, &vec![0xABu8; 4096]);
let tree = put_tree(
&s,
vec![TreeEntry {
name: b"f".to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
);
let c1 = make_commit(&s, tree, &[], "c1");
corrupt_payload_byte(&s, &blob);
assert!(matches!(
s.read_object(&blob),
Err(StoreError::HashMismatch { .. })
));
let (reach, truncated) = reachable_closure_checked(&s, [c1].iter()).unwrap();
assert!(!truncated);
assert!(reach.contains(&c1));
assert!(reach.contains(&tree));
assert!(reach.contains(&blob));
assert_eq!(reach.len(), 3);
}
#[test]
fn reachable_closure_still_fails_closed_on_corrupted_tree() {
let (_d, s) = store();
let blob = put_blob(&s, b"hi");
let tree = put_tree(
&s,
vec![TreeEntry {
name: b"f".to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
);
let c1 = make_commit(&s, tree, &[], "c1");
corrupt_payload_byte(&s, &tree);
let err = reachable_closure_checked(&s, [c1].iter()).unwrap_err();
assert!(matches!(err, StoreError::HashMismatch { .. }));
}
}