gunnar_wire/receive_pack.rs
1//! `git-receive-pack`, i.e. serving push. Protocol v0, which is the only
2//! protocol push has ever had.
3//!
4//! # The order that matters
5//!
6//! ```text
7//! advertise refs+caps → command list → push-options → PACK → index →
8//! connectivity → policy gate → ref transaction → report
9//! ```
10//!
11//! The two steps in the middle are the ones that separate a forge from a
12//! directory of files:
13//!
14//! * **Connectivity is verified before any reference moves.** A thin pack's
15//! deltas resolve against objects the receiver already had, so "the pack
16//! indexed cleanly" does not mean "the new tip is complete". A reference
17//! pointing at an incomplete commit is a repository that every subsequent
18//! clone fails on, and nothing in the push itself reports it.
19//! * **`atomic` is all-or-nothing at the storage layer**, not a loop that stops
20//! early. It is one `GitOps::put_refs_cas`, which is where the batch and the
21//! compare-and-swap are the *same* call; a per-reference loop with a break
22//! leaves the references it already applied applied.
23//!
24//! That method is why `--atomic` needed a contract change rather than a
25//! rearrangement here (`locked_on_target.md`'s `GitServe` reading contract): `put_refs` is a batch
26//! **without** CAS — `RefUpdate` has no expected-old field — and
27//! `update_ref` is CAS **without** a batch, and git defines `--atomic` as
28//! both at once. Neither composes into the other, so the non-atomic path
29//! below uses `update_ref` (one reference, still compare-and-swap) and the
30//! atomic path uses `put_refs_cas`.
31//!
32//! # Where the packfile goes
33//!
34//! Not decided here. `gunnar-wire` must work with no filesystem, and
35//! "keep the pushed pack" is inherently a storage decision: an appliance folds
36//! it into a znippy archive, a normal-OS server drops it in `objects/pack`. So
37//! the caller supplies a [`PackIngest`], and this module's only requirement is
38//! the one it can check: **after `ingest` returns, the objects must be readable
39//! from the store.** The connectivity walk proves it — and it needs them
40//! *decoded*, which is why the handle is a `GitServe` and not a `GitOps`.
41
42use std::io::{BufRead, Write};
43
44use crate::pack::IndexedPack;
45use gix_hash::ObjectId;
46use gunnar_pktline::{Packet, BAND_PACK};
47use gunnar_store::git_storage_trait::{GitServe, Observed, RefCas, RefRejection, RefTarget};
48
49use crate::caps::{self, PeerCapabilities};
50use crate::graph::{verify_connectivity, Frontier, Graph};
51use crate::refs::RefSnapshot;
52use crate::wire_io::{self, read_packet, read_section};
53use crate::{Error, Result, Session};
54
55/// The tracing target every line in this module carries, so a push is
56/// filterable with the same `RUST_LOG=gunnar::git=info` that selects the
57/// server's own transfer log.
58///
59/// # Why the string and not `gunnar_core::observe::TARGET_GIT`
60///
61/// That is where it is *named*, and this is a second spelling of it, which one
62/// implementation is normally against. The edge that would remove the second
63/// spelling runs the wrong way: `gunnar-core` carries the identity table, the
64/// ACL and the path sandbox, and this crate is bound by the no-OS rule and the
65/// crate layout to plumbing —
66/// pulling it in to reach one `&str` would put all of it in the appliance's
67/// crate budget.
68///
69/// So the two spellings are held together by a guard where both crates are
70/// already linked (`gunnar-cli`'s `observability` test), which costs nothing
71/// and cannot be forgotten by a build that does not link them.
72pub const GIT_TARGET: &str = "gunnar::git";
73
74/// One `<old> <new> <ref>` line from the client.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct RefCommand {
77 /// What the client believes the reference currently holds; null for a
78 /// create.
79 pub old: ObjectId,
80 /// What it should hold afterwards; null for a delete.
81 pub new: ObjectId,
82 /// The reference.
83 ///
84 /// `String`, not the `FullName` newtype that died with `RefStore`, because
85 /// `RefUpdate::name` and `RefRow::name` on the contract are `String` and a
86 /// second validated name type in gunnar is the duplication the 2026-08-10
87 /// demolition removes.
88 ///
89 /// **The validation did not go with the type.** It moved into
90 /// `ReceivePack::parse_command`, which routes every client-supplied name
91 /// through `gunnar_store::refname::validate` before one of these can
92 /// exist — and that is a path-traversal boundary
93 /// (`refs/heads/../../etc/passwd`), not a tidiness rule. What is gone is
94 /// only the *compile-time* guarantee that a value of this type was
95 /// checked; the runtime check is in one place and it is the same rule set.
96 pub name: String,
97}
98
99impl RefCommand {
100 /// Is this a deletion?
101 pub fn is_delete(&self) -> bool {
102 self.new.is_null()
103 }
104
105 /// Is this a creation?
106 pub fn is_create(&self) -> bool {
107 self.old.is_null()
108 }
109}
110
111/// What the client sends first: its ref commands (and the ones it could not
112/// name) plus the capabilities it chose. Read by [`ReceivePack::read_preamble`]
113/// — with the hash check already applied — and consumed by
114/// [`ReceivePack::serve_after_preamble`]. The split exists so the
115/// first-repository door can read this **before** a store exists.
116#[derive(Debug)]
117pub struct Preamble {
118 /// The nameable commands, in wire order.
119 pub commands: Vec<RefCommand>,
120 /// The commands whose names gunnar refused.
121 pub unnameable: Vec<UnnameableRef>,
122 /// What the client asked for.
123 pub peer: PeerCapabilities,
124}
125
126/// A command line this server will not accept a **name** from.
127///
128/// # Why an invalid name is not an error
129///
130/// It used to be. `read_commands` called `FullName::new` with `?`, so one bad
131/// name aborted the whole session before the pack had been read: the client was
132/// still writing and got a broken pipe, no `report-status` was ever produced,
133/// and every *other* reference in the same push was lost with no line saying
134/// which one was at fault. That is the `the remote end hung up unexpectedly`
135/// shape, and it is the same defect the shallow-clone fix in `read_commands`
136/// closed for one particular cause without closing the class.
137///
138/// **Stock git defers it to the report, and this is measured, not assumed.**
139/// `git receive-pack` 2.53.0, 2026-08-06, fed a raw two-command push of
140/// `refs/heads/goodone` and `refs/heads/bad~name` over one connection, answered:
141///
142/// ```text
143/// unpack ok
144/// ok refs/heads/goodone
145/// ng refs/heads/bad~name funny refname
146/// ```
147///
148/// exit 0, `refs/heads/goodone` created. So the good reference lands, the bad
149/// one is named in the report, and the session ends cleanly. gunnar now does
150/// the same.
151///
152/// The name is kept as **bytes**: it is precisely a name the validator refused,
153/// so there is nothing to hold it in, and it is echoed back verbatim because a
154/// client matches the `ng` line against the name it sent. Since the contract's
155/// `RefRow::name` is a `String`, a name that is *valid* but not UTF-8 lands
156/// here too — see `ReceivePack::parse_command`, which records that narrowing
157/// rather than hiding it.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct UnnameableRef {
160 /// The reference name exactly as the client spelled it.
161 pub name: Vec<u8>,
162 /// The one-line reason, rendered into the `ng` line.
163 pub reason: String,
164 /// Where the line sat in the client's command list, so the report can be
165 /// emitted in the order the client sent — which is what git does and what a
166 /// client zipping its own list against the report needs.
167 pub position: usize,
168 /// Whether the line asked for a deletion. A push carrying only unnameable
169 /// *deletes* sends no pack, and a server that waited for one would hang.
170 pub deletes: bool,
171}
172
173/// What happened to one reference.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum RefStatus {
176 /// Applied.
177 Ok,
178 /// Refused, with the reason the client is told.
179 Rejected(String),
180}
181
182impl RefStatus {
183 /// Was it applied?
184 pub fn is_ok(&self) -> bool {
185 matches!(self, RefStatus::Ok)
186 }
187}
188
189/// Everything `receive-pack` learned about the push, handed to every gate.
190///
191/// # Why this is a struct and not three arguments
192///
193/// The gate seam used to be `(&RefCommand, &dyn ObjectStore)` and nothing else,
194/// so a gate could not see which objects the push introduced or how many bytes
195/// the client sent. `gunnar-policy`'s size and quota gates
196/// (`gunnar_policy::gates::limits`) are the things standing between a forge and
197/// a client pushing until the disk fills, and neither could be enforced: the
198/// object set was recovered downstream by reading the written `.idx` back,
199/// which counts a thin pack's resolved bases as if the client had sent them,
200/// and the byte count was never produced at all.
201///
202/// A struct rather than a wider tuple so the *next* fact does not re-break
203/// every implementation of [`PushGate`].
204pub struct PushFacts<'a> {
205 /// The store, after the pack has been taken in.
206 ///
207 /// `GitServe`: a gate reads objects, and reading means *inflated and
208 /// delta-resolved*. `gunnar-policy`'s ancestry walk and its signature gate
209 /// were two of the three errors that crate was blocked on, and they are the
210 /// same missing capability the connectivity walk here needs.
211 pub objects: &'a dyn GitServe,
212 /// The objects this push genuinely introduced, or the reason they could not
213 /// be enumerated.
214 ///
215 /// **`Err` is not "none"**: a gate that cannot see what was pushed cannot
216 /// decide, and an undecidable gate refuses. Handing an empty slice for
217 /// "I do not know" is the shape that lets a configured limit be silently
218 /// satisfied, which is what this type exists to prevent.
219 pub new_objects: std::result::Result<&'a [ObjectId], &'a str>,
220 /// Packfile bytes the client sent, counted off the wire by
221 /// [`ReceivePack::serve`] rather than measured from what landed on disk.
222 ///
223 /// `Some(0)` for a deletions-only push, which carries no pack: that is a
224 /// measured zero, not an absent measurement, and a byte limit must not
225 /// refuse it. `None` only if the count is genuinely unavailable.
226 pub pack_bytes: Option<u64>,
227 /// The `push-option` lines the client sent, in wire order.
228 ///
229 /// # Why this field exists
230 ///
231 /// `push-options` was **advertised** by
232 /// [`caps::receive_pack_capabilities`](crate::caps::receive_pack_capabilities),
233 /// **parsed** by [`ReceivePack::serve`] and then thrown away: the only
234 /// consumer in the whole tree was `.len()` in one log line. A client that
235 /// sent `-o ci.skip` got the capability acknowledged on the wire and the
236 /// option silently ignored, which is a wrong answer on the wire rather than
237 /// a missing feature — the gate could not see them, so no policy could act
238 /// on one even in principle.
239 ///
240 /// Stock git hands push options to the `pre-receive` hook. gunnar has no
241 /// hooks and no shell; [`PushGate`] is what replaces them, so
242 /// this is where they have to arrive.
243 ///
244 /// **Empty means the client sent none**, which is also what a client that
245 /// never negotiated the capability produces. The two are not told apart
246 /// here on purpose: a gate that requires an option must refuse both, and
247 /// a gate that forbids unknown ones must accept both.
248 pub push_options: &'a [String],
249}
250
251/// What taking the pack into storage produced.
252///
253/// [`PackIngest`] returns this rather than a bare [`IndexedPack`] because the
254/// only place that can answer *"which objects did this push introduce"*
255/// honestly is the code that resolved the pack: it is the one that knows which
256/// entries came off the wire and which bases it pulled out of the store to
257/// complete a thin pack. Recovering it afterwards from the written index cannot
258/// tell those apart.
259///
260/// `Debug` and `Clone` because both of its fields already are, and because
261/// [`crate::fetch_pack::FetchReport`] carries one: a report a caller cannot
262/// print is a report a caller logs by hand, field by field, and gets wrong.
263#[derive(Debug, Clone)]
264pub struct PackReceipt {
265 /// The pack as indexed.
266 pub indexed: IndexedPack,
267 /// The genuinely-new objects, or why they are unknown. See
268 /// [`PushFacts::new_objects`] for why this is not an `Option`.
269 pub new_objects: std::result::Result<Vec<ObjectId>, String>,
270}
271
272impl PackReceipt {
273 /// A receipt from an ingest that does not enumerate the pushed objects.
274 ///
275 /// The reason, rather than an empty set, so gates refuse instead of
276 /// deciding on nothing.
277 pub fn without_object_set(indexed: IndexedPack) -> Self {
278 PackReceipt {
279 indexed,
280 new_objects: Err(
281 "this PackIngest does not report which objects the push introduced".to_owned(),
282 ),
283 }
284 }
285}
286
287/// A pre-receive gate.
288///
289/// `gunnar-policy` supplies the real ones; this crate only defines
290/// the seam so that receive-pack has somewhere to ask. **Measuring rather than
291/// asserting applies hardest here**: a gate that always returns `Ok` is
292/// indistinguishable from a working gate in any test that only pushes valid
293/// commits, so every implementation must be seen to reject before it is
294/// trusted to allow.
295pub trait PushGate {
296 /// `Err(reason)` rejects the reference; the reason is sent verbatim.
297 fn check(&self, command: &RefCommand, facts: &PushFacts<'_>)
298 -> std::result::Result<(), String>;
299}
300
301/// The gate that lets everything through. This is the default, and the thing
302/// `gunnar-policy` replaces.
303pub struct AllowAll;
304
305impl PushGate for AllowAll {
306 fn check(
307 &self,
308 _command: &RefCommand,
309 _facts: &PushFacts<'_>,
310 ) -> std::result::Result<(), String> {
311 Ok(())
312 }
313}
314
315/// How a pushed packfile is taken into storage.
316///
317/// The stream handed to [`PackIngest::ingest`] is positioned on the packfile's
318/// first byte (the `P` of `PACK`) because [`crate::receive_pack`] reads
319/// pkt-lines with exact-length reads and never buffers past the flush.
320pub trait PackIngest {
321 /// Consume the pack and make its objects readable from the store.
322 fn ingest(&self, pack: &mut dyn BufRead) -> Result<PackReceipt>;
323}
324
325/// A bare closure is still an ingest, and it reports **no** object set.
326///
327/// Kept because most of this crate's own tests are closures that only need the
328/// pack indexed. The receipt says so out loud
329/// ([`PackReceipt::without_object_set`]) rather than answering "no new
330/// objects", so a real gate behind such an ingest refuses rather than passing a
331/// limit it never evaluated.
332impl<F> PackIngest for F
333where
334 F: Fn(&mut dyn BufRead) -> Result<IndexedPack>,
335{
336 fn ingest(&self, pack: &mut dyn BufRead) -> Result<PackReceipt> {
337 self(pack).map(PackReceipt::without_object_set)
338 }
339}
340
341/// Counts the bytes an ingest consumes, so [`PushFacts::pack_bytes`] is the
342/// number that came off the wire.
343///
344/// Wrapping the reader is what makes the count true by construction: it is not
345/// `IndexedPack`'s business, it is not the storage backend's, and the only code
346/// that sees every byte of the packfile go past is the code that hands the
347/// stream over.
348pub(crate) struct Counting<'a> {
349 inner: &'a mut dyn BufRead,
350 pub(crate) bytes: u64,
351 /// The ceiling this push is being held to, if any. See [`PackCeiling`].
352 ceiling: Option<PackCeiling>,
353 /// **Bytes charged to the shared ledger that this push has not yet taken.**
354 ///
355 /// `fill_buf` charges what it is about to OFFER and `consume` discharges
356 /// what was actually taken, and the two are not the same call: a consumer
357 /// may peek at a bufferful three times and consume half of it. Without this
358 /// counter each peek would charge the same bytes again and a push would
359 /// spend its namespace's room several times over on bytes it never took.
360 ///
361 /// Charging at `consume` alone — after the bytes are already out — was the
362 /// alternative, and it does not close the race: every one of N pushes would
363 /// still hand its ingest a whole buffer measured against a room nobody had
364 /// spent yet. The charge has to happen BEFORE the bytes are offered, which
365 /// means it has to be remembered until they are taken.
366 ///
367 /// Left-over credit at the end of a transfer (bytes peeked and never
368 /// consumed) stays charged, which is the same over-conservative,
369 /// self-healing direction `gunnar_lfs::ledger` documents for an aborted
370 /// upload: it is bounded by one buffer, it costs only the holders alive
371 /// beside it, and it errs towards refusing rather than towards a byte the
372 /// operator pays for.
373 charged_ahead: u64,
374 /// Set the moment the count passed the ceiling, and never cleared. The
375 /// caller reads it rather than trying to recognise the abort in whatever
376 /// the ingest turned the cut stream into — an indexer is entitled to
377 /// report a truncated pack as a malformed one, and "your plan is full"
378 /// and "your pack is corrupt" are not the same sentence to a tenant.
379 pub(crate) cut: bool,
380}
381
382impl<'a> Counting<'a> {
383 /// A reader over `inner` held to `ceiling` — the receive loop's own guard,
384 /// lent to a FETCH so a mirror is cut at its namespace's room by the same
385 /// code that cuts a push (`fetch_pack::FetchPack::with_ceiling`).
386 pub(crate) fn new(inner: &'a mut dyn BufRead, ceiling: Option<PackCeiling>) -> Self {
387 Counting {
388 inner,
389 bytes: 0,
390 ceiling,
391 charged_ahead: 0,
392 cut: false,
393 }
394 }
395}
396
397impl Counting<'_> {
398 /// Has the count passed the ceiling?
399 ///
400 /// **Strictly greater**, so a push of exactly `ceiling` bytes lands. The
401 /// ceiling is the last byte that is allowed, not the first that is not —
402 /// see [`PackCeiling`] on why the number already carries slack.
403 fn over(&self) -> bool {
404 matches!(&self.ceiling, Some(ceiling) if self.bytes > ceiling.bytes)
405 }
406
407 /// How many more bytes may be handed out before the ceiling is passed —
408 /// the room under it **plus the one byte that trips it**, so the trip is
409 /// reachable. `None` when there is no ceiling.
410 ///
411 /// # Why the slice is truncated to this, rather than trusted
412 ///
413 /// `fill_buf` hands out whatever the source has buffered, and the source
414 /// decides how much that is: a `BufReader` gives a bufferful, a `Cursor`
415 /// over a `Vec` gives **the whole rest of the push in one call**. Without
416 /// this clamp the ceiling's precision would be the source's buffer size —
417 /// the guard would still fire, but a pack read out of memory would cross
418 /// it by its entire length before anything looked, and the guard's own
419 /// test would be measuring the fixture rather than the code (measure,
420 /// never assert). With it, a push that overshoots costs the server exactly
421 /// `bytes + 1` and the number is the same on every reader.
422 fn room(&self) -> Option<usize> {
423 let ceiling = self.ceiling.as_ref()?.bytes;
424 let room = ceiling.saturating_sub(self.bytes).saturating_add(1);
425 // A ceiling past `usize` on a 32-bit box is not a ceiling this read can
426 // reach in one call, so clamping to `usize::MAX` is the honest answer
427 // rather than a truncating cast.
428 Some(usize::try_from(room).unwrap_or(usize::MAX))
429 }
430
431 /// **How many of `want` bytes this push may hand on, asking the shared
432 /// ledger for whatever it has not already been credited.**
433 ///
434 /// `want` is what the caller would otherwise offer, already clamped to
435 /// [`Self::room`]. With no ceiling, or a ceiling with no ledger behind it,
436 /// the answer is `want` and nothing is charged — the community build and
437 /// every gunnar before 2026-09-08 take this path, and it is the same code
438 /// path, not a second one.
439 ///
440 /// The room the ledger is asked about is `ceiling + 1`: the ceiling plus
441 /// the one byte that trips it. A budget of exactly `ceiling` would starve
442 /// the trip byte, and a push of exactly its ceiling would then end as a
443 /// stream that simply stopped — which the indexer reports as a malformed
444 /// pack, and telling a tenant their pack is corrupt when their plan filled
445 /// up is the refusal this file's `refuse` exists to prevent.
446 fn admit(&mut self, want: usize) -> usize {
447 let Some(ledger) = self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) else {
448 return want;
449 };
450 // Unwrap-free: the `let else` above already proved there is a ceiling.
451 let room = self
452 .ceiling
453 .as_ref()
454 .map(|c| c.bytes.saturating_add(1))
455 .unwrap_or(u64::MAX);
456 let want = want as u64;
457 // Only the part that is not already paid for. On a re-peek of a buffer
458 // nothing was consumed from, this is 0 and the ledger is not touched.
459 let owing = want.saturating_sub(self.charged_ahead);
460 if owing > 0 {
461 self.charged_ahead = self
462 .charged_ahead
463 .saturating_add(ledger.admit_upto(owing, room));
464 }
465 // `min`: credit carried over from an earlier, larger offer does not
466 // make this offer bigger than it is.
467 usize::try_from(self.charged_ahead.min(want)).unwrap_or(usize::MAX)
468 }
469
470 /// Cut the stream: mark the push and return the error the ingest sees.
471 ///
472 /// The text here is for the **server log**, not the client: what the client
473 /// is told is [`PackCeiling::refusal`], installed over the ingest's own
474 /// message by [`ReceivePack::serve_after_preamble`]. Two sentences because
475 /// they answer two people.
476 fn refuse(&mut self) -> std::io::Error {
477 self.cut = true;
478 let ceiling = self.ceiling.as_ref().map(|c| c.bytes).unwrap_or_default();
479 std::io::Error::other(format!(
480 "the push passed its {ceiling}-byte ceiling after {} byte(s); the pack read was \
481 aborted mid-transfer and nothing was stored",
482 self.bytes
483 ))
484 }
485}
486
487impl std::io::Read for Counting<'_> {
488 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
489 if self.cut || self.over() {
490 let err = self.refuse();
491 return Err(err);
492 }
493 // **What the source actually has, before anything is charged for it —
494 // and asked for ONLY when something is going to be charged.**
495 //
496 // The ledger has to be asked about bytes that EXIST: charging
497 // `buf.len()` and reading fewer would leave the difference charged to
498 // the namespace with nothing to discharge it, and
499 // `simultaneous_pushes_that_all_fit_are_all_admitted` measured that as
500 // seven of eight legitimate pushes admitted where all eight fit. A
501 // quota that refuses a push nobody's bytes displaced is the same defect
502 // as one that admits too many, pointing the other way.
503 //
504 // `fill_buf` blocks exactly where `read` would — until one byte is
505 // available or the stream ends — so it costs no latency. It does cap
506 // this call at what the source has buffered, which is why it is skipped
507 // entirely with no ledger to ask: an unmetered `read` still goes
508 // straight through to the source and may still hand back more than one
509 // bufferful in a call, exactly as it did before this field existed.
510 let available = match self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) {
511 Some(_) => {
512 let available = self.inner.fill_buf()?.len();
513 if available == 0 {
514 // The client is done. Not a refusal: nothing to refuse.
515 return Ok(0);
516 }
517 available
518 }
519 None => usize::MAX,
520 };
521 let want = match self.room() {
522 Some(room) => room.min(buf.len()).min(available),
523 None => buf.len().min(available),
524 };
525 // The namespace's answer, not just this push's: `admit` returns `want`
526 // unchanged when there is no ledger, so the unmetered path is
527 // arithmetically untouched.
528 let allowed = self.admit(want);
529 if allowed == 0 && !buf.is_empty() {
530 // Nothing left in the namespace, and the client is still sending.
531 // Cut it here rather than returning `Ok(0)`: a zero-length read is
532 // EOF to every consumer in std, and "your plan is full" must not
533 // reach the indexer as "the pack ended early".
534 let err = self.refuse();
535 return Err(err);
536 }
537 let read = self.inner.read(&mut buf[..allowed])?;
538 self.bytes += read as u64;
539 self.charged_ahead = self.charged_ahead.saturating_sub(read as u64);
540 Ok(read)
541 }
542}
543
544impl BufRead for Counting<'_> {
545 /// **Where the ceiling actually bites**, because this is how the pack is
546 /// read: `read_pushed_pack_spooling` peeks with `fill_buf` and takes with
547 /// `consume`, so this is the path the shipping ingest takes and `read`
548 /// above is the path one that does not peek takes. Both are guarded and
549 /// both clamp to [`Counting::room`], so the overshoot is **one byte**, not
550 /// one buffer — a push that means to send 400 GiB past its ceiling costs
551 /// the server `ceiling + 1` and nothing more.
552 fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
553 if self.cut || self.over() {
554 let err = self.refuse();
555 return Err(err);
556 }
557 // `room()` is never `Some(0)` — `over()` above has already returned for
558 // that — so this never turns a live stream into a spurious EOF.
559 let room = self.room();
560 // **The namespace's half, and it fills the buffer TWICE.** The ledger
561 // has to be asked about the bytes actually in hand — asking it about
562 // `room` instead would reserve a tenant's whole remaining plan for the
563 // first connection to say hello and refuse a sibling push that would
564 // have fitted twice over — and it cannot be asked while a slice
565 // borrowed out of `self.inner` is alive. A second `fill_buf` with no
566 // `consume` between is a peek: it returns the same buffer it just
567 // returned, reads nothing more and cannot block. The `min` below makes
568 // even a source that ignores that safe.
569 //
570 // Skipped entirely with no ledger to ask, so the unmetered path fills
571 // once and clamps to `room`, exactly as it did before this existed.
572 let allowed = match self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) {
573 Some(_) => {
574 let available = self.inner.fill_buf()?.len();
575 let want = match room {
576 Some(room) if available > room => room,
577 _ => available,
578 };
579 let allowed = self.admit(want);
580 if allowed == 0 && available > 0 {
581 // The namespace's room is gone and the client is still
582 // sending. An empty slice would be EOF to the indexer, i.e.
583 // "the pack is truncated"; the cut says whose bytes filled
584 // the plan.
585 let err = self.refuse();
586 return Err(err);
587 }
588 Some(allowed)
589 }
590 None => None,
591 };
592 let filled = self.inner.fill_buf()?;
593 Ok(match (allowed, room) {
594 (Some(allowed), _) => &filled[..allowed.min(filled.len())],
595 (None, Some(room)) if filled.len() > room => &filled[..room],
596 _ => filled,
597 })
598 }
599
600 /// The only place `fill_buf`'s bytes are charged. `read` charges its own,
601 /// and the two never see the same byte: `BufRead::read`'s default goes
602 /// through `fill_buf`/`consume`, but this type overrides `read` to delegate
603 /// straight to the inner reader instead, so a byte is counted once whichever
604 /// way the consumer asks for it.
605 fn consume(&mut self, amount: usize) {
606 self.inner.consume(amount);
607 self.bytes += amount as u64;
608 // Taken, so no longer owed: the credit `fill_buf` bought for these
609 // bytes is spent. What is left is credit for bytes still in the buffer,
610 // which the next `fill_buf` will offer again and must not pay for
611 // twice.
612 self.charged_ahead = self.charged_ahead.saturating_sub(amount as u64);
613 }
614}
615
616/// Everything one push did.
617#[derive(Debug)]
618pub struct PushOutcome {
619 /// The commands as sent.
620 pub commands: Vec<RefCommand>,
621 /// One status per command, in the same order.
622 pub statuses: Vec<RefStatus>,
623 /// Command lines refused for their reference **name**, which therefore have
624 /// no [`RefCommand`] to carry a status. See [`UnnameableRef`].
625 pub unnameable: Vec<UnnameableRef>,
626 /// `push-option` lines, if the capability was negotiated.
627 pub push_options: Vec<String>,
628 /// The indexed pack, or `None` when the push was deletions only.
629 pub pack: Option<IndexedPack>,
630 /// Packfile bytes the client sent, counted off the wire. See
631 /// [`PushFacts::pack_bytes`].
632 pub pack_bytes: Option<u64>,
633 /// The objects the push introduced, or why they are unknown. Same value the
634 /// gates saw; see [`PushFacts::new_objects`].
635 pub new_objects: std::result::Result<Vec<ObjectId>, String>,
636 /// Whether the client asked for `--atomic`.
637 pub atomic: bool,
638 /// What the client was told about unpacking: `Ok` or a one-line reason.
639 pub unpack: std::result::Result<(), String>,
640}
641
642impl PushOutcome {
643 /// Did every reference land?
644 ///
645 /// An unnameable reference counts as a refusal: it is reported as `ng`, so
646 /// a caller reading this to mean *the push succeeded* must see false.
647 pub fn all_ok(&self) -> bool {
648 self.unpack.is_ok()
649 && self.unnameable.is_empty()
650 && self.statuses.iter().all(RefStatus::is_ok)
651 }
652}
653
654/// **How many packfile bytes this push may send, and what the client is told
655/// when it sends more.**
656///
657/// # Why a ceiling exists at all
658///
659/// `git-receive-pack`'s pack header carries an **object count, not a byte
660/// count**, so the size of a push is not knowable at the moment it is
661/// authorised. A quota asked before the pack (`gunnar_ssh::PlanGate::pack_quota`)
662/// refuses a tenant who is *already* at the ceiling, and a limit asked after it
663/// (`gunnar_policy::gates::limits`, whose own header says so plainly) refuses a
664/// pack that has *already been received* — so between those two there was
665/// nothing at all in front of one enormous push. A tenant one byte under a
666/// 10 GiB cap could send 400 GiB and the box would take every byte of it before
667/// anything looked.
668///
669/// This is that missing thing. It is enforced in the receive loop itself
670/// ([`Counting`]), so an unbounded push costs the server the ceiling and not
671/// the push.
672///
673/// # It is a backstop, not a meter
674///
675/// The polite refusal is `pack_quota`'s, before a byte moves. This fires only
676/// for a push that was under the cap when it started and crossed it in flight,
677/// which is why whoever sets it is expected to have added **slack** to it: the
678/// bytes on the wire are not the bytes that land (a thin pack is completed with
679/// bases the client never sent; the engine stores compressed), and a ceiling
680/// with no room refuses a push that would have fitted. `gunnar-wire` does not
681/// choose that number — it does not know what a plan is — it enforces the one
682/// it is handed.
683/// # …and it is one number shared by everything pushing at once
684///
685/// [`PackCeiling::bytes`] is measured for ONE push, and two pushes that arrive
686/// together get the same measurement — so before [`PackCeiling::ledger`] each
687/// of N simultaneous pushes was held to the whole of the namespace's remaining
688/// room, and the room was spent N times. `tests/concurrent_pack_ceiling.rs`
689/// measured it at eight packs of 41016 bytes into 65536 bytes of room. The
690/// ledger is what makes the number a shared budget rather than a per-connection
691/// allowance.
692#[derive(Debug, Clone)]
693pub struct PackCeiling {
694 /// Packfile bytes past which the read is aborted. A push of exactly this
695 /// many bytes is **accepted**; the next byte cuts it.
696 pub bytes: u64,
697 /// The line the client is told, verbatim, in place of whatever the ingest
698 /// made of the truncated stream. Composed by whoever set the ceiling — the
699 /// metered appliance names the plan, the cap and the billing URL, the same
700 /// shape every other entitlement refusal uses — because this crate knows
701 /// nothing about plans, caps or money.
702 pub refusal: String,
703 /// **Who else is spending [`Self::bytes`] right now.** `None` — the
704 /// default, and what a build with no byte cap installs — means the number
705 /// is this push's alone, which is what every gunnar before 2026-09-08 did
706 /// and is correct exactly as long as one push is the only push.
707 ///
708 /// `Some` makes the ceiling a budget the namespace shares: the receive loop
709 /// asks before every chunk it hands to the ingest, and takes what it is
710 /// granted. See [`PackLedger`].
711 ///
712 /// `PartialEq`/`Eq` came off this struct when the field arrived. Two
713 /// ceilings are not comparable any more, because a shared standing in a
714 /// counter is not a value — and nothing compared them: the equality the
715 /// tests use is `gunnar_ssh::PackCeiling`'s, which is the plain twin the
716 /// gates return and still derives both.
717 pub ledger: Option<std::sync::Arc<dyn PackLedger>>,
718}
719
720/// **The namespace's shared standing, asked one chunk at a time.**
721///
722/// A packfile's header carries an object count and no byte count, so a push
723/// cannot declare its size and the door cannot admit it up front the way the
724/// LFS door admits an object. The receive loop therefore asks about the chunk
725/// in its hand, before it hands it on, and takes what it is granted.
726///
727/// # Why a trait here instead of the ledger itself
728///
729/// The implementation is `gunnar_lfs::Mark` (a monotone `u64` per namespace and
730/// a per-holder mark — see that module for the whole design, including why
731/// there is deliberately nothing to give back), wired up by
732/// `gunnar_server::transport`, which is the one place that already holds both
733/// halves of the [`PackCeiling`] twin. This crate must not link `gunnar-lfs`
734/// and must not link `gunnar-core`: it is bound by the no-OS rule and the crate
735/// layout to plumbing, and
736/// its own [`GIT_TARGET`] refuses `gunnar-core` for a single `&str` on exactly
737/// that argument. So the edge is inverted rather than added, and `gunnar-wire`
738/// resolves the same 51 packages it resolved before the ledger existed
739/// (MEASURED 2026-09-08, `cargo tree -e normal`).
740pub trait PackLedger: Send + Sync + std::fmt::Debug {
741 /// **Charge up to `want` bytes against everything admitted to this
742 /// namespace since this push measured it, inside a total of `room`, and
743 /// return how many were charged.**
744 ///
745 /// Partial credit, not a verdict: a grant short of `want` is a chunk the
746 /// caller may take that much of, and a grant of `0` means the room is gone
747 /// and the push must be cut. An implementation that answered all-or-nothing
748 /// would refuse a push that fits, for asking about a buffer bigger than the
749 /// remainder.
750 ///
751 /// `room` is passed in on every call rather than held by the
752 /// implementation because it is the RECEIVE LOOP's number: it is
753 /// [`PackCeiling::bytes`] plus the one byte that makes the ceiling
754 /// reachable ([`Counting::room`] explains why that byte exists), and
755 /// spelling that `+ 1` in two crates is how two guards start disagreeing.
756 ///
757 /// **Must not block on I/O.** It is called once per chunk of a transfer at
758 /// wire speed; the shipping implementation takes a `Mutex` across a hash
759 /// lookup and an integer add, and nothing else.
760 fn admit_upto(&self, want: u64, room: u64) -> u64;
761}
762
763/// Knobs for one push.
764#[derive(Debug, Clone)]
765pub struct Config {
766 /// The `agent=` string.
767 pub agent: String,
768 /// Traversal budget for the connectivity check.
769 pub budget: Option<usize>,
770 /// **A line the client is told beside the report**, on the progress band
771 /// (`remote: …`), for a push that is ALLOWED but carries a warning — the
772 /// metered appliance's `plan expired <date>; renew at <url>` for a tenant
773 /// in Grace. `None` prints nothing. Sent only when the client negotiated a
774 /// sideband, which every git since 1.7 does; a client without one gets the
775 /// bare report, never a line where the report should be.
776 pub notice: Option<String>,
777 /// **The mid-upload ceiling.** `None` — the default, and what a community
778 /// build with no byte cap installs — reads the pack to its end whatever
779 /// its size, which is what every gunnar before 2026-09-05 did. See
780 /// [`PackCeiling`].
781 pub pack_ceiling: Option<PackCeiling>,
782}
783
784impl Default for Config {
785 fn default() -> Self {
786 Config {
787 agent: caps::default_agent(),
788 budget: Some(crate::graph::CONNECTIVITY_BUDGET),
789 notice: None,
790 pack_ceiling: None,
791 }
792 }
793}
794
795/// The server side of `git-receive-pack`.
796pub struct ReceivePack<'a> {
797 /// **One store, not two.** `ObjectStore` and `RefStore` were separate
798 /// gunnar traits and are deleted; `GitOps` says both, and `GitServe`
799 /// extends it with the decoded read the connectivity walk needs.
800 git: &'a dyn GitServe,
801 /// The repository's hash kind.
802 ///
803 /// Carried in rather than asked for — `GitServe` has no `hash_kind()` and
804 /// the frozen signature leaves whether `GitOps` should grow one open. It is not a
805 /// convenience here: `parse_command` refuses a command line whose oids are
806 /// in the wrong hash, and answering that from a default would accept a
807 /// sha1 push into a sha256 repository.
808 hash_kind: gix_hash::Kind,
809 config: Config,
810}
811
812/// An [`Observed`] in git's own vocabulary for a ref-status line.
813///
814/// git says `is at <oid> but expected <oid>`, and for a ref that did not exist
815/// it says the all-zero oid. `Unreadable` has no git spelling at all — the
816/// backend could not decode what the ref held — so it is named rather than
817/// flattened into "absent", which would tell a client the ref was empty when it
818/// was in fact corrupt.
819fn render(o: &Observed) -> String {
820 match o {
821 Observed::Nothing => "0000000000000000000000000000000000000000".to_owned(),
822 // A symbolic ref is spelled `ref: <name>`, as git does, so a client is
823 // never handed a branch name where it expects an oid.
824 Observed::Value(RefTarget::Object(oid)) => {
825 // Written out rather than pulling in a `hex` dependency for one
826 // call: this crate has none and a ref-status line is not worth one.
827 oid.iter()
828 .fold(String::with_capacity(oid.len() * 2), |mut acc, b| {
829 use std::fmt::Write as _;
830 let _ = write!(acc, "{b:02x}");
831 acc
832 })
833 }
834 Observed::Value(RefTarget::Symbolic(name)) => format!("ref: {name}"),
835 Observed::Unreadable(what) => format!("unreadable ({what})"),
836 }
837}
838
839impl<'a> ReceivePack<'a> {
840 /// Receive into `git` with default configuration.
841 pub fn new(git: &'a dyn GitServe, hash_kind: gix_hash::Kind) -> Self {
842 ReceivePack {
843 git,
844 hash_kind,
845 config: Config::default(),
846 }
847 }
848
849 /// Replace the configuration.
850 pub fn with_config(mut self, config: Config) -> Self {
851 self.config = config;
852 self
853 }
854
855 /// The repository's hash kind.
856 pub fn hash_kind(&self) -> gix_hash::Kind {
857 self.hash_kind
858 }
859
860 /// Write the reference advertisement and its capabilities.
861 pub fn write_ref_advertisement(&self, out: &mut dyn Write) -> Result<()> {
862 let capabilities = caps::receive_pack_capabilities(self.hash_kind(), &self.config.agent);
863 // ONE snapshot: `RefStore::iter` followed by a `resolve` per symbolic
864 // row was N+1 reads of a namespace that can move between them, so an
865 // advertisement could name an oid for a reference that had already
866 // been deleted. A client then pushes against what it was told.
867 let snapshot = RefSnapshot::read(self.git)?;
868 let mut refs = Vec::new();
869 for row in snapshot.rows() {
870 let Some(id) = snapshot.resolve_row(row)? else {
871 continue;
872 };
873 refs.push(gunnar_sendpack::RemoteRef::new(row.name.as_bytes(), id));
874 }
875 // Rendered by the shared grammar, including the `capabilities^{}`
876 // pseudo-ref an empty repository has to emit so a client can still
877 // learn the hash algorithm. `gunnar-client` parses these lines with the
878 // same module.
879 for line in gunnar_sendpack::advertisement::lines(&refs, &capabilities, self.hash_kind()) {
880 wire_io::text(out, &line)?;
881 }
882 wire_io::flush(out)?;
883 Ok(())
884 }
885
886 /// Serve one push, from the advertisement through the status report.
887 pub fn serve(
888 &self,
889 input: &mut dyn BufRead,
890 out: &mut dyn Write,
891 ingest: &dyn PackIngest,
892 gate: &dyn PushGate,
893 ) -> Result<Option<PushOutcome>> {
894 self.serve_session(Session::Connected, input, out, ingest, gate)
895 }
896
897 /// Serve one push for either [`Session`].
898 ///
899 /// **Push is the easy half of stateless-rpc, and it is worth saying why.**
900 /// `receive-pack` has no negotiation: the client is told the refs once, and
901 /// then sends its command list, its options and its packfile in one go. So
902 /// a smart-HTTP push is exactly one `POST` and the only difference from SSH
903 /// is that the advertisement was a separate `GET`. There is no round to cut
904 /// short here, and protocol v2 has no push side to complicate it.
905 pub fn serve_session(
906 &self,
907 session: Session,
908 input: &mut dyn BufRead,
909 out: &mut dyn Write,
910 ingest: &dyn PackIngest,
911 gate: &dyn PushGate,
912 ) -> Result<Option<PushOutcome>> {
913 if session.advertises_inline() {
914 let _s = crate::stage::enter("advertise");
915 self.write_ref_advertisement(out)?;
916 out.flush()?;
917 }
918 let Some(preamble) = Self::read_preamble(self.hash_kind(), input)? else {
919 return Ok(None);
920 };
921 self.serve_after_preamble(preamble, input, out, ingest, gate)
922 .map(Some)
923 }
924
925 /// The advertisement of a repository that **does not exist yet**: no
926 /// references, the capabilities and `object-format` of `hash_kind`. The
927 /// first-repository door writes this instead of [`Self::write_ref_advertisement`]
928 /// because there is no store to snapshot — and must not be one, until the
929 /// client has sent a command that passes the hash check.
930 pub fn write_empty_advertisement(
931 hash_kind: gix_hash::Kind,
932 config: &Config,
933 out: &mut dyn Write,
934 ) -> Result<()> {
935 let capabilities = caps::receive_pack_capabilities(hash_kind, &config.agent);
936 for line in gunnar_sendpack::advertisement::lines(&[], &capabilities, hash_kind) {
937 wire_io::text(out, &line)?;
938 }
939 wire_io::flush(out)?;
940 out.flush()?;
941 Ok(())
942 }
943
944 /// The client's half of the opening: its command list and capabilities,
945 /// with the hash check applied. `None` when the client took the
946 /// advertisement and sent nothing, which is `git push` with nothing to do
947 /// — and a sha1 working tree that read `object-format=sha256` and gave up
948 ///. Neither is an error and neither is a push.
949 ///
950 /// An associated function, not a method: the first-repository door calls
951 /// it **before** any store exists, so it must not need one.
952 pub fn read_preamble(
953 hash_kind: gix_hash::Kind,
954 input: &mut dyn BufRead,
955 ) -> Result<Option<Preamble>> {
956 let (commands, unnameable, peer) = {
957 let _s = crate::stage::enter("command_read");
958 Self::read_commands(hash_kind, input)?
959 };
960 if commands.is_empty() && unnameable.is_empty() {
961 tracing::info!(
962 target: GIT_TARGET,
963 event = "git.receive_pack.no_commands",
964 "receive-pack: the client sent no ref commands"
965 );
966 // The client took the advertisement and hung up; `git push` with
967 // nothing to do does exactly this. Not an error, and not a push.
968 return Ok(None);
969 }
970 caps::check_object_format(hash_kind, peer.value("object-format"))?;
971 Ok(Some(Preamble {
972 commands,
973 unnameable,
974 peer,
975 }))
976 }
977
978 /// Serve a push whose [`Preamble`] has been read: options, the pack, the
979 /// gates, the ref updates and the status report.
980 pub fn serve_after_preamble(
981 &self,
982 preamble: Preamble,
983 input: &mut dyn BufRead,
984 out: &mut dyn Write,
985 ingest: &dyn PackIngest,
986 gate: &dyn PushGate,
987 ) -> Result<PushOutcome> {
988 let Preamble {
989 commands,
990 unnameable,
991 peer,
992 } = preamble;
993 let _s_opts = crate::stage::enter("push_options");
994 let push_options = if peer.has("push-options") {
995 // The push side has its own ceiling for the same reason the fetch
996 // side does: the section ends when the *peer* sends a flush-pkt.
997 // The number is `gunnar-wire`'s one request ceiling, not a second
998 // constant that could drift from it (one implementation).
999 let mut budget =
1000 crate::limits::Budget::new(Some(crate::limits::DEFAULT_REQUEST_LIMIT_BYTES));
1001 read_section(input, "the push-options section", &mut budget)?
1002 .into_iter()
1003 .map(|line| String::from_utf8_lossy(&line).into_owned())
1004 .collect()
1005 } else {
1006 Vec::new()
1007 };
1008
1009 crate::stage::end(_s_opts);
1010 let sideband = peer.has("side-band-64k");
1011 let report_requested = peer.has("report-status") || peer.has("report-status-v2");
1012 let atomic = peer.has("atomic");
1013
1014 // Deletions carry no objects, so the client sends no pack at all. A
1015 // server that reads one anyway blocks until the connection times out.
1016 //
1017 // An unnameable reference counts here too. The client does not know
1018 // gunnar refused its name and streams the pack regardless, so a server
1019 // that skipped the read because every *nameable* command was a delete
1020 // would leave a pack in the pipe, write its report into the middle of
1021 // it, and desync the session.
1022 let expects_pack = commands.iter().any(|command| !command.is_delete())
1023 || unnameable.iter().any(|refused| !refused.deletes);
1024
1025 // ── the phase log ───────────────────────────────────────────────────
1026 //
1027 // Everything below this point can take minutes on a repository the
1028 // size of a kernel, and until now it said **nothing at all** until it
1029 // was over. The Fet Kaos Monkey rig's `linux.git` import (13 816 503
1030 // objects, 8 170 MB pack, 2 872 refs; 2026-08-05, load 25–33)
1031 // wrote its pack and its index in ~30 min and had still logged no line
1032 // at 40 min. That is an operability defect on its own terms and
1033 // separate from the walk being slow: an operator watching a working
1034 // import and one watching a hang were reading the identical empty log.
1035 //
1036 // So each phase announces itself on **entry** and reports on **exit**.
1037 // The entry line is the load-bearing one — an exit-only log still
1038 // leaves the ten minutes in between unattributed, which is precisely
1039 // the state that was unreadable.
1040 //
1041 // `info`, not `debug`: a phase that can run for ten minutes is not
1042 // diagnostic detail, and a default-level log an operator has to know to
1043 // turn on is one that is off during the incident.
1044 tracing::info!(
1045 target: GIT_TARGET,
1046 event = "git.receive_pack.commands",
1047 commands = commands.len(),
1048 deletes = commands.iter().filter(|c| c.is_delete()).count(),
1049 push_options = push_options.len(),
1050 atomic,
1051 expects_pack,
1052 "receive-pack: read the ref command list"
1053 );
1054 let mut pack = None;
1055 // `Some(0)`, not `None`: a deletions-only push carries no pack, and that
1056 // is a MEASURED zero. A byte limit must refuse an oversized pack, not a
1057 // `git push --delete`.
1058 let mut pack_bytes = Some(0u64);
1059 let mut new_objects: std::result::Result<Vec<ObjectId>, String> = Ok(Vec::new());
1060 let mut unpack: std::result::Result<(), String> = Ok(());
1061 if expects_pack {
1062 // Indexing and verifying what the client sent. This is the stage
1063 // the receipt's `num_objects` is about, and on a push it is where
1064 // the money is.
1065 let _s = crate::stage::enter("pack_ingest");
1066 tracing::info!(
1067 target: GIT_TARGET,
1068 event = "git.receive_pack.receiving",
1069 "receiving and indexing the pushed pack"
1070 );
1071 let started = std::time::Instant::now();
1072 // Counted HERE rather than by measuring the pack that landed on
1073 // disk. What a limit is defending against is what the client sent;
1074 // the file that lands is a different number, because completing a
1075 // thin pack adds bases the client did not send, and on a store with
1076 // no filesystem there is no file to measure at all.
1077 let mut counting = Counting {
1078 inner: input,
1079 bytes: 0,
1080 ceiling: self.config.pack_ceiling.clone(),
1081 charged_ahead: 0,
1082 cut: false,
1083 };
1084 let received = ingest.ingest(&mut counting);
1085 pack_bytes = Some(counting.bytes);
1086 let cut = counting.cut;
1087 match received {
1088 Ok(receipt) => {
1089 tracing::info!(
1090 target: GIT_TARGET,
1091 event = "git.receive_pack.indexed",
1092 objects = receipt.indexed.num_objects,
1093 pack = %receipt.indexed.pack_id,
1094 pack_bytes = counting.bytes,
1095 elapsed_ms = started.elapsed().as_millis() as u64,
1096 "indexed a pushed pack"
1097 );
1098 new_objects = receipt.new_objects;
1099 pack = Some(receipt.indexed);
1100 }
1101 Err(err) => {
1102 let message = err.client_message();
1103 tracing::warn!(
1104 target: GIT_TARGET,
1105 event = "git.receive_pack.index_failed",
1106 pack_bytes = counting.bytes,
1107 elapsed_ms = started.elapsed().as_millis() as u64,
1108 // **The unredacted error, in the server's own log.**
1109 //
1110 // This logged `client_message()` — the redacted
1111 // sentence the *client* is told — so a push that failed
1112 // to index left `the repository is unavailable; try
1113 // again` in the log and nothing anywhere said what the
1114 // store had actually refused. `Error::client_message`'s
1115 // own doc promises the opposite: "the unredacted text is
1116 // still what the server logs". The fetch side kept that
1117 // promise (`git.upload_pack.failed` carries `%err`);
1118 // this side did not, and an operator debugging a refused
1119 // push had the client's sentence twice and the cause
1120 // never.
1121 error = %err,
1122 // Kept beside it, so a log reader can match what the
1123 // operator was shown to what actually happened.
1124 client_message = %message,
1125 "the pushed pack did not index"
1126 );
1127 unpack = Err(message);
1128 }
1129 }
1130 // ── the ceiling, installed over whatever the ingest concluded ──
1131 //
1132 // Checked AFTER the match and unconditionally, not inside the `Err`
1133 // arm, because an ingest that swallowed the cut and answered `Ok`
1134 // would otherwise hand a partially-read push to `apply` — and a
1135 // guard that only fires when the code under it already failed is
1136 // the guard a measured assertion forbids.
1137 //
1138 // What the client is told is the ceiling's own line
1139 // (`PackCeiling::refusal`) and not `Error::client_message`'s: the
1140 // indexer's honest answer to a truncated stream is "the pack is
1141 // malformed", and telling a paying tenant their pack was corrupt
1142 // when what happened is that their plan filled up is the refusal an
1143 // operator gets a ticket about.
1144 if cut {
1145 let line = self
1146 .config
1147 .pack_ceiling
1148 .as_ref()
1149 .map(|ceiling| ceiling.refusal.clone())
1150 // Unreachable — `Counting::cut` is set only where a ceiling
1151 // exists — and still spelled out rather than
1152 // `unwrap_or_default()`, because an empty `unpack` line is
1153 // a report that says a push failed and will not say why.
1154 .unwrap_or_else(|| "the push was cut at its byte ceiling".to_owned());
1155 tracing::warn!(
1156 target: GIT_TARGET,
1157 event = "git.receive_pack.ceiling",
1158 pack_bytes = counting.bytes,
1159 ceiling = self.config.pack_ceiling.as_ref().map(|c| c.bytes),
1160 refusal = %line,
1161 "the pushed pack passed its byte ceiling and the transfer was aborted \
1162 mid-stream; nothing was indexed and no reference was moved"
1163 );
1164 // The pack is not `Some`: the ingest never completed one, and a
1165 // receipt here would make `PushOutcome` claim a pack that does
1166 // not exist.
1167 pack = None;
1168 new_objects = Err(
1169 "the push was cut at its byte ceiling before the pack was indexed".to_owned(),
1170 );
1171 unpack = Err(line);
1172 }
1173 }
1174
1175 let statuses = if unpack.is_err() {
1176 vec![RefStatus::Rejected("unpacker error".to_owned()); commands.len()]
1177 } else {
1178 let facts = PushFacts {
1179 objects: self.git,
1180 new_objects: new_objects.as_deref().map_err(String::as_str),
1181 pack_bytes,
1182 push_options: &push_options,
1183 };
1184 // `--atomic` is all-or-nothing across the batch, and an unnameable
1185 // reference is a refusal *in* the batch. `apply` cannot see one —
1186 // it is only ever handed the commands that have a name — so the
1187 // fact is passed in, and it must be passed **in** rather than
1188 // collapsed afterwards: an after-the-fact collapse would rewrite
1189 // statuses for references the transaction had already applied, and
1190 // report as refused what is on disk.
1191 self.apply(&commands, atomic, !unnameable.is_empty(), gate, &facts)?
1192 };
1193
1194 let outcome = PushOutcome {
1195 commands,
1196 statuses,
1197 unnameable,
1198 push_options,
1199 pack,
1200 pack_bytes,
1201 new_objects,
1202 atomic,
1203 unpack,
1204 };
1205
1206 if report_requested {
1207 let _s = crate::stage::enter("report");
1208 self.write_report(out, &outcome, sideband)?;
1209 out.flush()?;
1210 }
1211 Ok(outcome)
1212 }
1213
1214 fn read_commands(
1215 hash_kind: gix_hash::Kind,
1216 input: &mut dyn BufRead,
1217 ) -> Result<(Vec<RefCommand>, Vec<UnnameableRef>, PeerCapabilities)> {
1218 let mut commands = Vec::new();
1219 let mut unnameable = Vec::new();
1220 let mut peer = PeerCapabilities::default();
1221 let mut first = true;
1222 let mut position = 0usize;
1223 loop {
1224 let packet = match read_packet(input)? {
1225 None => {
1226 if first {
1227 return Ok((commands, unnameable, peer));
1228 }
1229 return Err(Error::UnexpectedEof("the push command list"));
1230 }
1231 Some(packet) => packet,
1232 };
1233 match packet {
1234 Packet::Flush => break,
1235 Packet::Data(data) => {
1236 let mut text: &[u8] = data.strip_suffix(b"\n").unwrap_or(&data);
1237 // ── a shallow announcement is not the first COMMAND ──────
1238 //
1239 // A shallow client sends `shallow <oid>` for each graft
1240 // before its commands. They are not commands, which this
1241 // already knew — and they are not the line the capabilities
1242 // ride on, which it did not.
1243 //
1244 // `first` used to mean "the first packet", so a shallow
1245 // pusher spent it on a `shallow` line. That line carries no
1246 // NUL, so no capabilities were parsed; `first` went false;
1247 // and the real first command was then handed to
1248 // `parse_command` with its whole capability section still
1249 // attached:
1250 //
1251 // ```text
1252 // invalid reference name "refs/heads/x\0 report-status-v2 side-band-64k
1253 // quiet object-format=sha1 agent=git/2.53.0-Linux": control character or space
1254 // ```
1255 //
1256 // So EVERY push from a shallow clone was refused, and the
1257 // client saw only `the remote end hung up unexpectedly`.
1258 // That is not an exotic client: `actions/checkout` clones at
1259 // `fetch-depth: 1` by default, so a CI job that pushes back
1260 // is the shape that hits it.
1261 //
1262 // ISOLATED 2026-08-05 against stock git 2.53.0 over
1263 // real SSH, one client, one commit, one server:
1264 //
1265 // | client | push |
1266 // |--------------------------------|-------------|
1267 // | `clone --depth 3` | **refused** |
1268 // | the same clone, `fetch --unshallow` first | accepted |
1269 //
1270 // The second failure hid behind the first: `peer` stayed
1271 // empty, so `report-status`, `side-band-64k`, `atomic`,
1272 // `push-options` and the `object-format` check were all
1273 // silently off for a shallow pusher even where the ref name
1274 // happened to validate.
1275 //
1276 // git does it this way round too — `receive-pack.c`'s
1277 // `read_head_info` takes the capabilities off the first
1278 // **ref command**, having consumed any `shallow` lines
1279 // ahead of it.
1280 if text.starts_with(b"shallow ") {
1281 continue;
1282 }
1283 if first {
1284 if let Some(nul) = text.iter().position(|&b| b == 0) {
1285 peer =
1286 PeerCapabilities::parse(&String::from_utf8_lossy(&text[nul + 1..]));
1287 text = &text[..nul];
1288 }
1289 first = false;
1290 }
1291 match Self::parse_command(hash_kind, text, position)? {
1292 Ok(command) => commands.push(command),
1293 Err(refused) => unnameable.push(refused),
1294 }
1295 position += 1;
1296 }
1297 other => {
1298 return Err(Error::protocol(format!(
1299 "unexpected {other:?} in the push command list"
1300 )))
1301 }
1302 }
1303 }
1304 Ok((commands, unnameable, peer))
1305 }
1306
1307 /// One `<old> <new> <ref>` line, decoded by the shared grammar and then
1308 /// validated as a reference name by this server.
1309 ///
1310 /// **The split is on bytes, not on a lossy `String`,** and that is
1311 /// [`gunnar_sendpack::command::parse_line`]'s job now rather than this
1312 /// function's. Reference names are bytes in git: `refs/heads/\xff` is a
1313 /// name git will create and serve. Routing the line through
1314 /// `String::from_utf8_lossy` first replaced every invalid byte with U+FFFD
1315 /// *before* the name was validated, so a non-UTF-8 name was accepted and
1316 /// stored under a name the client never asked for — and any two names
1317 /// differing only in their invalid bytes collapsed onto the same reference,
1318 /// making a push to one silently overwrite the other.
1319 ///
1320 /// What stays here is the part that is this **server's** policy rather than
1321 /// the format's: **validating the name**, and
1322 /// reporting a hash-kind mismatch as [`Error::ObjectFormatMismatch`] so the
1323 /// client is told which two formats disagreed. The grammar refuses the same
1324 /// oid; it just cannot know what gunnar wants to call the refusal.
1325 /// The grammar's own failures stay hard errors — a line that is not
1326 /// `<old> <new> <ref>`, or an oid in the wrong hash, means the two ends
1327 /// disagree about the *format* and there is nothing left to report over.
1328 /// A name that parses and this server will not have is a different thing:
1329 /// the conversation is intact and the client is owed a line about that one
1330 /// reference, so it comes back as [`UnnameableRef`] rather than as `Err`.
1331 fn parse_command(
1332 hash_kind: gix_hash::Kind,
1333 line: &[u8],
1334 position: usize,
1335 ) -> Result<std::result::Result<RefCommand, UnnameableRef>> {
1336 let command =
1337 gunnar_sendpack::command::parse_line(line, hash_kind).map_err(|err| match err {
1338 gunnar_sendpack::Error::ObjectFormat { ours, theirs } => {
1339 Error::ObjectFormatMismatch {
1340 ours,
1341 theirs: theirs.to_owned(),
1342 }
1343 }
1344 other => Error::protocol(other.to_string()),
1345 })?;
1346 // ── the validator moved; the rule set did not ───────────────────────
1347 //
1348 // This was `FullName::new`, whose constructor ran the rules. The type
1349 // is gone with `RefStore` and the rules live in
1350 // `gunnar_store::refname::validate` — the same rules, in the place
1351 // `locked_on_target.md`'s checklist names, reached the same way
1352 // `gunnar-core::check_branch` reaches them. **This call is
1353 // load-bearing**: `gunnar-policy`'s gate layer no longer catches
1354 // `refs/heads/../../etc/passwd`, because its `RefUpdate::name` is a
1355 // plain `String` now, so this is the path-traversal boundary.
1356 //
1357 // ⚠ **A narrowing, recorded rather than hidden.** Reference names are
1358 // bytes in git and `FullName` held bytes; `RefRow::name` and
1359 // `RefUpdate::name` on the contract are `String`, so a name that
1360 // passes validation but is not UTF-8 — `refs/heads/\xff`, which git
1361 // will create and serve — can no longer be represented and comes back
1362 // as an [`UnnameableRef`] instead. That refuses **one** reference with
1363 // a line the client can read, and leaves the rest of the push
1364 // untouched, which is the failure direction this whole type exists to
1365 // produce. It is a real interop narrowing and it belongs to the
1366 // contract, not to this function: widening it means widening
1367 // `RefRow::name`, a two-implementation decision.
1368 let name = match validate_ref_name(command.name.as_slice()) {
1369 Ok(name) => name,
1370 Err(err) => {
1371 tracing::warn!(
1372 target: GIT_TARGET,
1373 event = "git.receive_pack.unnameable_ref",
1374 ref_name = %String::from_utf8_lossy(&command.name),
1375 error = %err,
1376 "refusing one reference for its name; the rest of the push continues"
1377 );
1378 return Ok(Err(UnnameableRef {
1379 name: command.name.to_vec(),
1380 // git says `funny refname` and nothing else; gunnar's
1381 // validator already knows *which* rule was broken and the
1382 // client can only act on being told.
1383 //
1384 // **The rule and not the error.** `Error::InvalidRefName`
1385 // renders the offending name inside its own message, so
1386 // `format!("{err}")` here put the name in the `ng` line
1387 // twice — and the `ng` line is one pkt-line. A 60 000-byte
1388 // name then produced a 120 105-byte packet, `write_text`
1389 // refused it with `TooLong`, and the whole report failed to
1390 // write: the refusal path had its own way of dropping the
1391 // session, which is the exact defect this type exists to
1392 // remove. MEASURED 2026-08-06.
1393 reason: format!("funny refname: {err}"),
1394 position,
1395 deletes: command.new.is_null(),
1396 }));
1397 }
1398 };
1399 Ok(Ok(RefCommand {
1400 old: command.old,
1401 new: command.new,
1402 name,
1403 }))
1404 }
1405
1406 /// Connectivity, policy, then the reference transaction.
1407 fn apply(
1408 &self,
1409 commands: &[RefCommand],
1410 atomic: bool,
1411 already_refused: bool,
1412 gate: &dyn PushGate,
1413 facts: &PushFacts<'_>,
1414 ) -> Result<Vec<RefStatus>> {
1415 let mut statuses = vec![RefStatus::Ok; commands.len()];
1416 let _conn = crate::stage::enter("connectivity");
1417 tracing::info!(
1418 target: GIT_TARGET,
1419 event = "git.receive_pack.checking",
1420 commands = commands.len(),
1421 "checking that the push closes, before any reference moves"
1422 );
1423 let started = std::time::Instant::now();
1424
1425 // ── THE INDEX, BEFORE THE WALK AND NOT DURING IT (`S-0xx`) ─────────
1426 //
1427 // The znippy arm acks a push's *bytes* before its *oids* exist: a pack
1428 // entry does not carry an oid, so one is knowable only after its delta
1429 // chain has been applied, and doing 13.9 M of those is what the indexer
1430 // puts behind the ack. Everything below reads objects by oid.
1431 //
1432 // Without this call the walk's first `peel` fell into that backend's
1433 // read-time fallback, which resolves the whole pending pack **on this
1434 // thread and throws the rows away** — a second resolver beside the
1435 // indexer's, over the same objects, each holding its own O(pack) tables.
1436 // MEASURED on the rig 2026-08-30, gunnar v1.1.3-180-g01ce6c58,
1437 // torvalds/linux --mirror at 16 GB and at 8 GB:
1438 //
1439 // ```text
1440 // 07:43:49 git.receive_pack.checking commands=2874
1441 // 09:20:21 index.scanned "a reader scanned a pending pack for itself:
1442 // 13948244 objects in 5792442 ms"
1443 // ```
1444 //
1445 // 1h36m32s and 1h44m14s for the scan alone, and `git.receive_pack.checked`
1446 // never fired on either box: the clients sat unacked 2h14m30s and
1447 // 2h12m24s and the run ended only because the repository was deleted.
1448 // Stock git acks the same mirror in ~11 min, and it does it by running
1449 // `index-pack` first and `check_connected` second — which is exactly
1450 // this line and then the rest of this function.
1451 //
1452 // One call, before any read, so the resolve happens ONCE and everything
1453 // after it is a point lookup. A backend that indexes inside `put_pack`
1454 // (the gix arm) answers `Ok(0)` and pays nothing.
1455 match self.git.index_now() {
1456 Ok(0) => {}
1457 Ok(packs) => tracing::info!(
1458 target: GIT_TARGET,
1459 event = "git.receive_pack.indexed_for_check",
1460 packs = packs,
1461 elapsed_ms = started.elapsed().as_millis() as u64,
1462 "finished the deferred index before checking that the push closes"
1463 ),
1464 // **The push is REFUSED, and this used to be a warning it walked
1465 // past.** MEASURED on the rig 2026-08-30 at 8 GB, which is
1466 // what changed my mind: the backend's watchdog gave up by name
1467 // after 300 s (`index.gave_up ... 1.5G available, 1.6G needed`) —
1468 // exactly as designed, five minutes instead of the 1h45m park it
1469 // replaced — and then this arm logged a warning and went on to the
1470 // walk anyway. The walk's first read then fell into the very
1471 // read-time fallback `index_now` exists to keep it out of: a
1472 // whole-pack resolve on the connection thread, on a box that had
1473 // just said in writing that it cannot resolve this pack. The client
1474 // was back to hanging, one layer down.
1475 //
1476 // "Failing the push on this line would turn a slow index into a
1477 // refused mirror" — the old comment — mistook the error class. This
1478 // is not the backend being slow; it is the backend saying *I could
1479 // not make these objects addressable*. A check that cannot look
1480 // objects up cannot say whether the push closes, and the only
1481 // honest answers are "refused, here is why" or an unbounded wait.
1482 //
1483 // The backend's own sentence is carried through verbatim, because
1484 // it is the one that names what was available, what the floor is,
1485 // how far it got and what the operator can do. The bytes stay
1486 // durable and nothing partial is indexed, so the same push retried
1487 // on a bigger box is a no-op transfer and an ack.
1488 //
1489 // **A DIFFERENT event name from the ingest's**, deliberately.
1490 // `git.receive_pack.index_failed` already means *the pushed pack
1491 // could not be stored*; this one means *the pack is stored and
1492 // could not be indexed*. One name over two outcomes is a log
1493 // nobody can read.
1494 Err(err) => {
1495 tracing::warn!(
1496 target: GIT_TARGET,
1497 event = "git.receive_pack.index_for_check_failed",
1498 commands = commands.len(),
1499 elapsed_ms = started.elapsed().as_millis() as u64,
1500 error = %format_args!("{err:#}"),
1501 "the deferred index did not finish, so the push cannot be checked and is \
1502 refused; the pack's bytes are durable and nothing partial was indexed"
1503 );
1504 // **The ROOT cause on the `ng` line, the whole chain in the
1505 // log.** `{err:#}` prints outermost-first, and on the rig at
1506 // 8 GB the outermost frame is the absorber's own context —
1507 // "absorbing pack 0 at (0, 8723679817) — its bytes are durable
1508 // and it stays un-indexed; reads will keep falling back rather
1509 // than answer absent" — which is true for a READER and is not
1510 // what happened to this push, and which buried the sentence
1511 // that matters ("this box cannot index this pack: the memory
1512 // watchdog gave up (deadline) with 1.2G available against a
1513 // 1.6G floor") 150 characters into a one-line pkt.
1514 //
1515 // A `ng` line is read by a person at a terminal, once. It gets
1516 // the cause; the log keeps the chain.
1517 let reason = format!("the push cannot be checked: {}", err.root_cause());
1518 return Ok(commands
1519 .iter()
1520 .map(|command| {
1521 // A delete needs no object of this push to be
1522 // addressable, and refusing one because an unrelated
1523 // pack would not index is a refusal with no cause.
1524 if command.is_delete() {
1525 RefStatus::Ok
1526 } else {
1527 RefStatus::Rejected(reason.clone())
1528 }
1529 })
1530 .collect());
1531 }
1532 }
1533
1534 // The frontier is what the repository already pointed at. It is built
1535 // ONCE, not once per command: a push of a thousand references used to
1536 // peel every reference in the repository a thousand times.
1537 let snapshot = RefSnapshot::read(self.git)?;
1538 let mut frontier_values: Vec<ObjectId> = Vec::with_capacity(snapshot.rows().len());
1539 for row in snapshot.rows() {
1540 // Direct rows only, exactly as before: a symbolic reference points
1541 // at one of the others, so following it would put the same oid in
1542 // the frontier twice and add nothing.
1543 if let Some(bytes) = &row.oid {
1544 frontier_values.push(crate::graph::oid(bytes)?);
1545 }
1546 }
1547 let graph = Graph::new(self.git, self.hash_kind).with_budget(self.config.budget);
1548 let frontier = Frontier::new(&graph, &frontier_values)?;
1549
1550 // ── ONE walk for the whole push, not one per reference ──────────────
1551 //
1552 // `verify_connectivity` already takes a *slice* of tips and shares one
1553 // `seen` set across all of them, and `Frontier` is already built once
1554 // for the whole push. What was still per-command was the
1555 // call, so N references over one shared history walked that history N
1556 // times — the second axis the earlier fix never touched.
1557 //
1558 // A mirror push is exactly that shape and it is the ordinary case for
1559 // an import: `linux.git` is 13 816 503 objects under **2 872**
1560 // references on one mainline, so the union of their closures is the
1561 // closure of the newest and the per-command loop was doing it 2 872
1562 // times. MEASURED 2026-08-05 on the in-memory fixture
1563 // (`tests/connectivity_cost.rs`): 64 references over one 200-commit
1564 // history cost **54.2x** what pushing the newest alone costs, 33 376
1565 // object touches against 616. The ratio is the reference count, so at
1566 // 2 872 it is the reason a kernel-sized push writes its pack and its
1567 // index and then never reports.
1568 //
1569 // And in wall clock, on a real repository over the `fs` backend, with
1570 // every object already present and an empty pack so that the
1571 // connectivity walk is the only thing in the bracket. `small.git`,
1572 // 12 328 objects, sha1; N references taken down one first-parent line,
1573 // which is `linux.git`'s shape; 2026-08-05, `taskset -c 0-7`, best
1574 // of three, **box not idle** (load average 33–48):
1575 //
1576 // ```text
1577 // refs per-command batched ratio
1578 // 1 0.086 s 0.084 s 1.0x
1579 // 16 0.561 s 0.104 s 5.4x
1580 // 64 1.978 s 0.141 s 14.0x
1581 // 256 6.903 s 0.122 s 56.6x
1582 // 512 10.424 s 0.153 s 68.1x
1583 // ```
1584 //
1585 // The left column is linear in the reference count and the right one is
1586 // flat, which is the whole claim. `njord.git` (10 468 objects, one
1587 // 966 MB blob) is the same shape at 17.0x for 256 references.
1588 //
1589 // # Why the per-command walk is still here
1590 //
1591 // Because the event stream reports **per reference**, and one batched walk can only
1592 // say that *something* in the push does not close. So the batch is the
1593 // fast path and the per-command loop is the attribution path, entered
1594 // only when the batch has already failed. That keeps the common case
1595 // O(union) and pays O(sum) only on a push that is being refused
1596 // anyway — which is also the one case where an operator needs the
1597 // precision. git makes the cheaper choice here and refuses the whole
1598 // batch with one message (`check_connected` over the entire command
1599 // list, `receive-pack.c`); this keeps gunnar's finer report at a cost
1600 // that is only ever paid on failure.
1601 let new_tips: Vec<ObjectId> = commands
1602 .iter()
1603 .filter(|command| !command.is_delete())
1604 .map(|command| command.new)
1605 .collect();
1606 let batch = if new_tips.is_empty() {
1607 Ok(0)
1608 } else {
1609 verify_connectivity(&graph, &new_tips, &frontier)
1610 };
1611 match &batch {
1612 Ok(objects) => tracing::info!(
1613 target: GIT_TARGET,
1614 event = "git.receive_pack.checked",
1615 tips = new_tips.len(),
1616 objects = *objects,
1617 elapsed_ms = started.elapsed().as_millis() as u64,
1618 "connectivity verified"
1619 ),
1620 Err(err) => tracing::warn!(
1621 target: GIT_TARGET,
1622 event = "git.receive_pack.check_failed",
1623 tips = new_tips.len(),
1624 elapsed_ms = started.elapsed().as_millis() as u64,
1625 error = %err,
1626 "the push does not close; re-walking one reference at a time to say which"
1627 ),
1628 }
1629
1630 for (index, command) in commands.iter().enumerate() {
1631 // **A deletion skips the WALK, not the GATE.**
1632 //
1633 // `if command.is_delete() { continue; }` stood here, and the half
1634 // of it that is right is the walk: a deletion introduces no
1635 // objects, `command.new` is the zero oid, and there is nothing for
1636 // `verify_connectivity` to reach. The `continue` carried
1637 // `gate.check` out with it — so **every deletion bypassed the
1638 // pre-receive gate entirely**.
1639 //
1640 // What that made unenforceable is not a corner: `allow_delete` is
1641 // the field whose whole purpose is refusing this push, and
1642 // `allowed_principal_ids` and the ref-name denylist were bypassable
1643 // the same way. A branch could be protected against a force-push,
1644 // visibly and correctly, and deleted outright by anyone with WRITE
1645 // — which is a strictly worse outcome than the force-push beside
1646 // it, and was invisible behind it because the force-push guard
1647 // worked.
1648 //
1649 // `gunnar-policy`'s protection gate has always had a
1650 // `UpdateKind::Delete` arm with its own refusal message, and
1651 // `tests/protection.rs` exercises `allow_delete` directly. The gate
1652 // was written for this and simply never handed one.
1653 //
1654 // Found by `gunnar-ui/uitest`'s `a-protected-branch-refuses-a-deletion`
1655 // going red while the force-push row beside it passed; pinned in
1656 // `gunnar-cli/tests/policy_mirror.rs::a_protection_rule_refuses_a_real_branch_deletion`,
1657 // which drives stock git over real ssh.
1658 if !command.is_delete() && batch.is_err() {
1659 if let Err(err) = verify_connectivity(&graph, &[command.new], &frontier) {
1660 tracing::warn!(target: GIT_TARGET, ref_name = %command.name, error = %err, "push failed connectivity");
1661 statuses[index] =
1662 RefStatus::Rejected(format!("missing necessary objects: {err}"));
1663 continue;
1664 }
1665 }
1666 if let Err(reason) = gate.check(command, facts) {
1667 statuses[index] = RefStatus::Rejected(reason);
1668 }
1669 }
1670
1671 crate::stage::end(_conn);
1672 if atomic && (already_refused || statuses.iter().any(|s| !s.is_ok())) {
1673 // One refusal fails the batch. Every other reference is reported
1674 // as refused too, or the client is told a lie about what landed.
1675 // `already_refused` carries in the refusals that never became a
1676 // command at all — a reference whose *name* this server will not
1677 // have (see [`UnnameableRef`]).
1678 for status in statuses.iter_mut() {
1679 if status.is_ok() {
1680 *status = RefStatus::Rejected("atomic transaction failed".to_owned());
1681 }
1682 }
1683 return Ok(statuses);
1684 }
1685
1686 // `edit_for` peels a new annotated-tag tip **once, here on the write
1687 // path**, while the objects the push just delivered are warm and the
1688 // connectivity walk above has already proven them present, and hands the
1689 // terminal commit to the store as the ref row's peeled column. That is
1690 // the write half of the stored peel: with it stored, `advertise_from` reads the
1691 // peeled oid straight out of the row and never re-peels, so every later
1692 // clone, fetch and `ls-remote` pays zero object reads per tag instead of
1693 // one. The graph is the same one `verify_connectivity` just used.
1694 let edits: Vec<(usize, RefCas<'_>)> = commands
1695 .iter()
1696 .enumerate()
1697 .filter(|(index, _)| statuses[*index].is_ok())
1698 .map(|(index, command)| Ok((index, edit_for(command, &graph)?)))
1699 .collect::<Result<_>>()?;
1700
1701 let _txn = crate::stage::enter("ref_txn");
1702 tracing::info!(
1703 target: GIT_TARGET,
1704 event = "git.receive_pack.updating",
1705 edits = edits.len(),
1706 atomic,
1707 "applying the reference updates"
1708 );
1709 let started = std::time::Instant::now();
1710 if atomic {
1711 // ONE call. `put_refs_cas` applies every edit or none and names the
1712 // first whose expected value did not match; a loop with a break
1713 // leaves what it already applied applied, which is the thing
1714 // `--atomic` promises does not happen.
1715 let batch: Vec<RefCas<'_>> = edits
1716 .iter()
1717 .map(|(_, edit)| RefCas {
1718 name: edit.name.clone(),
1719 old: edit.old,
1720 new: edit.new,
1721 peeled: edit.peeled.clone(),
1722 })
1723 .collect();
1724 if let Err(err) = self.git.put_refs_cas(&batch) {
1725 let reason = format!("atomic transaction failed: {err:#}");
1726 for (index, _) in &edits {
1727 statuses[*index] = RefStatus::Rejected(reason.clone());
1728 }
1729 }
1730 } else {
1731 for (index, edit) in edits {
1732 // A **one-element `put_refs_cas`**, not `update_ref`: both are
1733 // the contract's compare-and-swap-one-reference call and a
1734 // single-edit atomic batch has identical single-ref semantics —
1735 // it applies that one ref or rejects it, raising the same typed
1736 // `RefRejection::Cas` a lost race needs. `update_ref` cannot
1737 // carry a peel (its signature is `name/old/new`), and `put_refs`
1738 // would lose the CAS; `put_refs_cas` keeps the CAS **and** takes
1739 // the `RefCas::peeled` this edit computed, so the non-atomic path
1740 // stores a tag's peel exactly as the atomic path above does
1741 // (one implementation: one writer for both, not a peel-losing
1742 // second one).
1743 // Per-ref independence is preserved because each edit is its own
1744 // call — a rejection on one does not touch the others.
1745 if let Err(err) = self.git.put_refs_cas(std::slice::from_ref(&edit)) {
1746 // ── THE TYPED REJECTION, RECOVERED ──────────────────────
1747 //
1748 // This used to render `{err:#}` and the comment here said
1749 // so: "A LOST COMPARE-AND-SWAP AND A BROKEN DISK ARE THE
1750 // SAME STRING HERE … Nothing may recover the distinction by
1751 // matching on this text; the fix is a typed rejection on
1752 // the contract."
1753 //
1754 // The contract had `RefRejection` all along
1755 // (`git-storage-trait`); `put_refs_cas` raised it and
1756 // `update_ref` raised a bare `bail!`. That is fixed, so the
1757 // distinction is a `downcast_ref` rather than a substring
1758 // match — and this is deliberately NOT parsing the message,
1759 // which the old comment forbade in as many words.
1760 //
1761 // A lost race is rendered in git's own words so a client
1762 // can act on it; anything else keeps the full chain,
1763 // because a broken disk deserves every line it has.
1764 statuses[index] = match err.downcast_ref::<RefRejection>() {
1765 Some(RefRejection::Cas {
1766 name,
1767 expected,
1768 actual,
1769 }) => RefStatus::Rejected(format!(
1770 "cannot lock ref '{name}': is at {} but expected {}",
1771 render(actual),
1772 render(expected)
1773 )),
1774 Some(RefRejection::Locked { name }) => RefStatus::Rejected(format!(
1775 "cannot lock ref '{name}': held by another writer, try again"
1776 )),
1777 _ => RefStatus::Rejected(format!("{err:#}")),
1778 };
1779 }
1780 }
1781 }
1782 tracing::info!(
1783 target: GIT_TARGET,
1784 event = "git.receive_pack.updated",
1785 // Counted off `statuses`, which is what the client is about to be
1786 // told, rather than off `edits`, which is only what was attempted.
1787 // A transaction that lost its compare-and-swap turns an edit into a
1788 // refusal, and a log reporting the attempt would report a reference
1789 // as moved that did not (measure, never assert).
1790 refs_ok = statuses.iter().filter(|s| s.is_ok()).count(),
1791 refs_rejected = statuses.iter().filter(|s| !s.is_ok()).count(),
1792 elapsed_ms = started.elapsed().as_millis() as u64,
1793 "the reference updates are applied"
1794 );
1795
1796 Ok(statuses)
1797 }
1798
1799 fn write_report(
1800 &self,
1801 out: &mut dyn Write,
1802 outcome: &PushOutcome,
1803 sideband: bool,
1804 ) -> Result<()> {
1805 // The lines come from the shared grammar (`gunnar-sendpack`), which is
1806 // the same code `gunnar-client` parses them with. What stays here is
1807 // the framing and the sideband, which belong to this transport.
1808 //
1809 // The two lists are merged back into the order the client sent, using
1810 // the position [`UnnameableRef`] carries. A client zips the report
1811 // against its own command list — `gunnar-client` does, and so does
1812 // `git push`'s per-ref output — so a report that silently reordered
1813 // would attribute a refusal to the wrong reference.
1814 let mut statuses: Vec<(usize, gunnar_sendpack::RefStatus)> = Vec::new();
1815 // The nameable commands sit at the positions the unnameable ones left
1816 // free, in order, which is enough to recover the original interleaving
1817 // without threading a position through `RefCommand` as well.
1818 let taken: std::collections::BTreeSet<usize> =
1819 outcome.unnameable.iter().map(|r| r.position).collect();
1820 let mut free = (0..).filter(|position| !taken.contains(position));
1821 for (command, status) in outcome.commands.iter().zip(&outcome.statuses) {
1822 let position = free.next().expect("the position sequence is infinite");
1823 statuses.push((
1824 position,
1825 match status {
1826 RefStatus::Ok => gunnar_sendpack::RefStatus::ok(command.name.as_bytes()),
1827 RefStatus::Rejected(reason) => {
1828 gunnar_sendpack::RefStatus::rejected(command.name.as_bytes(), reason)
1829 }
1830 },
1831 ));
1832 }
1833 for refused in &outcome.unnameable {
1834 statuses.push((
1835 refused.position,
1836 gunnar_sendpack::RefStatus::rejected(
1837 echoable_name(&refused.name, refused.reason.len()),
1838 &refused.reason,
1839 ),
1840 ));
1841 }
1842 statuses.sort_by_key(|(position, _)| *position);
1843 let statuses: Vec<gunnar_sendpack::RefStatus> =
1844 statuses.into_iter().map(|(_, status)| status).collect();
1845 let mut report = Vec::new();
1846 for line in gunnar_sendpack::report::lines(
1847 outcome.unpack.as_ref().copied().map_err(String::as_str),
1848 &statuses,
1849 ) {
1850 gunnar_pktline::write_text(&mut report, &line)?;
1851 }
1852 gunnar_pktline::write_flush(&mut report)?;
1853
1854 if sideband {
1855 // The notice first, on band 2, so `git push` prints it as
1856 // `remote: …` ABOVE the per-ref lines — where a person reads before
1857 // the summary — and separately framed so a client that ignores band
1858 // 2 still parses the report untouched.
1859 if let Some(notice) = &self.config.notice {
1860 let mut line = notice.clone();
1861 if !line.ends_with('\n') {
1862 line.push('\n');
1863 }
1864 wire_io::progress(out, true, &line)?;
1865 }
1866 // The whole report, its terminating flush-pkt included, rides band
1867 // 1; the connection is then ended by an *unframed* flush. That is
1868 // what `git-receive-pack` 2.53 puts on the wire (captured
1869 // 2026-08-03), and a client reading a demultiplexed stream cannot
1870 // see the difference between the two flushes any other way.
1871 wire_io::sideband(out, BAND_PACK, &report)?;
1872 wire_io::flush(out)?;
1873 } else {
1874 out.write_all(&report)?;
1875 }
1876 Ok(())
1877 }
1878}
1879
1880/// The most of a refused reference name that can go into an `ng` line without
1881/// the line outgrowing a pkt-line.
1882///
1883/// A refused name is the one name on the wire that is **not** bounded by
1884/// anything gunnar chose: it arrived because a client sent it, and the only
1885/// ceiling it ever passed is the command packet's own 65 516 bytes. Echoing it
1886/// whole into `ng <name> <reason>\n` therefore does not fit whenever the name is
1887/// within a few dozen bytes of that ceiling, and `write_text` refuses the packet
1888/// rather than emitting a header that lies. The report then does not get
1889/// written at all — the session dies on the refusal path, which is precisely the
1890/// failure [`UnnameableRef`] exists to remove.
1891///
1892/// So a name too big to echo is truncated and **says so**, rather than costing
1893/// the client the whole report. A name this long cannot match anything the
1894/// client will keep anyway: every legal name is at most
1895/// [`gunnar_store::MAX_REF_COMPONENT_BYTES`] per component.
1896fn echoable_name(name: &[u8], reason_len: usize) -> Vec<u8> {
1897 const MARKER: &[u8] = b"\xe2\x80\xa6[truncated]";
1898 // `ng ` + name + ` ` + reason + `\n`.
1899 let overhead = 3 + 1 + reason_len + 1;
1900 let budget = gunnar_pktline::MAX_PAYLOAD.saturating_sub(overhead);
1901 if name.len() <= budget {
1902 return name.to_vec();
1903 }
1904 let keep = budget.saturating_sub(MARKER.len());
1905 let mut out = name[..keep].to_vec();
1906 out.extend_from_slice(MARKER);
1907 out
1908}
1909
1910/// The rules `FullName::new` used to run, plus the one the contract adds.
1911///
1912/// Two failures with one shape, because both produce the same `ng` line and a
1913/// caller that had to tell them apart would be re-deriving the difference from
1914/// a message: the name is not a legal reference name, or it is legal and is not
1915/// UTF-8 and therefore cannot be a `RefRow::name`. See
1916/// [`ReceivePack::parse_command`] for why the second is a recorded narrowing
1917/// rather than a bug.
1918fn validate_ref_name(name: &[u8]) -> std::result::Result<String, String> {
1919 gunnar_store::refname::validate(name).map_err(|err| match err {
1920 // The validator renders the offending name inside its own message and
1921 // the `ng` line already carries the name, so only the *rule* is taken.
1922 // A 60 000-byte name once produced a 120 105-byte packet, `write_text`
1923 // refused it, and the whole report failed to write. MEASURED
1924 // 2026-08-06.
1925 gunnar_store::Error::InvalidRefName { reason, .. } => reason.to_owned(),
1926 other => other.to_string(),
1927 })?;
1928 String::from_utf8(name.to_vec())
1929 .map_err(|_| "a reference name gunnar can store must be UTF-8".to_owned())
1930}
1931
1932/// One command as a compare-and-swap edit, with a tag's peeled target attached.
1933///
1934/// The three shapes `PreviousValue`/`Change` used to spell out collapse into
1935/// two `Option`s, because that is what the contract says and it says the same
1936/// thing: `old: None` is *"must not exist"* (a create) and `new: None` is a
1937/// delete. A non-create carries `command.old`, which is the client's claim
1938/// about what the reference holds and the only thing that makes this a race
1939/// the server can lose safely.
1940///
1941/// The fourth field, `peeled`, is the one this fn computes rather than copies:
1942/// an annotated tag's terminal commit, so the store persists it and the
1943/// advertisement never re-peels. It needs the object graph, which is why
1944/// this takes `graph` and returns a `Result` — a peel is a store read.
1945fn edit_for<'a>(command: &'a RefCommand, graph: &Graph<'_>) -> Result<RefCas<'a>> {
1946 // The peeled column, filled only for a create/update whose new tip is an
1947 // annotated tag. `peel` follows the tag chain to the first non-tag object
1948 // (a commit, in the common case of one tag over a commit) and reports the
1949 // tags it walked through: `tags` is non-empty **iff** the tip really was a
1950 // tag object, which is the only case git's `packed-refs` writes a `^peeled`
1951 // line for. A delete has no tip; a branch head or a lightweight tag peels to
1952 // itself with no tag walked, so both carry `None` and the store keeps no
1953 // peel — matching packed-refs exactly. This is the one peel per new tag the
1954 // whole stored-peel fix costs, paid once at ingest instead of on every
1955 // advertisement.
1956 //
1957 // The oid is **owned** here (`RefCas::peeled` is `Option<Vec<u8>>`): unlike
1958 // `old`/`new`, which are slices of the command line the caller still holds,
1959 // the peel is derived from the store and borrows nothing.
1960 let peeled = if command.is_delete() {
1961 None
1962 } else {
1963 let peeled = graph.peel(&command.new)?;
1964 (!peeled.tags.is_empty()).then(|| peeled.id.as_bytes().to_vec())
1965 };
1966 Ok(RefCas {
1967 name: command.name.clone(),
1968 old: (!command.is_create()).then(|| command.old.as_bytes()),
1969 new: (!command.is_delete()).then(|| command.new.as_bytes()),
1970 peeled,
1971 })
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976 use super::*;
1977
1978 /// **[`echoable_name`] is a fix, not a guard, and it is not reachable from
1979 /// the wire today. Both facts, with the arithmetic that decides them.**
1980 ///
1981 /// A command line is one pkt-line, so the longest reference name a client
1982 /// can physically send is `MAX_PAYLOAD - (40 + 1 + 40 + 1 + 1)` =
1983 /// **65 433** bytes. The `ng` line it comes back in is
1984 /// `"ng " + name + " " + reason + "\n"`, so the name fits whenever the
1985 /// reason is at most `65 511 - 65 433` = **78** bytes. Every reason
1986 /// [`ReceivePack::parse_command`] can produce is `"funny refname: "` plus
1987 /// one of the validator's fixed strings, and the longest of those makes
1988 /// **51**. 51 < 78, so no push can currently reach the truncation.
1989 ///
1990 /// That margin is 27 bytes of coincidence between two constants nobody
1991 /// holds together. One longer refusal string and the report becomes
1992 /// unwritable — `write_text` refuses the packet, `write_report` returns
1993 /// `Err`, and the session dies **on the refusal path**, which is the exact
1994 /// failure [`UnnameableRef`] exists to remove. So the truncation stays, and
1995 /// this is where it is proven, since the wire cannot prove it.
1996 #[test]
1997 fn a_refused_name_too_long_to_echo_is_truncated_rather_than_costing_the_report() {
1998 let name = vec![b'x'; gunnar_pktline::MAX_PAYLOAD - 40];
1999 let reason = "funny refname: ".to_owned() + &"y".repeat(200);
2000
2001 let echoed = echoable_name(&name, reason.len());
2002
2003 // The property that matters, asserted FIRST so a red run names the
2004 // consequence and not a proxy for it: the line frames.
2005 let mut line = b"ng ".to_vec();
2006 line.extend_from_slice(&echoed);
2007 line.push(b' ');
2008 line.extend_from_slice(reason.as_bytes());
2009 let mut out = Vec::new();
2010 gunnar_pktline::write_text(&mut out, &line).unwrap_or_else(|err| {
2011 panic!(
2012 "the `ng` line for a refused {}-byte name does not fit in a pkt-line ({err}), \
2013 so the whole report fails to write and the session dies on the refusal path",
2014 name.len()
2015 )
2016 });
2017 assert!(
2018 echoed.ends_with("…[truncated]".as_bytes()),
2019 "the name was cut to fit and the client is not told; a truncated name that \
2020 does not say so is a name the client cannot match against its own list"
2021 );
2022
2023 // And the acceptance half: a name that fits is not touched.
2024 let short = b"refs/heads/main".to_vec();
2025 assert_eq!(
2026 echoable_name(&short, reason.len()),
2027 short,
2028 "a name that fits was truncated anyway"
2029 );
2030 }
2031}