use heddle_format::{compression::CompressionConfig, delta::MAX_DELTA_OUTPUT_SIZE};
use tempfile::TempDir;
use super::{
ObjectType, PackBuilder, PackObjectId, PackReader, PackRepresentationHash,
decode_tagged_entry_header, pack_index::PackIndex,
};
use crate::{
object::{ContentHash, StateId},
store::{StoreError, pack::pack_container_spec},
};
fn create_test_hash(n: u8) -> ContentHash {
let bytes: [u8; 32] = [n; 32];
ContentHash::from_bytes(bytes)
}
fn single_record_pack(
hash: ContentHash,
write_record_tail: impl FnOnce(&mut Vec<u8>),
) -> (Vec<u8>, Vec<u8>) {
let mut pack_data = Vec::new();
pack_data.extend_from_slice(pack_container_spec().magic);
pack_data.extend_from_slice(&pack_container_spec().version.to_be_bytes());
pack_data.extend_from_slice(&1u64.to_be_bytes());
let entry_offset = u64::try_from(pack_data.len()).expect("test pack offset fits in u64");
PackObjectId::Hash(hash).encode_tagged(&mut pack_data);
write_record_tail(&mut pack_data);
super::append_container_checksum(&mut pack_data);
let mut index = PackIndex::new();
index.add(PackObjectId::Hash(hash), entry_offset);
index.sort();
(pack_data, index.to_bytes())
}
fn assert_invalid_object_message_contains(error: StoreError, expected: &str) {
assert!(
matches!(error, StoreError::InvalidObject(ref message) if message.contains(expected)),
"expected InvalidObject containing '{expected}', got: {error:?}"
);
}
#[test]
fn test_pack_container_header_codec_matches_v4_bytes() {
let mut canonical = Vec::new();
canonical.extend_from_slice(b"LMPK");
canonical.extend_from_slice(&4_u32.to_be_bytes());
canonical.extend_from_slice(&0_u64.to_be_bytes());
let checksum = blake3::hash(&canonical);
canonical.extend_from_slice(checksum.as_bytes());
let mut encoded = Vec::new();
super::write_container_header(&mut encoded, pack_container_spec(), 0);
super::append_container_checksum(&mut encoded);
assert_eq!(encoded, canonical);
assert_eq!(
super::verify_container(&canonical, pack_container_spec()).unwrap(),
(0, 16, 16)
);
}
#[test]
fn test_pack_index_header_codec_matches_current_bytes() {
let current = b"LMI\0\
\0\0\0\x04\
\0\0\0\0\0\0\0\0"
.to_vec();
let encoded = PackIndex::new().to_bytes();
assert_eq!(encoded, current);
assert!(
PackIndex::from_bytes(¤t)
.unwrap()
.ids()
.unwrap()
.is_empty()
);
}
#[test]
fn test_pack_reader_refuses_v3_pack_with_migration_advice() {
let hash = create_test_hash(77);
let payload = b"legacy-v3-pack".to_vec();
let (mut pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, payload.len() as u64, record);
super::varint::encode_varint(payload.len() as u64, record);
record.extend_from_slice(&payload);
});
pack_data[4..8].copy_from_slice(&3_u32.to_be_bytes());
pack_data.truncate(pack_data.len() - super::PACK_CHECKSUM_LEN);
super::append_container_checksum(&mut pack_data);
let error = match PackReader::from_bytes(pack_data, index_data) {
Ok(_) => panic!("legacy pack version must be refused"),
Err(error) => error,
};
assert_invalid_object_message_contains(error, "run `heddle migrate`");
}
#[test]
fn test_pack_reader_refuses_v3_index_with_migration_advice() {
let hash = create_test_hash(77);
let payload = b"legacy-v3-index".to_vec();
let (pack_data, mut index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, payload.len() as u64, record);
super::varint::encode_varint(payload.len() as u64, record);
record.extend_from_slice(&payload);
});
index_data[4..8].copy_from_slice(&3_u32.to_be_bytes());
let error = match PackReader::from_bytes(pack_data, index_data) {
Ok(_) => panic!("legacy pack index version must be refused"),
Err(error) => error,
};
assert_invalid_object_message_contains(error, "run `heddle migrate`");
}
#[test]
fn test_pack_reader_refuses_newer_version_with_upgrade_advice() {
let mut pack_data = Vec::new();
super::write_container_header(&mut pack_data, pack_container_spec(), 0);
pack_data[4..8].copy_from_slice(&5_u32.to_be_bytes());
super::append_container_checksum(&mut pack_data);
let error = match PackReader::from_bytes(pack_data, PackIndex::new().to_bytes()) {
Ok(_) => panic!("future pack version must be refused"),
Err(error) => error,
};
assert_invalid_object_message_contains(error, "upgrade heddle");
}
#[test]
fn test_annotated_tag_pack_entry_roundtrips_with_distinct_id_kind() {
let hash = create_test_hash(78);
let payload = b"annotated-tag-msgpack".to_vec();
let mut builder = PackBuilder::new(CompressionConfig::default());
builder.add_id(
PackObjectId::AnnotatedTag(hash),
ObjectType::AnnotatedTag,
payload.clone(),
);
let (pack_data, index_data, _) = builder.build().unwrap();
let reader = PackReader::from_bytes(pack_data, index_data).unwrap();
assert_eq!(
reader
.get_object(&PackObjectId::AnnotatedTag(hash))
.unwrap(),
Some((ObjectType::AnnotatedTag, payload))
);
}
#[test]
fn test_pack_index_roundtrip() {
let mut index = PackIndex::new();
index.add(PackObjectId::Hash(create_test_hash(1)), 100);
index.add(PackObjectId::Hash(create_test_hash(2)), 200);
index.add(PackObjectId::StateId(StateId::from_bytes([3; 32])), 300);
index.add(PackObjectId::AnnotatedTag(create_test_hash(4)), 400);
index.sort();
let bytes = index.to_bytes();
assert_eq!(bytes.len(), 16 + 4 * 40);
let restored = PackIndex::from_bytes(&bytes).expect("Failed to deserialize index");
assert_eq!(
restored
.find(&PackObjectId::Hash(create_test_hash(1)))
.unwrap(),
Some(100)
);
assert_eq!(
restored
.find(&PackObjectId::Hash(create_test_hash(2)))
.unwrap(),
Some(200)
);
assert_eq!(
restored
.find(&PackObjectId::StateId(StateId::from_bytes([3; 32])))
.unwrap(),
Some(300)
);
assert_eq!(
restored
.find(&PackObjectId::AnnotatedTag(create_test_hash(4)))
.unwrap(),
Some(400)
);
assert_eq!(
restored
.find(&PackObjectId::Hash(create_test_hash(5)))
.unwrap(),
None
);
}
#[test]
fn test_pack_index_distinguishes_hash_and_state_with_identical_bytes() {
let bytes = [9; 32];
let hash_id = PackObjectId::Hash(ContentHash::from_bytes(bytes));
let state_id = PackObjectId::StateId(StateId::from_bytes(bytes));
let mut index = PackIndex::new();
index.add(hash_id, 100);
index.add(state_id, 200);
index.sort();
let encoded = index.to_bytes();
let restored = PackIndex::from_bytes(&encoded).unwrap();
assert_eq!(restored.find(&hash_id).unwrap(), Some(100));
assert_eq!(restored.find(&state_id).unwrap(), Some(200));
}
#[test]
fn test_pack_builder_basic() {
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
let hash1 = create_test_hash(1);
let data1 = b"Hello, World!".to_vec();
builder.add(hash1, ObjectType::Blob, data1.clone());
let hash2 = create_test_hash(2);
let data2 = b"Goodbye, World!".to_vec();
builder.add(hash2, ObjectType::Blob, data2.clone());
let (pack_data, index_data, stats) = builder.build().expect("Failed to build pack");
assert!(!pack_data.is_empty());
assert!(!index_data.is_empty());
assert_eq!(stats.object_count, 2);
assert!(stats.compression_ratio > 0.0 && stats.compression_ratio <= 1.0);
}
#[test]
fn test_pack_reader() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
let hash1 = create_test_hash(1);
let data1 = b"Test data 1".repeat(100);
builder.add(hash1, ObjectType::Blob, data1.clone());
let (pack_data, index_data, _) = builder.build().expect("Failed to build pack");
std::fs::write(&pack_path, &pack_data).expect("Failed to write pack file");
std::fs::write(&index_path, &index_data).expect("Failed to write index file");
let reader = PackReader::open(&pack_path, &index_path).expect("Failed to open pack");
let (obj_type, retrieved) = reader
.get_hashed_object(&hash1)
.expect("Failed to get object")
.expect("Object not found");
assert_eq!(obj_type, ObjectType::Blob);
assert_eq!(retrieved, data1);
}
#[test]
fn re_representation_preserves_logical_id_and_changes_representation_hash() {
let base_data = b"shared base body for pack identity ".repeat(32);
let mut target_data = base_data.clone();
target_data.extend_from_slice(b"target suffix");
let base_a = create_test_hash(0xA1);
let base_b = create_test_hash(0xB2);
let target = create_test_hash(0xC3);
let build = |base_a_path: &str, base_b_path: &str| {
let mut builder = PackBuilder::new(CompressionConfig {
enabled: false,
level: 0,
min_size: usize::MAX,
max_delta_size: usize::MAX,
});
builder.add_with_path(
base_a,
ObjectType::Blob,
base_data.clone(),
Some(base_a_path.to_string()),
);
builder.add_with_path(
base_b,
ObjectType::Blob,
base_data.clone(),
Some(base_b_path.to_string()),
);
builder.add_with_path(
target,
ObjectType::Blob,
target_data.clone(),
Some("src/z.rs".to_string()),
);
let logical_id = builder.logical_id();
let (pack, index, stats) = builder.build().unwrap();
assert!(stats.delta_count > 0);
(logical_id, pack, index)
};
let (logical_a, pack_a, index_a) = build("src/a.rs", "src/b.rs");
let (logical_b, pack_b, index_b) = build("src/b.rs", "src/a.rs");
let target_base = |pack: &[u8], index: &[u8]| {
let index = PackIndex::from_bytes(index).unwrap();
let offset = index.find(&PackObjectId::Hash(target)).unwrap().unwrap() as usize;
decode_tagged_entry_header(&pack[offset..])
.unwrap()
.delta_base
.unwrap()
};
assert_eq!(target_base(&pack_a, &index_a), PackObjectId::Hash(base_a));
assert_eq!(target_base(&pack_b, &index_b), PackObjectId::Hash(base_b));
assert_eq!(logical_a, logical_b);
assert_eq!(
logical_a.to_hex(),
"fc4500a1c4a0a38795c64ed6400dccbebe978112cb1b550a1e00c2f324a69a19"
);
let representation_a = PackRepresentationHash::compute(&pack_a);
let representation_b = PackRepresentationHash::compute(&pack_b);
assert_ne!(representation_a, representation_b);
let reader_a = PackReader::from_bytes(pack_a, index_a).unwrap();
let reader_b = PackReader::from_bytes(pack_b, index_b).unwrap();
assert_eq!(reader_a.logical_id().unwrap(), logical_a);
assert_eq!(reader_b.logical_id().unwrap(), logical_b);
assert_eq!(reader_a.representation_hash(), representation_a);
assert_eq!(reader_b.representation_hash(), representation_b);
let mut changed = PackBuilder::new(CompressionConfig::default());
changed.add(base_a, ObjectType::Blob, base_data);
changed.add(
base_b,
ObjectType::Blob,
b"different logical object".to_vec(),
);
changed.add(target, ObjectType::Blob, target_data);
assert_ne!(changed.logical_id(), logical_a);
}
#[test]
fn test_delta_compression() {
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
let base_hash = create_test_hash(1);
let base_data = b"This is the base content. ".repeat(100).to_vec();
builder.add(base_hash, ObjectType::Blob, base_data.clone());
let target_hash = create_test_hash(2);
let target_data = b"This is modified content. ".repeat(100).to_vec();
builder.add(target_hash, ObjectType::Blob, target_data.clone());
let (_pack_data, _index_data, stats) = builder.build().expect("Failed to build pack");
assert!(stats.delta_count > 0);
assert!(stats.compression_ratio < 1.0);
}
#[test]
fn test_pack_reader_rejects_compressed_size_that_overflows_record_end() {
let hash = create_test_hash(42);
let (pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, 1, record);
super::varint::encode_varint(u64::MAX, record);
});
let reader = PackReader::from_bytes(pack_data, index_data).expect("container is well-formed");
let error = reader
.get_hashed_object(&hash)
.expect_err("oversized compressed_size must fail before slicing");
assert!(
matches!(
error,
StoreError::InvalidObject(ref message)
if message.contains("overflows") || message.contains("platform limits")
),
"expected overflow/platform-limit error, got: {error:?}",
);
let bytes_error = reader
.get_hashed_object_bytes(&hash)
.expect_err("zero-copy path must reject oversized compressed_size too");
assert!(
matches!(
bytes_error,
StoreError::InvalidObject(ref message)
if message.contains("overflows") || message.contains("platform limits")
),
"expected overflow/platform-limit error, got: {bytes_error:?}",
);
}
#[test]
fn test_pack_reader_rejects_uncompressed_size_above_pack_object_limit() {
let hash = create_test_hash(48);
let (pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, u64::from(u32::MAX) + 1, record);
super::varint::encode_varint(1, record);
record.push(0);
});
let reader = PackReader::from_bytes(pack_data, index_data).expect("container is well-formed");
let error = reader
.get_hashed_object(&hash)
.expect_err("absurd uncompressed_size must fail before allocation");
assert_invalid_object_message_contains(error, "Pack object output size");
let bytes_error = reader
.get_hashed_object_bytes(&hash)
.expect_err("zero-copy path must reject absurd uncompressed_size too");
assert_invalid_object_message_contains(bytes_error, "Pack object output size");
}
#[test]
fn test_pack_reader_rejects_truncated_compressed_size_varint() {
let hash = create_test_hash(43);
let (pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, 4, record);
record.push(0x80);
});
let reader = PackReader::from_bytes(pack_data, index_data).expect("container is well-formed");
let error = reader
.get_hashed_object(&hash)
.expect_err("truncated compressed_size must not read into checksum bytes");
assert_invalid_object_message_contains(error, "Truncated compressed_size varint");
let bytes_error = reader
.get_hashed_object_bytes(&hash)
.expect_err("zero-copy path must reject truncated compressed_size too");
assert_invalid_object_message_contains(bytes_error, "Truncated compressed_size varint");
}
#[test]
fn test_pack_reader_rejects_compressed_size_past_content_end() {
let hash = create_test_hash(44);
let (pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, 10, record);
super::varint::encode_varint(10, record);
record.extend_from_slice(b"abc");
});
let reader = PackReader::from_bytes(pack_data, index_data).expect("container is well-formed");
let error = reader
.get_hashed_object(&hash)
.expect_err("record payload shorter than compressed_size must fail");
assert_invalid_object_message_contains(error, "Entry data out of bounds");
let bytes_error = reader
.get_hashed_object_bytes(&hash)
.expect_err("zero-copy path must reject payload shorter than compressed_size too");
assert_invalid_object_message_contains(bytes_error, "Entry data out of bounds");
}
#[test]
fn test_pack_reader_decodes_well_formed_manual_record() {
let hash = create_test_hash(45);
let payload = b"manual-pack-record".to_vec();
let (pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, payload.len() as u64, record);
super::varint::encode_varint(payload.len() as u64, record);
record.extend_from_slice(&payload);
});
let reader = PackReader::from_bytes(pack_data, index_data).expect("container is well-formed");
let (obj_type, data) = reader
.get_hashed_object(&hash)
.expect("well-formed record should decode")
.expect("record should exist");
assert_eq!(obj_type, ObjectType::Blob);
assert_eq!(data, payload);
let (bytes_type, bytes) = reader
.get_hashed_object_bytes(&hash)
.expect("well-formed zero-copy record should decode")
.expect("record should exist");
assert_eq!(bytes_type, ObjectType::Blob);
assert_eq!(bytes.as_ref(), payload.as_slice());
}
#[test]
fn test_pack_reader_rejects_delta_output_above_limit() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
let target_hash = create_test_hash(9);
let oversized =
u64::try_from(MAX_DELTA_OUTPUT_SIZE).expect("MAX_DELTA_OUTPUT_SIZE fits in u64") + 1;
let mut pack_data = Vec::new();
pack_data.extend_from_slice(pack_container_spec().magic);
pack_data.extend_from_slice(&pack_container_spec().version.to_be_bytes());
pack_data.extend_from_slice(&1u64.to_be_bytes());
let entry_offset = pack_data.len() as u64;
PackObjectId::Hash(target_hash).encode_tagged(&mut pack_data);
super::varint::encode_type_and_size(ObjectType::Delta, oversized, &mut pack_data);
super::varint::encode_varint(2, &mut pack_data);
PackObjectId::Hash(create_test_hash(1)).encode_tagged(&mut pack_data);
pack_data.extend_from_slice(&[0, b'x']);
let checksum = blake3::hash(&pack_data);
pack_data.extend_from_slice(checksum.as_bytes());
let mut index = PackIndex::new();
index.add(PackObjectId::Hash(target_hash), entry_offset);
index.sort();
std::fs::write(&pack_path, &pack_data).expect("Failed to write pack file");
std::fs::write(&index_path, index.to_bytes()).expect("Failed to write index file");
let reader = PackReader::open(&pack_path, &index_path).expect("Failed to open pack");
let error = reader
.get_hashed_object(&target_hash)
.expect_err("oversized delta output should fail");
assert!(
matches!(error, crate::store::StoreError::InvalidObject(message) if message.contains("Delta output size"))
);
}
#[cfg(feature = "zstd")]
#[test]
fn test_pack_reader_rejects_compressed_record_claiming_huge_uncompressed_size() {
let hash = create_test_hash(46);
let payload = b"small compressed payload".repeat(32);
let compressed = zstd::encode_all(payload.as_slice(), 3).expect("test zstd compression works");
let (pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, 0xFFFF_FFFF, record);
super::varint::encode_varint(compressed.len() as u64, record);
record.extend_from_slice(&compressed);
});
let reader = PackReader::from_bytes(pack_data, index_data).expect("container is well-formed");
let error = reader
.get_hashed_object(&hash)
.expect_err("hostile uncompressed_size must be rejected without eager allocation");
assert_invalid_object_message_contains(error, "Pack object output size");
let bytes_error = reader
.get_hashed_object_bytes(&hash)
.expect_err("zero-copy fallback must reject hostile uncompressed_size too");
assert_invalid_object_message_contains(bytes_error, "Pack object output size");
}
#[cfg(feature = "zstd")]
#[test]
fn test_pack_decompression_rejects_streaming_output_past_limit() {
let payload = vec![0xA5; 64 * 1024];
let compressed = zstd::encode_all(payload.as_slice(), 3).expect("test zstd compression works");
assert!(
compressed.len() < 1024,
"test payload should exercise compressed-small/decompressed-large shape"
);
let error = super::shared::decompress_pack_payload_with_limit(&compressed, 0, 32 * 1024)
.expect_err("streaming output above the cap must fail cleanly");
assert_invalid_object_message_contains(error, "Pack object output size");
}
#[cfg(feature = "zstd")]
#[test]
fn test_pack_reader_decodes_compressed_object_larger_than_initial_hint() {
let hash = create_test_hash(47);
let payload = vec![0x5C; super::shared::PACK_DECOMPRESSION_INITIAL_CAP + 64 * 1024];
assert!(payload.len() < super::shared::MAX_PACK_OBJECT_OUTPUT_SIZE);
let compressed = zstd::encode_all(payload.as_slice(), 3).expect("test zstd compression works");
assert!(
compressed.len() < payload.len(),
"manual pack must use the compressed reader path"
);
let (pack_data, index_data) = single_record_pack(hash, |record| {
super::varint::encode_type_and_size(ObjectType::Blob, payload.len() as u64, record);
super::varint::encode_varint(compressed.len() as u64, record);
record.extend_from_slice(&compressed);
});
let reader = PackReader::from_bytes(pack_data, index_data).expect("container is well-formed");
let (obj_type, data) = reader
.get_hashed_object(&hash)
.expect("large compressed object should decode")
.expect("record should exist");
assert_eq!(obj_type, ObjectType::Blob);
assert_eq!(data, payload);
let (bytes_type, bytes) = reader
.get_hashed_object_bytes(&hash)
.expect("large compressed object should decode through bytes path")
.expect("record should exist");
assert_eq!(bytes_type, ObjectType::Blob);
assert_eq!(bytes.as_ref(), payload.as_slice());
}
#[test]
fn test_pack_index_rejects_impossible_entry_count() {
let mut bytes = Vec::new();
bytes.extend_from_slice(super::pack_index::INDEX_MAGIC);
bytes.extend_from_slice(&super::pack_index::INDEX_VERSION.to_be_bytes());
bytes.extend_from_slice(&(2_u64).to_be_bytes());
bytes.extend_from_slice(create_test_hash(1).as_bytes());
bytes.extend_from_slice(&(123_u64).to_be_bytes());
let error = match PackIndex::from_bytes(&bytes) {
Ok(_) => panic!("impossible count should fail"),
Err(error) => error,
};
assert!(
matches!(error, crate::store::StoreError::InvalidObject(message) if message.contains("count"))
);
}
#[test]
fn test_pack_reader_rejects_truncated_pack() {
let temp_dir = TempDir::new().unwrap();
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
std::fs::write(&pack_path, b"short").unwrap();
std::fs::write(&index_path, b"").unwrap();
match PackReader::open(&pack_path, &index_path) {
Err(crate::store::StoreError::InvalidObject(msg)) => {
assert!(
msg.contains("too short") || msg.contains("Pack"),
"expected 'too short' error, got: {msg}"
);
}
Err(e) => panic!("expected InvalidObject, got: {e:?}"),
Ok(_) => panic!("expected error for truncated pack"),
}
}
#[test]
fn test_pack_reader_rejects_corrupt_checksum() {
let temp_dir = TempDir::new().unwrap();
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
builder.add(create_test_hash(1), ObjectType::Blob, b"data".to_vec());
let (mut pack_data, index_data, _) = builder.build().unwrap();
let last = pack_data.len() - 1;
pack_data[last] ^= 0xFF;
std::fs::write(&pack_path, &pack_data).unwrap();
std::fs::write(&index_path, &index_data).unwrap();
match PackReader::open(&pack_path, &index_path) {
Err(crate::store::StoreError::InvalidObject(msg)) => {
assert!(
msg.contains("checksum"),
"expected checksum error, got: {msg}"
);
}
Err(e) => panic!("expected InvalidObject, got: {e:?}"),
Ok(_) => panic!("expected error for corrupt checksum"),
}
}
#[test]
fn test_pack_index_rejects_bad_magic() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"BAAD"); bytes.extend_from_slice(&super::pack_index::INDEX_VERSION.to_be_bytes());
bytes.extend_from_slice(&0u64.to_be_bytes());
let err = PackIndex::from_bytes(&bytes).unwrap_err();
assert!(
matches!(err, crate::store::StoreError::InvalidObject(ref msg) if msg.contains("magic")),
"expected magic error, got: {err:?}"
);
}
#[test]
fn test_pack_index_rejects_bad_version() {
let mut bytes = Vec::new();
bytes.extend_from_slice(super::pack_index::INDEX_MAGIC);
bytes.extend_from_slice(&999u32.to_be_bytes()); bytes.extend_from_slice(&0u64.to_be_bytes());
let err = PackIndex::from_bytes(&bytes).unwrap_err();
assert!(
matches!(err, crate::store::StoreError::InvalidObject(ref msg) if msg.contains("version")),
"expected version error, got: {err:?}"
);
}
#[test]
fn test_pack_reader_missing_object_returns_none() {
let temp_dir = TempDir::new().unwrap();
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
builder.add(create_test_hash(1), ObjectType::Blob, b"data".to_vec());
let (pack_data, index_data, _) = builder.build().unwrap();
std::fs::write(&pack_path, &pack_data).unwrap();
std::fs::write(&index_path, &index_data).unwrap();
let reader = PackReader::open(&pack_path, &index_path).unwrap();
let result = reader.get_hashed_object(&create_test_hash(99)).unwrap();
assert!(result.is_none(), "non-existent hash should return None");
}
#[test]
fn stale_index_swapped_offsets_surfaces_as_invalid_object() {
use crate::store::{StoreError, pack::pack_index::PackIndex};
let blob_a = b"alpha-payload alpha-payload alpha-payload alpha".to_vec();
let blob_b = b"bravo-payload bravo-payload bravo-payload bravo".to_vec();
let hash_a = ContentHash::compute(&blob_a);
let hash_b = ContentHash::compute(&blob_b);
let temp_dir = TempDir::new().unwrap();
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
let mut builder = PackBuilder::new(CompressionConfig::default());
builder.add_with_path(hash_a, ObjectType::Blob, blob_a.clone(), None);
builder.add_with_path(hash_b, ObjectType::Blob, blob_b.clone(), None);
let (pack_data, index_data, _) = builder.build().unwrap();
std::fs::write(&pack_path, &pack_data).unwrap();
std::fs::write(&index_path, &index_data).unwrap();
{
let reader = PackReader::open(&pack_path, &index_path).unwrap();
let (_, got_a) = reader.get_hashed_object(&hash_a).unwrap().expect("A");
assert_eq!(got_a, blob_a);
let (_, got_b) = reader.get_hashed_object(&hash_b).unwrap().expect("B");
assert_eq!(got_b, blob_b);
}
let original_index = PackIndex::from_bytes(&index_data).unwrap();
let offset_a = original_index
.find(&PackObjectId::Hash(hash_a))
.unwrap()
.expect("index has A");
let offset_b = original_index
.find(&PackObjectId::Hash(hash_b))
.unwrap()
.expect("index has B");
assert_ne!(offset_a, offset_b);
let mut stale = PackIndex::new();
stale.add(PackObjectId::Hash(hash_a), offset_b); stale.add(PackObjectId::Hash(hash_b), offset_a); stale.sort();
std::fs::write(&index_path, stale.to_bytes()).unwrap();
let reader = PackReader::open(&pack_path, &index_path).unwrap();
let err = reader
.get_hashed_object(&hash_a)
.expect_err("stale index must surface as an error, not silent wrong bytes");
assert!(
matches!(&err, StoreError::InvalidObject(msg) if msg.contains("stale or corrupt")),
"expected InvalidObject('… stale or corrupt …'), got: {err:?}",
);
let err_bytes = reader
.get_hashed_object_bytes(&hash_a)
.expect_err("zero-copy path must reject too");
assert!(matches!(err_bytes, StoreError::InvalidObject(_)));
}
fn build_and_open_pack(
objects: Vec<(ContentHash, ObjectType, Vec<u8>, Option<String>)>,
) -> PackReader<'static> {
let temp_dir = TempDir::new().unwrap();
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
for (hash, obj_type, data, path) in objects {
builder.add_with_path(hash, obj_type, data, path);
}
let (pack_data, index_data, _) = builder.build().unwrap();
std::fs::write(&pack_path, &pack_data).unwrap();
std::fs::write(&index_path, &index_data).unwrap();
std::mem::forget(temp_dir);
PackReader::open(&pack_path, &index_path).unwrap()
}
#[test]
fn test_delta_chain_roundtrip() {
let shared = b"This is shared content that remains constant across all versions. ".repeat(10);
let mut objects = Vec::new();
for i in 0..5u8 {
let mut data = shared.clone();
data.extend_from_slice(format!("version {i} unique suffix data here").as_bytes());
let hash = ContentHash::compute(&data);
objects.push((
hash,
ObjectType::Blob,
data,
Some("test/file.txt".to_string()),
));
}
let hashes: Vec<ContentHash> = objects.iter().map(|(h, _, _, _)| *h).collect();
let originals: Vec<Vec<u8>> = objects.iter().map(|(_, _, d, _)| d.clone()).collect();
let reader = build_and_open_pack(objects);
for (i, (hash, expected)) in hashes.iter().zip(originals.iter()).enumerate() {
let (obj_type, data) = reader
.get_hashed_object(hash)
.unwrap_or_else(|e| panic!("Failed to get object {i}: {e}"))
.unwrap_or_else(|| panic!("Object {i} not found"));
assert_eq!(obj_type, ObjectType::Blob, "object {i} type mismatch");
assert_eq!(&data, expected, "object {i} data mismatch");
}
}
#[test]
fn test_delta_chain_produces_deltas() {
let shared = b"Shared base content for delta testing. ".repeat(20);
let mut objects = Vec::new();
for i in 0..4u8 {
let mut data = shared.clone();
data.extend_from_slice(&[i; 32]);
let hash = ContentHash::compute(&data);
objects.push((
hash,
ObjectType::Blob,
data,
Some("src/main.rs".to_string()),
));
}
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
for (hash, obj_type, data, path) in objects {
builder.add_with_path(hash, obj_type, data, path);
}
let (_, _, stats) = builder.build().unwrap();
assert!(
stats.delta_count >= 1,
"expected deltas, got {}",
stats.delta_count
);
}
#[test]
fn test_delta_window_pack_bytes_are_stable() {
let shared = b"Stable pack bytes fixture. ".repeat(16);
let mut builder = PackBuilder::new(CompressionConfig {
enabled: false,
level: 0,
min_size: usize::MAX,
max_delta_size: 10_000_000,
});
for i in 0..4u8 {
let mut data = shared.clone();
data.extend_from_slice(format!("version {i} suffix").as_bytes());
builder.add_with_path(
ContentHash::compute(&data),
ObjectType::Blob,
data,
Some("src/stable.rs".to_string()),
);
}
let (pack_data, index_data, stats) = builder.build().unwrap();
assert!(stats.delta_count > 0);
assert_eq!(
blake3::hash(&pack_data).to_string(),
"43e7db46c9bdbbc13c320c5ed1de1d28e3793beb4a3a5f1d73424eba536ee514"
);
assert_eq!(
blake3::hash(&index_data).to_string(),
"0d505a9c037871e263ce1839e8721a99d1e41675d7709d4b8f3286d5d9f2febd"
);
}
#[derive(Clone)]
struct RepackCorpusObject {
hash: ContentHash,
data: Vec<u8>,
path: String,
}
fn repack_window_corpus() -> Vec<RepackCorpusObject> {
const PATH_COUNT: usize = 19;
const VARIANT_COUNT: usize = 11;
const OBJECT_SIZE: usize = 512;
let mut objects = Vec::with_capacity(PATH_COUNT * VARIANT_COUNT * 2);
for generation in 0..2 {
for variant in 0..VARIANT_COUNT {
for path_index in 0..PATH_COUNT {
let mut data = Vec::with_capacity(OBJECT_SIZE);
for block_index in 0..(OBJECT_SIZE / 32) {
let seed = format!("{path_index}:{variant}:{block_index}");
data.extend_from_slice(blake3::hash(seed.as_bytes()).as_bytes());
}
if generation == 1 {
for offset in (variant..OBJECT_SIZE).step_by(127) {
data[offset] ^= 0x5a;
}
}
objects.push(RepackCorpusObject {
hash: ContentHash::compute(&data),
data,
path: format!("src/file_{path_index:02}.bin"),
});
}
}
}
objects
}
fn build_repack_corpus(
objects: &[RepackCorpusObject],
delta_window: usize,
with_path_hints: bool,
) -> (Vec<u8>, Vec<u8>, super::PackStats) {
let compression = CompressionConfig {
enabled: false,
level: 0,
min_size: usize::MAX,
max_delta_size: usize::MAX,
};
let mut builder = PackBuilder::for_repack(compression, delta_window);
for object in objects {
builder.add_with_path_id(
PackObjectId::Hash(object.hash),
ObjectType::Blob,
object.data.clone(),
with_path_hints.then(|| object.path.clone()),
);
}
builder.build().unwrap()
}
#[test]
fn repack_window_and_path_hints_improve_ratio_and_roundtrip() {
let objects = repack_window_corpus();
let (baseline_pack, _, baseline) = build_repack_corpus(&objects, 10, false);
let (wide_pack, wide_index, wide) = build_repack_corpus(&objects, 200, true);
let (no_hints_pack, _, no_hints) = build_repack_corpus(&objects, 200, false);
let (narrow_pack, _, narrow) = build_repack_corpus(&objects, 10, true);
eprintln!(
"baseline: deltas={}, bytes={}, ratio={:.6}; wide+hints: deltas={}, bytes={}, ratio={:.6}; no-hints: deltas={}, bytes={}, ratio={:.6}; narrow: deltas={}, bytes={}, ratio={:.6}",
baseline.delta_count,
baseline_pack.len(),
baseline.compression_ratio,
wide.delta_count,
wide_pack.len(),
wide.compression_ratio,
no_hints.delta_count,
no_hints_pack.len(),
no_hints.compression_ratio,
narrow.delta_count,
narrow_pack.len(),
narrow.compression_ratio,
);
assert!(wide.delta_count > baseline.delta_count);
assert!(wide_pack.len() < baseline_pack.len());
assert!(wide.compression_ratio < baseline.compression_ratio);
assert!(no_hints.compression_ratio > wide.compression_ratio);
assert!(no_hints_pack.len() > wide_pack.len());
assert!(narrow.compression_ratio > wide.compression_ratio);
assert!(narrow_pack.len() > wide_pack.len());
let reader = PackReader::from_bytes(wide_pack, wide_index).unwrap();
for object in &objects {
let (obj_type, data) = reader
.get_hashed_object(&object.hash)
.unwrap()
.expect("corpus object must be present");
assert_eq!(obj_type, ObjectType::Blob);
assert_eq!(ContentHash::compute(&data), object.hash);
assert_eq!(data, object.data);
}
}
#[test]
fn test_single_object_no_delta() {
let data = b"solo object content".repeat(50);
let hash = ContentHash::compute(&data);
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
builder.add(hash, ObjectType::Blob, data.clone());
let (_, _, stats) = builder.build().unwrap();
assert_eq!(stats.delta_count, 0);
assert_eq!(stats.object_count, 1);
}
#[test]
fn test_small_objects_skip_delta() {
let data1 = b"short object A".to_vec();
let data2 = b"short object B".to_vec();
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
builder.add_with_path(
ContentHash::compute(&data1),
ObjectType::Blob,
data1,
Some("tiny.txt".to_string()),
);
builder.add_with_path(
ContentHash::compute(&data2),
ObjectType::Blob,
data2,
Some("tiny.txt".to_string()),
);
let (_, _, stats) = builder.build().unwrap();
assert_eq!(
stats.delta_count, 0,
"small objects should not be delta-encoded"
);
}
#[test]
fn test_chain_resets_on_bad_delta() {
let data1: Vec<u8> = (0..1024).map(|i| ((i * 131 + 17) % 256) as u8).collect();
let data2: Vec<u8> = (0..1024).map(|i| ((i * 197 + 53) % 256) as u8).collect();
let _data3: Vec<u8> = (0..1024).map(|i| ((i * 251 + 89) % 256) as u8).collect();
let temp_dir = TempDir::new().unwrap();
let pack_path = temp_dir.path().join("test.pack");
let index_path = temp_dir.path().join("test.idx");
let mut builder2 = PackBuilder::new(CompressionConfig::default());
builder2.add_with_path(
ContentHash::compute(&data1),
ObjectType::Blob,
data1.clone(),
Some("file.bin".to_string()),
);
builder2.add_with_path(
ContentHash::compute(&data2),
ObjectType::Blob,
data2.clone(),
Some("file.bin".to_string()),
);
let (pd, id, _) = builder2.build().unwrap();
std::fs::write(&pack_path, &pd).unwrap();
std::fs::write(&index_path, &id).unwrap();
let reader = PackReader::open(&pack_path, &index_path).unwrap();
let (_, got) = reader
.get_hashed_object(&ContentHash::compute(&data1))
.unwrap()
.unwrap();
assert_eq!(got, data1);
let (_, got) = reader
.get_hashed_object(&ContentHash::compute(&data2))
.unwrap()
.unwrap();
assert_eq!(got, data2);
}
#[test]
fn test_different_paths_with_different_content_roundtrip() {
let base_a = vec![0xAA; 1024]; let base_b = vec![0xBB; 1024];
let hash_a = ContentHash::compute(&base_a);
let hash_b = ContentHash::compute(&base_b);
let reader = build_and_open_pack(vec![
(
hash_a,
ObjectType::Blob,
base_a.clone(),
Some("a.bin".to_string()),
),
(
hash_b,
ObjectType::Blob,
base_b.clone(),
Some("b.bin".to_string()),
),
]);
let (_, got_a) = reader.get_hashed_object(&hash_a).unwrap().unwrap();
assert_eq!(got_a, base_a);
let (_, got_b) = reader.get_hashed_object(&hash_b).unwrap().unwrap();
assert_eq!(got_b, base_b);
}
#[test]
fn test_objects_without_path_use_size_bucketing() {
let shared = b"Shared prefix for size bucketing test with enough content. ".repeat(10);
let mut objects = Vec::new();
for i in 0..3u8 {
let mut data = shared.clone();
data.extend_from_slice(&[i; 16]);
objects.push((ContentHash::compute(&data), ObjectType::Blob, data, None));
}
let originals: Vec<(ContentHash, Vec<u8>)> =
objects.iter().map(|(h, _, d, _)| (*h, d.clone())).collect();
let reader = build_and_open_pack(objects);
for (hash, expected) in &originals {
let (_, data) = reader.get_hashed_object(hash).unwrap().unwrap();
assert_eq!(&data, expected);
}
}
#[test]
fn test_tree_objects_can_be_delta_encoded() {
let shared = b"tree serialization data that is shared ".repeat(15);
let mut objects = Vec::new();
for i in 0..3u8 {
let mut data = shared.clone();
data.extend_from_slice(format!("tree version {i}").as_bytes());
objects.push((
ContentHash::compute(&data),
ObjectType::Tree,
data,
Some("src/".to_string()),
));
}
let originals: Vec<(ContentHash, Vec<u8>)> =
objects.iter().map(|(h, _, d, _)| (*h, d.clone())).collect();
let reader = build_and_open_pack(objects);
for (hash, expected) in &originals {
let (obj_type, data) = reader.get_hashed_object(hash).unwrap().unwrap();
assert_eq!(obj_type, ObjectType::Tree);
assert_eq!(&data, expected);
}
}
#[test]
fn test_state_objects_not_delta_encoded() {
let data1 = b"state data 1".repeat(50);
let data2 = b"state data 2".repeat(50);
let compression = CompressionConfig::default();
let mut builder = PackBuilder::new(compression);
builder.add(ContentHash::compute(&data1), ObjectType::State, data1);
builder.add(ContentHash::compute(&data2), ObjectType::State, data2);
let (_, _, stats) = builder.build().unwrap();
assert_eq!(stats.delta_count, 0, "states should never be delta-encoded");
}
#[test]
fn test_empty_bucket_is_noop() {
let compression = CompressionConfig::default();
let builder = PackBuilder::new(compression);
let (pack_data, _, stats) = builder.build().unwrap();
assert_eq!(stats.object_count, 0);
assert_eq!(stats.delta_count, 0);
assert!(pack_data.len() >= 16 + 32); }