crates/gunnar-pack/tests/common/mod.rs
//! Fixtures shared by the integration tests.
//!
//! Nothing in here re-implements anything from `gunnar-pack`: the verification path is
//! deliberately *gix's* (gix indexes the pack, gix's object database reads it back),
//! because a reader written next to the writer can be wrong in exactly the same way
//! and still agree with it (LAW 2, TOTAL-PROMPT §15).
#![allow(dead_code)]
use std::{collections::HashMap, path::Path};
use gix_hash::{Kind, ObjectId};
use gunnar_pack::PackedObjects;
/// A minimal in-memory object store implementing exactly the seam the pack writer
/// asks for: `gix_object::Find` + `gix_object::Exists`.
///
/// This is the same shape `gunnar-store`'s `ObjectStore` adapter will present, which
/// is the point: if the writer works against this, it works against redb, znippy or
/// the `fs` backend without changes.
pub struct MemObjects {
hash_kind: Kind,
objects: HashMap<ObjectId, (gix_object::Kind, Vec<u8>)>,
}
impl MemObjects {
pub fn new(hash_kind: Kind) -> Self {
MemObjects {
hash_kind,
objects: HashMap::new(),
}
}
pub fn hash_kind(&self) -> Kind {
self.hash_kind
}
/// Store `data` as `kind`, returning the id git would give it.
pub fn insert(&mut self, kind: gix_object::Kind, data: impl Into<Vec<u8>>) -> ObjectId {
let data = data.into();
let id =
gix_object::compute_hash(self.hash_kind, kind, &data).expect("hashing fixture object");
self.objects.insert(id, (kind, data));
id
}
pub fn insert_blob(&mut self, data: impl Into<Vec<u8>>) -> ObjectId {
self.insert(gix_object::Kind::Blob, data)
}
/// Encode and store a tree.
pub fn insert_tree(&mut self, entries: Vec<gix_object::tree::Entry>) -> ObjectId {
let mut tree = gix_object::Tree { entries };
tree.entries.sort();
let mut buf = Vec::new();
use gix_object::WriteTo;
tree.write_to(&mut buf).expect("encoding a tree");
self.insert(gix_object::Kind::Tree, buf)
}
/// Store a commit built from raw text.
///
/// Written by hand rather than through `gix_object::Commit` so the test does not
/// need `gix-actor`; the bytes are exactly git's commit format.
pub fn insert_commit(
&mut self,
tree: ObjectId,
parents: &[ObjectId],
message: &str,
) -> ObjectId {
let mut body = format!("tree {tree}\n");
for parent in parents {
body.push_str(&format!("parent {parent}\n"));
}
body.push_str("author Gunnar Test <gunnar@example.invalid> 1700000000 +0000\n");
body.push_str("committer Gunnar Test <gunnar@example.invalid> 1700000000 +0000\n");
body.push('\n');
body.push_str(message);
self.insert(gix_object::Kind::Commit, body.into_bytes())
}
/// Store an annotated tag, so all four object kinds are exercised.
pub fn insert_tag(&mut self, target: ObjectId, name: &str, message: &str) -> ObjectId {
let mut body = format!("object {target}\ntype commit\ntag {name}\n");
body.push_str("tagger Gunnar Test <gunnar@example.invalid> 1700000000 +0000\n");
body.push('\n');
body.push_str(message);
self.insert(gix_object::Kind::Tag, body.into_bytes())
}
pub fn raw(&self, id: &ObjectId) -> Option<&(gix_object::Kind, Vec<u8>)> {
self.objects.get(id)
}
pub fn len(&self) -> usize {
self.objects.len()
}
}
impl gix_object::Find for MemObjects {
fn try_find<'a>(
&self,
id: &gix_hash::oid,
buffer: &'a mut Vec<u8>,
) -> gix_error::Result<Option<gix_object::Data<'a>>> {
match self.objects.get(id) {
None => Ok(None),
Some((kind, data)) => {
buffer.clear();
buffer.extend_from_slice(data);
Ok(Some(gix_object::Data {
kind: *kind,
object_hash: self.hash_kind,
data: buffer.as_slice(),
}))
}
}
}
}
impl gix_object::Exists for MemObjects {
fn exists(&self, id: &gix_hash::oid) -> bool {
self.objects.contains_key(id)
}
}
/// A `<tmp>/objects/pack` directory, ready for `write_to_directory`.
pub struct PackDir {
pub root: tempfile::TempDir,
}
impl PackDir {
pub fn new() -> Self {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join("objects").join("pack")).expect("objects/pack");
PackDir { root }
}
pub fn objects_dir(&self) -> std::path::PathBuf {
self.root.path().join("objects")
}
pub fn pack_dir(&self) -> std::path::PathBuf {
self.objects_dir().join("pack")
}
}
/// Read an object back out of a directory of packs using **gix's own object
/// database**, i.e. the same code any real git client would use.
pub fn read_from_pack_dir(
objects_dir: &Path,
hash_kind: Kind,
id: &ObjectId,
) -> Option<(gix_object::Kind, Vec<u8>)> {
let odb = gix::odb::at_opts(
objects_dir.to_owned(),
hash_kind,
Vec::new(),
gix::odb::store::init::Options::default(),
)
.expect("opening the object database");
let mut buf = Vec::new();
use gix_object::Find;
odb.try_find(id, &mut buf)
.expect("looking the object up")
.map(|data| (data.kind, data.data.to_vec()))
}
/// Encode a size the way git's *delta* headers do, LSB-first and 7 bits per byte.
///
/// Note this is **not** the pack entry header encoding; that one lives in
/// `gix_pack::data::entry::Header` and the crate under test uses it.
fn delta_size(mut n: u64, out: &mut Vec<u8>) {
loop {
let byte = (n & 0x7f) as u8;
n >>= 7;
if n == 0 {
out.push(byte);
break;
}
out.push(byte | 0x80);
}
}
/// A git delta that ignores its base and inserts `literal` verbatim.
///
/// Legal, minimal, and enough to make an entry a real `ref-delta` that only resolves
/// if the base is found.
pub fn insert_only_delta(base_len: u64, literal: &[u8]) -> Vec<u8> {
assert!(
!literal.is_empty() && literal.len() <= 127,
"one insert instruction"
);
let mut out = Vec::new();
delta_size(base_len, &mut out);
delta_size(literal.len() as u64, &mut out);
out.push(literal.len() as u8);
out.extend_from_slice(literal);
out
}
/// Hand-assemble a **thin** pack holding a single `ref-delta` entry against
/// `base_id`, which is deliberately *not* in the pack.
///
/// The crate under test does not emit deltas by design (TOTAL-PROMPT §6/§16.3), so
/// this fixture exists to feed its *reader*. It is a fixture, not a second writer.
pub fn thin_pack_with_ref_delta(
hash_kind: Kind,
base_id: ObjectId,
base_len: u64,
literal: &[u8],
) -> Vec<u8> {
use std::io::Write;
let mut out = Vec::new();
let mut hasher = gix_hash::hasher(hash_kind);
let emit = |bytes: &[u8], out: &mut Vec<u8>, hasher: &mut gix_hash::Hasher| {
hasher.update(bytes);
out.extend_from_slice(bytes);
};
emit(b"PACK", &mut out, &mut hasher);
emit(&2u32.to_be_bytes(), &mut out, &mut hasher);
emit(&1u32.to_be_bytes(), &mut out, &mut hasher);
let delta = insert_only_delta(base_len, literal);
let mut header = Vec::new();
gix_pack::data::entry::Header::RefDelta { base_id }
.write_to(delta.len() as u64, &mut header)
.expect("encoding a ref-delta header");
emit(&header, &mut out, &mut hasher);
let mut deflate = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
deflate.write_all(&delta).expect("deflating the delta");
let compressed = deflate.finish().expect("finishing the deflate stream");
emit(&compressed, &mut out, &mut hasher);
let id = hasher.try_finalize().expect("pack checksum");
out.extend_from_slice(id.as_slice());
out
}
// ── the stock-`git` harness, shared (LAW 5) ─────────────────────────────────
//
// `tests/ofs_delta_off.rs` and `tests/deltified.rs` both need to run git and
// both need to know which delta FORM an entry uses, and the second of those is
// forty lines of format knowledge that had already been got wrong once. One
// copy, here, is what keeps the two files from drifting apart about what
// `verify-pack -v` prints.
/// Run `git`, failing loudly on a non-zero exit, and return stdout.
///
/// Output **and** exit code: a malformed invocation fails silently and its empty
/// output is shaped exactly like a clean result (`LAW 2`).
pub fn git(args: &[&str], cwd: &Path) -> String {
let out = git_raw(args, cwd);
assert!(
out.status.success(),
"git {args:?} exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
/// The same invocation with the verdict left to the caller — the shape a
/// negative control needs, because it must observe git *refusing*.
pub fn git_raw(args: &[&str], cwd: &Path) -> std::process::Output {
std::process::Command::new("git")
.args(args)
.current_dir(cwd)
// The developer's own config must not decide what a test proves.
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "gunnar")
.env("GIT_AUTHOR_EMAIL", "gunnar@example.invalid")
.env("GIT_COMMITTER_NAME", "gunnar")
.env("GIT_COMMITTER_EMAIL", "gunnar@example.invalid")
.env("GIT_AUTHOR_DATE", "1700000000 +0000")
.env("GIT_COMMITTER_DATE", "1700000000 +0000")
.output()
.unwrap_or_else(|err| panic!("running git {args:?}: {err}"))
}
/// **A missing `git` is a failure, not a skip** (`S-026`).
pub fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// How many entries of each form a pack holds, decided by **stock git's own
/// offsets** and the one byte the pack format puts at each of them.
///
/// Returns `(base entries, ofs deltas, ref deltas)`.
///
/// `git verify-pack -v` does not name the two delta forms: it prints the
/// *object* type (`blob`, `tree`, `commit`) for every entry and marks a delta by
/// appending `<depth> <base-sha>`. The first version of this helper matched on
/// the type column for `ofs-delta`/`ref-delta`, found neither, and reported that
/// a pack full of deltas held none — which a fixture guard caught before any
/// claim was made from it.
///
/// So the delta *form* is read where the format actually records it: the high
/// nibble of the first byte of the entry, `6` for `OBJ_OFS_DELTA` and `7` for
/// `OBJ_REF_DELTA`. Every offset comes from git, and the only thing read here is
/// a single byte at each of them. That is not gunnar's decoder agreeing with
/// gunnar's encoder; it is one byte of a format both were written against.
pub fn entry_kinds(idx: &Path, pack: &Path, cwd: &Path) -> (usize, usize, usize) {
let listing = git(&["verify-pack", "-v", &idx.to_string_lossy()], cwd);
let bytes = std::fs::read(pack).expect("reading the pack back");
let (mut base, mut ofs, mut refs) = (0, 0, 0);
for line in listing.lines() {
// `<oid> <type> <size> <size-in-pack> <offset>` and, for a delta, a
// trailing `<depth> <base-oid>`. Histogram lines carry no oid.
let fields: Vec<&str> = line.split_whitespace().collect();
let Some(first) = fields.first() else {
continue;
};
if first.len() < 40 || !first.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
if fields.len() < 5 {
continue;
}
if fields.len() < 7 {
base += 1;
continue;
}
let offset: usize = fields[4].parse().expect("git printed a pack offset");
match (bytes[offset] >> 4) & 0b111 {
6 => ofs += 1,
7 => refs += 1,
other => panic!("git called offset {offset} a delta, but its type nibble is {other}"),
}
}
assert!(
base + ofs + refs > 0,
"`git verify-pack -v` named no entries at all in {pack:?}. An empty parse is shaped \
exactly like a pack with no deltas in it"
);
(base, ofs, refs)
}
impl gix_object::FindHeader for MemObjects {
fn try_header(
&self,
id: &gix_hash::oid,
) -> gix_error::Result<Option<gix_object::Header>> {
Ok(self.objects.get(id).map(|(kind, data)| gix_object::Header {
kind: *kind,
size: data.len() as u64,
}))
}
}
// ── the packed-repository fixture, shared (LAW 5) ───────────────────────────
//
// `tests/ofs_delta_off.rs` wrote both of these first. `tests/pack_extents.rs`
// needs exactly the same two things — a repository whose entries are deltas
// worth copying, and gunnar's own view onto its packs — and copying fifty lines
// to get them is what LAW 5 forbids.
/// A repository whose history *churns* one large file, so `git repack` has
/// something to delta.
///
/// **Not random blobs.** A corpus of unique random content deltas to nothing, and
/// would make a working delta-copy path indistinguishable from a dead one — the
/// identity-value trap `P-001`'s Verify step names by name.
pub fn churned_repo(dir: &std::path::Path) -> Vec<ObjectId> {
churned_repo_with(dir, gix_hash::Kind::Sha1)
}
/// The same fixture at a chosen object format.
///
/// `sha256` is not a second corpus, it is the *same* corpus in the other hash —
/// which is what makes "sha1 reproduces and sha256 does not" a statement about
/// the hash rather than about two different repositories. `S-026`: the hash is
/// a fixture parameter, never a reason to skip.
pub fn churned_repo_with(dir: &std::path::Path, hash: gix_hash::Kind) -> Vec<ObjectId> {
let format = match hash {
gix_hash::Kind::Sha1 => "sha1",
_ => "sha256",
};
git(
&[
"init",
"-q",
&format!("--object-format={format}"),
"-b",
"main",
".",
],
dir,
);
// Big enough that git bothers to delta it. The first attempt used 400 lines
// over 12 revisions and `git repack -adf` produced **zero** deltas, which the
// fixture assertion below caught before any conclusion was drawn from it.
let mut lines: Vec<String> = (0..4000)
.map(|i| format!("line {i} of the corpus, with enough text on it to matter\n"))
.collect();
for rev in 0..24 {
// Rewrite ~1% of the lines, which is what a commit does.
for slot in 0..40 {
let i = (rev * 37 + slot * 53) % lines.len();
lines[i] = format!("line {i} rewritten at revision {rev}\n");
}
std::fs::write(dir.join("corpus.txt"), lines.concat()).expect("writing the corpus");
git(&["add", "corpus.txt"], dir);
git(&["commit", "-q", "-m", &format!("revision {rev}")], dir);
}
// What a server that has ever run `gc` looks like, and the only state in
// which any of this is reachable: an unpacked repository has no entry to
// copy and every case below would pass on the fallback.
git(&["repack", "-adfq"], dir);
let listing = git(&["rev-list", "--objects", "--all"], dir);
let ids: Vec<ObjectId> = listing
.lines()
.filter_map(|l| l.split_whitespace().next())
.map(|hex| ObjectId::from_hex(hex.as_bytes()).expect("a git-printed oid"))
.collect();
assert!(
ids.len() > 20,
"the fixture holds {} objects, too few to contain a delta chain",
ids.len()
);
ids
}
/// Open the repository's packs the way `gunnar-store`'s `fs` backend does.
///
/// Reached through the `gix` dev-dependency's re-export rather than a direct
/// `gix-odb` one: this crate is on the server side of the dependency guard and
/// its manifest may carry gix as a **fixture** only.
pub fn packed_objects(dir: &std::path::Path) -> PackedObjects {
let repo = gix::open(dir).expect("opening the fixture repository");
// The `Arc<Store>`, not the handle. A handle owns `RefCell` scratch and is
// `Send` but not `Sync`, so it cannot be captured by the factory; the store
// behind it is both, and minting a fresh handle per call is the shape
// `gunnar-store`'s `fs` backend uses for exactly this reason.
let store = repo.objects.store();
PackedObjects::new(move || {
let mut handle = store.to_handle_arc();
// `entry_by_location` hands out slices into a mapped pack; gix panics
// rather than let a caller read memory it may have unmapped.
handle.prevent_pack_unload();
Box::new(handle) as Box<dyn gix_pack::Find + Send>
})
}