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}