crates/gunnar-wire/src/pack.rs
//! **The pack-shaped values the protocol carries.** Vocabulary, not machinery.
//!
//! # Why these live here and not in `gunnar-pack`
//!
//! Every one of these types used to be `gunnar_pack::…`, and naming them was
//! the last edge `gunnar-wire` had into a pack *implementation*. It was a
//! **vocabulary** edge and never a call: nothing under `gunnar-wire/src` has
//! called `index_pack`, `PackWriter`, `write_pack_copy` or
//! `gix_pack::data::output::entry::iter_from_counts` since the engine seam
//! landed — [`crate::upload_pack`] emits through
//! [`GitServe::emit_pack`](gunnar_store::git_storage_trait::GitServe::emit_pack) and the
//! receive path takes the bytes through a caller-supplied
//! [`PackIngest`](crate::receive_pack::PackIngest) whose only shipping
//! implementation goes straight to `GitOps::put_pack`.
//!
//! But cargo links what a manifest names, not what a call graph reaches. One
//! `pub struct` field spelled `gix_pack::data::Version` was enough to put
//! `gix-pack` — and behind it `gix-diff`, `gix-traverse`, `gix-revwalk`,
//! `gix-commitgraph`, `gix-fs`, `gix-tempfile`, `gix-path`, `gix-chunk`,
//! `gix-zlib` and `gix-features/parallel` — into **every** build of the
//! appliance, including `--no-default-features --features server,engine-znippy`
//! whose whole point is that the pack pipeline is not there.
//!
//! So the vocabulary moved to the side that speaks it. These are the words
//! `receive-pack` and `upload-pack` use to *report* what an engine did; the
//! engine behind the contract is what actually did it, and which crate it used
//! is its own business. `gunnar-pack` remains a **dev-dependency** of this
//! crate, where it is what it always was for these tests: an outside oracle
//! that indexes a pack gunnar produced and disagrees when gunnar is wrong.
//!
//! # The residue, said out loud
//!
//! [`ObjectId`] and [`Kind`] are still `gix-hash`'s. They are cheap value types
//! — a 32-byte inline array and a two-variant enum — with no pipeline behind
//! them, and the contract's own `Oid<'a> = &'a [u8]` is a *borrow*, so it
//! cannot be what an owned id in a returned struct is spelled as. Replacing
//! them is a workspace-wide rename with no dependency payoff on its own, and it
//! is not what this module is for.
use std::path::PathBuf;
use gix_hash::{Kind, ObjectId};
/// **A packfile's format version**, as the four bytes after `PACK` say it.
///
/// Local, and two variants wide, because the field below is the only thing
/// `gunnar-wire` and `gunnar-server` ever wanted out of `gix-pack` — see this
/// module's header for what naming it cost.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PackVersion {
/// Version 2. Everything git has written by default since 2005.
V2,
/// Version 3. Reserved by the format; git does not emit it.
V3,
}
impl PackVersion {
/// The number as it appears in the pack header.
pub fn as_u32(self) -> u32 {
match self {
PackVersion::V2 => 2,
PackVersion::V3 => 3,
}
}
/// Read a version out of a pack header.
///
/// **Anything that is not 3 is read as 2**, which is what the pack readers
/// in this constellation have always done: the header is attacker-supplied
/// on a receive path, and a version this does not know is not a reason to
/// invent a third variant that no code below can handle.
pub fn from_u32(raw: u32) -> Self {
match raw {
3 => PackVersion::V3,
_ => PackVersion::V2,
}
}
}
impl std::fmt::Display for PackVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_u32())
}
}
/// **What came out of taking a pushed pack into storage.**
///
/// A *receipt*, not a handle: every field is a fact read back off what was
/// stored, and nothing here reaches into the store to answer. The three
/// `Option<PathBuf>`s are `None` on every engine that does not keep git's
/// on-disk layout — which is the default one — and `None` there is the truth
/// rather than a degraded answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedPack {
/// How many objects the resulting pack holds.
///
/// For a **thin** pack this is larger than the number of entries that
/// arrived on the wire: bases pulled in from the store to complete it are
/// counted too.
pub num_objects: u32,
/// The pack's trailing checksum, which is its identity and the `<id>` in
/// `pack-<id>.pack`.
pub pack_id: ObjectId,
/// The checksum of the written index file, or a null id where no index file
/// exists.
pub index_id: ObjectId,
/// The pack format version that was read.
pub pack_version: PackVersion,
/// The hash kind of the pack and index.
pub hash_kind: Kind,
/// Where the `.idx` landed, if one was written.
pub index_path: Option<PathBuf>,
/// Where the `.pack` landed, if one was written.
pub data_path: Option<PathBuf>,
/// How many bytes the stored pack occupies, when the store knows and there
/// is no file to `stat`.
///
/// # Why this is not the same question as `data_path`
///
/// A packfile engine answers "how big is the pack that landed" by measuring
/// the file it wrote. An engine that writes no packfile — the znippy store
/// appends the received bytes VERBATIM into its archive — has no file, and
/// for a while that made the whole measurement an *unknown*: every push
/// into the engine gunnar ships logged
/// `pack_bytes_unknown="pack … was indexed without a directory"`.
///
/// That was false. The store counts the bytes it appends
/// (`PushReceipt::pack_bytes`); nobody carried the number this far. An
/// absence rendered where a measurement exists is the same defect as a
/// measurement rendered where an absence exists, and it is worse
/// here, because a reader is told gunnar does not know something it does.
///
/// `None` means "ask `data_path`" — the pack IS a file and its size is that
/// file's size, which is the truer answer for an engine that has one. It
/// still means genuinely unknown when there is no file either, and callers
/// must keep reporting that as unknown rather than as zero: rendering the
/// missing file as `new_object_bytes: 0` reads as *a push that stored
/// nothing*, in the one record a verifier is supposed to be able to trust.
pub stored_bytes: Option<u64>,
/// The `.keep` file guarding the new pack from collection until refs point
/// at it. The caller owns it and is expected to remove it once the ref
/// transaction is applied.
pub keep_path: Option<PathBuf>,
}
/// **What one packfile emission produced.**
///
/// Filled in from the engine's own `PackStats` on the way out of
/// [`GitServe::emit_pack`](gunnar_store::git_storage_trait::GitServe::emit_pack), so every
/// number is read back off what the engine reported rather than restated from
/// what was asked for.
///
/// **No `Default`**, and that is a property rather than an omission: `ObjectId`
/// has none, and an outcome with a zeroed pack id is one that claims a pack was
/// written and cannot name it. `gunnar-pack`'s own `WriteOutcome` refused the
/// same shape for the same reason.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteOutcome {
/// How many objects the pack contains. Zero is legal.
pub num_objects: u32,
/// The pack's identity: the checksum over every byte preceding it, which is
/// also the pack's trailing bytes and the `pack-<id>.pack` name git gives
/// it.
pub pack_id: ObjectId,
/// Total bytes written, header and trailer included.
pub bytes_written: u64,
/// Objects whose already-deflated pack entry was **copied** verbatim: no
/// inflate, no deflate, delta chains preserved.
///
/// This and the next field are pack-copy's applied output. A byte count and a
/// wall clock cannot tell a pack-copy from a re-deflate; these two can, and
/// the day an engine starts re-deflating, `objects_recompressed` climbs
/// instead of a test staying green.
pub objects_copied_from_pack: usize,
/// Objects the engine deflated from their decompressed content.
pub objects_recompressed: usize,
/// Objects emitted as a **newly computed** delta against another object in
/// the same pack.
///
/// Zero on the serve path, which reuses the pushing client's deltas and has
/// no encoder. It exists because `bytes_written` alone cannot tell a
/// deltified pack from a whole-object one: on 2026-08-03 the client shipped
/// 407.08 MiB of pack at **0.0% deltified** while every byte count, wall
/// clock and correctness test in the tree was green.
pub objects_deltified: usize,
/// Thin-delta bases the engine **offered** — entries copied as an
/// `OBJ_REF_DELTA` naming a base outside this pack.
///
/// # Why the offer is counted and not only the refusal
///
/// `objects_recompressed` says how many objects lost their delta, and
/// cannot say how many *could have kept it*. A voucher that refuses
/// everything and a transfer that had no thin deltas to begin with produce
/// the same recompressed count and are opposite situations. The refusal
/// *rate* — `thin_bases_vetoed` over this — is the number that tells them
/// apart, and no other field here can be made to yield it.
pub thin_bases_offered: usize,
/// Of those, the ones the `client_has` voucher would not vouch for, whose
/// objects were therefore written whole.
pub thin_bases_vetoed: usize,
/// Deltas a base closure declined because the base it would have named is
/// an object this pack **already carries**, at a location in another pack of
/// a multi-pack store.
///
/// Zero on a single-pack store, on a negotiated fetch and whenever the
/// closure is off.
pub closure_bases_declined: usize,
}
/// Ceiling on a single entry's allocation while decoding an untrusted pack:
/// 1 GiB.
///
/// Chosen to be far above any object a real repository contains and far below
/// what hurts a server. The size being guarded is **read out of the pack**, so
/// it is attacker-controlled: a pack can claim an entry is enormous and,
/// without this, a decoder tries to allocate exactly that.
///
/// The same number `gunnar-pack` applies on its own receive path, and the
/// twinning is **checked** rather than asserted in prose:
/// `tests/pack_vocabulary.rs` compares the two. It lives out there and not in a
/// `#[cfg(test)]` block down this file because `xtask::engine_seam` reads
/// `crates/*/src` and does not read `#[cfg]`, deliberately — a rule with a
/// "but it is only a test" escape hatch inside a shipped file is a rule with a
/// hole in it, and the guard found this very line the first time it ran.
pub const DEFAULT_ALLOC_LIMIT_BYTES: usize = 1 << 30;
#[cfg(test)]
mod tests {
use super::*;
/// The header value round-trips, and an unknown version reads as 2 rather
/// than as a panic or a third variant. `PackVersion::from_u32` is fed a
/// number that came off the wire.
#[test]
fn pack_version_round_trips_and_degrades_to_v2() {
assert_eq!(PackVersion::from_u32(2), PackVersion::V2);
assert_eq!(PackVersion::from_u32(3), PackVersion::V3);
assert_eq!(PackVersion::V2.as_u32(), 2);
assert_eq!(PackVersion::V3.as_u32(), 3);
for hostile in [0, 1, 4, u32::MAX] {
assert_eq!(
PackVersion::from_u32(hostile),
PackVersion::V2,
"a version this does not know must read as 2, not invent a variant"
);
}
}
}