Skip to main content

gunnar_wire/
pack.rs

1//! **The pack-shaped values the protocol carries.** Vocabulary, not machinery.
2//!
3//! # Why these live here and not in `gunnar-pack`
4//!
5//! Every one of these types used to be `gunnar_pack::…`, and naming them was
6//! the last edge `gunnar-wire` had into a pack *implementation*. It was a
7//! **vocabulary** edge and never a call: nothing under `gunnar-wire/src` has
8//! called `index_pack`, `PackWriter`, `write_pack_copy` or
9//! `gix_pack::data::output::entry::iter_from_counts` since the engine seam
10//! landed — [`crate::upload_pack`] emits through
11//! [`GitServe::emit_pack`](gunnar_store::git_storage_trait::GitServe::emit_pack) and the
12//! receive path takes the bytes through a caller-supplied
13//! [`PackIngest`](crate::receive_pack::PackIngest) whose only shipping
14//! implementation goes straight to `GitOps::put_pack`.
15//!
16//! But cargo links what a manifest names, not what a call graph reaches. One
17//! `pub struct` field spelled `gix_pack::data::Version` was enough to put
18//! `gix-pack` — and behind it `gix-diff`, `gix-traverse`, `gix-revwalk`,
19//! `gix-commitgraph`, `gix-fs`, `gix-tempfile`, `gix-path`, `gix-chunk`,
20//! `gix-zlib` and `gix-features/parallel` — into **every** build of the
21//! appliance, including `--no-default-features --features server,engine-znippy`
22//! whose whole point is that the pack pipeline is not there.
23//!
24//! So the vocabulary moved to the side that speaks it. These are the words
25//! `receive-pack` and `upload-pack` use to *report* what an engine did; the
26//! engine behind the contract is what actually did it, and which crate it used
27//! is its own business. `gunnar-pack` remains a **dev-dependency** of this
28//! crate, where it is what it always was for these tests: an outside oracle
29//! that indexes a pack gunnar produced and disagrees when gunnar is wrong.
30//!
31//! # The residue, said out loud
32//!
33//! [`ObjectId`] and [`Kind`] are still `gix-hash`'s. They are cheap value types
34//! — a 32-byte inline array and a two-variant enum — with no pipeline behind
35//! them, and the contract's own `Oid<'a> = &'a [u8]` is a *borrow*, so it
36//! cannot be what an owned id in a returned struct is spelled as. Replacing
37//! them is a workspace-wide rename with no dependency payoff on its own, and it
38//! is not what this module is for.
39
40use std::path::PathBuf;
41
42use gix_hash::{Kind, ObjectId};
43
44/// **A packfile's format version**, as the four bytes after `PACK` say it.
45///
46/// Local, and two variants wide, because the field below is the only thing
47/// `gunnar-wire` and `gunnar-server` ever wanted out of `gix-pack` — see this
48/// module's header for what naming it cost.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum PackVersion {
51    /// Version 2. Everything git has written by default since 2005.
52    V2,
53    /// Version 3. Reserved by the format; git does not emit it.
54    V3,
55}
56
57impl PackVersion {
58    /// The number as it appears in the pack header.
59    pub fn as_u32(self) -> u32 {
60        match self {
61            PackVersion::V2 => 2,
62            PackVersion::V3 => 3,
63        }
64    }
65
66    /// Read a version out of a pack header.
67    ///
68    /// **Anything that is not 3 is read as 2**, which is what the pack readers
69    /// in this constellation have always done: the header is attacker-supplied
70    /// on a receive path, and a version this does not know is not a reason to
71    /// invent a third variant that no code below can handle.
72    pub fn from_u32(raw: u32) -> Self {
73        match raw {
74            3 => PackVersion::V3,
75            _ => PackVersion::V2,
76        }
77    }
78}
79
80impl std::fmt::Display for PackVersion {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "{}", self.as_u32())
83    }
84}
85
86/// **What came out of taking a pushed pack into storage.**
87///
88/// A *receipt*, not a handle: every field is a fact read back off what was
89/// stored, and nothing here reaches into the store to answer. The three
90/// `Option<PathBuf>`s are `None` on every engine that does not keep git's
91/// on-disk layout — which is the default one — and `None` there is the truth
92/// rather than a degraded answer.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct IndexedPack {
95    /// How many objects the resulting pack holds.
96    ///
97    /// For a **thin** pack this is larger than the number of entries that
98    /// arrived on the wire: bases pulled in from the store to complete it are
99    /// counted too.
100    pub num_objects: u32,
101    /// The pack's trailing checksum, which is its identity and the `<id>` in
102    /// `pack-<id>.pack`.
103    pub pack_id: ObjectId,
104    /// The checksum of the written index file, or a null id where no index file
105    /// exists.
106    pub index_id: ObjectId,
107    /// The pack format version that was read.
108    pub pack_version: PackVersion,
109    /// The hash kind of the pack and index.
110    pub hash_kind: Kind,
111    /// Where the `.idx` landed, if one was written.
112    pub index_path: Option<PathBuf>,
113    /// Where the `.pack` landed, if one was written.
114    pub data_path: Option<PathBuf>,
115    /// How many bytes the stored pack occupies, when the store knows and there
116    /// is no file to `stat`.
117    ///
118    /// # Why this is not the same question as `data_path`
119    ///
120    /// A packfile engine answers "how big is the pack that landed" by measuring
121    /// the file it wrote. An engine that writes no packfile — the znippy store
122    /// appends the received bytes VERBATIM into its archive — has no file, and
123    /// for a while that made the whole measurement an *unknown*: every push
124    /// into the engine gunnar ships logged
125    /// `pack_bytes_unknown="pack … was indexed without a directory"`.
126    ///
127    /// That was false. The store counts the bytes it appends
128    /// (`PushReceipt::pack_bytes`); nobody carried the number this far. An
129    /// absence rendered where a measurement exists is the same defect as a
130    /// measurement rendered where an absence exists, and it is worse
131    /// here, because a reader is told gunnar does not know something it does.
132    ///
133    /// `None` means "ask `data_path`" — the pack IS a file and its size is that
134    /// file's size, which is the truer answer for an engine that has one. It
135    /// still means genuinely unknown when there is no file either, and callers
136    /// must keep reporting that as unknown rather than as zero: rendering the
137    /// missing file as `new_object_bytes: 0` reads as *a push that stored
138    /// nothing*, in the one record a verifier is supposed to be able to trust.
139    pub stored_bytes: Option<u64>,
140    /// The `.keep` file guarding the new pack from collection until refs point
141    /// at it. The caller owns it and is expected to remove it once the ref
142    /// transaction is applied.
143    pub keep_path: Option<PathBuf>,
144}
145
146/// **What one packfile emission produced.**
147///
148/// Filled in from the engine's own `PackStats` on the way out of
149/// [`GitServe::emit_pack`](gunnar_store::git_storage_trait::GitServe::emit_pack), so every
150/// number is read back off what the engine reported rather than restated from
151/// what was asked for.
152///
153/// **No `Default`**, and that is a property rather than an omission: `ObjectId`
154/// has none, and an outcome with a zeroed pack id is one that claims a pack was
155/// written and cannot name it. `gunnar-pack`'s own `WriteOutcome` refused the
156/// same shape for the same reason.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct WriteOutcome {
159    /// How many objects the pack contains. Zero is legal.
160    pub num_objects: u32,
161    /// The pack's identity: the checksum over every byte preceding it, which is
162    /// also the pack's trailing bytes and the `pack-<id>.pack` name git gives
163    /// it.
164    pub pack_id: ObjectId,
165    /// Total bytes written, header and trailer included.
166    pub bytes_written: u64,
167    /// Objects whose already-deflated pack entry was **copied** verbatim: no
168    /// inflate, no deflate, delta chains preserved.
169    ///
170    /// This and the next field are pack-copy's applied output. A byte count and a
171    /// wall clock cannot tell a pack-copy from a re-deflate; these two can, and
172    /// the day an engine starts re-deflating, `objects_recompressed` climbs
173    /// instead of a test staying green.
174    pub objects_copied_from_pack: usize,
175    /// Objects the engine deflated from their decompressed content.
176    pub objects_recompressed: usize,
177    /// Objects emitted as a **newly computed** delta against another object in
178    /// the same pack.
179    ///
180    /// Zero on the serve path, which reuses the pushing client's deltas and has
181    /// no encoder. It exists because `bytes_written` alone cannot tell a
182    /// deltified pack from a whole-object one: on 2026-08-03 the client shipped
183    /// 407.08 MiB of pack at **0.0% deltified** while every byte count, wall
184    /// clock and correctness test in the tree was green.
185    pub objects_deltified: usize,
186    /// Thin-delta bases the engine **offered** — entries copied as an
187    /// `OBJ_REF_DELTA` naming a base outside this pack.
188    ///
189    /// # Why the offer is counted and not only the refusal
190    ///
191    /// `objects_recompressed` says how many objects lost their delta, and
192    /// cannot say how many *could have kept it*. A voucher that refuses
193    /// everything and a transfer that had no thin deltas to begin with produce
194    /// the same recompressed count and are opposite situations. The refusal
195    /// *rate* — `thin_bases_vetoed` over this — is the number that tells them
196    /// apart, and no other field here can be made to yield it.
197    pub thin_bases_offered: usize,
198    /// Of those, the ones the `client_has` voucher would not vouch for, whose
199    /// objects were therefore written whole.
200    pub thin_bases_vetoed: usize,
201    /// Deltas a base closure declined because the base it would have named is
202    /// an object this pack **already carries**, at a location in another pack of
203    /// a multi-pack store.
204    ///
205    /// Zero on a single-pack store, on a negotiated fetch and whenever the
206    /// closure is off.
207    pub closure_bases_declined: usize,
208}
209
210/// Ceiling on a single entry's allocation while decoding an untrusted pack:
211/// 1 GiB.
212///
213/// Chosen to be far above any object a real repository contains and far below
214/// what hurts a server. The size being guarded is **read out of the pack**, so
215/// it is attacker-controlled: a pack can claim an entry is enormous and,
216/// without this, a decoder tries to allocate exactly that.
217///
218/// The same number `gunnar-pack` applies on its own receive path, and the
219/// twinning is **checked** rather than asserted in prose:
220/// `tests/pack_vocabulary.rs` compares the two. It lives out there and not in a
221/// `#[cfg(test)]` block down this file because `xtask::engine_seam` reads
222/// `crates/*/src` and does not read `#[cfg]`, deliberately — a rule with a
223/// "but it is only a test" escape hatch inside a shipped file is a rule with a
224/// hole in it, and the guard found this very line the first time it ran.
225pub const DEFAULT_ALLOC_LIMIT_BYTES: usize = 1 << 30;
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    /// The header value round-trips, and an unknown version reads as 2 rather
232    /// than as a panic or a third variant. `PackVersion::from_u32` is fed a
233    /// number that came off the wire.
234    #[test]
235    fn pack_version_round_trips_and_degrades_to_v2() {
236        assert_eq!(PackVersion::from_u32(2), PackVersion::V2);
237        assert_eq!(PackVersion::from_u32(3), PackVersion::V3);
238        assert_eq!(PackVersion::V2.as_u32(), 2);
239        assert_eq!(PackVersion::V3.as_u32(), 3);
240        for hostile in [0, 1, 4, u32::MAX] {
241            assert_eq!(
242                PackVersion::from_u32(hostile),
243                PackVersion::V2,
244                "a version this does not know must read as 2, not invent a variant"
245            );
246        }
247    }
248}