Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
            );
        }
    }
}