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 // The fourth (GHUNT-1, 2026-10-28): v0 carries `deepen-relative` as a CAPABILITY on
1204 // the first `want` line, never as a request line (stock git dies on one: "expected
1205 // to get object ID, not 'deepen-relative'"). Read only from the line parser, it was
1206 // never set on a v0 connection, so `git fetch --deepen=N` over v0 counted N from the
1207 // tip and handed the client its own boundary back: `--deepen 5` from a depth-1 clone
1208 // left 5 commits where stock git leaves 6.
1209 request.shallow.relative = request.shallow.relative || peer.has("deepen-relative");
1210
1211 let graph = self.graph();
1212 let want_commits = self.commit_wants(&graph, &request.wants)?;
1213 let refs = RefSnapshot::read(self.git)?;
1214 let shallow_outcome = shallow::compute(&graph, &refs, &want_commits, &request.shallow)?;
1215 if request.shallow.is_deepening() {
1216 for id in &shallow_outcome.newly_shallow {
1217 wire_io::text(out, format!("shallow {id}").as_bytes())?;
1218 }
1219 for id in &shallow_outcome.unshallow {
1220 wire_io::text(out, format!("unshallow {id}").as_bytes())?;
1221 }
1222 wire_io::flush(out)?;
1223 out.flush()?;
1224 }
1225
1226 // ── negotiation: two ACK modes, chosen by the client ────────────────
1227 //
1228 // Which one is in play is decided by what
1229 // [`caps::UPLOAD_PACK_V0`] advertised, and that table's comment carries
1230 // the measurement that settled it. In short:
1231 //
1232 // **`multi_ack_detailed`** is what any git since 1.6.6 picks, and the
1233 // only mode in which a multi-round negotiation over **stateless-rpc**
1234 // has a correct answer at all. A round answers `ACK <oid> common` per
1235 // newly-common `have`, `ACK <oid> ready` once the wants are reachable,
1236 // then `NAK` — git writes that `NAK` at every multi-ack round boundary
1237 // whether or not it is ready. With **`no-done`** the server then writes
1238 // the terminal `ACK` and the packfile into the same response instead of
1239 // waiting for the client to say `done`, which over HTTP saves a whole
1240 // request.
1241 //
1242 // **Single-ACK** is the fallback, for a client that offered neither.
1243 // It is `b9fee55`'s shape and not a reversion of it: one `ACK <oid>` at
1244 // the first recognised `have`, nothing at any later flush, and nothing
1245 // after `done`. That is git's `have_obj.nr` rule, and it is correct on
1246 // the connected sessions such a client can actually reach.
1247 let mut haves: Vec<ObjectId> = Vec::new();
1248 let multi_ack_detailed = peer.has("multi_ack_detailed");
1249 let no_done = multi_ack_detailed && peer.has("no-done");
1250 // In single-ACK this is git's `have_obj.nr > 0` — "an ACK has gone out,
1251 // say nothing more". In multi-ack it is git's `last_hex`, the LAST
1252 // `have` found common, which is what the terminal `ACK` names.
1253 let mut acked: Option<ObjectId> = None;
1254 // git's `sent_ready`: `ACK <oid> ready` is announced once per session.
1255 // Over stateless-rpc the session is one `POST`, so the latch resets
1256 // with it and every request that reaches readiness says so — which is
1257 // also what stock does, its upload-pack being a fresh process per
1258 // request.
1259 let mut ready_sent = false;
1260 // Set when `no-done` let this session write its terminal `ACK` inside
1261 // the round, so the block after the loop does not write a second one.
1262 let mut terminal_written = false;
1263 let mut done = false;
1264 // How many of `haves` a flush has already judged; the rest arrived in the block `done`
1265 // closes (GHUNT-2).
1266 let mut judged = 0usize;
1267 while !done {
1268 match read_packet(input)? {
1269 None => return Ok(summary),
1270 Some(Packet::Flush) => {
1271 summary.fetch_rounds += 1;
1272 judged = haves.len();
1273 // ── one line per block, and NOTHING once an ACK has gone
1274 // out ────────────────────────────────────────────────
1275 //
1276 // The `_ => NAK` this replaces answered every later block
1277 // with a `NAK` **after** the ACK had already ended the
1278 // negotiation, and git's client stops reading the moment it
1279 // sees a plain ACK. The surplus `NAK` therefore sat in the
1280 // pipe until the client switched to sideband and read `N`
1281 // as a band number:
1282 //
1283 // ```text
1284 // fetch-pack: protocol error: bad band #78
1285 // ```
1286 //
1287 // MEASURED 2026-08-05, `fx/knut`, client cut 40
1288 // commits back: three blocks of `have`s, so gunnar wrote
1289 // NAK / ACK / NAK and the third line corrupted the stream.
1290 // `fx/funnel-core` at five commits back sends one block and
1291 // was clean, which is why the small fixture in the interop
1292 // suite never showed it — the trigger is a client with
1293 // enough history to send a SECOND block.
1294 //
1295 // git's rule, `upload-pack.c:get_common_commits`, is on
1296 // `have_obj.nr` and not on the round: with `multi_ack` off
1297 // it writes `NAK` at a flush only while it has recognised
1298 // **no** `have` at all, and the single `ACK` goes out inline
1299 // at the first one it does recognise. `acked` is gunnar's
1300 // `have_obj.nr > 0`.
1301 if multi_ack_detailed {
1302 // The SAME `negotiate` the v2 `command=fetch` path
1303 // runs, over the same per-connection `HaveMemo`, so
1304 // `ready` is decided once for both protocol versions
1305 // and `Negotiation::ack`'s suppression of redundant
1306 // parent-chain ACKs applies here too (one implementation).
1307 let negotiation = negotiate(
1308 &graph,
1309 &request.wants,
1310 &haves,
1311 /* wait_for_done */ false,
1312 &self.haves,
1313 )?;
1314 for id in &negotiation.ack {
1315 wire_io::text(out, format!("ACK {id} common").as_bytes())?;
1316 }
1317 if let Some(id) = negotiation.common.last() {
1318 acked = Some(*id);
1319 }
1320 let ready = negotiation.ready && acked.is_some();
1321 if let (true, false, Some(id)) = (ready, ready_sent, acked) {
1322 wire_io::text(out, format!("ACK {id} ready").as_bytes())?;
1323 ready_sent = true;
1324 }
1325 // Unconditional, and NOT the surplus `NAK` the comment
1326 // above warns about: that one followed a *terminal*
1327 // single-ACK, after which git reads nothing. In
1328 // multi-ack the client keeps reading until `done`, and
1329 // git writes this `NAK` at every round boundary
1330 // (`have_obj.nr == 0 || multi_ack`). A client that does
1331 // not get one waits for a round that has ended.
1332 wire_io::text(out, b"NAK")?;
1333 out.flush()?;
1334 if no_done && ready {
1335 let id = acked.expect("`ready` is gated on a common `have`");
1336 wire_io::text(out, format!("ACK {id}").as_bytes())?;
1337 out.flush()?;
1338 terminal_written = true;
1339 break;
1340 }
1341 } else {
1342 match (acked, self.first_common(&haves)) {
1343 (Some(_), _) => {}
1344 (None, Some(id)) => {
1345 acked = Some(id);
1346 wire_io::text(out, format!("ACK {id}").as_bytes())?;
1347 out.flush()?;
1348 }
1349 (None, None) => {
1350 wire_io::text(out, b"NAK")?;
1351 out.flush()?;
1352 }
1353 }
1354 }
1355 if session.is_stateless() {
1356 // The round IS the request. See the doc comment: the
1357 // client's next `have` list arrives on a new
1358 // connection, carrying its `want`s again.
1359 summary.common = haves
1360 .iter()
1361 .filter(|id| has_for_negotiation(self.git, id))
1362 .count();
1363 return Ok(summary);
1364 }
1365 }
1366 Some(Packet::Data(data)) => {
1367 budget.spend(data.len(), "v0 negotiation")?;
1368 let text = data.strip_suffix(b"\n").unwrap_or(&data);
1369 if text == b"done" {
1370 done = true;
1371 } else if let Some(rest) = text.strip_prefix(b"have ") {
1372 haves.push(parse_oid(&String::from_utf8_lossy(rest), self.hash_kind())?);
1373 }
1374 }
1375 Some(other) => {
1376 return Err(Error::protocol(format!(
1377 "unexpected {other:?} during v0 negotiation"
1378 )))
1379 }
1380 }
1381 }
1382
1383 // ── after `done`, a single-ACK server that has already ACKed says
1384 // NOTHING ─────────────────────────────────────────────────────────
1385 //
1386 // This used to be `acked.or_else(first_common)` and wrote an `ACK`
1387 // either way, so a negotiation that found a common commit sent the
1388 // **same ACK twice** — once from the flush arm above, once here. The
1389 // second one lands after the client has switched its reader into
1390 // sideband mode, which demultiplexes `A` as a band number:
1391 //
1392 // ```text
1393 // fetch-pack: protocol error: bad band #65
1394 // fatal: protocol error: bad pack header
1395 // ```
1396 //
1397 // MEASURED 2026-08-05 against stock git 2.53.0 over real SSH,
1398 // `fx/funnel-core` from the production-shaped corpus, client cut five
1399 // commits back:
1400 //
1401 // | fetch | v0 | v2 |
1402 // |---|---|---|
1403 // | full clone | 28 objects, OK | OK |
1404 // | **incremental** | **`bad band #65`, 0 objects** | 28 objects, OK |
1405 //
1406 // So **every** v0 fetch that shares history with the server was
1407 // corrupt, and only a clone — which sends no `have` and gets no ACK —
1408 // came through. `GIT_TRACE_PACKET` shows the duplicate plainly:
1409 // `fetch< ACK 36d2cb41…` followed by `sideband< ACK 36d2cb41…`.
1410 //
1411 // git's own rule, `upload-pack.c:get_common_commits`: with `multi_ack`
1412 // off, `done` returns without writing when any `have` was recognised,
1413 // and writes `NAK` only when none was. Its client is the other half of
1414 // the same contract — `fetch-pack.c:find_common` zeroes `flushes` on a
1415 // plain `ACK`, so `while (flushes || multi_ack)` after `done` reads
1416 // **no** lines at all and the next byte it expects is the pack.
1417 //
1418 // The `first_common` fallback is gone with it: it could only fire when
1419 // the flush arm had already run and decided otherwise, and reaching a
1420 // different verdict about the same `haves` two lines apart is the shape
1421 // of the bug rather than a safety net.
1422 //
1423 // In **multi-ack** the rule is the other one git writes at `done`: an
1424 // `ACK <last_hex>` when anything was common, `NAK` when nothing was.
1425 // The asymmetry is not an inconsistency — in single-ACK the client
1426 // stopped reading at the earlier terminal `ACK`, and in multi-ack it is
1427 // still reading and this line is the terminal one. `terminal_written`
1428 // is the `no-done` case, where it has already gone out with the pack.
1429 // ── the `have`s of the block `done` closes (GHUNT-2, 2026-10-28) ───────────
1430 //
1431 // git counts every recognised `have` as it ARRIVES (`get_common_commits`:
1432 // `have_obj.nr`, `last_hex`), flush or no flush; this loop judges them only at a
1433 // flush. A fetch with fewer `have`s than git's first flush (16) sends them and
1434 // `done` in one block, so they were never judged: the server answered `NAK`, and
1435 // stock git printed `warning: no common commits` on every small v0 fetch (the pack
1436 // itself was right, `common` below reads every have). Judged here, git's way: in
1437 // multi-ack the terminal `ACK` names the LAST common one; in single-ACK the first
1438 // common one is the `ACK` git would have written inline at its `have`.
1439 if !terminal_written && judged < haves.len() {
1440 let tail = &haves[judged..];
1441 if multi_ack_detailed {
1442 if let Some(id) = tail
1443 .iter()
1444 .rev()
1445 .find(|id| has_for_negotiation(self.git, id))
1446 {
1447 acked = Some(*id);
1448 }
1449 } else if acked.is_none() {
1450 if let Some(id) = self.first_common(tail) {
1451 wire_io::text(out, format!("ACK {id}").as_bytes())?;
1452 acked = Some(id);
1453 }
1454 }
1455 }
1456 if !terminal_written {
1457 if multi_ack_detailed {
1458 match acked {
1459 Some(id) => wire_io::text(out, format!("ACK {id}").as_bytes())?,
1460 None => wire_io::text(out, b"NAK")?,
1461 }
1462 } else if acked.is_none() {
1463 wire_io::text(out, b"NAK")?;
1464 }
1465 out.flush()?;
1466 }
1467
1468 request.haves = haves;
1469 let common: Vec<ObjectId> = request
1470 .haves
1471 .iter()
1472 .filter(|id| graph.contains_have(id))
1473 .copied()
1474 .collect();
1475 summary.fetch_rounds += 1;
1476 summary.common = common.len();
1477 let written = self.send_pack(
1478 &graph,
1479 &refs,
1480 &request.wants,
1481 &common,
1482 &request,
1483 &shallow_outcome,
1484 out,
1485 sideband,
1486 )?;
1487 summary.objects_sent = written.written.num_objects;
1488 summary.objects_selected = written.objects_selected;
1489 summary.objects_copied_from_pack = written.written.objects_copied_from_pack;
1490 summary.objects_recompressed = written.written.objects_recompressed;
1491 summary.answered_from_bitmap = written.answered_from_bitmap;
1492 summary.thin_bases_offered = written.written.thin_bases_offered;
1493 summary.thin_bases_vetoed = written.written.thin_bases_vetoed;
1494 summary.closure_bases_declined = written.written.closure_bases_declined;
1495 if sideband {
1496 // A sidebanded v0 response ends with an **unframed** flush-pkt.
1497 // Without it the client sits waiting for another band-1 line and
1498 // reports "unexpected disconnect while reading sideband packet"
1499 // when the pipe closes, a message that points at the transport
1500 // and not at the missing four bytes. (Found by the real-git
1501 // interop suite, 2026-08-03; the raw, non-sidebanded form needs no
1502 // terminator because the pack's own trailer ends it.)
1503 wire_io::flush(out)?;
1504 }
1505 out.flush()?;
1506 Ok(summary)
1507 }
1508
1509 fn parse_v0_want_line(&self, text: &[u8], request: &mut FetchRequest) -> Result<()> {
1510 let line = String::from_utf8_lossy(text).into_owned();
1511 let (name, value) = split_argument(&line);
1512 let missing = || Error::protocol(format!("v0 line {line:?} is missing its value"));
1513 match name {
1514 "want" => request
1515 .wants
1516 .push(parse_oid(value.ok_or_else(missing)?, self.hash_kind())?),
1517 "shallow" => request
1518 .shallow
1519 .client_shallow
1520 .push(parse_oid(value.ok_or_else(missing)?, self.hash_kind())?),
1521 "deepen" => {
1522 request.shallow.depth = Some(
1523 value
1524 .ok_or_else(missing)?
1525 .parse::<usize>()
1526 .map_err(|_| Error::protocol(format!("deepen wants a number: {line:?}")))?,
1527 )
1528 }
1529 "deepen-since" => {
1530 request.shallow.since =
1531 Some(value.ok_or_else(missing)?.parse::<i64>().map_err(|_| {
1532 Error::protocol(format!("deepen-since wants unix seconds: {line:?}"))
1533 })?)
1534 }
1535 "deepen-not" => request
1536 .shallow
1537 .not
1538 .push(value.ok_or_else(missing)?.to_owned()),
1539 "deepen-relative" => request.shallow.relative = true,
1540 "filter" => {
1541 if !self.config.offered.filter {
1542 return Err(Error::UnsupportedFilter {
1543 spec: value.unwrap_or_default().to_owned(),
1544 reason: "this server does not offer the filter capability",
1545 });
1546 }
1547 request.filter = Some(Filter::parse(value.ok_or_else(missing)?)?)
1548 }
1549 "" => {}
1550 other => {
1551 return Err(Error::protocol(format!(
1552 "unknown v0 request line {other:?}"
1553 )))
1554 }
1555 }
1556 Ok(())
1557 }
1558
1559 fn first_common(&self, haves: &[ObjectId]) -> Option<ObjectId> {
1560 haves
1561 .iter()
1562 .find(|id| has_for_negotiation(self.git, id))
1563 .copied()
1564 }
1565
1566 // ── shared ───────────────────────────────────────────────────────────
1567
1568 /// Report a fatal condition to the client in whichever way this point of
1569 /// the protocol allows: an `ERR` line before a pack has started, band 3
1570 /// once the sideband is open.
1571 pub fn report_error(&self, out: &mut dyn Write, sideband: bool, message: &str) -> Result<()> {
1572 if sideband {
1573 crate::wire_io::sideband_error(out, message)
1574 } else {
1575 err_packet(out, message)
1576 }
1577 }
1578
1579 /// `HEAD`, which is **not a row in `refs()`**.
1580 ///
1581 /// `locked_on_target.md`'s `HEAD`-is-not-a-row question, settled from the gix arm's own words:
1582 /// `all()` walks `refs/` and *"deliberately excludes the pseudo-refs such
1583 /// as `HEAD`"*, which is the contract every backend honours. So it has its
1584 /// own accessor pair on `GitServe` and this asks the store, not the
1585 /// snapshot. Its target, though, is resolved **against the snapshot** — the
1586 /// same one the advertisement is built from — so `HEAD` cannot be
1587 /// advertised pointing at an oid no other line in the same advertisement
1588 /// agrees with.
1589 fn head_ref(&self, refs: &RefSnapshot) -> Result<Option<AdvertisedRef>> {
1590 let Some(row) = self.git.head().map_err(crate::graph::backend)? else {
1591 return Ok(None);
1592 };
1593 let symref_target = row.symref_target.clone();
1594 let resolved = match &row.oid {
1595 Some(bytes) => Some(crate::graph::oid(bytes)?),
1596 None => match &symref_target {
1597 Some(name) => refs.resolve(name)?,
1598 None => None,
1599 },
1600 };
1601 Ok(Some(AdvertisedRef {
1602 name: gunnar_store::HEAD.to_owned(),
1603 target: resolved,
1604 peeled: None,
1605 symref_target,
1606 }))
1607 }
1608
1609 fn collect_refs(
1610 &self,
1611 prefixes: &[String],
1612 peel: bool,
1613 symrefs: bool,
1614 ) -> Result<Vec<AdvertisedRef>> {
1615 let refs = RefSnapshot::read(self.git)?;
1616 self.advertise_from(&refs, prefixes, peel, symrefs)
1617 }
1618
1619 /// [`UploadPack::collect_refs`] against a snapshot the caller already took.
1620 fn advertise_from(
1621 &self,
1622 refs: &RefSnapshot,
1623 prefixes: &[String],
1624 peel: bool,
1625 symrefs: bool,
1626 ) -> Result<Vec<AdvertisedRef>> {
1627 let graph = self.graph();
1628 let mut out = Vec::new();
1629
1630 // HEAD first, exactly as `git-upload-pack` orders it.
1631 if prefixes.is_empty() || prefixes.iter().any(|p| "HEAD".starts_with(p.as_str())) {
1632 if let Some(mut head) = self.head_ref(refs)? {
1633 if !symrefs {
1634 head.symref_target = None;
1635 }
1636 out.push(head);
1637 }
1638 }
1639
1640 for row in refs.rows() {
1641 if !prefixes.is_empty() && !prefixes.iter().any(|p| row.name.starts_with(p.as_str())) {
1642 continue;
1643 }
1644 let resolved = refs.resolve_row(row)?;
1645 // The store's own peel column when it has one, and only then the
1646 // walk. `RefRow::peeled` is *"for an annotated tag, the commit it
1647 // peels to"*; `None` means the backend does not say, never that
1648 // the tag peels to nothing, so the fallback is a real peel and not
1649 // an unpeeled advertisement.
1650 let peeled = match (peel, resolved) {
1651 (true, Some(id)) => match RefSnapshot::peeled(row)? {
1652 Some(known) => Some(known),
1653 None if graph.kind(&id)? == Some(object::Kind::Tag) => {
1654 Some(graph.peel(&id)?.id)
1655 }
1656 None => None,
1657 },
1658 _ => None,
1659 };
1660 out.push(AdvertisedRef {
1661 name: row.name.clone(),
1662 target: resolved,
1663 peeled,
1664 symref_target: symrefs.then(|| row.symref_target.clone()).flatten(),
1665 });
1666 }
1667 Ok(out)
1668 }
1669
1670 /// Every object id a client is allowed to `want`.
1671 fn advertised_oids(&self) -> Result<HashSet<ObjectId>> {
1672 let mut out = HashSet::new();
1673 for entry in self.collect_refs(&[], true, false)? {
1674 if let Some(id) = entry.target {
1675 out.insert(id);
1676 }
1677 if let Some(id) = entry.peeled {
1678 out.insert(id);
1679 }
1680 }
1681 Ok(out)
1682 }
1683
1684 /// **Under a policy, "there is no such object" and "there is, and you may
1685 /// not have it" are ONE answer.**
1686 ///
1687 /// They were two. `wants_exist` ran first and answered `ObjectMissing`, and
1688 /// only a want that DID exist went on to be measured against the
1689 /// advertisement and answered `WantNotAdvertised`. The difference is an
1690 /// oracle, and the thing it answers is precisely what the policy exists to
1691 /// hide: `WantNotAdvertised`'s own doc says serving arbitrary ids "leaks
1692 /// objects that are not reachable from any reference, which is how
1693 /// 'deleted' branches stay fetchable" — and telling a caller that an oid IS
1694 /// here, one `want` at a time, leaks the same fact one bit at a time. An
1695 /// oid from a force-pushed branch, a deleted branch, or somebody else's
1696 /// paste can be tested against a public repository for free.
1697 ///
1698 /// This is the same merge `gunnar-ssh` makes between "no such repository"
1699 /// and "you have no grant": one string on the wire, the difference kept in
1700 /// the server's log.
1701 ///
1702 /// `wants_exist` is not called at all under a policy now, rather than
1703 /// called and its answer laundered. Existence is implied by the check that
1704 /// remains: an advertised oid is a reference target, and a reachable one
1705 /// was just walked to. A want that passes is servable by construction, and
1706 /// a reference pointing at an object the store does not hold is repository
1707 /// corruption that the pack build reports as the server's problem — which
1708 /// is what it is.
1709 ///
1710 /// [`WantPolicy::Any`] keeps the plain existence check, and it is not an
1711 /// oracle there: under `Any` everything that exists is servable, so
1712 /// "missing" is the whole truth and there is nothing to hide behind it.
1713 fn check_wants(&self, wants: &[ObjectId]) -> Result<()> {
1714 if wants.is_empty() || self.config.want_policy == WantPolicy::Any {
1715 return self.wants_exist(wants);
1716 }
1717 let advertised = self.advertised_oids()?;
1718 // The reachable closure is built at most once, and only if some want
1719 // was not itself advertised.
1720 let mut reachable: Option<HashSet<ObjectId>> = None;
1721 for want in wants {
1722 if advertised.contains(want) {
1723 continue;
1724 }
1725 if self.config.want_policy == WantPolicy::AdvertisedTips {
1726 return Err(Error::WantNotAdvertised { oid: *want });
1727 }
1728 let closure = match &reachable {
1729 Some(closure) => closure,
1730 None => reachable.insert(self.reachable_from_refs()?),
1731 };
1732 if !closure.contains(want) {
1733 return Err(Error::WantNotAdvertised { oid: *want });
1734 }
1735 }
1736 Ok(())
1737 }
1738
1739 fn wants_exist(&self, wants: &[ObjectId]) -> Result<()> {
1740 for want in wants {
1741 if !self
1742 .git
1743 .has(want.as_bytes())
1744 .map_err(crate::graph::backend)?
1745 {
1746 return Err(Error::missing(*want, "a want"));
1747 }
1748 }
1749 Ok(())
1750 }
1751
1752 /// Every object reachable from any reference.
1753 ///
1754 /// One repository-sized set, and only one. This used to be
1755 /// `select_objects(…).objects.into_iter().collect()`, which holds a `Vec` of
1756 /// every id in the repository *while* the `HashSet` built out of it fills —
1757 /// ~820 MB of peak at ten million sha256 ids where ~500 MB is needed. See
1758 /// [`reachable_closure`].
1759 fn reachable_from_refs(&self) -> Result<HashSet<ObjectId>> {
1760 let tips: Vec<ObjectId> = self
1761 .collect_refs(&[], false, false)?
1762 .into_iter()
1763 .filter_map(|entry| entry.target)
1764 .collect();
1765 let graph = self.graph();
1766 Ok(reachable_closure(&graph, &tips)?.into_inner())
1767 }
1768
1769 /// The wants that peel to commits, deduplicated, in the order the client
1770 /// named them.
1771 ///
1772 /// # Both sets are here because the input is the client's
1773 ///
1774 /// `wants` is whatever the peer sent and is bounded only by what the
1775 /// request reader is willing to accept, so every per-want cost in here is a
1776 /// cost the peer chooses. Two of them used to be superlinear:
1777 ///
1778 /// * `asked` stops a repeated `want` being **peeled twice**. `peel` is an
1779 /// object read, so a client naming the same tag ten thousand times used
1780 /// to buy ten thousand store reads for one answer. This is the half a
1781 /// counting store can see, and `tests/want_cost.rs` holds it.
1782 /// * `seen` replaces a `Vec::contains` over the answer so far, which was a
1783 /// linear scan run once per want, so `O(wants²)` `ObjectId` comparisons.
1784 /// Ten thousand distinct wants — an ordinary mirror clone — was fifty
1785 /// million comparisons.
1786 ///
1787 /// The second half has **no deterministic instrument**, and that is worth
1788 /// stating rather than papering over: both spellings return identical
1789 /// output and touch the store identically, so the only difference is time,
1790 /// and this crate's cost tests deliberately count touches rather than
1791 /// timing anything (see `tests/connectivity_cost.rs`). It is fixed here and
1792 /// guarded only by the half above.
1793 fn commit_wants(&self, graph: &Graph<'_>, wants: &[ObjectId]) -> Result<Vec<ObjectId>> {
1794 let mut asked: HashSet<ObjectId> = HashSet::new();
1795 let mut seen: HashSet<ObjectId> = HashSet::new();
1796 let mut out = Vec::new();
1797 for want in wants {
1798 if !asked.insert(*want) {
1799 continue;
1800 }
1801 let peeled = graph.peel(want)?;
1802 if peeled.kind == object::Kind::Commit && seen.insert(peeled.id) {
1803 out.push(peeled.id);
1804 }
1805 }
1806 Ok(out)
1807 }
1808
1809 #[allow(clippy::too_many_arguments)]
1810 fn send_pack(
1811 &self,
1812 graph: &Graph<'_>,
1813 refs: &RefSnapshot,
1814 wants: &[ObjectId],
1815 common: &[ObjectId],
1816 request: &FetchRequest,
1817 shallow_outcome: &ShallowOutcome,
1818 out: &mut dyn Write,
1819 sideband: bool,
1820 ) -> Result<SentPack> {
1821 // The one place either protocol version opens a sideband, and therefore
1822 // the one place that has to record it. Past this line a failure's reason
1823 // rides band 3; before it, an `ERR` pkt-line. See
1824 // [`UploadPack::sideband_open`].
1825 self.sideband_open
1826 .store(sideband, std::sync::atomic::Ordering::Relaxed);
1827 // The serving defaults live in one place, `SelectOptions::for_serving`,
1828 // and the request-specific fields are laid over them here. Building the
1829 // whole struct inline is what let a test measure a configuration the
1830 // server never runs in; see `Selection::answered_from_bitmap`.
1831 let options = SelectOptions {
1832 boundary: shallow_outcome.boundary.clone(),
1833 have_boundary: shallow_outcome.client_boundary.clone(),
1834 filter: request.filter.as_ref(),
1835 restrict: request
1836 .shallow
1837 .is_deepening()
1838 .then_some(&shallow_outcome.include),
1839 missing: Missing::Fail,
1840 ..SelectOptions::for_serving(&self.config.limits)
1841 };
1842
1843 // The wants, plus the parents of everything this fetch just promised to
1844 // unshallow. Those parents are not a second want: they come from the
1845 // server's own walk of its own graph, already filtered by `restrict` to
1846 // what the deepening decided, so they widen the pack without widening
1847 // what the client is allowed to name. `check_wants` stays the gate on
1848 // the client's side of that line.
1849 let mut tips = wants.to_vec();
1850 if !shallow_outcome.unshallow_parents.is_empty() {
1851 // A set, not `tips.contains`: `wants` is the client's list.
1852 let mut named: HashSet<ObjectId> = tips.iter().copied().collect();
1853 for id in &shallow_outcome.unshallow_parents {
1854 if named.insert(*id) {
1855 tips.push(*id);
1856 }
1857 }
1858 }
1859 let mut selection = {
1860 let _s = crate::stage::enter("select");
1861 select_objects(graph, &tips, common, &options)?
1862 };
1863
1864 if request.include_tag {
1865 let _s = crate::stage::enter("include_tag");
1866 self.add_reachable_tags(graph, refs, &mut selection.objects)?;
1867 }
1868
1869 let show_progress = sideband && !request.no_progress;
1870 progress(
1871 out,
1872 show_progress,
1873 &format!(
1874 "gunnar: packing {} objects from {} commits\n",
1875 selection.objects.len(),
1876 selection.commits.len()
1877 ),
1878 )?;
1879
1880 // ── Copy, do not re-deflate ─────────────────────────────────────────
1881 //
1882 // `ObjectStore::packed()` is `None` for a store with no packs to copy
1883 // from: `mem`, and any cold tier that keeps inflated content per oid
1884 // instead. Those fall through to the whole-object writer they have
1885 // always used.
1886 //
1887 // `ofs_delta` is the client's own answer about whether it can decode
1888 // `OBJ_OFS_DELTA`, and until 2026-08-03 it gated the copy path
1889 // entirely: a client that did not advertise it got the pre-pack-copy
1890 // server, no delta reuse and no entry copying at all. MEASURED on the
1891 // churned fixture, that cost such a client **18.4x the bytes** — for
1892 // nothing, because `OBJ_REF_DELTA` needs no capability and carries the
1893 // identical delta bytes. The capability now decides how a copied delta
1894 // *names its base* (`WriteOptions::ofs_delta`), not whether copying
1895 // happens.
1896 // ── the unselected delta base ───────────────────────────────────────
1897 //
1898 // `WriteOptions::base_closure` lets `write_pack_copy` add a delta's
1899 // unselected base rather than expand the delta. MEASURED over the
1900 // corpus's single-branch clones: −2.82% wire bytes, −51.9% CPU.
1901 //
1902 // Three conditions, and each one is a different kind of reason:
1903 //
1904 // 1. **the negotiation found nothing in common.** This is the one that
1905 // decides most of the value, and it is measured rather than argued.
1906 // On a *clone*, an unselected base is an object nobody is getting,
1907 // and shipping it buys back a whole exploded object. On a
1908 // *negotiated* fetch it is usually an object the client already
1909 // holds — it sits below the cut — so shipping it is the duplication
1910 // thin packs exist to avoid. MEASURED 2026-08-05 on
1911 // `tests/pack_copy.rs`'s churned fixture, one fetch 8 commits deep
1912 // from a client that withheld `thin-pack`: **31 360 → 55 513 bytes,
1913 // +77%**, adding 57 bases to recover 24 deltas. A fetch's answer to
1914 // that delta is the thin voucher, not this.
1915 // 2. **a filtered request** turns it off. `--filter=blob:limit=1k` says
1916 // which objects the client will accept, an unselected base is by
1917 // construction an object the selection did not choose, and a small
1918 // blob's delta base is usually a large one — so a closure under a
1919 // filter ships exactly the object the filter excluded, silently.
1920 // 3. **the server's object ceiling** bounds how large a base may be. It
1921 // is `Some` on every request, so it cannot be folded into the same
1922 // answer as the other two; doing that turned the closure off
1923 // everywhere and `tests/base_closure.rs` is what caught it.
1924 // ⚠ **THE THREE CONDITIONS ABOVE NO LONGER CROSS THE SEAM, and one of
1925 // them is a real loss.** `WriteOptions::base_closure` was a knob on
1926 // gunnar's own pack writer; the engine owns the format now and
1927 // `git_storage_trait::Caps` is deliberately the CLIENT's capabilities
1928 // (`thin`, `ofs_delta`) and not the server's policy, which is the right
1929 // line to draw. Conditions 1 and 2 survive by construction — a non-thin
1930 // pack must be closed over its bases whatever anyone prefers, and
1931 // `thin` below already carries "the client said it can take an open
1932 // one". Condition **3** does not: there is no longer a way to tell an
1933 // engine how large an unselected delta base it may pull in, so
1934 // `Limits::object_bytes` bounds what this end *selects* and no longer
1935 // bounds what the engine *adds*. The measurements above are kept
1936 // because they are the record of why the knob existed; the knob is
1937 // gone. Recorded here rather than silently dropped.
1938 // ── May this answer be a THIN pack? ─────────────────────────────────
1939 //
1940 // Pack-copy copies 100% of a clone's entries and only 97.6% of a
1941 // fetch's, and the missing 2.4% is not a rounding error — it is every
1942 // object whose delta base sits on the client's side of the negotiation
1943 // cut, each one inflated and re-deflated **whole**. MEASURED
1944 // 2026-08-04 on a 60-file churn fetched 8 commits deep: 24 of 40
1945 // objects expanded, 3.98x stock git's thin pack of the same objects.
1946 // Naming those bases instead of expanding them is the fix, and it
1947 // computes nothing: the deltas are the pushing client's, already in
1948 // gunnar's pack. `gunnar-delta` stays off this path.
1949 //
1950 // Three conditions, and every one of them is a correctness condition
1951 // rather than a tuning knob, because a thin pack whose bases the
1952 // receiver lacks is not a smaller answer but a **failed fetch**:
1953 //
1954 // 1. the client advertised `thin-pack`. Nothing else may be read as
1955 // consent to receive one.
1956 // 2. the negotiation found something in common. With no `common` the
1957 // request is a clone, the client holds nothing, and there is
1958 // nothing a base could safely point at. `selection.client_has` is
1959 // then **empty** — since 2026-08-05 it carries only what the
1960 // receiver already held, never the objects being sent — so this
1961 // would be a no-op. Saying it here means the clone path is provably
1962 // untouched rather than incidentally so.
1963 // 3. the client is not shallow. A `have X` from a shallow client is
1964 // not proof it holds `X`'s ancestry, which is the whole reason
1965 // `ShallowOutcome::client_boundary` exists; the walk honours that
1966 // boundary, and this declines to bet the fetch on it.
1967 // 4. the request carries **no filter**, and this one became live on
1968 // 2026-08-11. `have` is a set of commit *tips*, and every consumer
1969 // of it — `selection.client_has` here, `client_bases` in the znippy
1970 // engine — turns it into what the receiver holds by closing over it.
1971 // For a partial clone that closure is a **lie in the dangerous
1972 // direction**: a `blob:none` client's tips reach every blob under
1973 // them and it was deliberately sent none of them, so a base vouched
1974 // for this way can be an object it has never seen and its
1975 // `index-pack --fix-thin` dies with `pack has N unresolved deltas`.
1976 // It cost nothing while no engine read `have`; znippy's does now.
1977 //
1978 // Past the gate every individual base is still checked against
1979 // `selection.client_has`, and one that does not answer costs its
1980 // object the delta and nothing else.
1981 let thin = request.thin_pack
1982 && !common.is_empty()
1983 && request.filter.is_none()
1984 && shallow_outcome.boundary.is_empty()
1985 && shallow_outcome.client_boundary.is_empty();
1986
1987 let outcome = {
1988 let _s = crate::stage::enter("pack_write");
1989 let mut pack = PackOutput::new(out, sideband);
1990 // ── the engine emits; this streams ────────────────────────────
1991 //
1992 // What stood here was gix: `write_pack_copy_thin` drove
1993 // `iter_from_counts`, and its `None` arm inflated every object
1994 // through gix's object traits. Both are gone with the packfile
1995 // engine, and the seam that chose between them (`ObjectStore::packed`)
1996 // went with them, because its return type named the pack writer.
1997 //
1998 // The engine owns the format now. It closes the request over its
1999 // delta bases, orders bases before deltas, re-encodes each entry
2000 // header and copies every payload byte for byte. This end knows
2001 // nothing about entry headers, which is the point.
2002 //
2003 // `None` is refused rather than improvised around: a whole-object
2004 // fallback produces a pack that passes `index-pack --strict` and
2005 // `fsck` while sending a measured 18.4x the wire bytes, which is
2006 // the failure nobody notices.
2007 // ── ✅ DECIDED 2026-08-10 — the first parameter is an EXACT SET ──
2008 //
2009 // The ruling went the way this call site needed. The
2010 // contract's first parameter is now named `objects` and reads
2011 // *"emit a packfile containing exactly `objects`"*. **The engine
2012 // must not compute a closure.** `GitServe::select` owns the
2013 // close-over-tips half and keeps it, so there is still exactly one
2014 // way to ask each question.
2015 //
2016 // This was not a rename. The znippy arm's `emit_pack` **was closing
2017 // over its input** — it opened with `self.select(objects, have)?`
2018 // and emitted that — so this call site was, as written, an
2019 // over-send waiting for the first engine to be wired up:
2020 //
2021 // | request | closure of the selection | the selection |
2022 // |---|---|---|
2023 // | clone, no filter | identical | identical |
2024 // | `--filter=blob:none` | **adds back every excluded blob** | correct |
2025 // | `--depth=N` | **adds back the grafted-away ancestry** | correct |
2026 //
2027 // An over-send passes `index-pack --strict` and `fsck`. It is the
2028 // failure nobody notices, and it is the failure class this whole
2029 // seam exists to abolish. It is now pinned on the engine side by
2030 // `znippy-plugin-git`'s
2031 // `emit_pack_emits_exactly_the_set_it_is_given_and_never_its_closure`,
2032 // which asserts an object **count** and was seen RED at 4 against
2033 // an expected 1.
2034 //
2035 // **`selected` below is genuinely the exact set to emit**, and that
2036 // is checked rather than assumed: `select_objects` applies the
2037 // partial-clone `filter` at insertion (`Graph::keeps_blob` /
2038 // `keeps_tree`), the shallow `boundary` and `have_boundary`, and a
2039 // deepening's `restrict`; `include_tag` then adds to
2040 // `selection.objects` above. Every one of those decisions is a
2041 // deliberate *exclusion*, and every one of them is undone by a
2042 // closure.
2043 //
2044 // `have` is the negotiated common **tips**, and that half is right
2045 // as frozen: it is what lets an engine derive for itself which
2046 // bases a thin delta may name — never to derive membership. Tips
2047 // and not the walk's exclusion closure, deliberately — the closure
2048 // is repository-sized, the engine can rebuild it from its own
2049 // index, and `ReachSet::client_has` is documented as *what the
2050 // receiver held BEFORE this transfer*, which is exactly what a set
2051 // of pre-transfer tips names and what `Selection::client_has` had
2052 // to be narrowed to on 2026-08-05.
2053 //
2054 // ✅ **The znippy arm reads `have` since 2026-08-11**, and this is
2055 // what it reads it for: with `caps.thin` set, a delta whose base
2056 // falls outside the request but inside the closure of these tips
2057 // goes out as a `REF_DELTA` naming that base, carrying the stored
2058 // delta stream unchanged, instead of being rebuilt whole. `vouched`
2059 // below is that set's size, and `recompressed` falling while it is
2060 // non-zero is the pass working.
2061 //
2062 // ⚠ **Do not assert `stats.objects == selected.len()` here.** The
2063 // contract's word is "exactly", and it means *no reachability
2064 // closure*; it does not mean the counts must match. A packfile's
2065 // `OFS_DELTA` names its base by a backwards distance **within the
2066 // pack**, so an engine copying stored entries has to pull in any
2067 // delta base that fell outside the selection or emit something
2068 // unparseable. That is a format requirement, not a decision about
2069 // what the client asked for, and `caps.thin` is the allowance to
2070 // skip it. `stats.objects` is therefore `selected.len()` plus the
2071 // bases the format forced, and it is logged rather than asserted.
2072 //
2073 // There is no `Ok(None)` and no fallback. An engine that cannot
2074 // emit says so as an error: a whole-object fallback produces a pack
2075 // that passes `fsck` while sending a measured **18.4x** the wire
2076 // bytes.
2077 let caps = gunnar_store::git_storage_trait::Caps {
2078 ofs_delta: request.ofs_delta,
2079 thin,
2080 };
2081 let selected: Vec<&[u8]> = selection.objects.iter().map(|id| id.as_bytes()).collect();
2082 let vouched: Vec<&[u8]> = common.iter().map(|id| id.as_bytes()).collect();
2083 // The trailer IS the pack's identity and it is the last bytes of
2084 // the stream, so it is read off what was emitted rather than
2085 // recomputed — the same property the buffered writer had, kept
2086 // through a streaming emitter by watching the tail go past.
2087 // `PackStats` deliberately does not carry a pack id, and it should
2088 // not have to: a receipt that *stated* the id would be a second
2089 // authority on it, and this one cannot disagree with the bytes.
2090 let mut tapped = TrailerTap::new(&mut pack, self.hash_kind.len_in_bytes());
2091 let stats = self
2092 .git
2093 .emit_pack(&selected, &vouched, &caps, &mut tapped)
2094 .map_err(crate::graph::backend)?;
2095 let pack_id = crate::graph::oid(&tapped.trailer()?)?;
2096 let outcome = crate::pack::WriteOutcome {
2097 num_objects: stats.objects as u32,
2098 pack_id,
2099 bytes_written: stats.bytes,
2100 // pack-copy's applied-output assertion, and it is READ BACK off
2101 // what the engine reported rather than asserted here. A byte
2102 // count and a wall clock cannot tell a pack-copy from a
2103 // re-deflate; these two can, and the day an engine starts
2104 // re-deflating, `recompressed` climbs instead of a test staying
2105 // green.
2106 objects_copied_from_pack: stats.copied as usize,
2107 objects_recompressed: stats.recompressed as usize,
2108 // Deltas are the pushing client's, preserved rather than
2109 // computed, so nothing here deltified anything.
2110 objects_deltified: 0,
2111 // Still zero, and now for a smaller reason: the narrowing pass
2112 // exists but `PackStats` does not carry its counts, so there is
2113 // nothing honest to put here. `recompressed` is the observable
2114 // — it falls as bases are offered — and inventing a number for
2115 // these three would be worse than reporting none.
2116 thin_bases_offered: 0,
2117 closure_bases_declined: 0,
2118 thin_bases_vetoed: 0,
2119 };
2120 pack.flush()?;
2121 outcome
2122 };
2123
2124 tracing::debug!(
2125 // `gunnar::git`, not the module-path default. This line was on
2126 // `gunnar_wire::upload_pack` and was the LAST entry the observability
2127 // suite's `known` remainder held for this crate: it is the only line
2128 // the whole clone path emits, and `RUST_LOG=gunnar::git=debug` — the
2129 // filter this repository documents for looking at a transfer — did
2130 // not select it. An operator turning the git log up to find out what
2131 // a clone did got the push path and nothing else.
2132 target: crate::receive_pack::GIT_TARGET,
2133 event = "git.upload_pack.sent",
2134 objects = outcome.num_objects,
2135 // The other half of `objects`, and the reason it is here:
2136 // `objects` is what the ENGINE emitted and this is what
2137 // `select` chose. On a negotiated fetch over a packed store
2138 // the first was measured at 8.72x the second, and no line
2139 // anywhere said so.
2140 selected = selection.objects.len(),
2141 bytes = outcome.bytes_written,
2142 copied = outcome.objects_copied_from_pack,
2143 recompressed = outcome.objects_recompressed,
2144 // How much this transfer was allowed to point a delta at. The two
2145 // selection paths reach it differently — an id set on the walk, a
2146 // bitmap otherwise — and this is where a regression in either would
2147 // show up as `recompressed` climbing beside it.
2148 vouched = selection.client_has.len(),
2149 // WHICH path produced that vouch. Without it every line above is
2150 // ambiguous between two paths that measurably disagree; see
2151 // [`UploadSummary::answered_from_bitmap`].
2152 bitmap = selection.answered_from_bitmap,
2153 thin,
2154 sideband,
2155 "served a packfile"
2156 );
2157 Ok(SentPack {
2158 written: outcome,
2159 answered_from_bitmap: selection.answered_from_bitmap,
2160 objects_selected: selection.objects.len(),
2161 })
2162 }
2163
2164 /// `include-tag`: add annotated tags whose target is already in the pack.
2165 fn add_reachable_tags(
2166 &self,
2167 graph: &Graph<'_>,
2168 refs: &RefSnapshot,
2169 objects: &mut Vec<ObjectId>,
2170 ) -> Result<()> {
2171 let mut selected: HashSet<ObjectId> = objects.iter().copied().collect();
2172 let mut extra = Vec::new();
2173 for row in refs.with_prefix(Some("refs/tags/")) {
2174 // A symbolic reference under `refs/tags/` is not a tag; the old
2175 // `Target::Object(id) else { continue }` said exactly this and this
2176 // says it against the row.
2177 let Some(bytes) = &row.oid else { continue };
2178 let id = crate::graph::oid(bytes)?;
2179 if selected.contains(&id) || !graph.contains(&id)? {
2180 continue;
2181 }
2182 if graph.kind(&id)? != Some(object::Kind::Tag) {
2183 continue;
2184 }
2185 let peeled = graph.peel(&id)?;
2186 if selected.contains(&peeled.id) {
2187 for tag in peeled.tags {
2188 // `selected` absorbs what `extra` used to be scanned for
2189 // — one set, one membership test, and a tag chain
2190 // that appears under two references is still added once.
2191 if selected.insert(tag) {
2192 extra.push(tag);
2193 }
2194 }
2195 }
2196 }
2197 objects.extend(extra);
2198 Ok(())
2199 }
2200}
2201
2202/// How many `have` facts one connection may remember, derived from the request
2203/// ceiling rather than chosen separately (one implementation).
2204///
2205/// A `have` costs the client a whole pkt-line — four header bytes, `have `, and
2206/// a hex object id, so 45 bytes at sha1 and 77 at sha256. Dividing the request
2207/// ceiling by the *cheapest* of those is deliberate: it makes the memo's ceiling
2208/// the number of distinct `have`s a client could send in one ceiling's worth of
2209/// bytes, which is the honest ratio between what the peer spends and what the
2210/// server remembers.
2211fn memo_capacity(limits: &Limits) -> Option<usize> {
2212 /// `0004` + `have ` + 40 hex digits + `\n`.
2213 const CHEAPEST_HAVE_LINE: usize = 4 + 5 + 40 + 1;
2214 limits.request_bytes.map(|bytes| bytes / CHEAPEST_HAVE_LINE)
2215}
2216
2217struct V2Command {
2218 name: String,
2219 capabilities: PeerCapabilities,
2220 arguments: Vec<Vec<u8>>,
2221}
2222
2223/// Split a v0 `want <oid> <capabilities>` line into its command and its
2224/// capability string.
2225///
2226/// A NUL is honoured too, because that is what some older clients and every
2227/// `receive-pack` client uses, and accepting both costs one branch.
2228fn split_v0_capabilities(line: &[u8]) -> Option<(&[u8], &[u8])> {
2229 if let Some(nul) = line.iter().position(|&b| b == 0) {
2230 return Some((&line[..nul], &line[nul + 1..]));
2231 }
2232 let rest = line.strip_prefix(b"want ")?;
2233 let space = rest.iter().position(|&b| b == b' ')?;
2234 Some((&line[..b"want ".len() + space], &rest[space + 1..]))
2235}
2236
2237/// Drop repeated ids, keeping the first occurrence of each.
2238///
2239/// # Once, here, rather than in each consumer
2240///
2241/// The want list is the peer's, and it is bounded only by what the request
2242/// reader accepts, so **every** per-line cost in it is a cost the peer chooses.
2243/// A repeated `want` used to be paid for four separate times over: an existence
2244/// probe in `wants_exist`, a `peel` in `commit_wants`, another `peel` as a tip
2245/// of the selection walk, and a pass in `negotiate`. Deduplicating in each of
2246/// those is four places to keep agreeing; deduplicating the list once, before
2247/// anybody reads it, is one (one implementation).
2248///
2249/// Order is preserved because it is observable: the selection walk emits
2250/// objects in tip order, so sorting here would quietly reorder packs.
2251fn dedupe_oids(ids: &mut Vec<ObjectId>) {
2252 if ids.len() < 2 {
2253 return;
2254 }
2255 let mut seen: HashSet<ObjectId> = HashSet::with_capacity(ids.len());
2256 ids.retain(|id| seen.insert(*id));
2257}
2258
2259/// Split `name value` at the first space; a line with no space is all name.
2260fn split_argument(line: &str) -> (&str, Option<&str>) {
2261 match line.split_once(' ') {
2262 Some((name, value)) => (name, Some(value)),
2263 None => (line, None),
2264 }
2265}
2266
2267fn parse_oid(hex: &str, hash_kind: Kind) -> Result<ObjectId> {
2268 let id = ObjectId::from_hex(hex.trim().as_bytes())
2269 .map_err(|err| Error::protocol(format!("{hex:?} is not an object id: {err}")))?;
2270 if id.kind() != hash_kind {
2271 return Err(Error::ObjectFormatMismatch {
2272 ours: caps::object_format_name(hash_kind),
2273 theirs: caps::object_format_name(id.kind()).to_owned(),
2274 });
2275 }
2276 Ok(id)
2277}
2278
2279/// A pass-through writer that remembers the **last `n` bytes** it saw.
2280///
2281/// One use: a packfile's trailer is its identity and it is the last
2282/// `hash_kind.len_in_bytes()` bytes of the stream. `GitServe::emit_pack`
2283/// streams straight to the client and its
2284/// [`PackStats`](gunnar_store::git_storage_trait::PackStats) deliberately does
2285/// not carry a pack id — a receipt that *stated* the id would be a second
2286/// authority on it, and two authorities can disagree (one implementation). So
2287/// the id is read off the bytes that actually went out, which is what the old
2288/// buffered writer did and the property worth keeping.
2289///
2290/// It costs one `n`-byte array and one `copy_within` per write call, and nothing
2291/// per byte beyond the copy every writer already makes. It buffers **nothing**:
2292/// every byte is forwarded before it is recorded, so a slow client is not held
2293/// up and a large pack does not accumulate here.
2294struct TrailerTap<'w> {
2295 inner: &'w mut dyn Write,
2296 /// The tail, oldest first, never longer than `want`.
2297 tail: Vec<u8>,
2298 want: usize,
2299}
2300
2301impl<'w> TrailerTap<'w> {
2302 fn new(inner: &'w mut dyn Write, want: usize) -> Self {
2303 TrailerTap {
2304 inner,
2305 tail: Vec::with_capacity(want),
2306 want,
2307 }
2308 }
2309
2310 /// The last `want` bytes written.
2311 ///
2312 /// An error rather than a shorter slice when fewer went past: a pack
2313 /// without a full trailer is not a pack, and inventing an id for it would
2314 /// put a wrong pack id in a log line that exists to identify what was sent.
2315 fn trailer(&self) -> Result<Vec<u8>> {
2316 if self.tail.len() < self.want {
2317 return Err(Error::Store(gunnar_store::Error::backend(format!(
2318 "the engine emitted {} bytes, which is shorter than the {}-byte pack trailer \
2319 every packfile ends with",
2320 self.tail.len(),
2321 self.want
2322 ))));
2323 }
2324 Ok(self.tail.clone())
2325 }
2326}
2327
2328impl Write for TrailerTap<'_> {
2329 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
2330 let written = self.inner.write(data)?;
2331 let fresh = &data[..written];
2332 if fresh.len() >= self.want {
2333 self.tail.clear();
2334 self.tail
2335 .extend_from_slice(&fresh[fresh.len() - self.want..]);
2336 } else {
2337 let overflow = (self.tail.len() + fresh.len()).saturating_sub(self.want);
2338 self.tail.drain(..overflow);
2339 self.tail.extend_from_slice(fresh);
2340 }
2341 Ok(written)
2342 }
2343
2344 fn flush(&mut self) -> std::io::Result<()> {
2345 self.inner.flush()
2346 }
2347}