use crypto::{Ed25519Signer, StateSigningExt};
use objects::{
object::{
Attribution, Blob, FileProvenance, LineSpan, Origin, OriginSet, Principal, State, Tree,
TreeEntry,
},
store::ObjectStore,
};
use repo::Repository;
use sley::ObjectId as GitObjectId;
use tempfile::TempDir;
use super::{
objects::check_tree_objects,
state::check_states,
};
fn setup_repo() -> (TempDir, Repository) {
let temp = TempDir::new().expect("create temp dir");
let repo = Repository::init_default(temp.path()).expect("init repo");
(temp, repo)
}
fn sample_attribution() -> Attribution {
Attribution::human(Principal::new("Test User", "test@example.com"))
}
fn put_empty_tree(repo: &Repository) -> objects::error::Result<objects::object::ContentHash> {
repo.store().put_tree(&Tree::new())
}
fn sample_origin(state_id: objects::object::ChangeId) -> Origin {
Origin {
state_id,
attribution: sample_attribution(),
created_at: chrono::Utc::now(),
authored_at: None,
}
}
#[test]
fn test_check_states_thorough_rejects_parent_cycles() {
let (_temp, repo) = setup_repo();
let tree_hash = put_empty_tree(&repo).expect("put tree");
let state_a_id = objects::object::ChangeId::generate();
let state_b_id = objects::object::ChangeId::generate();
let state_a =
State::new(tree_hash, vec![state_b_id], sample_attribution()).with_change_id(state_a_id);
let state_b =
State::new(tree_hash, vec![state_a_id], sample_attribution()).with_change_id(state_b_id);
repo.store().put_state(&state_a).expect("put state a");
repo.store().put_state(&state_b).expect("put state b");
let mut errors = Vec::new();
let mut objects_checked = 0;
check_states(&repo, &mut errors, &mut objects_checked, true).expect("check states");
assert!(
errors.iter().any(|error| error.kind == "state_cycle"),
"expected cycle error, got {:?}",
errors.iter().map(|error| &error.kind).collect::<Vec<_>>()
);
}
#[test]
fn test_check_states_thorough_rejects_invalid_signature() {
let (_temp, repo) = setup_repo();
let tree_hash = put_empty_tree(&repo).expect("put tree");
let mut state = State::new(tree_hash, vec![], sample_attribution());
let signer = Ed25519Signer::from_seed(&[7u8; 32]).expect("create signer");
state.sign(&signer).expect("sign state");
state.signature.as_mut().expect("signature").signature = "00".repeat(64);
repo.store().put_state(&state).expect("put state");
let mut errors = Vec::new();
let mut objects_checked = 0;
check_states(&repo, &mut errors, &mut objects_checked, true).expect("check states");
assert!(
errors.iter().any(|error| error.kind == "invalid_signature"),
"expected invalid signature error, got {:?}",
errors.iter().map(|error| &error.kind).collect::<Vec<_>>()
);
}
#[test]
fn test_check_states_thorough_rejects_invalid_provenance() {
let (_temp, repo) = setup_repo();
let blob = Blob::from("hello\nworld\n");
let blob_hash = repo.store().put_blob(&blob).expect("put blob");
let tree = Tree::from_entries(vec![
objects::object::TreeEntry::file("file.txt", blob_hash, false).unwrap(),
]);
let tree_hash = repo.store().put_tree(&tree).expect("put tree");
let bad_provenance = FileProvenance::new(
objects::object::ContentHash::compute(b"wrong"),
1,
vec![LineSpan {
start_line: 0,
line_len: 1,
origin_set_index: 0,
}],
vec![sample_origin(objects::object::ChangeId::generate())],
vec![OriginSet {
origin_indexes: vec![0],
}],
);
let provenance_blob = Blob::new(rmp_serde::to_vec(&bad_provenance).unwrap());
let provenance_blob_hash = repo
.store()
.put_blob(&provenance_blob)
.expect("put provenance blob");
let provenance_tree = Tree::from_entries(vec![
objects::object::TreeEntry::file("file.txt", provenance_blob_hash, false).unwrap(),
]);
let provenance_root = repo
.store()
.put_tree(&provenance_tree)
.expect("put provenance tree");
let state =
State::new(tree_hash, vec![], sample_attribution()).with_provenance(provenance_root);
repo.store().put_state(&state).expect("put state");
let mut errors = Vec::new();
let mut objects_checked = 0;
check_states(&repo, &mut errors, &mut objects_checked, true).expect("check states");
assert!(
errors
.iter()
.any(|error| error.kind == "invalid_provenance"),
"expected invalid provenance error, got {:?}",
errors.iter().map(|error| &error.kind).collect::<Vec<_>>()
);
}
#[test]
fn test_fsck_treats_explicitly_missing_partial_fetch_blob_as_warning() {
let (_temp, repo) = setup_repo();
let blob = Blob::from("partial fetch blob\n");
let blob_hash = blob.hash();
let tree = Tree::from_entries(vec![
objects::object::TreeEntry::file("README.md", blob_hash, false).unwrap(),
]);
let tree_hash = repo.store().put_tree(&tree).expect("put tree");
let state = State::new(tree_hash, vec![], sample_attribution());
repo.store().put_state(&state).expect("put state");
repo.record_missing_blob(blob_hash)
.expect("record missing blob");
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut objects_checked = 0;
check_tree_objects(&repo, &mut errors, &mut warnings, &mut objects_checked)
.expect("check tree objects");
assert!(
errors.iter().all(|error| error.kind != "missing_blob"),
"explicitly missing partial-fetch blob should not be treated as corruption: {:?}",
errors.iter().map(|error| &error.kind).collect::<Vec<_>>()
);
assert!(
warnings
.iter()
.any(|warning| warning.contains("explicitly absent under partial fetch")),
"expected partial-fetch warning, got {warnings:?}"
);
}
#[test]
fn test_fsck_does_not_require_gitlink_target_object() {
let (_temp, repo) = setup_repo();
let target: GitObjectId = "0303030303030303030303030303030303030303"
.parse()
.expect("git oid");
let tree = Tree::from_entries(vec![
TreeEntry::gitlink("vendor", target).expect("gitlink entry"),
]);
let tree_hash = repo.store().put_tree(&tree).expect("put tree");
let state = State::new(tree_hash, vec![], sample_attribution());
repo.store().put_state(&state).expect("put state");
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut objects_checked = 0;
check_tree_objects(&repo, &mut errors, &mut warnings, &mut objects_checked)
.expect("check tree objects");
assert!(
errors.is_empty(),
"gitlink target lives outside the Heddle object store: {errors:?}"
);
assert!(warnings.is_empty(), "warnings={warnings:?}");
}
#[test]
fn test_tree_blob_checks_characterization() {
use objects::object::ContentHash;
let (_temp, repo) = setup_repo();
let missing_hash = ContentHash::compute(b"ghost-blob");
let partial_blob = Blob::from("partial-fetch\n");
let partial_hash = partial_blob.hash();
let shared_blob = Blob::from("shared-leaf\n");
let shared_blob_hash = repo.store().put_blob(&shared_blob).expect("put shared blob");
let shared_tree = Tree::from_entries(vec![
objects::object::TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
]);
let shared_tree_hash = repo.store().put_tree(&shared_tree).expect("put shared tree");
let dangling_tree_hash = ContentHash::compute(b"missing-subtree");
let dangling_parent = Tree::from_entries(vec![
objects::object::TreeEntry::directory("missing", dangling_tree_hash).unwrap(),
objects::object::TreeEntry::file("absent.txt", missing_hash, false).unwrap(),
objects::object::TreeEntry::file("partial.txt", partial_hash, false).unwrap(),
]);
let dangling_parent_hash = repo
.store()
.put_tree(&dangling_parent)
.expect("put dangling parent");
let state_shared_a = State::new(shared_tree_hash, vec![], sample_attribution());
let state_shared_b = State::new(shared_tree_hash, vec![], sample_attribution());
let state_dangling = State::new(dangling_parent_hash, vec![], sample_attribution());
repo.store().put_state(&state_shared_a).expect("put state a");
repo.store().put_state(&state_shared_b).expect("put state b");
repo.store().put_state(&state_dangling).expect("put state dangling");
repo.record_missing_blob(partial_hash)
.expect("record partial-fetch blob");
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut objects_checked = 0;
check_tree_objects(&repo, &mut errors, &mut warnings, &mut objects_checked)
.expect("check tree objects");
assert_eq!(
objects_checked, 6,
"three unique trees plus three unique blob checks"
);
let error_kinds: Vec<_> = errors.iter().map(|error| error.kind.as_str()).collect();
assert_eq!(
error_kinds,
vec!["missing_blob", "missing_blob"],
"tree-phase then blob-phase ordering must be preserved"
);
assert_eq!(
errors[0].message,
"Tree entry 'absent.txt' references missing blob"
);
assert_eq!(errors[1].message, "Tree references missing blob");
assert_eq!(errors[0].object, errors[1].object, "same missing blob hash");
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].contains("partial.txt")
&& warnings[0].contains("explicitly absent under partial fetch"),
"partial-fetch warning: {}",
warnings[0]
);
}
#[test]
fn test_require_blob_clears_stale_partial_fetch_marker_when_blob_exists() {
let (_temp, repo) = setup_repo();
let blob = Blob::from("present after refetch\n");
let blob_hash = repo.store().put_blob(&blob).expect("put blob");
repo.record_missing_blob(blob_hash)
.expect("record missing blob");
let loaded = repo.require_blob(&blob_hash).expect("require blob");
assert_eq!(loaded.content(), blob.content());
assert!(!repo.is_missing_blob(&blob_hash).expect("check missing"));
assert!(repo.missing_blobs().expect("list missing").is_empty());
}