crates/gunnar-pack/tests/roundtrip.rs
//! The verification that matters: **gix indexes what gunnar-pack wrote**.
//!
//! Not one assertion here re-reads gunnar-pack's bytes with gunnar-pack's own reader.
//! A writer and a reader built side by side can share a misunderstanding of the format
//! and agree with each other perfectly (LAW 2 / TOTAL-PROMPT §15), so every claim about
//! a produced pack is made by handing it to `gix_pack::Bundle::write_to_directory` and
//! then reading the objects back out through gix's object database.
mod common;
use std::io::Cursor;
use common::{read_from_pack_dir, thin_pack_with_ref_delta, MemObjects, PackDir};
use gix_hash::Kind;
use gunnar_pack::{
empty_pack, index_pack, index_thin_pack, missing_objects, write_pack_to_vec, Error,
IndexOptions, PackWriter, WriteOptions,
};
/// Hand `pack` to gix and let *it* say what is in there.
fn index_with_gix(pack: &[u8], hash_kind: Kind) -> (PackDir, gunnar_pack::IndexedPack) {
let dir = PackDir::new();
let indexed = index_pack(
&mut Cursor::new(pack),
Some(&dir.pack_dir()),
&IndexOptions::new(hash_kind),
None,
)
.expect("gix should index a pack written by gunnar-pack");
(dir, indexed)
}
// ─────────────────────────────── the format itself ──────────────────────────────
#[test]
fn header_is_the_literal_bytes_git_expects() {
let mut store = MemObjects::new(Kind::Sha1);
let ids = vec![store.insert_blob("one"), store.insert_blob("two")];
let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();
assert_eq!(&pack[0..4], b"PACK");
assert_eq!(&pack[4..8], &[0, 0, 0, 2], "pack version 2");
assert_eq!(&pack[8..12], &[0, 0, 0, 2], "two objects, big endian");
}
#[test]
fn trailer_width_follows_the_hash_kind() {
let sha1 = empty_pack(Kind::Sha1).unwrap();
let sha256 = empty_pack(Kind::Sha256).unwrap();
assert_eq!(sha1.len(), 12 + 20, "header plus a 20 byte sha1 trailer");
assert_eq!(
sha256.len(),
12 + 32,
"header plus a 32 byte sha256 trailer"
);
assert_eq!(sha1[..12], sha256[..12], "identical headers");
assert_ne!(
sha1[12..32],
sha256[12..32],
"different hash functions over the same header must differ"
);
}
// ─────────────────────────────── blobs, both hash kinds ─────────────────────────
#[test]
fn sha1_blobs_index_and_read_back_byte_exact() {
let mut store = MemObjects::new(Kind::Sha1);
let payloads: Vec<Vec<u8>> = vec![
b"hello gunnar".to_vec(),
Vec::new(), // an empty blob is a real object
vec![0u8, 1, 2, 255, 254, 128], // binary, not text
vec![b'x'; 200_000], // bigger than one deflate buffer
];
let ids: Vec<_> = payloads
.iter()
.map(|p| store.insert_blob(p.clone()))
.collect();
let (pack, outcome) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();
let (dir, indexed) = index_with_gix(&pack, Kind::Sha1);
assert_eq!(indexed.num_objects, 4, "gix counted what we declared");
assert_eq!(outcome.num_objects, 4);
assert_eq!(
indexed.pack_id, outcome.pack_id,
"our trailer and gix's recomputed pack checksum must agree"
);
assert_eq!(outcome.bytes_written as usize, pack.len());
for (id, payload) in ids.iter().zip(&payloads) {
let (kind, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, id)
.expect("object must be readable from the pack");
assert_eq!(kind, gix_object::Kind::Blob);
assert_eq!(&data, payload, "blob {id} must come back byte-exact");
}
}
#[test]
fn sha256_blobs_index_and_read_back_byte_exact() {
let mut store = MemObjects::new(Kind::Sha256);
let payload = b"a sha256 repository is not a sha1 repository".to_vec();
let id = store.insert_blob(payload.clone());
assert_eq!(id.kind(), Kind::Sha256);
assert_eq!(id.as_slice().len(), 32, "sha256 ids are 32 bytes wide");
let (pack, outcome) =
write_pack_to_vec(&[id], &store, &WriteOptions::new(Kind::Sha256)).unwrap();
assert_eq!(outcome.pack_id.kind(), Kind::Sha256);
let (dir, indexed) = index_with_gix(&pack, Kind::Sha256);
assert_eq!(indexed.num_objects, 1);
assert_eq!(indexed.hash_kind, Kind::Sha256);
assert_eq!(indexed.pack_id, outcome.pack_id);
let (kind, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha256, &id).expect("readable");
assert_eq!(kind, gix_object::Kind::Blob);
assert_eq!(data, payload);
}
#[test]
fn a_sha256_pack_is_not_readable_as_sha1() {
// The red side of the previous test: if sha256 were quietly being written as sha1,
// this would succeed and the sha256 green above would prove nothing.
let mut store = MemObjects::new(Kind::Sha256);
let id = store.insert_blob("payload");
let (pack, _) = write_pack_to_vec(&[id], &store, &WriteOptions::new(Kind::Sha256)).unwrap();
let dir = PackDir::new();
let result = index_pack(
&mut Cursor::new(&pack),
Some(&dir.pack_dir()),
&IndexOptions::new(Kind::Sha1),
None,
);
assert!(
result.is_err(),
"a sha256 pack indexed as sha1 must fail, got {:?}",
result.map(|r| r.num_objects)
);
}
// ─────────────────────────────── trees and commits ──────────────────────────────
fn tree_entry(
name: &str,
oid: gix_hash::ObjectId,
kind: gix_object::tree::EntryKind,
) -> gix_object::tree::Entry {
gix_object::tree::Entry {
mode: kind.into(),
filename: name.into(),
oid,
}
}
fn history_round_trip(hash_kind: Kind) {
let mut store = MemObjects::new(hash_kind);
let readme = store.insert_blob("# gunnar\n");
let src = store.insert_blob("fn main() {}\n");
let subtree = store.insert_tree(vec![tree_entry(
"main.rs",
src,
gix_object::tree::EntryKind::Blob,
)]);
let root = store.insert_tree(vec![
tree_entry("README.md", readme, gix_object::tree::EntryKind::Blob),
tree_entry("src", subtree, gix_object::tree::EntryKind::Tree),
]);
let first = store.insert_commit(root, &[], "first\n");
let second = store.insert_commit(root, &[first], "second, with a parent\n");
let tag = store.insert_tag(second, "v1.0.0", "the release\n");
// blob, tree, commit and tag: every object kind the type field can carry.
let ids = vec![readme, src, subtree, root, first, second, tag];
let (pack, outcome) = write_pack_to_vec(&ids, &store, &WriteOptions::new(hash_kind)).unwrap();
let (dir, indexed) = index_with_gix(&pack, hash_kind);
assert_eq!(indexed.num_objects, 7);
assert_eq!(indexed.pack_id, outcome.pack_id);
for id in &ids {
let (kind, data) = read_from_pack_dir(&dir.objects_dir(), hash_kind, id).expect("readable");
let (expected_kind, expected_data) = store.raw(id).unwrap();
assert_eq!(kind, *expected_kind, "object kind survives the round trip");
assert_eq!(
&data, expected_data,
"object {id} must come back byte-exact"
);
}
// …and gix must be able to *parse* what came back, rather than hand back bytes.
let (_, commit_bytes) = read_from_pack_dir(&dir.objects_dir(), hash_kind, &second).unwrap();
let commit =
gix_object::CommitRef::from_bytes(&commit_bytes, hash_kind).expect("a parseable commit");
assert_eq!(commit.tree(), root);
assert_eq!(commit.parents().collect::<Vec<_>>(), vec![first]);
let (_, tree_bytes) = read_from_pack_dir(&dir.objects_dir(), hash_kind, &root).unwrap();
let tree = gix_object::TreeRef::from_bytes(&tree_bytes, hash_kind).expect("a parseable tree");
assert_eq!(tree.entries.len(), 2);
assert_eq!(tree.entries[0].filename, "README.md");
assert_eq!(tree.entries[1].oid, subtree);
let (kind, tag_bytes) = read_from_pack_dir(&dir.objects_dir(), hash_kind, &tag).unwrap();
assert_eq!(
kind,
gix_object::Kind::Tag,
"a tag must not come back as something else"
);
let tag_ref = gix_object::TagRef::from_bytes(&tag_bytes, hash_kind).expect("a parseable tag");
assert_eq!(tag_ref.target(), second);
assert_eq!(tag_ref.name, "v1.0.0");
}
#[test]
fn trees_and_commits_round_trip_sha1() {
history_round_trip(Kind::Sha1);
}
#[test]
fn trees_and_commits_round_trip_sha256() {
history_round_trip(Kind::Sha256);
}
// ─────────────────────────────── empty packs ────────────────────────────────────
#[test]
fn an_empty_pack_is_legal_and_indexes() {
for hash_kind in [Kind::Sha1, Kind::Sha256] {
let (pack, outcome) = write_pack_to_vec(
&[],
&MemObjects::new(hash_kind),
&WriteOptions::new(hash_kind),
)
.expect("writing an empty pack must not fail");
assert_eq!(outcome.num_objects, 0);
assert_eq!(pack, empty_pack(hash_kind).unwrap());
let (_dir, indexed) = index_with_gix(&pack, hash_kind);
assert_eq!(
indexed.num_objects, 0,
"an empty pack indexes to zero objects"
);
}
}
// ─────────────────────────── the guard must be able to fail ─────────────────────
#[test]
fn a_corrupted_pack_is_rejected() {
let mut store = MemObjects::new(Kind::Sha1);
let ids = vec![
store.insert_blob("the quick brown fox jumps over the lazy dog, repeatedly and at length"),
store.insert_blob("a second object so the first is not the whole pack"),
];
let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();
// Same bytes, unmodified: this must pass, or the rejection below proves nothing.
let dir = PackDir::new();
index_pack(
&mut Cursor::new(&pack),
Some(&dir.pack_dir()),
&IndexOptions::new(Kind::Sha1),
None,
)
.expect("the pristine pack must index");
// Now flip exactly one bit of one byte, well inside the first object's deflated
// data, past the 12 byte header and its entry header, far from the trailer.
let victim = 20;
assert!(
victim < pack.len() - 20,
"the byte must not be in the trailer"
);
let mut corrupt = pack.clone();
corrupt[victim] ^= 0b0000_0001;
assert_ne!(corrupt, pack);
let dir = PackDir::new();
let result = index_pack(
&mut Cursor::new(&corrupt),
Some(&dir.pack_dir()),
&IndexOptions::new(Kind::Sha1),
None,
);
match result {
Err(Error::Index(_)) => {}
Err(other) => panic!("expected an indexing rejection, got {other:?}"),
Ok(indexed) => panic!(
"a corrupted pack was ACCEPTED with {} objects, so the verification is blind",
indexed.num_objects
),
}
}
#[test]
fn a_truncated_pack_is_rejected() {
let mut store = MemObjects::new(Kind::Sha1);
let ids = vec![store.insert_blob("some content that survives a few hundred bytes of deflate")];
let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();
let truncated = &pack[..pack.len() - 5];
let dir = PackDir::new();
assert!(
index_pack(
&mut Cursor::new(truncated),
Some(&dir.pack_dir()),
&IndexOptions::new(Kind::Sha1),
None,
)
.is_err(),
"a pack missing its trailer must be rejected"
);
}
// ─────────────────────────────── thin packs ─────────────────────────────────────
#[test]
fn a_thin_pack_resolves_against_the_store() {
let mut store = MemObjects::new(Kind::Sha1);
let base_content = b"the base object, which the pusher already knew we had".to_vec();
let base_id = store.insert_blob(base_content.clone());
let literal = b"the resolved delta result".to_vec();
let pack = thin_pack_with_ref_delta(Kind::Sha1, base_id, base_content.len() as u64, &literal);
// The red half first: without a base lookup, this pack CANNOT be indexed.
let dir = PackDir::new();
let without_bases = index_pack(
&mut Cursor::new(&pack),
Some(&dir.pack_dir()),
&IndexOptions::new(Kind::Sha1),
None,
);
assert!(
without_bases.is_err(),
"a thin pack must NOT index without its bases, otherwise the thin wiring below is untested"
);
// …and the green half: with the store wired in, it resolves.
let dir = PackDir::new();
let indexed = index_thin_pack(
&mut Cursor::new(&pack),
Some(&dir.pack_dir()),
&store,
&IndexOptions::new(Kind::Sha1),
None,
)
.expect("a thin pack must index when its bases are in the store");
assert_eq!(
indexed.num_objects, 2,
"the completed pack holds the delta result and the base pulled in to resolve it"
);
let resolved_id =
gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, &literal).unwrap();
let (kind, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, &resolved_id)
.expect("the delta result must be readable");
assert_eq!(kind, gix_object::Kind::Blob);
assert_eq!(
data, literal,
"the delta must resolve to exactly its target"
);
let (_, base) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, &base_id)
.expect("the completed pack must also carry the base");
assert_eq!(base, base_content);
}
#[test]
fn a_thin_pack_with_an_unknown_base_is_rejected() {
let store = MemObjects::new(Kind::Sha1);
let unknown =
gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, b"never stored").unwrap();
let pack = thin_pack_with_ref_delta(Kind::Sha1, unknown, 12, b"result");
let dir = PackDir::new();
assert!(
index_thin_pack(
&mut Cursor::new(&pack),
Some(&dir.pack_dir()),
&store,
&IndexOptions::new(Kind::Sha1),
None,
)
.is_err(),
"a delta whose base is nowhere must be rejected, not silently dropped"
);
}
// ─────────────────────────────── writer contract ────────────────────────────────
#[test]
fn a_missing_object_is_an_error_not_a_short_pack() {
let mut store = MemObjects::new(Kind::Sha1);
let present = store.insert_blob("here");
let absent = gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, b"not here").unwrap();
match write_pack_to_vec(&[present, absent], &store, &WriteOptions::new(Kind::Sha1)) {
Err(Error::ObjectMissing { id }) => assert_eq!(id, absent),
other => panic!("expected ObjectMissing, got {:?}", other.map(|(_, o)| o)),
}
assert_eq!(missing_objects(&[present, absent], &store), vec![absent]);
assert!(missing_objects(&[present], &store).is_empty());
}
#[test]
fn an_id_of_the_wrong_hash_kind_is_rejected() {
let mut sha1_store = MemObjects::new(Kind::Sha1);
let sha1_id = sha1_store.insert_blob("content");
match write_pack_to_vec(&[sha1_id], &sha1_store, &WriteOptions::new(Kind::Sha256)) {
Err(Error::HashKindMismatch {
actual, expected, ..
}) => {
assert_eq!(actual, Kind::Sha1);
assert_eq!(expected, Kind::Sha256);
}
other => panic!("expected HashKindMismatch, got {:?}", other.map(|(_, o)| o)),
}
}
#[test]
fn duplicate_ids_are_written_once() {
let mut store = MemObjects::new(Kind::Sha1);
let id = store.insert_blob("only once");
let (pack, outcome) =
write_pack_to_vec(&[id, id, id], &store, &WriteOptions::new(Kind::Sha1)).unwrap();
assert_eq!(outcome.num_objects, 1);
let (_dir, indexed) = index_with_gix(&pack, Kind::Sha1);
assert_eq!(indexed.num_objects, 1);
}
#[test]
fn finishing_early_is_an_error_rather_than_a_truncated_pack() {
let mut store = MemObjects::new(Kind::Sha1);
let id = store.insert_blob("one");
let (kind, data) = store.raw(&id).unwrap().clone();
let mut out = Vec::new();
let mut writer = PackWriter::new(&mut out, 2, &WriteOptions::new(Kind::Sha1)).unwrap();
writer.add_object(kind, &data).unwrap();
assert_eq!(writer.objects_written(), 1);
assert_eq!(writer.objects_remaining(), 1);
match writer.finish() {
Err(Error::ObjectCountMismatch { declared, written }) => {
assert_eq!((declared, written), (2, 1));
}
other => panic!("expected ObjectCountMismatch, got {other:?}"),
}
}
#[test]
fn writing_past_the_declared_count_is_an_error() {
let mut store = MemObjects::new(Kind::Sha1);
let id = store.insert_blob("one");
let (kind, data) = store.raw(&id).unwrap().clone();
let mut out = Vec::new();
let mut writer = PackWriter::new(&mut out, 1, &WriteOptions::new(Kind::Sha1)).unwrap();
writer.add_object(kind, &data).unwrap();
assert!(matches!(
writer.add_object(kind, &data),
Err(Error::PackFull { declared: 1 })
));
}
#[test]
fn compression_level_changes_the_bytes_but_not_the_content() {
let mut store = MemObjects::new(Kind::Sha1);
let payload = vec![b'a'; 50_000];
let id = store.insert_blob(payload.clone());
let (fast, _) = write_pack_to_vec(
&[id],
&store,
&WriteOptions::new(Kind::Sha1).compression_level(1),
)
.unwrap();
let (best, _) = write_pack_to_vec(
&[id],
&store,
&WriteOptions::new(Kind::Sha1).compression_level(9),
)
.unwrap();
assert_ne!(fast, best, "the deflate level must actually reach flate2");
for pack in [fast, best] {
let (dir, indexed) = index_with_gix(&pack, Kind::Sha1);
assert_eq!(indexed.num_objects, 1);
let (_, data) = read_from_pack_dir(&dir.objects_dir(), Kind::Sha1, &id).unwrap();
assert_eq!(data, payload);
}
}
#[test]
fn an_allocation_limit_stops_an_oversized_object() {
// receive-pack's cheap defence: a pushed pack claiming a huge object must be
// stopped while it is being decoded, not after it has been allocated.
let mut store = MemObjects::new(Kind::Sha1);
let ids = vec![store.insert_blob(vec![b'z'; 100_000])];
let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();
index_pack(
&mut Cursor::new(&pack),
None,
&IndexOptions::new(Kind::Sha1),
None,
)
.expect("without a limit the same pack indexes fine");
assert!(
index_pack(
&mut Cursor::new(&pack),
None,
&IndexOptions::new(Kind::Sha1).alloc_limit_bytes(1_024),
None,
)
.is_err(),
"an object above the allocation limit must be refused"
);
}
#[test]
fn validation_only_indexing_keeps_nothing() {
let mut store = MemObjects::new(Kind::Sha1);
let ids = vec![store.insert_blob("validate me")];
let (pack, _) = write_pack_to_vec(&ids, &store, &WriteOptions::new(Kind::Sha1)).unwrap();
let indexed = index_pack(
&mut Cursor::new(&pack),
None,
&IndexOptions::new(Kind::Sha1),
None,
)
.expect("validating without a directory must work");
assert_eq!(indexed.num_objects, 1);
assert!(
indexed.data_path.is_none(),
"nothing is kept when no directory is given"
);
assert!(indexed.index_path.is_none());
}