use chrono::{TimeZone, Utc};
use heddle_format::compression::CompressionConfig;
use serde::Serialize;
use tempfile::TempDir;
use super::{
FsStore, LooseObjectWriteMode,
fs_paths::{blobs_dir, hash_path, packs_dir, trees_dir},
};
use crate::{
fs_atomic::temp_path,
object::{
Action, Attribution, Blob, ChangeId, ContentHash, Operation, Principal, State, Tree,
TreeEntry,
},
store::{
HeddleError, ObjectStore,
pack::{ObjectType as PackObjectType, PackBuilder, PackObjectId},
},
sync::RwLockExt,
};
fn create_test_store() -> (TempDir, FsStore) {
let temp_dir = TempDir::new().unwrap();
let heddle_dir = temp_dir.path().join(".heddle");
let store = FsStore::new(&heddle_dir);
store.init().unwrap();
(temp_dir, store)
}
#[test]
fn test_blob_roundtrip() {
let (_temp, store) = create_test_store();
let blob = Blob::from("hello world");
let hash = store.put_blob(&blob).unwrap();
let retrieved = store.get_blob(&hash).unwrap().unwrap();
assert_eq!(retrieved.content(), blob.content());
}
#[test]
fn test_default_loose_object_write_mode_is_durable_outside_snapshot_batch() {
let (_temp, store) = create_test_store();
assert_eq!(
store.loose_object_write_mode(),
LooseObjectWriteMode::Durable
);
let blob = Blob::from("durable default");
store.put_blob(&blob).unwrap();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn test_durable_loose_object_write_mode_does_not_queue_directory_syncs() {
let temp_dir = TempDir::new().unwrap();
let heddle_dir = temp_dir.path().join(".heddle");
let mut store = FsStore::new(&heddle_dir);
store.set_loose_object_write_mode(LooseObjectWriteMode::Durable);
store.init().unwrap();
let blob = Blob::from("durable sync");
store.put_blob(&blob).unwrap();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn test_snapshot_write_batch_defers_directory_sync_until_flush() {
let (_temp, store) = create_test_store();
store.begin_snapshot_write_batch().unwrap();
let blob = Blob::from("batched sync");
let hash = store.put_blob(&blob).unwrap();
assert_eq!(store.pending_directory_sync_count(), 1);
assert!(store.get_blob(&hash).unwrap().is_some());
store.flush_snapshot_write_batch().unwrap();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn test_abort_snapshot_write_batch_clears_pending_directory_syncs() {
let (_temp, store) = create_test_store();
store.begin_snapshot_write_batch().unwrap();
store.put_blob(&Blob::from("aborted batch")).unwrap();
assert_eq!(store.pending_directory_sync_count(), 1);
store.abort_snapshot_write_batch();
assert_eq!(store.pending_directory_sync_count(), 0);
}
#[test]
fn put_blobs_packed_writes_a_single_packfile_no_loose_blobs() {
use crate::store::pack::PackObjectId;
let (_temp, store) = create_test_store();
let blobs: Vec<(ContentHash, Vec<u8>)> = (0..50)
.map(|i| {
let blob = Blob::from(format!("packed blob {i}"));
(blob.hash(), blob.into_content())
})
.collect();
store.put_blobs_packed(blobs.clone()).unwrap();
let loose_count = std::fs::read_dir(blobs_dir(store.root()))
.map(|iter| iter.count())
.unwrap_or(0);
assert_eq!(loose_count, 0, "expected zero loose blob shards");
for (hash, _) in &blobs {
let id = PackObjectId::Hash(*hash);
assert!(
store.get_pack_object(&id).unwrap().is_some(),
"blob {hash:?} not visible after put_blobs_packed",
);
}
}
#[test]
fn put_blobs_packed_skips_blobs_already_present() {
let (_temp, store) = create_test_store();
let preexisting = Blob::from("already here");
let pre_hash = store.put_blob(&preexisting).unwrap();
let fresh = Blob::from("brand new");
let fresh_hash = fresh.hash();
store
.put_blobs_packed(vec![
(pre_hash, preexisting.into_content()),
(fresh_hash, fresh.into_content()),
])
.unwrap();
assert!(store.get_blob(&pre_hash).unwrap().is_some());
assert!(store.get_blob(&fresh_hash).unwrap().is_some());
}
#[test]
fn second_fs_store_sees_packs_installed_after_its_construction() {
let temp_dir = TempDir::new().unwrap();
let heddle_dir = temp_dir.path().join(".heddle");
let store_a = FsStore::new(&heddle_dir);
store_a.init().unwrap();
let store_b = FsStore::new(&heddle_dir);
let new_blob = Blob::from("worktree-installed content");
let new_hash = new_blob.hash();
assert!(!store_a.has_blob(&new_hash).unwrap());
assert!(!store_b.has_blob(&new_hash).unwrap());
store_b
.put_blobs_packed(vec![(new_hash, new_blob.clone().into_content())])
.unwrap();
assert!(store_b.has_blob(&new_hash).unwrap());
assert!(
store_a.has_blob(&new_hash).unwrap(),
"stale pack manager must recover via reload-on-miss",
);
let recovered = store_a.get_blob(&new_hash).unwrap();
assert_eq!(
recovered.as_ref().map(|b| b.content().to_vec()),
Some(new_blob.into_content())
);
}
#[test]
fn list_states_sees_packs_installed_after_store_construction() {
let temp_dir = TempDir::new().unwrap();
let heddle_dir = temp_dir.path().join(".heddle");
let store_a = FsStore::new(&heddle_dir);
store_a.init().unwrap();
let store_b = FsStore::new(&heddle_dir);
assert!(store_a.list_states().unwrap().is_empty());
let tree_hash = ContentHash::compute(b"packed tree");
let attribution = Attribution::human(Principal::new("Pack Test", "pack@example.com"));
let state = State::new(tree_hash, vec![], attribution).with_intent("packed state");
let mut builder = PackBuilder::new(CompressionConfig::disabled());
builder.add_id(
PackObjectId::ChangeId(state.change_id),
PackObjectType::State,
rmp_serde::to_vec_named(&state).unwrap(),
);
let (pack_data, index_data, _) = builder.build().unwrap();
store_b.install_pack(&pack_data, &index_data).unwrap();
assert_eq!(
store_a.list_states().unwrap(),
vec![state.change_id],
"stale pack manager must refresh before enumerating packed states"
);
}
#[test]
fn put_blobs_packed_with_empty_input_is_a_noop() {
let (_temp, store) = create_test_store();
store.put_blobs_packed(Vec::new()).unwrap();
let pack_dir = store.root().join("objects").join("packs");
let pack_count = std::fs::read_dir(&pack_dir)
.map(|iter| iter.count())
.unwrap_or(0);
assert_eq!(pack_count, 0, "empty bulk install should not touch packs/");
}
#[test]
fn install_pack_rejects_hash_mismatch_without_partial_commit() {
let (_temp, store) = create_test_store();
let valid_blob = Blob::from("valid object that must not be committed");
let valid_hash = valid_blob.hash();
let claimed_hash = Blob::from("claimed object bytes").hash();
let poisoned_bytes = b"different object bytes".to_vec();
assert_ne!(
ContentHash::compute_typed("blob", &poisoned_bytes),
claimed_hash
);
let mut builder = PackBuilder::new(CompressionConfig::disabled());
builder.add(
valid_hash,
PackObjectType::Blob,
valid_blob.clone().into_content(),
);
builder.add(claimed_hash, PackObjectType::Blob, poisoned_bytes);
let (pack_data, index_data, _) = builder.build().unwrap();
let error = store
.install_pack(&pack_data, &index_data)
.expect_err("poisoned native pack must be rejected");
assert!(
matches!(error, HeddleError::Corruption { expected, .. } if expected == claimed_hash),
"expected claimed-hash mismatch corruption, got {error:?}",
);
assert!(
store.get_blob(&valid_hash).unwrap().is_none(),
"valid entry before the poisoned entry must not be partially committed",
);
assert!(
store.get_blob(&claimed_hash).unwrap().is_none(),
"poisoned object must not be readable under its claimed hash",
);
let pack_count = std::fs::read_dir(packs_dir(store.root()))
.map(|iter| iter.count())
.unwrap_or(0);
assert_eq!(pack_count, 0, "rejected pack must not commit pack files");
}
#[test]
fn install_pack_accepts_valid_mixed_native_pack() {
let (_temp, store) = create_test_store();
let blob = Blob::from("native pack blob");
let blob_hash = blob.hash();
let tree = Tree::from_entries(vec![TreeEntry::file("file.txt", blob_hash, false).unwrap()]);
let tree_hash = tree.hash();
let attribution = Attribution::human(Principal::new("Pack Test", "pack@example.com"));
let state = State::new(tree_hash, vec![], attribution.clone()).with_intent("packed state");
let mut action = Action::new(
None,
state.change_id,
Operation::Snapshot,
"packed action",
attribution,
)
.with_timestamp(Utc.timestamp_opt(1_700_000_000, 0).unwrap());
let action_id = action.id();
let mut builder = PackBuilder::new(CompressionConfig::disabled());
builder.add(blob_hash, PackObjectType::Blob, blob.clone().into_content());
builder.add(
tree_hash,
PackObjectType::Tree,
rmp_serde::to_vec_named(&tree).unwrap(),
);
builder.add_id(
PackObjectId::ChangeId(state.change_id),
PackObjectType::State,
rmp_serde::to_vec_named(&state).unwrap(),
);
builder.add(
*action_id.as_hash(),
PackObjectType::Action,
rmp_serde::to_vec_named(&action).unwrap(),
);
let (pack_data, index_data, _) = builder.build().unwrap();
let ids = store.install_pack(&pack_data, &index_data).unwrap();
assert_eq!(ids.len(), 4);
assert_eq!(
store.get_blob(&blob_hash).unwrap().unwrap().content(),
blob.content(),
);
assert_eq!(
store.get_tree(&tree_hash).unwrap().unwrap().entries(),
tree.entries(),
);
assert_eq!(
store.get_state(&state.change_id).unwrap().unwrap().intent,
Some("packed state".to_string()),
);
assert_eq!(
store.get_action(&action_id).unwrap().unwrap().description,
"packed action",
);
}
#[test]
fn loose_state_shadows_stale_packed_copy_on_cold_read() {
let (_temp, store) = create_test_store();
let tree = Tree::new();
let tree_hash = tree.hash();
store.put_tree(&tree).unwrap();
let attribution = Attribution::human(Principal::new("Adopt", "adopt@example.com"));
let packed = State::new(tree_hash, vec![], attribution).with_intent("stale-packed");
let change_id = packed.change_id;
let mut builder = PackBuilder::new(CompressionConfig::disabled());
builder.add_id(
PackObjectId::ChangeId(change_id),
PackObjectType::State,
rmp_serde::to_vec_named(&packed).unwrap(),
);
let (pack_data, index_data, _) = builder.build().unwrap();
store.install_pack(&pack_data, &index_data).unwrap();
store.clear_recent_object_caches();
assert_eq!(
store.get_state(&change_id).unwrap().unwrap().intent,
Some("stale-packed".to_string()),
"packed state is read before any loose copy exists",
);
let fresh = packed.clone().with_intent("fresh-loose");
store.put_state(&fresh).unwrap();
store.clear_recent_object_caches();
assert_eq!(
store.get_state(&change_id).unwrap().unwrap().intent,
Some("fresh-loose".to_string()),
"loose write shadows the stale packed copy on a cold read",
);
}
#[test]
fn install_pack_refreshes_state_as_loose_authoritative_copy() {
let (_temp, store) = create_test_store();
let tree = Tree::new();
let tree_hash = tree.hash();
store.put_tree(&tree).unwrap();
let attribution = Attribution::human(Principal::new("Sync", "sync@example.com"));
let base = State::new(tree_hash, vec![], attribution);
let change_id = base.change_id;
store.put_state(&base).unwrap();
let discussion_hash = ContentHash::compute(b"discussion-sidecar");
let refreshed = base.clone().with_discussions(discussion_hash);
let mut builder = PackBuilder::new(CompressionConfig::disabled());
builder.add_id(
PackObjectId::ChangeId(change_id),
PackObjectType::State,
rmp_serde::to_vec_named(&refreshed).unwrap(),
);
let (pack_data, index_data, _) = builder.build().unwrap();
store.install_pack(&pack_data, &index_data).unwrap();
store.clear_recent_object_caches();
assert_eq!(
store.get_state(&change_id).unwrap().unwrap().discussions,
Some(discussion_hash),
"received state pack must refresh the loose state body so mutable \
tail pointers are visible on cold reads",
);
}
#[cfg(feature = "zstd")]
#[test]
fn install_pack_accepts_valid_compressed_blob_pack() {
let (_temp, store) = create_test_store();
let content = b"compressible native pack blob\n".repeat(512);
let blob = Blob::from(content);
let blob_hash = blob.hash();
let mut builder = PackBuilder::new(CompressionConfig {
enabled: true,
min_size: 0,
max_delta_size: 0,
..CompressionConfig::default()
});
builder.add(blob_hash, PackObjectType::Blob, blob.clone().into_content());
let (pack_data, index_data, stats) = builder.build().unwrap();
assert!(
stats.total_compressed < stats.total_uncompressed,
"test pack must exercise the compressed get_object_bytes fallback",
);
let ids = store.install_pack(&pack_data, &index_data).unwrap();
assert_eq!(ids, vec![PackObjectId::Hash(blob_hash)]);
assert_eq!(
store.get_blob(&blob_hash).unwrap().unwrap().content(),
blob.content(),
);
}
fn count_packs(store: &FsStore) -> usize {
std::fs::read_dir(packs_dir(store.root()))
.map(|iter| {
iter.flatten()
.filter(|entry| {
entry
.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == "pack")
.unwrap_or(false)
})
.count()
})
.unwrap_or(0)
}
fn count_loose_objects(store: &FsStore) -> usize {
fn count_files_recursive(dir: &std::path::Path) -> usize {
let mut total = 0;
if let Ok(iter) = std::fs::read_dir(dir) {
for entry in iter.flatten() {
let path = entry.path();
if path.is_dir() {
total += count_files_recursive(&path);
} else if path.is_file() {
total += 1;
}
}
}
total
}
count_files_recursive(&blobs_dir(store.root()))
+ count_files_recursive(&trees_dir(store.root()))
}
#[test]
fn pack_objects_then_prune_leaves_no_loose_packed_duplicates() {
let (_temp, store) = create_test_store();
let mut hashes = Vec::new();
for i in 0..16 {
let blob = Blob::from(format!("gc-consolidation-blob-{i}"));
hashes.push(store.put_blob(&blob).unwrap());
}
for (i, hash) in hashes.iter().take(4).enumerate() {
let tree = Tree::from_entries(vec![
TreeEntry::file(format!("file-{i}.txt"), *hash, false).unwrap(),
]);
store.put_tree(&tree).unwrap();
}
let loose_before = count_loose_objects(&store);
assert!(
loose_before >= 20,
"expected the test corpus to be written loose, got {loose_before}"
);
let (packed, _saved) = store.pack_objects(false).unwrap();
assert!(packed >= 20, "pack_objects should pack the loose corpus");
let (pruned, _freed) = store.prune_loose_objects().unwrap();
assert!(
pruned >= 20,
"prune should remove the now-packed loose copies"
);
let loose_after = count_loose_objects(&store);
assert_eq!(
loose_after, 0,
"after gc (pack + prune) the object store must not retain loose copies \
of objects that now live in a pack (regression: pack-without-prune)"
);
for hash in &hashes {
assert!(
store.get_blob(hash).unwrap().is_some(),
"blob {hash:?} must survive consolidation"
);
}
}
#[derive(Serialize)]
struct LegacyTreeV1ForTest {
entries: Vec<LegacyTreeEntryV1ForTest>,
}
#[derive(Serialize)]
struct LegacyTreeEntryV1ForTest {
name: String,
mode: LegacyFileModeForTest,
entry_type: LegacyEntryTypeForTest,
hash: ContentHash,
}
#[derive(Serialize)]
enum LegacyFileModeForTest {
Normal,
}
#[derive(Serialize)]
enum LegacyEntryTypeForTest {
Blob,
}
fn legacy_tree_v1_bytes_for_test(name: &str, hash: ContentHash) -> Vec<u8> {
rmp_serde::to_vec(&LegacyTreeV1ForTest {
entries: vec![LegacyTreeEntryV1ForTest {
name: name.to_string(),
mode: LegacyFileModeForTest::Normal,
entry_type: LegacyEntryTypeForTest::Blob,
hash,
}],
})
.unwrap()
}
#[test]
fn gc_preserves_and_repacks_loose_v2_tree_shadow_over_packed_v1_body() {
let (_temp, store) = create_test_store();
let blob_hash = ContentHash::compute(b"shadowed-tree-blob");
let tree = Tree::from_entries(vec![TreeEntry::file("file.txt", blob_hash, false).unwrap()]);
let tree_hash = tree.hash();
let legacy_bytes = legacy_tree_v1_bytes_for_test("file.txt", blob_hash);
let current_bytes = rmp_serde::to_vec(&tree).unwrap();
let mut builder = PackBuilder::new(CompressionConfig::default());
builder.add(tree_hash, PackObjectType::Tree, legacy_bytes);
let (pack_data, index_data, _) = builder.build().unwrap();
store.install_pack_files(&pack_data, &index_data).unwrap();
store
.put_tree_serialized(¤t_bytes, tree_hash)
.expect("write migrated loose V2 shadow");
let tree_path = hash_path(&trees_dir(store.root()), &tree_hash);
assert!(tree_path.exists(), "test must have a loose V2 shadow");
assert!(store.get_tree(&tree_hash).unwrap().is_some());
let (pruned, _) = store.prune_loose_objects().unwrap();
assert_eq!(
pruned, 0,
"prune must keep a loose migrated V2 tree when the packed body differs"
);
assert!(tree_path.exists(), "loose V2 shadow must survive prune");
store.pack_objects(false).unwrap();
let Some((obj_type, packed_after)) = store
.get_pack_object(&PackObjectId::Hash(tree_hash))
.unwrap()
else {
panic!("tree should remain packed after GC");
};
assert_eq!(obj_type, PackObjectType::Tree);
assert_eq!(
packed_after, current_bytes,
"GC must carry the loose migrated V2 body, not the old packed V1 body"
);
let (pruned, _) = store.prune_loose_objects().unwrap();
assert_eq!(pruned, 1);
assert!(
!tree_path.exists(),
"once the pack carries V2 bytes, the loose duplicate can be pruned"
);
}
#[test]
fn repeated_gc_does_not_grow_pack_count() {
let (_temp, store) = create_test_store();
for i in 0..16 {
let blob = Blob::from(format!("repeated-gc-blob-{i}"));
store.put_blob(&blob).unwrap();
}
store.pack_objects(false).unwrap();
store.prune_loose_objects().unwrap();
let packs_after_first = count_packs(&store);
assert_eq!(
packs_after_first, 1,
"a single gc should consolidate the corpus into exactly one pack"
);
let (packed_second, _) = store.pack_objects(false).unwrap();
store.prune_loose_objects().unwrap();
let packs_after_second = count_packs(&store);
assert_eq!(
packed_second, 0,
"a second gc with no loose objects must not re-pack already-packed content"
);
assert_eq!(
packs_after_second, 1,
"repeated gc must not grow the pack count (regression: unbounded pack accumulation)"
);
}
#[test]
fn gc_consolidates_into_single_pack_over_existing_pack() {
let (_temp, store) = create_test_store();
for i in 0..12 {
store.put_blob(&Blob::from(format!("round1-{i}"))).unwrap();
}
store.pack_objects(false).unwrap();
store.prune_loose_objects().unwrap();
assert_eq!(count_packs(&store), 1, "round 1 produces one pack");
let mut round2 = Vec::new();
for i in 0..12 {
round2.push(store.put_blob(&Blob::from(format!("round2-{i}"))).unwrap());
}
assert!(count_loose_objects(&store) >= 12);
store.pack_objects(false).unwrap();
store.prune_loose_objects().unwrap();
assert_eq!(
count_packs(&store),
1,
"gc must consolidate loose + existing pack into a SINGLE pack \
(regression: a second gc minted a new pack, growing pack count and \
slowing every object lookup)"
);
assert_eq!(
count_loose_objects(&store),
0,
"gc must prune the loose copies it just packed"
);
for hash in &round2 {
assert!(store.get_blob(hash).unwrap().is_some());
}
assert!(
store
.get_blob(&Blob::from("round1-0").hash())
.unwrap()
.is_some(),
"objects from the pre-existing pack must survive consolidation"
);
}
#[test]
fn test_blob_deduplication() {
let (_temp, store) = create_test_store();
let blob1 = Blob::from("same content");
let blob2 = Blob::from("same content");
let hash1 = store.put_blob(&blob1).unwrap();
let hash2 = store.put_blob(&blob2).unwrap();
assert_eq!(hash1, hash2);
}
#[test]
fn test_blob_not_found() {
let (_temp, store) = create_test_store();
let hash = ContentHash::compute(b"nonexistent");
let result = store.get_blob(&hash).unwrap();
assert!(result.is_none());
}
#[test]
fn test_tree_roundtrip() {
let (_temp, store) = create_test_store();
let blob_hash = ContentHash::compute(b"file content");
let tree = Tree::from_entries(vec![TreeEntry::file("foo.txt", blob_hash, false).unwrap()]);
let hash = store.put_tree(&tree).unwrap();
let retrieved = store.get_tree(&hash).unwrap().unwrap();
assert_eq!(retrieved.entries().len(), 1);
assert_eq!(
retrieved.get("foo.txt").unwrap().blob_hash(),
Some(blob_hash)
);
}
#[test]
fn test_state_roundtrip() {
let (_temp, store) = create_test_store();
let tree_hash = ContentHash::compute(b"tree");
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let state = State::new(tree_hash, vec![], attribution).with_intent("Test state");
store.put_state(&state).unwrap();
let retrieved = store.get_state(&state.change_id).unwrap().unwrap();
assert_eq!(retrieved.change_id, state.change_id);
assert_eq!(retrieved.intent, Some("Test state".to_string()));
}
#[test]
fn test_state_roundtrip_preserves_non_utf8_raw_message() {
let (_temp, store) = create_test_store();
let raw = b"caf\xe9\n".to_vec();
assert!(
String::from_utf8(raw.clone()).is_err(),
"fixture must be non-UTF8"
);
let tree_hash = ContentHash::compute(b"tree");
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let state = State::new(tree_hash, vec![], attribution).with_raw_message(&raw);
store.put_state(&state).unwrap();
let retrieved = store.get_state(&state.change_id).unwrap().unwrap();
assert_eq!(
retrieved.raw_message.as_deref(),
Some(raw.as_slice()),
"raw message bytes must round-trip verbatim through the store"
);
}
#[test]
fn test_list_states() {
let (_temp, store) = create_test_store();
let tree_hash = ContentHash::compute(b"tree");
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let state1 = State::new(tree_hash, vec![], attribution.clone());
let state2 = State::new(tree_hash, vec![], attribution);
store.put_state(&state1).unwrap();
store.put_state(&state2).unwrap();
let states = store.list_states().unwrap();
assert_eq!(states.len(), 2);
}
#[test]
fn test_has_blob() {
let (_temp, store) = create_test_store();
let blob = Blob::from("test");
let hash = store.put_blob(&blob).unwrap();
assert!(store.has_blob(&hash).unwrap());
assert!(!store.has_blob(&ContentHash::compute(b"other")).unwrap());
}
#[test]
fn test_empty_blob() {
let (_temp, store) = create_test_store();
let blob = Blob::from("");
let hash = store.put_blob(&blob).unwrap();
let retrieved = store.get_blob(&hash).unwrap().unwrap();
assert_eq!(retrieved.content(), b"");
}
#[test]
fn test_large_blob() {
let (_temp, store) = create_test_store();
let large_content = vec![b'a'; 300 * 1024];
let blob = Blob::from(large_content.as_slice());
let hash = store.put_blob(&blob).unwrap();
let retrieved = store.get_blob(&hash).unwrap().unwrap();
assert_eq!(retrieved.content(), large_content.as_slice());
}
#[test]
fn test_recent_blob_cache_does_not_hide_deleted_loose_object() {
let (_temp, store) = create_test_store();
let blob = Blob::from("cached content");
let hash = store.put_blob(&blob).unwrap();
let path = hash_path(&blobs_dir(store.root()), &hash);
std::fs::remove_file(path).unwrap();
let retrieved = store.get_blob(&hash).unwrap();
assert!(retrieved.is_none());
}
#[test]
fn test_orphaned_temp_blob_file_is_ignored() {
let (_temp, store) = create_test_store();
let blob = Blob::from("orphan temp");
let hash = blob.hash();
let path = hash_path(&blobs_dir(store.root()), &hash);
let temp = temp_path(&path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&temp, b"partial blob data").unwrap();
assert!(!store.has_blob(&hash).unwrap());
assert!(store.get_blob(&hash).unwrap().is_none());
assert!(!store.list_blobs().unwrap().contains(&hash));
}
#[test]
fn test_truncated_blob_file_is_rejected() {
let (_temp, store) = create_test_store();
let blob = Blob::from("full blob payload");
let hash = blob.hash();
let path = hash_path(&blobs_dir(store.root()), &hash);
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::write(&path, b"truncated").unwrap();
let error = store
.get_blob(&hash)
.expect_err("truncated blob should be rejected");
assert!(matches!(
error,
HeddleError::Corruption { .. } | HeddleError::InvalidObject(_)
));
}
#[test]
fn test_get_state_rejects_wrong_object_swap() {
let (_temp, store) = create_test_store();
let tree_hash = ContentHash::compute(b"tree");
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let state1 = State::new(tree_hash, vec![], attribution.clone());
let state2 = State::new(tree_hash, vec![], attribution);
store.put_state(&state1).unwrap();
store.put_state(&state2).unwrap();
let swapped_path = store
.root
.join("objects/states")
.join(format!("{}.state", state1.change_id.to_string_full()));
std::fs::write(&swapped_path, rmp_serde::to_vec(&state2).unwrap()).unwrap();
store.clear_recent_object_caches();
let error = store
.get_state(&state1.change_id)
.expect_err("swapped state should be rejected");
assert!(
matches!(error, HeddleError::InvalidObject(message) if message.contains("state change_id mismatch"))
);
}
#[test]
fn test_get_action_rejects_wrong_object_swap() {
let (_temp, store) = create_test_store();
let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
let mut action1 = Action::new(
None,
ChangeId::generate(),
Operation::Snapshot,
"first action",
attribution.clone(),
)
.with_timestamp(Utc.timestamp_opt(1_700_000_000, 0).unwrap());
let mut action2 = Action::new(
None,
ChangeId::generate(),
Operation::Snapshot,
"second action",
attribution,
)
.with_timestamp(Utc.timestamp_opt(1_700_000_001, 0).unwrap());
let action1_id = store.put_action(&mut action1).unwrap();
store.put_action(&mut action2).unwrap();
let swapped_path = store
.root
.join("actions")
.join(format!("{}.action", action1_id.as_hash().to_hex()));
std::fs::write(&swapped_path, rmp_serde::to_vec(&action2).unwrap()).unwrap();
let error = store
.get_action(&action1_id)
.expect_err("swapped action should be rejected");
assert!(
matches!(error, HeddleError::InvalidObject(message) if message.contains("action id mismatch"))
);
}
#[test]
fn test_get_tree_rejects_invalid_deserialized_entry_name() {
let (_temp, store) = create_test_store();
let invalid_tree =
Tree::from_entries_unchecked_for_tests(vec![TreeEntry::new_unchecked_for_tests(
"bad/name",
crate::object::TreeEntryTarget::Blob {
hash: ContentHash::compute(b"blob"),
executable: false,
},
)]);
let tree_hash = invalid_tree.hash();
let tree_path = store
.root
.join("objects/trees")
.join(&tree_hash.to_hex()[..2])
.join(&tree_hash.to_hex()[2..]);
let parent = tree_path.parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::write(&tree_path, rmp_serde::to_vec(&invalid_tree).unwrap()).unwrap();
let error = store
.get_tree(&tree_hash)
.expect_err("invalid tree should be rejected");
assert!(matches!(error, HeddleError::InvalidTreeEntry(_)));
}
#[test]
fn loose_blob_path_rejects_torn_cache_mirror() {
let (_temp, store) = create_test_store();
let blob = Blob::from("authoritative bytes");
let hash = store.put_blob(&blob).unwrap();
let path = hash_path(&blobs_dir(store.root()), &hash);
assert!(path.exists(), "blob should be loose on disk");
let probed = store.loose_blob_path(&hash);
assert_eq!(probed, Some(path.clone()));
assert!(
store
.verified_loose_blobs
.read()
.unwrap()
.get(&hash)
.is_some(),
"verified cache should pick up the hash after first probe"
);
*store.verified_loose_blobs.write_or_poisoned() =
super::fs_store::RecentObjectCache::with_capacity(65_536);
std::fs::write(&path, b"torn-write garbage").unwrap();
let probed = store.loose_blob_path(&hash);
assert!(
probed.is_none(),
"corrupted loose blob must not be served as canonical bytes"
);
}