gunnar_wire/upload_pack.rs
1//! `git-upload-pack`: serving fetch and clone.
2//!
3//! Protocol **v2** is the real implementation; **v0** is a fallback that has to
4//! exist because real clients still open it. That is not hypothetical: `git`
5//! 2.53 falls back to v0 whenever `GIT_PROTOCOL` does not reach the server
6//! process, which is exactly what happens over the `ext::` transport. Measured
7//! 2026-08-03, and the reason `tests/real_git_interop.rs` gets a genuine,
8//! unforced v0 exchange for free.
9//!
10//! # Shape
11//!
12//! Nothing here owns a socket. [`UploadPack::serve`] takes `&mut dyn BufRead`
13//! and `&mut dyn Write`; `gunnar-ssh` supplies a russh channel, a test supplies
14//! a pipe or a `Vec<u8>`, and the state machine cannot tell.
15//!
16//! # `object-format`
17//!
18//! Echoed from the repository's own hash kind — carried in at construction,
19//! from `RepoStore::hash_kind()` — and never from a default. Answering `sha1` to a sha256 repository does not error; it returns
20//! an *empty* response, which is why it cost a session in `nornir-git` and why
21//! [`crate::caps::check_object_format`] turns a mismatch into a hard error here.
22
23use std::collections::HashSet;
24use std::io::{BufRead, Write};
25
26use gix_hash::{Kind, ObjectId};
27// `write_pack` and `WriteOptions` are gone from this module: the engine owns
28// the format now, and the three knobs that used to be `WriteOptions` —
29// `ofs_delta`, `thin`, the base-closure ceiling — cross the seam as
30// `git_storage_trait::Caps`. `WriteOutcome` stays, because it is what this end
31// reports and the server logs.
32use gunnar_pktline::Packet;
33use gunnar_store::git_storage_trait::GitServe;
34use gunnar_store::object;
35
36use crate::caps::{self, Offered, PeerCapabilities};
37use crate::filter::Filter;
38use crate::graph::{
39 has_for_negotiation, reachable_closure, select_objects, Graph, Missing, SelectOptions,
40};
41use crate::limits::{Budget, Limits};
42use crate::negotiate::{negotiate, HaveMemo};
43use crate::refs::RefSnapshot;
44use crate::shallow::{self, ShallowOutcome, ShallowRequest};
45use crate::wire_io::{self, err_packet, progress, read_packet, read_section, PackOutput};
46use crate::{Error, ProtocolVersion, Result, Session};
47
48/// Knobs the SSH layer sets per connection.
49#[derive(Debug, Clone)]
50pub struct Config {
51 /// The `agent=` string. Defaults to `gunnar/<version>`.
52 pub agent: String,
53 /// Which optional v2 features are on offer.
54 pub offered: Offered,
55 /// Which object ids a client may `want`.
56 pub want_policy: WantPolicy,
57 /// An operator's explicit cap on the objects one traversal may hold.
58 ///
59 /// **`None` by default, and that is not a guard left off.**
60 /// [`Limits::walk_bytes`] is the ceiling that ships, in bytes, and this is
61 /// `--fetch-budget` for an operator who wants a second one in objects.
62 ///
63 /// It used to default to [`crate::DEFAULT_BUDGET`], and leaving it there
64 /// would have made the byte ceiling decorative: MEASURED 2026-08-05,
65 /// ten million objects refuses a clone of a 14 042 000-object repository
66 /// that the same machine serves in 5.59 GB, so the object cap and not the
67 /// memory would still have been what decided.
68 ///
69 /// See [`crate::DEFAULT_BUDGET`] for why the unit was wrong and
70 /// [`limits`](crate::limits) for the ceilings that are about bytes.
71 pub budget: Option<usize>,
72 /// The memory ceilings this connection serves under.
73 ///
74 /// Defaults to [`Limits::default`], which is **every ceiling on**. See
75 /// [`limits`](crate::limits).
76 pub limits: Limits,
77 /// The server-wide purse this connection's traversals spend from.
78 ///
79 /// **`None` by default, and this is the one field where that is not a guard
80 /// left off.** A pool is shared state between connections, so it can only be
81 /// created by whatever owns them all: `gunnar serve` builds one sized to the
82 /// machine and hands the same `Arc` to every session. A `Config` built by
83 /// hand — a test, `gunnar mirror`, an embedding — has no siblings to share
84 /// with, and giving it a private pool of its own would be a ceiling that
85 /// bounds nothing while reading as if it did.
86 ///
87 /// [`Limits::walk_bytes`] is the per-request half and is on by default. See
88 /// [`WalkPool`](crate::WalkPool) for why one without the other is not a
89 /// bound on the server.
90 pub walk_pool: Option<std::sync::Arc<crate::WalkPool>>,
91}
92
93impl Default for Config {
94 fn default() -> Self {
95 Config {
96 agent: caps::default_agent(),
97 offered: Offered::default(),
98 want_policy: WantPolicy::default(),
99 budget: None,
100 limits: Limits::default(),
101 walk_pool: None,
102 }
103 }
104}
105
106/// Which object ids a client is allowed to ask for.
107///
108/// git spells this as three separate `uploadpack.allow*SHA1InWant` settings,
109/// and the middle one is not optional for a server that advertises `filter`: a
110/// partial clone's **lazy fetch** asks for bare blob ids that were never
111/// advertised, so [`WantPolicy::AdvertisedTips`] turns every `git checkout` in
112/// a `blob:none` clone into "the remote end hung up unexpectedly".
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum WantPolicy {
115 /// Only ids that appeared in the advertisement.
116 ///
117 /// The strictest setting, and the one that breaks partial clone.
118 AdvertisedTips,
119 /// Anything reachable from a reference. This is git's
120 /// `uploadpack.allowReachableSHA1InWant`, and the default here.
121 ///
122 /// **The cost is honest and real:** proving reachability means walking the
123 /// repository's whole object closure once per request that names a
124 /// non-tip id. The `git` package's `__gunnar_reach__` bitmaps are what make this
125 /// cheap; until they exist, this walk is the price of partial clone.
126 #[default]
127 ReachableFromRefs,
128 /// Anything present in the store, reachable or not.
129 ///
130 /// Makes every object that was ever pushed fetchable by whoever can name
131 /// it, including on branches that have since been deleted.
132 Any,
133}
134
135impl WantPolicy {
136 /// Does this policy make good on a capability row that
137 /// [`requires`](crate::caps::Capability::requires) `promise`?
138 ///
139 /// # Why the answer is a `match` and not a `>=`
140 ///
141 /// The three variants happen to be orderable, and encoding that as an
142 /// ordering would make adding a fourth silently inherit whichever
143 /// neighbours it landed between. Written out, a new variant does not
144 /// compile until someone decides what it promises — which is the property
145 /// that was missing, not the ordering.
146 ///
147 /// The mapping is git's, bit for bit: `ALLOW_TIP_SHA1` prints
148 /// `allow-tip-sha1-in-want`, `ALLOW_REACHABLE_SHA1` prints
149 /// `allow-reachable-sha1-in-want`, and `ALLOW_ANY_SHA1` is both bits set,
150 /// so it prints both (`upload-pack.c`, `send_ref`).
151 ///
152 /// [`WantPolicy::AdvertisedTips`] promises nothing, on purpose: gunnar
153 /// hides no reference, so "only ids in the advertisement" is exactly what a
154 /// v0 client assumes when neither capability is present.
155 pub fn advertises(self, promise: WantPolicy) -> bool {
156 match (self, promise) {
157 // Reachability is promised by the policy that serves it and by the
158 // one that serves more.
159 (WantPolicy::ReachableFromRefs, WantPolicy::ReachableFromRefs) => true,
160 (WantPolicy::Any, WantPolicy::ReachableFromRefs) => true,
161 (WantPolicy::Any, WantPolicy::Any) => true,
162 (WantPolicy::AdvertisedTips, _) => false,
163 (WantPolicy::ReachableFromRefs, WantPolicy::Any) => false,
164 (_, WantPolicy::AdvertisedTips) => false,
165 }
166 }
167}
168
169/// The server side of `git-upload-pack`.
170///
171/// One of these serves **one connection**. That matters for [`Self::fetch`],
172/// which a v2 client calls once per negotiation round: the `have`s a client has
173/// already been answered about are memoised here rather than re-resolved
174/// against the object store every round. The memo holds no decisions, only
175/// facts about the store (see `negotiate::HaveMemo`), so a shared or reused
176/// instance answers the same, only slower.
177pub struct UploadPack<'a> {
178 /// **One store, not two.** `ObjectStore` and `RefStore` were separate
179 /// gunnar traits and are deleted; `GitOps` says both, and `GitServe`
180 /// extends it with the decoded read this crate cannot work without. So the
181 /// objects and the references are the same handle, which is also the only
182 /// way an advertisement and the pack it promises can be one snapshot.
183 git: &'a dyn GitServe,
184 /// The repository's hash kind.
185 ///
186 /// Carried rather than asked for: `GitServe` has no `hash_kind()` and the
187 /// frozen signature leaves whether `GitOps` should grow one open. `RepoStore::hash_kind()`
188 /// already exists and the server already holds one per repository, so
189 /// nothing is being invented here — only carried the last hop, and the open
190 /// question keeps one fewer consumer.
191 hash_kind: Kind,
192 config: Config,
193 haves: HaveMemo,
194 /// Whether a sideband is open on this connection, and therefore which of
195 /// the two channels [`UploadPack::report_error`] must use.
196 ///
197 /// Set by [`UploadPack::send_pack`], which is the one place either protocol
198 /// version opens one. Before that an `ERR` pkt-line is the only thing a
199 /// client will read; after it, a bare pkt-line in the middle of a
200 /// demultiplexed stream is a framing error rather than a message, so the
201 /// reason has to ride band 3.
202 ///
203 /// An `AtomicBool` rather than a `Cell` to match [`HaveMemo`]'s `Mutex`:
204 /// this type is built inside `spawn_blocking` and nothing shares it across
205 /// threads today, but a `Cell` here would silently make it `!Sync` and the
206 /// day someone does share it the compiler should be the one saying so.
207 sideband_open: std::sync::atomic::AtomicBool,
208}
209
210/// One reference as it goes out in an advertisement.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct AdvertisedRef {
213 /// Full reference name.
214 ///
215 /// `String`, not the `FullName` newtype that died with `RefStore`: the
216 /// contract speaks `RefRow::name: String` and minting a second validated
217 /// name type in gunnar is the duplication the demolition removes. What the
218 /// newtype carried *by construction* is now a rule about the producer —
219 /// see `receive_pack::parse_command`, which is where a client-supplied name
220 /// is validated before it can reach a store.
221 pub name: String,
222 /// What it resolves to, or `None` for an unborn `HEAD`.
223 pub target: Option<ObjectId>,
224 /// The commit an annotated tag points at, when peeling was asked for.
225 pub peeled: Option<ObjectId>,
226 /// The reference a symbolic reference points at.
227 pub symref_target: Option<String>,
228}
229
230/// The arguments of `command=ls-refs`.
231#[derive(Debug, Clone, Default, PartialEq, Eq)]
232pub struct LsRefsRequest {
233 /// Report `symref-target:` for symbolic references.
234 pub symrefs: bool,
235 /// Report `peeled:` for annotated tags.
236 pub peel: bool,
237 /// Report an unborn `HEAD` instead of omitting it.
238 pub unborn: bool,
239 /// Only references starting with one of these; empty means all.
240 pub ref_prefixes: Vec<String>,
241}
242
243impl LsRefsRequest {
244 /// Parse the argument section of `command=ls-refs`.
245 pub fn parse(lines: &[Vec<u8>]) -> Result<Self> {
246 let mut out = LsRefsRequest::default();
247 for line in lines {
248 let text = String::from_utf8_lossy(line).into_owned();
249 let (name, value) = split_argument(&text);
250 let Some(argument) = caps::argument(caps::LS_REFS_ARGUMENTS, name) else {
251 return Err(Error::UnknownArgument {
252 command: "ls-refs",
253 argument: text,
254 });
255 };
256 match (argument.name, argument.value, value) {
257 ("symrefs", _, _) => out.symrefs = true,
258 ("peel", _, _) => out.peel = true,
259 ("unborn", _, _) => out.unborn = true,
260 ("ref-prefix", caps::Value::Required, Some(prefix)) => {
261 out.ref_prefixes.push(prefix.to_owned())
262 }
263 _ => {
264 return Err(Error::UnknownArgument {
265 command: "ls-refs",
266 argument: text,
267 })
268 }
269 }
270 }
271 Ok(out)
272 }
273}
274
275/// The arguments of `command=fetch`, and of a v0 `want` section.
276#[derive(Debug, Clone, Default, PartialEq, Eq)]
277pub struct FetchRequest {
278 /// `want <oid>`.
279 pub wants: Vec<ObjectId>,
280 /// `want-ref <name>`, the `ref-in-want` feature.
281 pub want_refs: Vec<String>,
282 /// `have <oid>`.
283 pub haves: Vec<ObjectId>,
284 /// `done`: stop negotiating and send the pack.
285 pub done: bool,
286 /// `thin-pack`, and since the thin-pack ruling this one is **honoured**, not ignored.
287 ///
288 /// Pack-copy preserves the deltas the pushing client computed, but until
289 /// 2026-08-04 only those whose base was also in the transfer: the rest
290 /// were inflated and deflated whole, because `allow_thin_pack: false` will
291 /// not point an entry at a base the receiver may not have. On a **clone**
292 /// that costs nothing, since the transfer is the whole history and every
293 /// base is in it. On a **negotiated fetch** it is the dominant cost —
294 /// MEASURED 2026-08-04, 24 of 40 objects expanded and 3.98x stock
295 /// git's thin pack of the same object set.
296 ///
297 /// What changed is not the caution, only what backs it: the walk that
298 /// decides the transfer already knows what the client holds
299 /// (`graph::Selection::client_has`), so a base can now be *checked*
300 /// instead of assumed unsafe. A base that does not answer still costs its
301 /// object the delta. See `send_pack` for the three gating conditions.
302 pub thin_pack: bool,
303 /// `no-progress`.
304 pub no_progress: bool,
305 /// `include-tag`.
306 pub include_tag: bool,
307 /// `ofs-delta`, and since pack-copy this one is **honoured**, not ignored.
308 ///
309 /// The pack-copy path emits an entry exactly as it found it in a pack, and
310 /// most of those entries are `ofs-delta`s. A client that did not ask for
311 /// `ofs-delta` cannot read them, so it gets whole objects instead. That is
312 /// what `git-upload-pack` does too: it passes `--delta-base-offset` to
313 /// `pack-objects` only when the client advertised the capability.
314 ///
315 /// Every git since 2005 sends it and stock `git 2.53.0` always does, so
316 /// this is the fallback, not the path. It is a real fallback though, and
317 /// `tests/pack_copy.rs` exercises it rather than assuming nobody lands
318 /// there.
319 pub ofs_delta: bool,
320 /// `filter <spec>`.
321 pub filter: Option<Filter>,
322 /// `wait-for-done`.
323 pub wait_for_done: bool,
324 /// Everything shallow-related.
325 pub shallow: ShallowRequest,
326}
327
328impl FetchRequest {
329 /// Parse the argument section of `command=fetch`.
330 ///
331 /// Unknown arguments are an error rather than a shrug. The argument table
332 /// in [`crate::caps`] is the same one the advertisement is generated from,
333 /// so "accepted here" and "advertised there" cannot drift.
334 pub fn parse(lines: &[Vec<u8>], hash_kind: Kind, offered: Offered) -> Result<Self> {
335 let mut out = FetchRequest::default();
336 for line in lines {
337 let text = String::from_utf8_lossy(line).into_owned();
338 let (name, value) = split_argument(&text);
339 let argument = caps::argument(caps::FETCH_ARGUMENTS, name)
340 .filter(|argument| offered.accepts(argument))
341 .ok_or_else(|| Error::UnknownArgument {
342 command: "fetch",
343 argument: text.clone(),
344 })?;
345 let required = || {
346 value.ok_or_else(|| Error::UnknownArgument {
347 command: "fetch",
348 argument: text.clone(),
349 })
350 };
351 match argument.name {
352 "want" => out.wants.push(parse_oid(required()?, hash_kind)?),
353 "have" => out.haves.push(parse_oid(required()?, hash_kind)?),
354 "want-ref" => out.want_refs.push(required()?.to_owned()),
355 "done" => out.done = true,
356 "thin-pack" => out.thin_pack = true,
357 "no-progress" => out.no_progress = true,
358 "include-tag" => out.include_tag = true,
359 "ofs-delta" => out.ofs_delta = true,
360 "wait-for-done" => out.wait_for_done = true,
361 "filter" => out.filter = Some(Filter::parse(required()?)?),
362 "shallow" => out
363 .shallow
364 .client_shallow
365 .push(parse_oid(required()?, hash_kind)?),
366 "deepen" => {
367 out.shallow.depth = Some(required()?.parse::<usize>().map_err(|_| {
368 Error::protocol(format!("deepen wants a number, got {text:?}"))
369 })?)
370 }
371 "deepen-relative" => out.shallow.relative = true,
372 "deepen-since" => {
373 out.shallow.since = Some(required()?.parse::<i64>().map_err(|_| {
374 Error::protocol(format!("deepen-since wants unix seconds, got {text:?}"))
375 })?)
376 }
377 "deepen-not" => out.shallow.not.push(required()?.to_owned()),
378 other => {
379 return Err(Error::UnknownArgument {
380 command: "fetch",
381 argument: other.to_owned(),
382 })
383 }
384 }
385 }
386 Ok(out)
387 }
388}
389
390/// What a whole `upload-pack` session did.
391///
392/// Returned rather than logged because the SSH layer wants it for metrics and
393/// the interop tests want it as evidence: "the fetch succeeded" is true of a
394/// server that negotiated properly and of one that shipped the entire
395/// repository every time, and only `fetch_rounds` and `objects_sent` tell them
396/// apart.
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
398pub struct UploadSummary {
399 /// Which protocol was spoken.
400 pub protocol: ProtocolVersion,
401 /// How many `ls-refs` commands were served.
402 pub ls_refs: usize,
403 /// How many negotiation rounds the client took, the one that carried the
404 /// packfile included.
405 pub fetch_rounds: usize,
406 /// Objects in the packfile, if one was sent.
407 ///
408 /// **This is what the ENGINE emitted, not what this end chose.** See
409 /// [`objects_selected`](Self::objects_selected) for the other half; a
410 /// reader that has only this one cannot tell the two apart.
411 pub objects_sent: u32,
412 /// Objects [`GitServe::select`] chose — the **exact set** `emit_pack` was
413 /// handed.
414 ///
415 /// # Why both counts, and why this one is asserted rather than logged
416 ///
417 /// `emit_pack`'s first parameter is an exact set and the engine must not
418 /// close it over reachability (DECIDED 2026-08-10). It may still emit
419 /// *more* than it was given, legitimately: an `OFS_DELTA` names its base by
420 /// a backwards distance **inside the pack**, so an engine copying stored
421 /// entries has to bring along any delta base that fell outside the
422 /// selection or emit something unparseable. `Caps::thin` is the allowance
423 /// to skip that, and an engine is free not to take it.
424 ///
425 /// So the gap is expected and **unbounded**, which is precisely why it has
426 /// to be reported rather than assumed small. MEASURED on the laptop 2026-08-10,
427 /// the `h2h-linear-sha1-2048c-1024f-16k` corpus over the znippy engine, a
428 /// negotiated fetch from a receiver `N` commits behind the tip:
429 ///
430 /// ```text
431 /// N commits behind selected sent amplification
432 /// 1 18 157 8.72x
433 /// 256 4382 7801 1.78x
434 /// ```
435 ///
436 /// Until this field existed the left-hand column reached exactly one place
437 /// — a human-readable sideband progress line that `git fetch -q` suppresses
438 /// and no meter parses — so `bench/`'s `gunnar.vs_git_push` could report a
439 /// 33.6% divergence with nothing anywhere able to attribute it. That is the
440 /// failure a published number can hide: one describing a path other than the
441 /// one its name claims.
442 ///
443 /// Zero for a session that sent no packfile. Summed over rounds in v2, for
444 /// the same reason [`objects_sent`](Self::objects_sent) is.
445 pub objects_selected: usize,
446 /// How many `have`s turned out to be common on the final round.
447 pub common: usize,
448 /// Of [`objects_sent`](Self::objects_sent), how many left as a **copy** of
449 /// the pack entry they were already stored as.
450 ///
451 /// Reported for the same reason `fetch_rounds` and `objects_sent` are: "the
452 /// clone succeeded" is true of a server that shipped the client's own
453 /// deltas back and of one that inflated and re-deflated 528 MiB to send
454 /// 18× the bytes, and nothing else on this struct tells them apart. A byte
455 /// count cannot either, since it moves with the corpus.
456 pub objects_copied_from_pack: usize,
457 /// Of [`objects_sent`](Self::objects_sent), how many had to be deflated
458 /// from their decompressed content: everything, on a store with no packs,
459 /// and on a packed one only the loose objects and the deltas whose base
460 /// did not come along.
461 pub objects_recompressed: usize,
462 /// Whether the packfile round's object selection came from a `.bitmap`
463 /// rather than from the walk.
464 ///
465 /// # Why this is on the summary and not only in a log line
466 ///
467 /// Because the two paths do **not** produce identical bytes and every
468 /// measurement of this server is therefore a measurement of one of them.
469 /// The bitmap path vouches for the client's whole closure
470 /// (`ClientHas::Bits`) where `Exclusion::Edges`' walk vouches only for the
471 /// frontier, so a delta base the walk expands the bitmap names — same
472 /// request, same repository, fewer bytes. Nothing else a server reports
473 /// says which one answered, and the failure is silent in the direction that
474 /// matters: a repository with no `.bitmap` on disk falls through to the
475 /// walk and every number taken on it is a walk number wearing the
476 /// default's name.
477 ///
478 /// MEASURED 2026-08-04: the head-to-head fetch arm's server fixture is
479 /// a `git clone --bare` carve, `git clone` writes no `.bitmap`, and so
480 /// `gunnar.vs_git_fetch_divergent` — the one arm gunnar loses on bytes —
481 /// had never once exercised the shipping path. `false` here is what says
482 /// so.
483 ///
484 /// `false` for a session that sent no packfile, and for a request shape
485 /// [`crate::bitmap::select`] declines (a filter, a shallow boundary, a
486 /// deepening).
487 pub answered_from_bitmap: bool,
488 /// Thin-delta bases `gix` offered over the session, and how many
489 /// `client_has` refused. See [`crate::pack::WriteOutcome::thin_bases_offered`].
490 pub thin_bases_offered: usize,
491 /// See [`crate::pack::WriteOutcome::thin_bases_vetoed`].
492 pub thin_bases_vetoed: usize,
493 /// See [`crate::pack::WriteOutcome::closure_bases_declined`]. Non-zero only
494 /// on a clone out of a **multi-pack** store whose selection reaches one
495 /// object by two routes, which is the shape no fixture in this tree had
496 /// until 2026-08-05.
497 pub closure_bases_declined: usize,
498}
499
500/// What one `fetch` produced.
501#[derive(Debug, Clone, Default)]
502pub struct FetchOutcome {
503 /// The `have`s this repository holds.
504 pub common: Vec<ObjectId>,
505 /// Whether the server declared a cut point.
506 pub ready: bool,
507 /// Whether a packfile was sent, as opposed to another negotiation round.
508 pub sent_pack: bool,
509 /// How many objects went into it — what the **engine** emitted.
510 pub objects_sent: u32,
511 /// How many objects `select` chose. See
512 /// [`UploadSummary::objects_selected`], which is where the two-column
513 /// argument is written down.
514 pub objects_selected: usize,
515 /// How many of those were copied out of a pack rather than deflated
516 /// out of an inflated object. See [`UploadSummary::objects_copied_from_pack`].
517 pub objects_copied_from_pack: usize,
518 /// How many had to be deflated. See
519 /// [`UploadSummary::objects_recompressed`].
520 pub objects_recompressed: usize,
521 /// Whether a `.bitmap` answered this round's selection. See
522 /// [`UploadSummary::answered_from_bitmap`].
523 pub answered_from_bitmap: bool,
524 /// See [`UploadSummary::thin_bases_offered`].
525 pub thin_bases_offered: usize,
526 /// See [`UploadSummary::thin_bases_vetoed`].
527 pub thin_bases_vetoed: usize,
528 /// See [`UploadSummary::closure_bases_declined`].
529 pub closure_bases_declined: usize,
530}
531
532/// What [`UploadPack::send_pack`] wrote, and which selection path chose it.
533///
534/// A pair rather than two out-parameters, and its own type rather than a
535/// tuple, because the second half is the thing a reader of the first half most
536/// needs to know: a byte count from the walk and a byte count from the bitmap
537/// are answers to different questions (see
538/// [`UploadSummary::answered_from_bitmap`]).
539struct SentPack {
540 written: crate::pack::WriteOutcome,
541 answered_from_bitmap: bool,
542 /// The exact set handed to `emit_pack`. See
543 /// [`UploadSummary::objects_selected`].
544 objects_selected: usize,
545}
546
547impl<'a> UploadPack<'a> {
548 /// Serve `objects`/`refs` with default configuration.
549 pub fn new(git: &'a dyn GitServe, hash_kind: Kind) -> Self {
550 let config = Config::default();
551 UploadPack {
552 haves: HaveMemo::with_capacity(memo_capacity(&config.limits)),
553 git,
554 hash_kind,
555 config,
556 sideband_open: std::sync::atomic::AtomicBool::new(false),
557 }
558 }
559
560 /// Replace the configuration.
561 ///
562 /// The `have` memo is rebuilt, because its ceiling is derived from
563 /// [`Config::limits`] — a configuration that lifted the request ceiling and
564 /// left a memo capped at the old one would be honouring a limit nobody set.
565 pub fn with_config(mut self, config: Config) -> Self {
566 self.haves = HaveMemo::with_capacity(memo_capacity(&config.limits));
567 self.config = config;
568 self
569 }
570
571 /// The repository's hash kind, and the only source of `object-format`.
572 pub fn hash_kind(&self) -> Kind {
573 self.hash_kind
574 }
575
576 /// **One traversal scope for this call.** Its lifetime is what releases the
577 /// [`WalkPool`](crate::WalkPool) reservation and the thread's id charge, so
578 /// it is built per call and dropped where the walk ends — never held on
579 /// `self`, which lives for the whole connection.
580 fn graph(&self) -> Graph<'_> {
581 Graph::new(self.git, self.hash_kind)
582 .with_budget(self.config.budget)
583 .with_walk_limit(self.config.limits.walk_bytes, self.config.walk_pool.clone())
584 }
585
586 /// Serve one connection at the given protocol version.
587 pub fn serve(
588 &self,
589 version: ProtocolVersion,
590 input: &mut dyn BufRead,
591 out: &mut dyn Write,
592 ) -> Result<UploadSummary> {
593 self.serve_session(version, Session::Connected, input, out)
594 }
595
596 /// Serve one *call* at the given protocol version and [`Session`].
597 ///
598 /// [`Session::Connected`] is what [`serve`](Self::serve) does and what SSH
599 /// uses. [`Session::StatelessRpc`] is one smart-HTTP `POST`: no
600 /// advertisement is written (it was a separate `GET`), and the call answers
601 /// one round and returns.
602 pub fn serve_session(
603 &self,
604 version: ProtocolVersion,
605 session: Session,
606 input: &mut dyn BufRead,
607 out: &mut dyn Write,
608 ) -> Result<UploadSummary> {
609 let served = match version {
610 ProtocolVersion::V2 => self.serve_v2_session(session, input, out),
611 ProtocolVersion::V0 => self.serve_v0_session(session, input, out),
612 };
613 // ── the reason goes to the CLIENT, not only to the log ───────────────
614 //
615 // Every refusal this crate makes — an unsupported `filter`, an unknown
616 // v2 command, an `object-format` mismatch, a `want` that is not
617 // reachable — used to reach the transport as an `Err` and stop there.
618 // The transport logs it and closes the channel, so `git` printed
619 //
620 // ```text
621 // fatal: the remote end hung up unexpectedly
622 // ```
623 //
624 // and the operator had to be on the server to learn why. MEASURED on
625 // 2026-08-05, `git -c protocol.version=2 fetch <gunnar> <unknown
626 // oid>`: the client got that line and nothing else, while the server
627 // log carried `err=object 0000…0001 is not in this repository (a want)`
628 // in full. `report_error` was written for exactly this and had **zero**
629 // call sites in the tree, tests included, which is why nothing noticed.
630 //
631 // Here rather than at either transport: `serve_session` is the one
632 // entry point SSH and smart HTTP both come through, so one call covers
633 // both and they cannot drift (one implementation).
634 //
635 // A failure to write the reason is discarded on purpose. The commonest
636 // way to get here at all is a client that has already hung up, and
637 // replacing the real error with "could not tell you about the real
638 // error" would lose the only useful fact in the exchange.
639 if let Err(err) = &served {
640 let sideband = self
641 .sideband_open
642 .load(std::sync::atomic::Ordering::Relaxed);
643 let _ = self.report_error(out, sideband, &err.client_message());
644 let _ = out.flush();
645 }
646 served
647 }
648
649 /// The advertisement a smart-HTTP `GET /info/refs?service=git-upload-pack`
650 /// answers with, for either protocol version.
651 ///
652 /// The `# service=git-upload-pack` preamble is **not** written here: it is
653 /// an HTTP framing detail (and one protocol v2 does not use at all), so it
654 /// belongs to `gunnar-http` and not to a state machine that also runs over
655 /// SSH, where no such line exists.
656 pub fn advertise(&self, version: ProtocolVersion, out: &mut dyn Write) -> Result<()> {
657 match version {
658 ProtocolVersion::V2 => self.write_capability_advertisement(out),
659 ProtocolVersion::V0 => self.write_ref_advertisement(out),
660 }
661 }
662
663 // ── protocol v2 ──────────────────────────────────────────────────────
664
665 /// The v2 capability advertisement, as bytes.
666 ///
667 /// Sent before the client says anything, exactly as `git-upload-pack` does.
668 pub fn capability_advertisement(&self) -> Result<Vec<u8>> {
669 let mut out = Vec::new();
670 self.write_capability_advertisement(&mut out)?;
671 Ok(out)
672 }
673
674 /// Write the v2 capability advertisement.
675 pub fn write_capability_advertisement(&self, out: &mut dyn Write) -> Result<()> {
676 wire_io::text(out, b"version 2")?;
677 // The list is `caps::UPLOAD_PACK_V2`, not five literals here. It used
678 // to be five literals here — a fourth hand-written advertisement, in a
679 // different file from the other three — which is how `server-option`
680 // came to be advertised with nothing in the tree that had to agree
681 // with it. `caps::ADVERTISED` now names every surface and
682 // `tests/advertisement.rs` walks all of them.
683 for line in caps::advertise(
684 caps::UPLOAD_PACK_V2,
685 self.hash_kind(),
686 &self.config.agent,
687 self.config.offered,
688 // No v2 row carries a `requires`: protocol v2 has no
689 // `allow-*-sha1-in-want` capability, because `upload_pack_v2` in
690 // git sets `ALLOW_ANY_SHA1` unconditionally and the question never
691 // reaches the wire. Passed anyway so the surface cannot acquire a
692 // policy-dependent row without this call site being the one that
693 // decides what it renders under.
694 self.config.want_policy,
695 ) {
696 wire_io::text(out, line.as_bytes())?;
697 }
698 wire_io::flush(out)?;
699 Ok(())
700 }
701
702 /// The whole v2 session: advertisement, then commands until the client
703 /// stops.
704 pub fn serve_v2(&self, input: &mut dyn BufRead, out: &mut dyn Write) -> Result<UploadSummary> {
705 self.serve_v2_session(Session::Connected, input, out)
706 }
707
708 /// The v2 session, for either [`Session`].
709 ///
710 /// Under [`Session::StatelessRpc`] the advertisement is skipped and the
711 /// call returns after **one** command, because that is what one HTTP `POST`
712 /// carries. Returning after one is not merely equivalent to hitting EOF: a
713 /// body carrying a second command would otherwise be served on a
714 /// negotiation state the next `POST` cannot see, which is the shape of a
715 /// clone that loops.
716 pub fn serve_v2_session(
717 &self,
718 session: Session,
719 input: &mut dyn BufRead,
720 out: &mut dyn Write,
721 ) -> Result<UploadSummary> {
722 let mut summary = UploadSummary {
723 protocol: ProtocolVersion::V2,
724 ..Default::default()
725 };
726 if session.advertises_inline() {
727 let _s = crate::stage::enter("advertise");
728 self.write_capability_advertisement(out)?;
729 out.flush()?;
730 }
731 loop {
732 let command = {
733 let _s = crate::stage::enter("request_read");
734 self.read_command(input)?
735 };
736 let Some(command) = command else {
737 return Ok(summary);
738 };
739 caps::check_object_format(
740 self.hash_kind(),
741 command.capabilities.value("object-format"),
742 )?;
743 match command.name.as_str() {
744 "ls-refs" => {
745 let _s = crate::stage::enter("ls_refs");
746 let request = LsRefsRequest::parse(&command.arguments)?;
747 self.ls_refs(&request, out)?;
748 summary.ls_refs += 1;
749 }
750 "fetch" => {
751 let request = {
752 let _s = crate::stage::enter("request_parse");
753 FetchRequest::parse(
754 &command.arguments,
755 self.hash_kind(),
756 self.config.offered,
757 )?
758 };
759 let outcome = self.fetch(&request, out)?;
760 summary.fetch_rounds += 1;
761 summary.common = outcome.common.len();
762 summary.objects_sent += outcome.objects_sent;
763 summary.objects_selected += outcome.objects_selected;
764 summary.objects_copied_from_pack += outcome.objects_copied_from_pack;
765 summary.objects_recompressed += outcome.objects_recompressed;
766 // `|=` and not `=`: only the round that carries the
767 // packfile has a selection at all, and the rounds before it
768 // return `FetchOutcome::default()`, whose `false` would
769 // otherwise overwrite the one answer this field exists to
770 // report. Seen red on 2026-08-04 by omitting this line
771 // entirely: every arm of a five-arm A/B reported
772 // `answered_from_bitmap: false` including the ones served
773 // from a bitmap, which is the exact shape of the four
774 // instruments that spent that day measuring the wrong path.
775 summary.answered_from_bitmap |= outcome.answered_from_bitmap;
776 summary.thin_bases_offered += outcome.thin_bases_offered;
777 summary.thin_bases_vetoed += outcome.thin_bases_vetoed;
778 summary.closure_bases_declined += outcome.closure_bases_declined;
779 }
780 other => {
781 return Err(Error::protocol(format!("unknown v2 command {other:?}")));
782 }
783 }
784 {
785 let _s = crate::stage::enter("flush");
786 out.flush()?;
787 }
788 if session.is_stateless() {
789 return Ok(summary);
790 }
791 }
792 }
793
794 /// Read one v2 command — `command=…`, its capability lines, and, after the
795 /// delim-pkt, its argument section.
796 ///
797 /// **One [`Budget`] spans the whole command**, capability lines and
798 /// arguments together, because they land in the same two `Vec`s and a
799 /// ceiling that only covered one half would be walked around by putting the
800 /// bytes in the other. The budget is per *round*: a v2 client re-sends its
801 /// whole `have` list on every `command=fetch`, so what is being bounded is
802 /// what the server holds at once, not what the client says over a session.
803 fn read_command(&self, input: &mut dyn BufRead) -> Result<Option<V2Command>> {
804 let mut budget = Budget::new(self.config.limits.request_bytes);
805 let mut name: Option<String> = None;
806 let mut capability_lines: Vec<String> = Vec::new();
807 let mut arguments: Vec<Vec<u8>> = Vec::new();
808 let saw_delim;
809
810 loop {
811 match read_packet(input)? {
812 // A clean hang-up, or the client's flush-pkt: both mean the
813 // session is over. Over SSH a client that is done simply
814 // closes, so EOF here is normal and must not be an error.
815 None | Some(Packet::Flush) => {
816 return Ok(None);
817 }
818 Some(Packet::Delim) => {
819 saw_delim = true;
820 break;
821 }
822 Some(Packet::ResponseEnd) => {
823 return Err(Error::protocol("a response-end-pkt in a v2 request"))
824 }
825 Some(Packet::Data(data)) => {
826 budget.spend(data.len(), "v2 request")?;
827 let text = String::from_utf8_lossy(data.strip_suffix(b"\n").unwrap_or(&data))
828 .into_owned();
829 match text.strip_prefix("command=") {
830 Some(command) => name = Some(command.to_owned()),
831 None => capability_lines.push(text),
832 }
833 }
834 }
835 }
836
837 if saw_delim {
838 arguments = read_section(input, "a v2 argument section", &mut budget)?;
839 }
840
841 let name = name.ok_or_else(|| Error::protocol("a v2 request with no command="))?;
842 Ok(Some(V2Command {
843 name,
844 capabilities: PeerCapabilities::parse(&capability_lines.join(" ")),
845 arguments,
846 }))
847 }
848
849 /// `command=ls-refs`.
850 pub fn ls_refs(&self, request: &LsRefsRequest, out: &mut dyn Write) -> Result<()> {
851 let refs = self.collect_refs(&request.ref_prefixes, request.peel, request.symrefs)?;
852 for entry in refs {
853 let mut line = match entry.target {
854 Some(id) => format!("{id} {}", entry.name),
855 None if request.unborn => format!("unborn {}", entry.name),
856 None => continue,
857 };
858 if let Some(symref) = &entry.symref_target {
859 line.push_str(&format!(" symref-target:{symref}"));
860 }
861 if let Some(peeled) = entry.peeled {
862 line.push_str(&format!(" peeled:{peeled}"));
863 }
864 wire_io::text(out, line.as_bytes())?;
865 }
866 wire_io::flush(out)?;
867 Ok(())
868 }
869
870 /// `command=fetch`, one round.
871 ///
872 /// Returns without a packfile when negotiation is not finished; the client
873 /// then sends another `command=fetch` with more `have`s.
874 pub fn fetch(&self, request: &FetchRequest, out: &mut dyn Write) -> Result<FetchOutcome> {
875 let graph = self.graph();
876 // ONE snapshot of the reference namespace for the whole round: the
877 // `want-ref`s, the shallow computation and `include-tag` all resolve
878 // against the same one, so a reference that moves mid-request cannot
879 // make this fetch promise an oid under a name it no longer has.
880 let refs = RefSnapshot::read(self.git)?;
881 let mut wants = request.wants.clone();
882 let mut wanted_refs: Vec<(ObjectId, String)> = Vec::new();
883 {
884 let _s = crate::stage::enter("want_check");
885 for name in &request.want_refs {
886 gunnar_store::refname::validate(name.as_bytes())?;
887 // `HEAD` is NOT a row, so `resolve` — a row lookup — cannot find
888 // it. It has an accessor pair instead, and `ls-refs` already
889 // advertises it through `head()`. Without this arm every
890 // protocol-v2 `git clone` fails: the advertisement offers
891 // `HEAD symref-target:refs/heads/…`, the client answers
892 // `want-ref HEAD`, and the server says the ref does not exist.
893 //
894 // `refname::validate` admits `HEAD`, so it passes validation and
895 // dies one line later — which is why nothing caught it: the wire
896 // suites drive the state machine directly, and `ext::` negotiates
897 // v0, where `want-ref` never appears. Found by running a real
898 // `git clone` (2026-08-10).
899 let resolved = if name == "HEAD" {
900 refs.head(self.git)?
901 } else {
902 refs.resolve(name)?
903 };
904 let Some(id) = resolved else {
905 return Err(Error::protocol(format!(
906 "want-ref names {name:?}, which does not exist"
907 )));
908 };
909 wanted_refs.push((id, name.clone()));
910 wants.push(id);
911 }
912 dedupe_oids(&mut wants);
913 self.check_wants(&wants)?;
914 }
915
916 // `done` means the client has stopped negotiating, so the expensive
917 // reachability question is not asked at all, only which `have`s are
918 // present, which is what the pack needs anyway.
919 let negotiation = {
920 let _s = crate::stage::enter("negotiate");
921 negotiate(
922 &graph,
923 &wants,
924 &request.haves,
925 request.wait_for_done || request.done,
926 &self.haves,
927 )?
928 };
929
930 if !request.done {
931 wire_io::text(out, b"acknowledgments")?;
932 // `ack`, not `common`, in **both** branches, which is git's
933 // `send_acks` line for line: `if (!acks->nr) NAK` and then one
934 // `ACK` per entry. The pack is still computed from the whole of
935 // `common`; what goes on the wire is the subset the client had not
936 // already told the server about, and that is 25 400 bytes on the
937 // divergent fixture. See `Negotiation::ack`.
938 //
939 // A round that finds commons but no *new* ones therefore answers
940 // `NAK`, exactly as git's does — and the section is never empty,
941 // which an `ack.is_empty()` fall-through would have made it.
942 if negotiation.ack.is_empty() {
943 wire_io::text(out, b"NAK")?;
944 } else {
945 for id in &negotiation.ack {
946 wire_io::text(out, format!("ACK {id}").as_bytes())?;
947 }
948 }
949 if negotiation.ready {
950 wire_io::text(out, b"ready")?;
951 wire_io::delim(out)?;
952 } else {
953 wire_io::flush(out)?;
954 return Ok(FetchOutcome {
955 common: negotiation.common,
956 ready: false,
957 sent_pack: false,
958 ..Default::default()
959 });
960 }
961 }
962
963 let want_commits = {
964 let _s = crate::stage::enter("peel_wants");
965 self.commit_wants(&graph, &wants)?
966 };
967 let shallow_outcome = {
968 let _s = crate::stage::enter("shallow");
969 shallow::compute(&graph, &refs, &want_commits, &request.shallow)?
970 };
971 if shallow_outcome.has_info() {
972 wire_io::text(out, b"shallow-info")?;
973 for id in &shallow_outcome.newly_shallow {
974 // No trailing newline, matching what `git-upload-pack` 2.53
975 // puts on the wire (captured 2026-08-03).
976 wire_io::data(out, format!("shallow {id}").as_bytes())?;
977 }
978 for id in &shallow_outcome.unshallow {
979 wire_io::data(out, format!("unshallow {id}").as_bytes())?;
980 }
981 wire_io::delim(out)?;
982 }
983
984 if !wanted_refs.is_empty() {
985 wire_io::text(out, b"wanted-refs")?;
986 for (id, name) in &wanted_refs {
987 wire_io::text(out, format!("{id} {name}").as_bytes())?;
988 }
989 wire_io::delim(out)?;
990 }
991
992 wire_io::text(out, b"packfile")?;
993 let written = self.send_pack(
994 &graph,
995 &refs,
996 &wants,
997 &negotiation.common,
998 request,
999 &shallow_outcome,
1000 out,
1001 true,
1002 )?;
1003 {
1004 let _s = crate::stage::enter("flush");
1005 wire_io::flush(out)?;
1006 }
1007
1008 Ok(FetchOutcome {
1009 common: negotiation.common,
1010 ready: negotiation.ready,
1011 sent_pack: true,
1012 objects_sent: written.written.num_objects,
1013 objects_selected: written.objects_selected,
1014 objects_copied_from_pack: written.written.objects_copied_from_pack,
1015 objects_recompressed: written.written.objects_recompressed,
1016 answered_from_bitmap: written.answered_from_bitmap,
1017 thin_bases_offered: written.written.thin_bases_offered,
1018 thin_bases_vetoed: written.written.thin_bases_vetoed,
1019 closure_bases_declined: written.written.closure_bases_declined,
1020 })
1021 }
1022
1023 // ── protocol v0 ──────────────────────────────────────────────────────
1024
1025 /// The v0 reference advertisement.
1026 pub fn write_ref_advertisement(&self, out: &mut dyn Write) -> Result<()> {
1027 let capabilities = caps::upload_pack_v0_capabilities(
1028 self.hash_kind(),
1029 &self.config.agent,
1030 self.config.offered,
1031 self.config.want_policy,
1032 );
1033 let refs_snapshot = RefSnapshot::read(self.git)?;
1034 let head = self.head_ref(&refs_snapshot)?;
1035 let mut extra = capabilities.clone();
1036 if let Some(symref) = head.as_ref().and_then(|h| h.symref_target.as_ref()) {
1037 extra.push(format!("symref=HEAD:{symref}"));
1038 }
1039
1040 let refs = self.advertise_from(&refs_snapshot, &[], true, true)?;
1041 let mut first = true;
1042 for entry in refs {
1043 let Some(target) = entry.target else {
1044 continue;
1045 };
1046 let mut line = format!("{target} {}", entry.name).into_bytes();
1047 if first {
1048 line.push(0);
1049 line.extend_from_slice(extra.join(" ").as_bytes());
1050 first = false;
1051 }
1052 wire_io::text(out, &line)?;
1053 if let Some(peeled) = entry.peeled {
1054 wire_io::text(out, format!("{peeled} {}^{{}}", entry.name).as_bytes())?;
1055 }
1056 }
1057 if first {
1058 // An empty repository still has to say who it is, or the client
1059 // cannot learn the hash algorithm and reports nothing useful.
1060 let mut line = format!("{} capabilities^{{}}", self.hash_kind().null()).into_bytes();
1061 line.push(0);
1062 line.extend_from_slice(extra.join(" ").as_bytes());
1063 wire_io::text(out, &line)?;
1064 }
1065 wire_io::flush(out)?;
1066 Ok(())
1067 }
1068
1069 /// The whole v0 session.
1070 pub fn serve_v0(&self, input: &mut dyn BufRead, out: &mut dyn Write) -> Result<UploadSummary> {
1071 self.serve_v0_session(Session::Connected, input, out)
1072 }
1073
1074 /// The v0 session, for either [`Session`].
1075 ///
1076 /// # What stateless-rpc changes here, and it is one branch
1077 ///
1078 /// v0 has no `command=` boundary, so a stateless round is delimited by the
1079 /// client's flush-pkt after its `have` list. Under
1080 /// [`Session::StatelessRpc`] that flush ends the **call**: the server
1081 /// answers `ACK`/`NAK` and returns, and the client opens a new `POST`
1082 /// carrying its whole `want` list again plus the `have`s it has since
1083 /// decided to send. Under [`Session::Connected`] the same flush ends only a
1084 /// *round* and the loop keeps reading, which is what SSH needs.
1085 ///
1086 /// Getting this wrong is not a subtle bug: a server that kept reading would
1087 /// block on a body that has already ended, and the client would sit waiting
1088 /// for a response that is waiting for it.
1089 pub fn serve_v0_session(
1090 &self,
1091 session: Session,
1092 input: &mut dyn BufRead,
1093 out: &mut dyn Write,
1094 ) -> Result<UploadSummary> {
1095 let mut summary = UploadSummary {
1096 protocol: ProtocolVersion::V0,
1097 // The v0 advertisement IS the `ls-refs`, so it is counted where it
1098 // is written and not from a constant: a stateless `POST` writes no
1099 // advertisement, and a summary claiming one would make the metric
1100 // say a ref list went out when none did.
1101 ls_refs: usize::from(session.advertises_inline()),
1102 ..Default::default()
1103 };
1104 if session.advertises_inline() {
1105 self.write_ref_advertisement(out)?;
1106 out.flush()?;
1107 }
1108
1109 // **One budget for the whole v0 session**, not one per section. v0 has
1110 // no `command=` boundary: the `want` list and every subsequent round of
1111 // `have`s accumulate into the *same* two vectors, held until the pack is
1112 // written, so a per-section ceiling would be spent afresh on every round
1113 // and bound nothing at all. This is the shape a hostile client reaches
1114 // for — open a connection, send `have <oid>` forever, never send `done`
1115 // — and it costs the server memory before any repository is touched.
1116 let mut budget = Budget::new(self.config.limits.request_bytes);
1117 let mut request = FetchRequest::default();
1118 let mut peer = PeerCapabilities::default();
1119 let mut first = true;
1120 loop {
1121 let packet = match read_packet(input)? {
1122 // `git ls-remote` over v0 takes the advertisement and hangs
1123 // up. That is a completed request, not a truncated one.
1124 None => return Ok(summary),
1125 Some(packet) => packet,
1126 };
1127 match packet {
1128 Packet::Flush => break,
1129 Packet::Data(data) => {
1130 budget.spend(data.len(), "v0 want section")?;
1131 let mut text: &[u8] = data.strip_suffix(b"\n").unwrap_or(&data);
1132 if first {
1133 // v0 upload-pack puts the client's capabilities on the
1134 // first `want` line, **space-separated after the oid**,
1135 // not after a NUL, which is receive-pack's form.
1136 // Reading it as a NUL split leaves the capability
1137 // string glued to the object id, and the request dies
1138 // with "a hash sized 133 hexadecimal characters".
1139 if let Some((head, caps)) = split_v0_capabilities(text) {
1140 peer = PeerCapabilities::parse(&String::from_utf8_lossy(caps));
1141 text = head;
1142 }
1143 first = false;
1144 }
1145 self.parse_v0_want_line(text, &mut request)?;
1146 }
1147 other => {
1148 return Err(Error::protocol(format!(
1149 "unexpected {other:?} in a v0 want section"
1150 )))
1151 }
1152 }
1153 }
1154
1155 caps::check_object_format(self.hash_kind(), peer.value("object-format"))?;
1156 if request.wants.is_empty() {
1157 return Ok(summary);
1158 }
1159 dedupe_oids(&mut request.wants);
1160 self.check_wants(&request.wants)?;
1161
1162 // `side-band-64k` and NOT git's older plain `side-band`, which gunnar
1163 // does not advertise. Reading a capability that is not on offer is the
1164 // inverse of the drift `caps::Honoured` catches, and here it was not
1165 // merely dead: the two forms differ in the maximum payload a band may
1166 // carry — 65515 against 1000 — and `wire_io::sideband` frames at the
1167 // 64k size unconditionally. So a peer that guessed `side-band` and got
1168 // it honoured would have been handed packets it must reject. Advertise
1169 // what you keep, keep what you advertise, read nothing else;
1170 // `tests/advertisement.rs::every_capability_the_server_reads_is_one_it_advertises`
1171 // is what says so.
1172 let sideband = peer.has("side-band-64k");
1173 request.no_progress = request.no_progress || peer.has("no-progress");
1174 request.include_tag = request.include_tag || peer.has("include-tag");
1175 // v0 carries this as a capability rather than as a request line, and it
1176 // decides whether the pack may contain `ofs-delta` entries at all, so
1177 // whether pack-copy is usable on this connection. Reading it
1178 // here rather than in `parse_v0_want_line` is where the other two
1179 // capability-shaped flags are read.
1180 request.ofs_delta = request.ofs_delta || peer.has("ofs-delta");
1181 // The third capability-shaped flag, and it was missing.
1182 //
1183 // v0 advertises `thin-pack` (`caps::upload_pack_v0_capabilities`) and
1184 // `FetchRequest::thin_pack` was only ever set from a **v2** `thin-pack`
1185 // argument, so the `thin` gate in `send_pack` could not be true on a v0
1186 // connection: every v0 fetch was served as a whole pack against an
1187 // advertisement that promised otherwise.
1188 //
1189 // MEASURED 2026-08-05, `fx/knut` from the production-shaped corpus,
1190 // a client cut 40 commits back, the same 431 objects served on both
1191 // arms, stock git 2.53.0 as the client:
1192 //
1193 // | protocol | bytes_out |
1194 // |---|---|
1195 // | v0, before | 942 987 |
1196 // | v2 | 702 138 |
1197 //
1198 // 1.343x, 240 849 bytes, for no reason but this line. v2 has been git's
1199 // default since 2.26 so this is the compatibility path rather than the
1200 // common one, which is why it went four months unnoticed — and is also
1201 // why nothing else was going to notice it.
1202 request.thin_pack = request.thin_pack || peer.has("thin-pack");
1203
1204 let graph = self.graph();
1205 let want_commits = self.commit_wants(&graph, &request.wants)?;
1206 let refs = RefSnapshot::read(self.git)?;
1207 let shallow_outcome = shallow::compute(&graph, &refs, &want_commits, &request.shallow)?;
1208 if request.shallow.is_deepening() {
1209 for id in &shallow_outcome.newly_shallow {
1210 wire_io::text(out, format!("shallow {id}").as_bytes())?;
1211 }
1212 for id in &shallow_outcome.unshallow {
1213 wire_io::text(out, format!("unshallow {id}").as_bytes())?;
1214 }
1215 wire_io::flush(out)?;
1216 out.flush()?;
1217 }
1218
1219 // ── negotiation: two ACK modes, chosen by the client ────────────────
1220 //
1221 // Which one is in play is decided by what
1222 // [`caps::UPLOAD_PACK_V0`] advertised, and that table's comment carries
1223 // the measurement that settled it. In short:
1224 //
1225 // **`multi_ack_detailed`** is what any git since 1.6.6 picks, and the
1226 // only mode in which a multi-round negotiation over **stateless-rpc**
1227 // has a correct answer at all. A round answers `ACK <oid> common` per
1228 // newly-common `have`, `ACK <oid> ready` once the wants are reachable,
1229 // then `NAK` — git writes that `NAK` at every multi-ack round boundary
1230 // whether or not it is ready. With **`no-done`** the server then writes
1231 // the terminal `ACK` and the packfile into the same response instead of
1232 // waiting for the client to say `done`, which over HTTP saves a whole
1233 // request.
1234 //
1235 // **Single-ACK** is the fallback, for a client that offered neither.
1236 // It is `b9fee55`'s shape and not a reversion of it: one `ACK <oid>` at
1237 // the first recognised `have`, nothing at any later flush, and nothing
1238 // after `done`. That is git's `have_obj.nr` rule, and it is correct on
1239 // the connected sessions such a client can actually reach.
1240 let mut haves: Vec<ObjectId> = Vec::new();
1241 let multi_ack_detailed = peer.has("multi_ack_detailed");
1242 let no_done = multi_ack_detailed && peer.has("no-done");
1243 // In single-ACK this is git's `have_obj.nr > 0` — "an ACK has gone out,
1244 // say nothing more". In multi-ack it is git's `last_hex`, the LAST
1245 // `have` found common, which is what the terminal `ACK` names.
1246 let mut acked: Option<ObjectId> = None;
1247 // git's `sent_ready`: `ACK <oid> ready` is announced once per session.
1248 // Over stateless-rpc the session is one `POST`, so the latch resets
1249 // with it and every request that reaches readiness says so — which is
1250 // also what stock does, its upload-pack being a fresh process per
1251 // request.
1252 let mut ready_sent = false;
1253 // Set when `no-done` let this session write its terminal `ACK` inside
1254 // the round, so the block after the loop does not write a second one.
1255 let mut terminal_written = false;
1256 let mut done = false;
1257 while !done {
1258 match read_packet(input)? {
1259 None => return Ok(summary),
1260 Some(Packet::Flush) => {
1261 summary.fetch_rounds += 1;
1262 // ── one line per block, and NOTHING once an ACK has gone
1263 // out ────────────────────────────────────────────────
1264 //
1265 // The `_ => NAK` this replaces answered every later block
1266 // with a `NAK` **after** the ACK had already ended the
1267 // negotiation, and git's client stops reading the moment it
1268 // sees a plain ACK. The surplus `NAK` therefore sat in the
1269 // pipe until the client switched to sideband and read `N`
1270 // as a band number:
1271 //
1272 // ```text
1273 // fetch-pack: protocol error: bad band #78
1274 // ```
1275 //
1276 // MEASURED 2026-08-05, `fx/knut`, client cut 40
1277 // commits back: three blocks of `have`s, so gunnar wrote
1278 // NAK / ACK / NAK and the third line corrupted the stream.
1279 // `fx/funnel-core` at five commits back sends one block and
1280 // was clean, which is why the small fixture in the interop
1281 // suite never showed it — the trigger is a client with
1282 // enough history to send a SECOND block.
1283 //
1284 // git's rule, `upload-pack.c:get_common_commits`, is on
1285 // `have_obj.nr` and not on the round: with `multi_ack` off
1286 // it writes `NAK` at a flush only while it has recognised
1287 // **no** `have` at all, and the single `ACK` goes out inline
1288 // at the first one it does recognise. `acked` is gunnar's
1289 // `have_obj.nr > 0`.
1290 if multi_ack_detailed {
1291 // The SAME `negotiate` the v2 `command=fetch` path
1292 // runs, over the same per-connection `HaveMemo`, so
1293 // `ready` is decided once for both protocol versions
1294 // and `Negotiation::ack`'s suppression of redundant
1295 // parent-chain ACKs applies here too (one implementation).
1296 let negotiation = negotiate(
1297 &graph,
1298 &request.wants,
1299 &haves,
1300 /* wait_for_done */ false,
1301 &self.haves,
1302 )?;
1303 for id in &negotiation.ack {
1304 wire_io::text(out, format!("ACK {id} common").as_bytes())?;
1305 }
1306 if let Some(id) = negotiation.common.last() {
1307 acked = Some(*id);
1308 }
1309 let ready = negotiation.ready && acked.is_some();
1310 if let (true, false, Some(id)) = (ready, ready_sent, acked) {
1311 wire_io::text(out, format!("ACK {id} ready").as_bytes())?;
1312 ready_sent = true;
1313 }
1314 // Unconditional, and NOT the surplus `NAK` the comment
1315 // above warns about: that one followed a *terminal*
1316 // single-ACK, after which git reads nothing. In
1317 // multi-ack the client keeps reading until `done`, and
1318 // git writes this `NAK` at every round boundary
1319 // (`have_obj.nr == 0 || multi_ack`). A client that does
1320 // not get one waits for a round that has ended.
1321 wire_io::text(out, b"NAK")?;
1322 out.flush()?;
1323 if no_done && ready {
1324 let id = acked.expect("`ready` is gated on a common `have`");
1325 wire_io::text(out, format!("ACK {id}").as_bytes())?;
1326 out.flush()?;
1327 terminal_written = true;
1328 break;
1329 }
1330 } else {
1331 match (acked, self.first_common(&haves)) {
1332 (Some(_), _) => {}
1333 (None, Some(id)) => {
1334 acked = Some(id);
1335 wire_io::text(out, format!("ACK {id}").as_bytes())?;
1336 out.flush()?;
1337 }
1338 (None, None) => {
1339 wire_io::text(out, b"NAK")?;
1340 out.flush()?;
1341 }
1342 }
1343 }
1344 if session.is_stateless() {
1345 // The round IS the request. See the doc comment: the
1346 // client's next `have` list arrives on a new
1347 // connection, carrying its `want`s again.
1348 summary.common = haves
1349 .iter()
1350 .filter(|id| has_for_negotiation(self.git, id))
1351 .count();
1352 return Ok(summary);
1353 }
1354 }
1355 Some(Packet::Data(data)) => {
1356 budget.spend(data.len(), "v0 negotiation")?;
1357 let text = data.strip_suffix(b"\n").unwrap_or(&data);
1358 if text == b"done" {
1359 done = true;
1360 } else if let Some(rest) = text.strip_prefix(b"have ") {
1361 haves.push(parse_oid(&String::from_utf8_lossy(rest), self.hash_kind())?);
1362 }
1363 }
1364 Some(other) => {
1365 return Err(Error::protocol(format!(
1366 "unexpected {other:?} during v0 negotiation"
1367 )))
1368 }
1369 }
1370 }
1371
1372 // ── after `done`, a single-ACK server that has already ACKed says
1373 // NOTHING ─────────────────────────────────────────────────────────
1374 //
1375 // This used to be `acked.or_else(first_common)` and wrote an `ACK`
1376 // either way, so a negotiation that found a common commit sent the
1377 // **same ACK twice** — once from the flush arm above, once here. The
1378 // second one lands after the client has switched its reader into
1379 // sideband mode, which demultiplexes `A` as a band number:
1380 //
1381 // ```text
1382 // fetch-pack: protocol error: bad band #65
1383 // fatal: protocol error: bad pack header
1384 // ```
1385 //
1386 // MEASURED 2026-08-05 against stock git 2.53.0 over real SSH,
1387 // `fx/funnel-core` from the production-shaped corpus, client cut five
1388 // commits back:
1389 //
1390 // | fetch | v0 | v2 |
1391 // |---|---|---|
1392 // | full clone | 28 objects, OK | OK |
1393 // | **incremental** | **`bad band #65`, 0 objects** | 28 objects, OK |
1394 //
1395 // So **every** v0 fetch that shares history with the server was
1396 // corrupt, and only a clone — which sends no `have` and gets no ACK —
1397 // came through. `GIT_TRACE_PACKET` shows the duplicate plainly:
1398 // `fetch< ACK 36d2cb41…` followed by `sideband< ACK 36d2cb41…`.
1399 //
1400 // git's own rule, `upload-pack.c:get_common_commits`: with `multi_ack`
1401 // off, `done` returns without writing when any `have` was recognised,
1402 // and writes `NAK` only when none was. Its client is the other half of
1403 // the same contract — `fetch-pack.c:find_common` zeroes `flushes` on a
1404 // plain `ACK`, so `while (flushes || multi_ack)` after `done` reads
1405 // **no** lines at all and the next byte it expects is the pack.
1406 //
1407 // The `first_common` fallback is gone with it: it could only fire when
1408 // the flush arm had already run and decided otherwise, and reaching a
1409 // different verdict about the same `haves` two lines apart is the shape
1410 // of the bug rather than a safety net.
1411 //
1412 // In **multi-ack** the rule is the other one git writes at `done`: an
1413 // `ACK <last_hex>` when anything was common, `NAK` when nothing was.
1414 // The asymmetry is not an inconsistency — in single-ACK the client
1415 // stopped reading at the earlier terminal `ACK`, and in multi-ack it is
1416 // still reading and this line is the terminal one. `terminal_written`
1417 // is the `no-done` case, where it has already gone out with the pack.
1418 if !terminal_written {
1419 if multi_ack_detailed {
1420 match acked {
1421 Some(id) => wire_io::text(out, format!("ACK {id}").as_bytes())?,
1422 None => wire_io::text(out, b"NAK")?,
1423 }
1424 } else if acked.is_none() {
1425 wire_io::text(out, b"NAK")?;
1426 }
1427 out.flush()?;
1428 }
1429
1430 request.haves = haves;
1431 let common: Vec<ObjectId> = request
1432 .haves
1433 .iter()
1434 .filter(|id| graph.contains_have(id))
1435 .copied()
1436 .collect();
1437 summary.fetch_rounds += 1;
1438 summary.common = common.len();
1439 let written = self.send_pack(
1440 &graph,
1441 &refs,
1442 &request.wants,
1443 &common,
1444 &request,
1445 &shallow_outcome,
1446 out,
1447 sideband,
1448 )?;
1449 summary.objects_sent = written.written.num_objects;
1450 summary.objects_selected = written.objects_selected;
1451 summary.objects_copied_from_pack = written.written.objects_copied_from_pack;
1452 summary.objects_recompressed = written.written.objects_recompressed;
1453 summary.answered_from_bitmap = written.answered_from_bitmap;
1454 summary.thin_bases_offered = written.written.thin_bases_offered;
1455 summary.thin_bases_vetoed = written.written.thin_bases_vetoed;
1456 summary.closure_bases_declined = written.written.closure_bases_declined;
1457 if sideband {
1458 // A sidebanded v0 response ends with an **unframed** flush-pkt.
1459 // Without it the client sits waiting for another band-1 line and
1460 // reports "unexpected disconnect while reading sideband packet"
1461 // when the pipe closes, a message that points at the transport
1462 // and not at the missing four bytes. (Found by the real-git
1463 // interop suite, 2026-08-03; the raw, non-sidebanded form needs no
1464 // terminator because the pack's own trailer ends it.)
1465 wire_io::flush(out)?;
1466 }
1467 out.flush()?;
1468 Ok(summary)
1469 }
1470
1471 fn parse_v0_want_line(&self, text: &[u8], request: &mut FetchRequest) -> Result<()> {
1472 let line = String::from_utf8_lossy(text).into_owned();
1473 let (name, value) = split_argument(&line);
1474 let missing = || Error::protocol(format!("v0 line {line:?} is missing its value"));
1475 match name {
1476 "want" => request
1477 .wants
1478 .push(parse_oid(value.ok_or_else(missing)?, self.hash_kind())?),
1479 "shallow" => request
1480 .shallow
1481 .client_shallow
1482 .push(parse_oid(value.ok_or_else(missing)?, self.hash_kind())?),
1483 "deepen" => {
1484 request.shallow.depth = Some(
1485 value
1486 .ok_or_else(missing)?
1487 .parse::<usize>()
1488 .map_err(|_| Error::protocol(format!("deepen wants a number: {line:?}")))?,
1489 )
1490 }
1491 "deepen-since" => {
1492 request.shallow.since =
1493 Some(value.ok_or_else(missing)?.parse::<i64>().map_err(|_| {
1494 Error::protocol(format!("deepen-since wants unix seconds: {line:?}"))
1495 })?)
1496 }
1497 "deepen-not" => request
1498 .shallow
1499 .not
1500 .push(value.ok_or_else(missing)?.to_owned()),
1501 "deepen-relative" => request.shallow.relative = true,
1502 "filter" => {
1503 if !self.config.offered.filter {
1504 return Err(Error::UnsupportedFilter {
1505 spec: value.unwrap_or_default().to_owned(),
1506 reason: "this server does not offer the filter capability",
1507 });
1508 }
1509 request.filter = Some(Filter::parse(value.ok_or_else(missing)?)?)
1510 }
1511 "" => {}
1512 other => {
1513 return Err(Error::protocol(format!(
1514 "unknown v0 request line {other:?}"
1515 )))
1516 }
1517 }
1518 Ok(())
1519 }
1520
1521 fn first_common(&self, haves: &[ObjectId]) -> Option<ObjectId> {
1522 haves
1523 .iter()
1524 .find(|id| has_for_negotiation(self.git, id))
1525 .copied()
1526 }
1527
1528 // ── shared ───────────────────────────────────────────────────────────
1529
1530 /// Report a fatal condition to the client in whichever way this point of
1531 /// the protocol allows: an `ERR` line before a pack has started, band 3
1532 /// once the sideband is open.
1533 pub fn report_error(&self, out: &mut dyn Write, sideband: bool, message: &str) -> Result<()> {
1534 if sideband {
1535 crate::wire_io::sideband_error(out, message)
1536 } else {
1537 err_packet(out, message)
1538 }
1539 }
1540
1541 /// `HEAD`, which is **not a row in `refs()`**.
1542 ///
1543 /// `locked_on_target.md`'s `HEAD`-is-not-a-row question, settled from the gix arm's own words:
1544 /// `all()` walks `refs/` and *"deliberately excludes the pseudo-refs such
1545 /// as `HEAD`"*, which is the contract every backend honours. So it has its
1546 /// own accessor pair on `GitServe` and this asks the store, not the
1547 /// snapshot. Its target, though, is resolved **against the snapshot** — the
1548 /// same one the advertisement is built from — so `HEAD` cannot be
1549 /// advertised pointing at an oid no other line in the same advertisement
1550 /// agrees with.
1551 fn head_ref(&self, refs: &RefSnapshot) -> Result<Option<AdvertisedRef>> {
1552 let Some(row) = self.git.head().map_err(crate::graph::backend)? else {
1553 return Ok(None);
1554 };
1555 let symref_target = row.symref_target.clone();
1556 let resolved = match &row.oid {
1557 Some(bytes) => Some(crate::graph::oid(bytes)?),
1558 None => match &symref_target {
1559 Some(name) => refs.resolve(name)?,
1560 None => None,
1561 },
1562 };
1563 Ok(Some(AdvertisedRef {
1564 name: gunnar_store::HEAD.to_owned(),
1565 target: resolved,
1566 peeled: None,
1567 symref_target,
1568 }))
1569 }
1570
1571 fn collect_refs(
1572 &self,
1573 prefixes: &[String],
1574 peel: bool,
1575 symrefs: bool,
1576 ) -> Result<Vec<AdvertisedRef>> {
1577 let refs = RefSnapshot::read(self.git)?;
1578 self.advertise_from(&refs, prefixes, peel, symrefs)
1579 }
1580
1581 /// [`UploadPack::collect_refs`] against a snapshot the caller already took.
1582 fn advertise_from(
1583 &self,
1584 refs: &RefSnapshot,
1585 prefixes: &[String],
1586 peel: bool,
1587 symrefs: bool,
1588 ) -> Result<Vec<AdvertisedRef>> {
1589 let graph = self.graph();
1590 let mut out = Vec::new();
1591
1592 // HEAD first, exactly as `git-upload-pack` orders it.
1593 if prefixes.is_empty() || prefixes.iter().any(|p| "HEAD".starts_with(p.as_str())) {
1594 if let Some(mut head) = self.head_ref(refs)? {
1595 if !symrefs {
1596 head.symref_target = None;
1597 }
1598 out.push(head);
1599 }
1600 }
1601
1602 for row in refs.rows() {
1603 if !prefixes.is_empty() && !prefixes.iter().any(|p| row.name.starts_with(p.as_str())) {
1604 continue;
1605 }
1606 let resolved = refs.resolve_row(row)?;
1607 // The store's own peel column when it has one, and only then the
1608 // walk. `RefRow::peeled` is *"for an annotated tag, the commit it
1609 // peels to"*; `None` means the backend does not say, never that
1610 // the tag peels to nothing, so the fallback is a real peel and not
1611 // an unpeeled advertisement.
1612 let peeled = match (peel, resolved) {
1613 (true, Some(id)) => match RefSnapshot::peeled(row)? {
1614 Some(known) => Some(known),
1615 None if graph.kind(&id)? == Some(object::Kind::Tag) => {
1616 Some(graph.peel(&id)?.id)
1617 }
1618 None => None,
1619 },
1620 _ => None,
1621 };
1622 out.push(AdvertisedRef {
1623 name: row.name.clone(),
1624 target: resolved,
1625 peeled,
1626 symref_target: symrefs.then(|| row.symref_target.clone()).flatten(),
1627 });
1628 }
1629 Ok(out)
1630 }
1631
1632 /// Every object id a client is allowed to `want`.
1633 fn advertised_oids(&self) -> Result<HashSet<ObjectId>> {
1634 let mut out = HashSet::new();
1635 for entry in self.collect_refs(&[], true, false)? {
1636 if let Some(id) = entry.target {
1637 out.insert(id);
1638 }
1639 if let Some(id) = entry.peeled {
1640 out.insert(id);
1641 }
1642 }
1643 Ok(out)
1644 }
1645
1646 /// **Under a policy, "there is no such object" and "there is, and you may
1647 /// not have it" are ONE answer.**
1648 ///
1649 /// They were two. `wants_exist` ran first and answered `ObjectMissing`, and
1650 /// only a want that DID exist went on to be measured against the
1651 /// advertisement and answered `WantNotAdvertised`. The difference is an
1652 /// oracle, and the thing it answers is precisely what the policy exists to
1653 /// hide: `WantNotAdvertised`'s own doc says serving arbitrary ids "leaks
1654 /// objects that are not reachable from any reference, which is how
1655 /// 'deleted' branches stay fetchable" — and telling a caller that an oid IS
1656 /// here, one `want` at a time, leaks the same fact one bit at a time. An
1657 /// oid from a force-pushed branch, a deleted branch, or somebody else's
1658 /// paste can be tested against a public repository for free.
1659 ///
1660 /// This is the same merge `gunnar-ssh` makes between "no such repository"
1661 /// and "you have no grant": one string on the wire, the difference kept in
1662 /// the server's log.
1663 ///
1664 /// `wants_exist` is not called at all under a policy now, rather than
1665 /// called and its answer laundered. Existence is implied by the check that
1666 /// remains: an advertised oid is a reference target, and a reachable one
1667 /// was just walked to. A want that passes is servable by construction, and
1668 /// a reference pointing at an object the store does not hold is repository
1669 /// corruption that the pack build reports as the server's problem — which
1670 /// is what it is.
1671 ///
1672 /// [`WantPolicy::Any`] keeps the plain existence check, and it is not an
1673 /// oracle there: under `Any` everything that exists is servable, so
1674 /// "missing" is the whole truth and there is nothing to hide behind it.
1675 fn check_wants(&self, wants: &[ObjectId]) -> Result<()> {
1676 if wants.is_empty() || self.config.want_policy == WantPolicy::Any {
1677 return self.wants_exist(wants);
1678 }
1679 let advertised = self.advertised_oids()?;
1680 // The reachable closure is built at most once, and only if some want
1681 // was not itself advertised.
1682 let mut reachable: Option<HashSet<ObjectId>> = None;
1683 for want in wants {
1684 if advertised.contains(want) {
1685 continue;
1686 }
1687 if self.config.want_policy == WantPolicy::AdvertisedTips {
1688 return Err(Error::WantNotAdvertised { oid: *want });
1689 }
1690 let closure = match &reachable {
1691 Some(closure) => closure,
1692 None => reachable.insert(self.reachable_from_refs()?),
1693 };
1694 if !closure.contains(want) {
1695 return Err(Error::WantNotAdvertised { oid: *want });
1696 }
1697 }
1698 Ok(())
1699 }
1700
1701 fn wants_exist(&self, wants: &[ObjectId]) -> Result<()> {
1702 for want in wants {
1703 if !self
1704 .git
1705 .has(want.as_bytes())
1706 .map_err(crate::graph::backend)?
1707 {
1708 return Err(Error::missing(*want, "a want"));
1709 }
1710 }
1711 Ok(())
1712 }
1713
1714 /// Every object reachable from any reference.
1715 ///
1716 /// One repository-sized set, and only one. This used to be
1717 /// `select_objects(…).objects.into_iter().collect()`, which holds a `Vec` of
1718 /// every id in the repository *while* the `HashSet` built out of it fills —
1719 /// ~820 MB of peak at ten million sha256 ids where ~500 MB is needed. See
1720 /// [`reachable_closure`].
1721 fn reachable_from_refs(&self) -> Result<HashSet<ObjectId>> {
1722 let tips: Vec<ObjectId> = self
1723 .collect_refs(&[], false, false)?
1724 .into_iter()
1725 .filter_map(|entry| entry.target)
1726 .collect();
1727 let graph = self.graph();
1728 Ok(reachable_closure(&graph, &tips)?.into_inner())
1729 }
1730
1731 /// The wants that peel to commits, deduplicated, in the order the client
1732 /// named them.
1733 ///
1734 /// # Both sets are here because the input is the client's
1735 ///
1736 /// `wants` is whatever the peer sent and is bounded only by what the
1737 /// request reader is willing to accept, so every per-want cost in here is a
1738 /// cost the peer chooses. Two of them used to be superlinear:
1739 ///
1740 /// * `asked` stops a repeated `want` being **peeled twice**. `peel` is an
1741 /// object read, so a client naming the same tag ten thousand times used
1742 /// to buy ten thousand store reads for one answer. This is the half a
1743 /// counting store can see, and `tests/want_cost.rs` holds it.
1744 /// * `seen` replaces a `Vec::contains` over the answer so far, which was a
1745 /// linear scan run once per want, so `O(wants²)` `ObjectId` comparisons.
1746 /// Ten thousand distinct wants — an ordinary mirror clone — was fifty
1747 /// million comparisons.
1748 ///
1749 /// The second half has **no deterministic instrument**, and that is worth
1750 /// stating rather than papering over: both spellings return identical
1751 /// output and touch the store identically, so the only difference is time,
1752 /// and this crate's cost tests deliberately count touches rather than
1753 /// timing anything (see `tests/connectivity_cost.rs`). It is fixed here and
1754 /// guarded only by the half above.
1755 fn commit_wants(&self, graph: &Graph<'_>, wants: &[ObjectId]) -> Result<Vec<ObjectId>> {
1756 let mut asked: HashSet<ObjectId> = HashSet::new();
1757 let mut seen: HashSet<ObjectId> = HashSet::new();
1758 let mut out = Vec::new();
1759 for want in wants {
1760 if !asked.insert(*want) {
1761 continue;
1762 }
1763 let peeled = graph.peel(want)?;
1764 if peeled.kind == object::Kind::Commit && seen.insert(peeled.id) {
1765 out.push(peeled.id);
1766 }
1767 }
1768 Ok(out)
1769 }
1770
1771 #[allow(clippy::too_many_arguments)]
1772 fn send_pack(
1773 &self,
1774 graph: &Graph<'_>,
1775 refs: &RefSnapshot,
1776 wants: &[ObjectId],
1777 common: &[ObjectId],
1778 request: &FetchRequest,
1779 shallow_outcome: &ShallowOutcome,
1780 out: &mut dyn Write,
1781 sideband: bool,
1782 ) -> Result<SentPack> {
1783 // The one place either protocol version opens a sideband, and therefore
1784 // the one place that has to record it. Past this line a failure's reason
1785 // rides band 3; before it, an `ERR` pkt-line. See
1786 // [`UploadPack::sideband_open`].
1787 self.sideband_open
1788 .store(sideband, std::sync::atomic::Ordering::Relaxed);
1789 // The serving defaults live in one place, `SelectOptions::for_serving`,
1790 // and the request-specific fields are laid over them here. Building the
1791 // whole struct inline is what let a test measure a configuration the
1792 // server never runs in; see `Selection::answered_from_bitmap`.
1793 let options = SelectOptions {
1794 boundary: shallow_outcome.boundary.clone(),
1795 have_boundary: shallow_outcome.client_boundary.clone(),
1796 filter: request.filter.as_ref(),
1797 restrict: request
1798 .shallow
1799 .is_deepening()
1800 .then_some(&shallow_outcome.include),
1801 missing: Missing::Fail,
1802 ..SelectOptions::for_serving(&self.config.limits)
1803 };
1804
1805 // The wants, plus the parents of everything this fetch just promised to
1806 // unshallow. Those parents are not a second want: they come from the
1807 // server's own walk of its own graph, already filtered by `restrict` to
1808 // what the deepening decided, so they widen the pack without widening
1809 // what the client is allowed to name. `check_wants` stays the gate on
1810 // the client's side of that line.
1811 let mut tips = wants.to_vec();
1812 if !shallow_outcome.unshallow_parents.is_empty() {
1813 // A set, not `tips.contains`: `wants` is the client's list.
1814 let mut named: HashSet<ObjectId> = tips.iter().copied().collect();
1815 for id in &shallow_outcome.unshallow_parents {
1816 if named.insert(*id) {
1817 tips.push(*id);
1818 }
1819 }
1820 }
1821 let mut selection = {
1822 let _s = crate::stage::enter("select");
1823 select_objects(graph, &tips, common, &options)?
1824 };
1825
1826 if request.include_tag {
1827 let _s = crate::stage::enter("include_tag");
1828 self.add_reachable_tags(graph, refs, &mut selection.objects)?;
1829 }
1830
1831 let show_progress = sideband && !request.no_progress;
1832 progress(
1833 out,
1834 show_progress,
1835 &format!(
1836 "gunnar: packing {} objects from {} commits\n",
1837 selection.objects.len(),
1838 selection.commits.len()
1839 ),
1840 )?;
1841
1842 // ── Copy, do not re-deflate ─────────────────────────────────────────
1843 //
1844 // `ObjectStore::packed()` is `None` for a store with no packs to copy
1845 // from: `mem`, and any cold tier that keeps inflated content per oid
1846 // instead. Those fall through to the whole-object writer they have
1847 // always used.
1848 //
1849 // `ofs_delta` is the client's own answer about whether it can decode
1850 // `OBJ_OFS_DELTA`, and until 2026-08-03 it gated the copy path
1851 // entirely: a client that did not advertise it got the pre-pack-copy
1852 // server, no delta reuse and no entry copying at all. MEASURED on the
1853 // churned fixture, that cost such a client **18.4x the bytes** — for
1854 // nothing, because `OBJ_REF_DELTA` needs no capability and carries the
1855 // identical delta bytes. The capability now decides how a copied delta
1856 // *names its base* (`WriteOptions::ofs_delta`), not whether copying
1857 // happens.
1858 // ── the unselected delta base ───────────────────────────────────────
1859 //
1860 // `WriteOptions::base_closure` lets `write_pack_copy` add a delta's
1861 // unselected base rather than expand the delta. MEASURED over the
1862 // corpus's single-branch clones: −2.82% wire bytes, −51.9% CPU.
1863 //
1864 // Three conditions, and each one is a different kind of reason:
1865 //
1866 // 1. **the negotiation found nothing in common.** This is the one that
1867 // decides most of the value, and it is measured rather than argued.
1868 // On a *clone*, an unselected base is an object nobody is getting,
1869 // and shipping it buys back a whole exploded object. On a
1870 // *negotiated* fetch it is usually an object the client already
1871 // holds — it sits below the cut — so shipping it is the duplication
1872 // thin packs exist to avoid. MEASURED 2026-08-05 on
1873 // `tests/pack_copy.rs`'s churned fixture, one fetch 8 commits deep
1874 // from a client that withheld `thin-pack`: **31 360 → 55 513 bytes,
1875 // +77%**, adding 57 bases to recover 24 deltas. A fetch's answer to
1876 // that delta is the thin voucher, not this.
1877 // 2. **a filtered request** turns it off. `--filter=blob:limit=1k` says
1878 // which objects the client will accept, an unselected base is by
1879 // construction an object the selection did not choose, and a small
1880 // blob's delta base is usually a large one — so a closure under a
1881 // filter ships exactly the object the filter excluded, silently.
1882 // 3. **the server's object ceiling** bounds how large a base may be. It
1883 // is `Some` on every request, so it cannot be folded into the same
1884 // answer as the other two; doing that turned the closure off
1885 // everywhere and `tests/base_closure.rs` is what caught it.
1886 // ⚠ **THE THREE CONDITIONS ABOVE NO LONGER CROSS THE SEAM, and one of
1887 // them is a real loss.** `WriteOptions::base_closure` was a knob on
1888 // gunnar's own pack writer; the engine owns the format now and
1889 // `git_storage_trait::Caps` is deliberately the CLIENT's capabilities
1890 // (`thin`, `ofs_delta`) and not the server's policy, which is the right
1891 // line to draw. Conditions 1 and 2 survive by construction — a non-thin
1892 // pack must be closed over its bases whatever anyone prefers, and
1893 // `thin` below already carries "the client said it can take an open
1894 // one". Condition **3** does not: there is no longer a way to tell an
1895 // engine how large an unselected delta base it may pull in, so
1896 // `Limits::object_bytes` bounds what this end *selects* and no longer
1897 // bounds what the engine *adds*. The measurements above are kept
1898 // because they are the record of why the knob existed; the knob is
1899 // gone. Recorded here rather than silently dropped.
1900 // ── May this answer be a THIN pack? ─────────────────────────────────
1901 //
1902 // Pack-copy copies 100% of a clone's entries and only 97.6% of a
1903 // fetch's, and the missing 2.4% is not a rounding error — it is every
1904 // object whose delta base sits on the client's side of the negotiation
1905 // cut, each one inflated and re-deflated **whole**. MEASURED
1906 // 2026-08-04 on a 60-file churn fetched 8 commits deep: 24 of 40
1907 // objects expanded, 3.98x stock git's thin pack of the same objects.
1908 // Naming those bases instead of expanding them is the fix, and it
1909 // computes nothing: the deltas are the pushing client's, already in
1910 // gunnar's pack. `gunnar-delta` stays off this path.
1911 //
1912 // Three conditions, and every one of them is a correctness condition
1913 // rather than a tuning knob, because a thin pack whose bases the
1914 // receiver lacks is not a smaller answer but a **failed fetch**:
1915 //
1916 // 1. the client advertised `thin-pack`. Nothing else may be read as
1917 // consent to receive one.
1918 // 2. the negotiation found something in common. With no `common` the
1919 // request is a clone, the client holds nothing, and there is
1920 // nothing a base could safely point at. `selection.client_has` is
1921 // then **empty** — since 2026-08-05 it carries only what the
1922 // receiver already held, never the objects being sent — so this
1923 // would be a no-op. Saying it here means the clone path is provably
1924 // untouched rather than incidentally so.
1925 // 3. the client is not shallow. A `have X` from a shallow client is
1926 // not proof it holds `X`'s ancestry, which is the whole reason
1927 // `ShallowOutcome::client_boundary` exists; the walk honours that
1928 // boundary, and this declines to bet the fetch on it.
1929 // 4. the request carries **no filter**, and this one became live on
1930 // 2026-08-11. `have` is a set of commit *tips*, and every consumer
1931 // of it — `selection.client_has` here, `client_bases` in the znippy
1932 // engine — turns it into what the receiver holds by closing over it.
1933 // For a partial clone that closure is a **lie in the dangerous
1934 // direction**: a `blob:none` client's tips reach every blob under
1935 // them and it was deliberately sent none of them, so a base vouched
1936 // for this way can be an object it has never seen and its
1937 // `index-pack --fix-thin` dies with `pack has N unresolved deltas`.
1938 // It cost nothing while no engine read `have`; znippy's does now.
1939 //
1940 // Past the gate every individual base is still checked against
1941 // `selection.client_has`, and one that does not answer costs its
1942 // object the delta and nothing else.
1943 let thin = request.thin_pack
1944 && !common.is_empty()
1945 && request.filter.is_none()
1946 && shallow_outcome.boundary.is_empty()
1947 && shallow_outcome.client_boundary.is_empty();
1948
1949 let outcome = {
1950 let _s = crate::stage::enter("pack_write");
1951 let mut pack = PackOutput::new(out, sideband);
1952 // ── the engine emits; this streams ────────────────────────────
1953 //
1954 // What stood here was gix: `write_pack_copy_thin` drove
1955 // `iter_from_counts`, and its `None` arm inflated every object
1956 // through gix's object traits. Both are gone with the packfile
1957 // engine, and the seam that chose between them (`ObjectStore::packed`)
1958 // went with them, because its return type named the pack writer.
1959 //
1960 // The engine owns the format now. It closes the request over its
1961 // delta bases, orders bases before deltas, re-encodes each entry
1962 // header and copies every payload byte for byte. This end knows
1963 // nothing about entry headers, which is the point.
1964 //
1965 // `None` is refused rather than improvised around: a whole-object
1966 // fallback produces a pack that passes `index-pack --strict` and
1967 // `fsck` while sending a measured 18.4x the wire bytes, which is
1968 // the failure nobody notices.
1969 // ── ✅ DECIDED 2026-08-10 — the first parameter is an EXACT SET ──
1970 //
1971 // The ruling went the way this call site needed. The
1972 // contract's first parameter is now named `objects` and reads
1973 // *"emit a packfile containing exactly `objects`"*. **The engine
1974 // must not compute a closure.** `GitServe::select` owns the
1975 // close-over-tips half and keeps it, so there is still exactly one
1976 // way to ask each question.
1977 //
1978 // This was not a rename. The znippy arm's `emit_pack` **was closing
1979 // over its input** — it opened with `self.select(objects, have)?`
1980 // and emitted that — so this call site was, as written, an
1981 // over-send waiting for the first engine to be wired up:
1982 //
1983 // | request | closure of the selection | the selection |
1984 // |---|---|---|
1985 // | clone, no filter | identical | identical |
1986 // | `--filter=blob:none` | **adds back every excluded blob** | correct |
1987 // | `--depth=N` | **adds back the grafted-away ancestry** | correct |
1988 //
1989 // An over-send passes `index-pack --strict` and `fsck`. It is the
1990 // failure nobody notices, and it is the failure class this whole
1991 // seam exists to abolish. It is now pinned on the engine side by
1992 // `znippy-plugin-git`'s
1993 // `emit_pack_emits_exactly_the_set_it_is_given_and_never_its_closure`,
1994 // which asserts an object **count** and was seen RED at 4 against
1995 // an expected 1.
1996 //
1997 // **`selected` below is genuinely the exact set to emit**, and that
1998 // is checked rather than assumed: `select_objects` applies the
1999 // partial-clone `filter` at insertion (`Graph::keeps_blob` /
2000 // `keeps_tree`), the shallow `boundary` and `have_boundary`, and a
2001 // deepening's `restrict`; `include_tag` then adds to
2002 // `selection.objects` above. Every one of those decisions is a
2003 // deliberate *exclusion*, and every one of them is undone by a
2004 // closure.
2005 //
2006 // `have` is the negotiated common **tips**, and that half is right
2007 // as frozen: it is what lets an engine derive for itself which
2008 // bases a thin delta may name — never to derive membership. Tips
2009 // and not the walk's exclusion closure, deliberately — the closure
2010 // is repository-sized, the engine can rebuild it from its own
2011 // index, and `ReachSet::client_has` is documented as *what the
2012 // receiver held BEFORE this transfer*, which is exactly what a set
2013 // of pre-transfer tips names and what `Selection::client_has` had
2014 // to be narrowed to on 2026-08-05.
2015 //
2016 // ✅ **The znippy arm reads `have` since 2026-08-11**, and this is
2017 // what it reads it for: with `caps.thin` set, a delta whose base
2018 // falls outside the request but inside the closure of these tips
2019 // goes out as a `REF_DELTA` naming that base, carrying the stored
2020 // delta stream unchanged, instead of being rebuilt whole. `vouched`
2021 // below is that set's size, and `recompressed` falling while it is
2022 // non-zero is the pass working.
2023 //
2024 // ⚠ **Do not assert `stats.objects == selected.len()` here.** The
2025 // contract's word is "exactly", and it means *no reachability
2026 // closure*; it does not mean the counts must match. A packfile's
2027 // `OFS_DELTA` names its base by a backwards distance **within the
2028 // pack**, so an engine copying stored entries has to pull in any
2029 // delta base that fell outside the selection or emit something
2030 // unparseable. That is a format requirement, not a decision about
2031 // what the client asked for, and `caps.thin` is the allowance to
2032 // skip it. `stats.objects` is therefore `selected.len()` plus the
2033 // bases the format forced, and it is logged rather than asserted.
2034 //
2035 // There is no `Ok(None)` and no fallback. An engine that cannot
2036 // emit says so as an error: a whole-object fallback produces a pack
2037 // that passes `fsck` while sending a measured **18.4x** the wire
2038 // bytes.
2039 let caps = gunnar_store::git_storage_trait::Caps {
2040 ofs_delta: request.ofs_delta,
2041 thin,
2042 };
2043 let selected: Vec<&[u8]> = selection.objects.iter().map(|id| id.as_bytes()).collect();
2044 let vouched: Vec<&[u8]> = common.iter().map(|id| id.as_bytes()).collect();
2045 // The trailer IS the pack's identity and it is the last bytes of
2046 // the stream, so it is read off what was emitted rather than
2047 // recomputed — the same property the buffered writer had, kept
2048 // through a streaming emitter by watching the tail go past.
2049 // `PackStats` deliberately does not carry a pack id, and it should
2050 // not have to: a receipt that *stated* the id would be a second
2051 // authority on it, and this one cannot disagree with the bytes.
2052 let mut tapped = TrailerTap::new(&mut pack, self.hash_kind.len_in_bytes());
2053 let stats = self
2054 .git
2055 .emit_pack(&selected, &vouched, &caps, &mut tapped)
2056 .map_err(crate::graph::backend)?;
2057 let pack_id = crate::graph::oid(&tapped.trailer()?)?;
2058 let outcome = crate::pack::WriteOutcome {
2059 num_objects: stats.objects as u32,
2060 pack_id,
2061 bytes_written: stats.bytes,
2062 // pack-copy's applied-output assertion, and it is READ BACK off
2063 // what the engine reported rather than asserted here. A byte
2064 // count and a wall clock cannot tell a pack-copy from a
2065 // re-deflate; these two can, and the day an engine starts
2066 // re-deflating, `recompressed` climbs instead of a test staying
2067 // green.
2068 objects_copied_from_pack: stats.copied as usize,
2069 objects_recompressed: stats.recompressed as usize,
2070 // Deltas are the pushing client's, preserved rather than
2071 // computed, so nothing here deltified anything.
2072 objects_deltified: 0,
2073 // Still zero, and now for a smaller reason: the narrowing pass
2074 // exists but `PackStats` does not carry its counts, so there is
2075 // nothing honest to put here. `recompressed` is the observable
2076 // — it falls as bases are offered — and inventing a number for
2077 // these three would be worse than reporting none.
2078 thin_bases_offered: 0,
2079 closure_bases_declined: 0,
2080 thin_bases_vetoed: 0,
2081 };
2082 pack.flush()?;
2083 outcome
2084 };
2085
2086 tracing::debug!(
2087 // `gunnar::git`, not the module-path default. This line was on
2088 // `gunnar_wire::upload_pack` and was the LAST entry the observability
2089 // suite's `known` remainder held for this crate: it is the only line
2090 // the whole clone path emits, and `RUST_LOG=gunnar::git=debug` — the
2091 // filter this repository documents for looking at a transfer — did
2092 // not select it. An operator turning the git log up to find out what
2093 // a clone did got the push path and nothing else.
2094 target: crate::receive_pack::GIT_TARGET,
2095 event = "git.upload_pack.sent",
2096 objects = outcome.num_objects,
2097 // The other half of `objects`, and the reason it is here:
2098 // `objects` is what the ENGINE emitted and this is what
2099 // `select` chose. On a negotiated fetch over a packed store
2100 // the first was measured at 8.72x the second, and no line
2101 // anywhere said so.
2102 selected = selection.objects.len(),
2103 bytes = outcome.bytes_written,
2104 copied = outcome.objects_copied_from_pack,
2105 recompressed = outcome.objects_recompressed,
2106 // How much this transfer was allowed to point a delta at. The two
2107 // selection paths reach it differently — an id set on the walk, a
2108 // bitmap otherwise — and this is where a regression in either would
2109 // show up as `recompressed` climbing beside it.
2110 vouched = selection.client_has.len(),
2111 // WHICH path produced that vouch. Without it every line above is
2112 // ambiguous between two paths that measurably disagree; see
2113 // [`UploadSummary::answered_from_bitmap`].
2114 bitmap = selection.answered_from_bitmap,
2115 thin,
2116 sideband,
2117 "served a packfile"
2118 );
2119 Ok(SentPack {
2120 written: outcome,
2121 answered_from_bitmap: selection.answered_from_bitmap,
2122 objects_selected: selection.objects.len(),
2123 })
2124 }
2125
2126 /// `include-tag`: add annotated tags whose target is already in the pack.
2127 fn add_reachable_tags(
2128 &self,
2129 graph: &Graph<'_>,
2130 refs: &RefSnapshot,
2131 objects: &mut Vec<ObjectId>,
2132 ) -> Result<()> {
2133 let mut selected: HashSet<ObjectId> = objects.iter().copied().collect();
2134 let mut extra = Vec::new();
2135 for row in refs.with_prefix(Some("refs/tags/")) {
2136 // A symbolic reference under `refs/tags/` is not a tag; the old
2137 // `Target::Object(id) else { continue }` said exactly this and this
2138 // says it against the row.
2139 let Some(bytes) = &row.oid else { continue };
2140 let id = crate::graph::oid(bytes)?;
2141 if selected.contains(&id) || !graph.contains(&id)? {
2142 continue;
2143 }
2144 if graph.kind(&id)? != Some(object::Kind::Tag) {
2145 continue;
2146 }
2147 let peeled = graph.peel(&id)?;
2148 if selected.contains(&peeled.id) {
2149 for tag in peeled.tags {
2150 // `selected` absorbs what `extra` used to be scanned for
2151 // — one set, one membership test, and a tag chain
2152 // that appears under two references is still added once.
2153 if selected.insert(tag) {
2154 extra.push(tag);
2155 }
2156 }
2157 }
2158 }
2159 objects.extend(extra);
2160 Ok(())
2161 }
2162}
2163
2164/// How many `have` facts one connection may remember, derived from the request
2165/// ceiling rather than chosen separately (one implementation).
2166///
2167/// A `have` costs the client a whole pkt-line — four header bytes, `have `, and
2168/// a hex object id, so 45 bytes at sha1 and 77 at sha256. Dividing the request
2169/// ceiling by the *cheapest* of those is deliberate: it makes the memo's ceiling
2170/// the number of distinct `have`s a client could send in one ceiling's worth of
2171/// bytes, which is the honest ratio between what the peer spends and what the
2172/// server remembers.
2173fn memo_capacity(limits: &Limits) -> Option<usize> {
2174 /// `0004` + `have ` + 40 hex digits + `\n`.
2175 const CHEAPEST_HAVE_LINE: usize = 4 + 5 + 40 + 1;
2176 limits.request_bytes.map(|bytes| bytes / CHEAPEST_HAVE_LINE)
2177}
2178
2179struct V2Command {
2180 name: String,
2181 capabilities: PeerCapabilities,
2182 arguments: Vec<Vec<u8>>,
2183}
2184
2185/// Split a v0 `want <oid> <capabilities>` line into its command and its
2186/// capability string.
2187///
2188/// A NUL is honoured too, because that is what some older clients and every
2189/// `receive-pack` client uses, and accepting both costs one branch.
2190fn split_v0_capabilities(line: &[u8]) -> Option<(&[u8], &[u8])> {
2191 if let Some(nul) = line.iter().position(|&b| b == 0) {
2192 return Some((&line[..nul], &line[nul + 1..]));
2193 }
2194 let rest = line.strip_prefix(b"want ")?;
2195 let space = rest.iter().position(|&b| b == b' ')?;
2196 Some((&line[..b"want ".len() + space], &rest[space + 1..]))
2197}
2198
2199/// Drop repeated ids, keeping the first occurrence of each.
2200///
2201/// # Once, here, rather than in each consumer
2202///
2203/// The want list is the peer's, and it is bounded only by what the request
2204/// reader accepts, so **every** per-line cost in it is a cost the peer chooses.
2205/// A repeated `want` used to be paid for four separate times over: an existence
2206/// probe in `wants_exist`, a `peel` in `commit_wants`, another `peel` as a tip
2207/// of the selection walk, and a pass in `negotiate`. Deduplicating in each of
2208/// those is four places to keep agreeing; deduplicating the list once, before
2209/// anybody reads it, is one (one implementation).
2210///
2211/// Order is preserved because it is observable: the selection walk emits
2212/// objects in tip order, so sorting here would quietly reorder packs.
2213fn dedupe_oids(ids: &mut Vec<ObjectId>) {
2214 if ids.len() < 2 {
2215 return;
2216 }
2217 let mut seen: HashSet<ObjectId> = HashSet::with_capacity(ids.len());
2218 ids.retain(|id| seen.insert(*id));
2219}
2220
2221/// Split `name value` at the first space; a line with no space is all name.
2222fn split_argument(line: &str) -> (&str, Option<&str>) {
2223 match line.split_once(' ') {
2224 Some((name, value)) => (name, Some(value)),
2225 None => (line, None),
2226 }
2227}
2228
2229fn parse_oid(hex: &str, hash_kind: Kind) -> Result<ObjectId> {
2230 let id = ObjectId::from_hex(hex.trim().as_bytes())
2231 .map_err(|err| Error::protocol(format!("{hex:?} is not an object id: {err}")))?;
2232 if id.kind() != hash_kind {
2233 return Err(Error::ObjectFormatMismatch {
2234 ours: caps::object_format_name(hash_kind),
2235 theirs: caps::object_format_name(id.kind()).to_owned(),
2236 });
2237 }
2238 Ok(id)
2239}
2240
2241/// A pass-through writer that remembers the **last `n` bytes** it saw.
2242///
2243/// One use: a packfile's trailer is its identity and it is the last
2244/// `hash_kind.len_in_bytes()` bytes of the stream. `GitServe::emit_pack`
2245/// streams straight to the client and its
2246/// [`PackStats`](gunnar_store::git_storage_trait::PackStats) deliberately does
2247/// not carry a pack id — a receipt that *stated* the id would be a second
2248/// authority on it, and two authorities can disagree (one implementation). So
2249/// the id is read off the bytes that actually went out, which is what the old
2250/// buffered writer did and the property worth keeping.
2251///
2252/// It costs one `n`-byte array and one `copy_within` per write call, and nothing
2253/// per byte beyond the copy every writer already makes. It buffers **nothing**:
2254/// every byte is forwarded before it is recorded, so a slow client is not held
2255/// up and a large pack does not accumulate here.
2256struct TrailerTap<'w> {
2257 inner: &'w mut dyn Write,
2258 /// The tail, oldest first, never longer than `want`.
2259 tail: Vec<u8>,
2260 want: usize,
2261}
2262
2263impl<'w> TrailerTap<'w> {
2264 fn new(inner: &'w mut dyn Write, want: usize) -> Self {
2265 TrailerTap {
2266 inner,
2267 tail: Vec::with_capacity(want),
2268 want,
2269 }
2270 }
2271
2272 /// The last `want` bytes written.
2273 ///
2274 /// An error rather than a shorter slice when fewer went past: a pack
2275 /// without a full trailer is not a pack, and inventing an id for it would
2276 /// put a wrong pack id in a log line that exists to identify what was sent.
2277 fn trailer(&self) -> Result<Vec<u8>> {
2278 if self.tail.len() < self.want {
2279 return Err(Error::Store(gunnar_store::Error::backend(format!(
2280 "the engine emitted {} bytes, which is shorter than the {}-byte pack trailer \
2281 every packfile ends with",
2282 self.tail.len(),
2283 self.want
2284 ))));
2285 }
2286 Ok(self.tail.clone())
2287 }
2288}
2289
2290impl Write for TrailerTap<'_> {
2291 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
2292 let written = self.inner.write(data)?;
2293 let fresh = &data[..written];
2294 if fresh.len() >= self.want {
2295 self.tail.clear();
2296 self.tail
2297 .extend_from_slice(&fresh[fresh.len() - self.want..]);
2298 } else {
2299 let overflow = (self.tail.len() + fresh.len()).saturating_sub(self.want);
2300 self.tail.drain(..overflow);
2301 self.tail.extend_from_slice(fresh);
2302 }
2303 Ok(written)
2304 }
2305
2306 fn flush(&mut self) -> std::io::Result<()> {
2307 self.inner.flush()
2308 }
2309}