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(|| {
1155 "the push was cut at its byte ceiling".to_owned()
1156 });
1157 tracing::warn!(
1158 target: GIT_TARGET,
1159 event = "git.receive_pack.ceiling",
1160 pack_bytes = counting.bytes,
1161 ceiling = self.config.pack_ceiling.as_ref().map(|c| c.bytes),
1162 refusal = %line,
1163 "the pushed pack passed its byte ceiling and the transfer was aborted \
1164 mid-stream; nothing was indexed and no reference was moved"
1165 );
1166 // The pack is not `Some`: the ingest never completed one, and a
1167 // receipt here would make `PushOutcome` claim a pack that does
1168 // not exist.
1169 pack = None;
1170 new_objects = Err(
1171 "the push was cut at its byte ceiling before the pack was indexed"
1172 .to_owned(),
1173 );
1174 unpack = Err(line);
1175 }
1176 }
1177
1178 let statuses = if unpack.is_err() {
1179 vec![RefStatus::Rejected("unpacker error".to_owned()); commands.len()]
1180 } else {
1181 let facts = PushFacts {
1182 objects: self.git,
1183 new_objects: new_objects.as_deref().map_err(String::as_str),
1184 pack_bytes,
1185 push_options: &push_options,
1186 };
1187 // `--atomic` is all-or-nothing across the batch, and an unnameable
1188 // reference is a refusal *in* the batch. `apply` cannot see one —
1189 // it is only ever handed the commands that have a name — so the
1190 // fact is passed in, and it must be passed **in** rather than
1191 // collapsed afterwards: an after-the-fact collapse would rewrite
1192 // statuses for references the transaction had already applied, and
1193 // report as refused what is on disk.
1194 self.apply(&commands, atomic, !unnameable.is_empty(), gate, &facts)?
1195 };
1196
1197 let outcome = PushOutcome {
1198 commands,
1199 statuses,
1200 unnameable,
1201 push_options,
1202 pack,
1203 pack_bytes,
1204 new_objects,
1205 atomic,
1206 unpack,
1207 };
1208
1209 if report_requested {
1210 let _s = crate::stage::enter("report");
1211 self.write_report(out, &outcome, sideband)?;
1212 out.flush()?;
1213 }
1214 Ok(outcome)
1215 }
1216
1217 fn read_commands(
1218 hash_kind: gix_hash::Kind,
1219 input: &mut dyn BufRead,
1220 ) -> Result<(Vec<RefCommand>, Vec<UnnameableRef>, PeerCapabilities)> {
1221 let mut commands = Vec::new();
1222 let mut unnameable = Vec::new();
1223 let mut peer = PeerCapabilities::default();
1224 let mut first = true;
1225 let mut position = 0usize;
1226 loop {
1227 let packet = match read_packet(input)? {
1228 None => {
1229 if first {
1230 return Ok((commands, unnameable, peer));
1231 }
1232 return Err(Error::UnexpectedEof("the push command list"));
1233 }
1234 Some(packet) => packet,
1235 };
1236 match packet {
1237 Packet::Flush => break,
1238 Packet::Data(data) => {
1239 let mut text: &[u8] = data.strip_suffix(b"\n").unwrap_or(&data);
1240 // ── a shallow announcement is not the first COMMAND ──────
1241 //
1242 // A shallow client sends `shallow <oid>` for each graft
1243 // before its commands. They are not commands, which this
1244 // already knew — and they are not the line the capabilities
1245 // ride on, which it did not.
1246 //
1247 // `first` used to mean "the first packet", so a shallow
1248 // pusher spent it on a `shallow` line. That line carries no
1249 // NUL, so no capabilities were parsed; `first` went false;
1250 // and the real first command was then handed to
1251 // `parse_command` with its whole capability section still
1252 // attached:
1253 //
1254 // ```text
1255 // invalid reference name "refs/heads/x\0 report-status-v2 side-band-64k
1256 // quiet object-format=sha1 agent=git/2.53.0-Linux": control character or space
1257 // ```
1258 //
1259 // So EVERY push from a shallow clone was refused, and the
1260 // client saw only `the remote end hung up unexpectedly`.
1261 // That is not an exotic client: `actions/checkout` clones at
1262 // `fetch-depth: 1` by default, so a CI job that pushes back
1263 // is the shape that hits it.
1264 //
1265 // ISOLATED 2026-08-05 against stock git 2.53.0 over
1266 // real SSH, one client, one commit, one server:
1267 //
1268 // | client | push |
1269 // |--------------------------------|-------------|
1270 // | `clone --depth 3` | **refused** |
1271 // | the same clone, `fetch --unshallow` first | accepted |
1272 //
1273 // The second failure hid behind the first: `peer` stayed
1274 // empty, so `report-status`, `side-band-64k`, `atomic`,
1275 // `push-options` and the `object-format` check were all
1276 // silently off for a shallow pusher even where the ref name
1277 // happened to validate.
1278 //
1279 // git does it this way round too — `receive-pack.c`'s
1280 // `read_head_info` takes the capabilities off the first
1281 // **ref command**, having consumed any `shallow` lines
1282 // ahead of it.
1283 if text.starts_with(b"shallow ") {
1284 continue;
1285 }
1286 if first {
1287 if let Some(nul) = text.iter().position(|&b| b == 0) {
1288 peer =
1289 PeerCapabilities::parse(&String::from_utf8_lossy(&text[nul + 1..]));
1290 text = &text[..nul];
1291 }
1292 first = false;
1293 }
1294 match Self::parse_command(hash_kind, text, position)? {
1295 Ok(command) => commands.push(command),
1296 Err(refused) => unnameable.push(refused),
1297 }
1298 position += 1;
1299 }
1300 other => {
1301 return Err(Error::protocol(format!(
1302 "unexpected {other:?} in the push command list"
1303 )))
1304 }
1305 }
1306 }
1307 Ok((commands, unnameable, peer))
1308 }
1309
1310 /// One `<old> <new> <ref>` line, decoded by the shared grammar and then
1311 /// validated as a reference name by this server.
1312 ///
1313 /// **The split is on bytes, not on a lossy `String`,** and that is
1314 /// [`gunnar_sendpack::command::parse_line`]'s job now rather than this
1315 /// function's. Reference names are bytes in git: `refs/heads/\xff` is a
1316 /// name git will create and serve. Routing the line through
1317 /// `String::from_utf8_lossy` first replaced every invalid byte with U+FFFD
1318 /// *before* the name was validated, so a non-UTF-8 name was accepted and
1319 /// stored under a name the client never asked for — and any two names
1320 /// differing only in their invalid bytes collapsed onto the same reference,
1321 /// making a push to one silently overwrite the other.
1322 ///
1323 /// What stays here is the part that is this **server's** policy rather than
1324 /// the format's: **validating the name**, and
1325 /// reporting a hash-kind mismatch as [`Error::ObjectFormatMismatch`] so the
1326 /// client is told which two formats disagreed. The grammar refuses the same
1327 /// oid; it just cannot know what gunnar wants to call the refusal.
1328 /// The grammar's own failures stay hard errors — a line that is not
1329 /// `<old> <new> <ref>`, or an oid in the wrong hash, means the two ends
1330 /// disagree about the *format* and there is nothing left to report over.
1331 /// A name that parses and this server will not have is a different thing:
1332 /// the conversation is intact and the client is owed a line about that one
1333 /// reference, so it comes back as [`UnnameableRef`] rather than as `Err`.
1334 fn parse_command(
1335 hash_kind: gix_hash::Kind,
1336 line: &[u8],
1337 position: usize,
1338 ) -> Result<std::result::Result<RefCommand, UnnameableRef>> {
1339 let command =
1340 gunnar_sendpack::command::parse_line(line, hash_kind).map_err(|err| match err {
1341 gunnar_sendpack::Error::ObjectFormat { ours, theirs } => {
1342 Error::ObjectFormatMismatch {
1343 ours,
1344 theirs: theirs.to_owned(),
1345 }
1346 }
1347 other => Error::protocol(other.to_string()),
1348 })?;
1349 // ── the validator moved; the rule set did not ───────────────────────
1350 //
1351 // This was `FullName::new`, whose constructor ran the rules. The type
1352 // is gone with `RefStore` and the rules live in
1353 // `gunnar_store::refname::validate` — the same rules, in the place
1354 // `locked_on_target.md`'s checklist names, reached the same way
1355 // `gunnar-core::check_branch` reaches them. **This call is
1356 // load-bearing**: `gunnar-policy`'s gate layer no longer catches
1357 // `refs/heads/../../etc/passwd`, because its `RefUpdate::name` is a
1358 // plain `String` now, so this is the path-traversal boundary.
1359 //
1360 // ⚠ **A narrowing, recorded rather than hidden.** Reference names are
1361 // bytes in git and `FullName` held bytes; `RefRow::name` and
1362 // `RefUpdate::name` on the contract are `String`, so a name that
1363 // passes validation but is not UTF-8 — `refs/heads/\xff`, which git
1364 // will create and serve — can no longer be represented and comes back
1365 // as an [`UnnameableRef`] instead. That refuses **one** reference with
1366 // a line the client can read, and leaves the rest of the push
1367 // untouched, which is the failure direction this whole type exists to
1368 // produce. It is a real interop narrowing and it belongs to the
1369 // contract, not to this function: widening it means widening
1370 // `RefRow::name`, a two-implementation decision.
1371 let name = match validate_ref_name(command.name.as_slice()) {
1372 Ok(name) => name,
1373 Err(err) => {
1374 tracing::warn!(
1375 target: GIT_TARGET,
1376 event = "git.receive_pack.unnameable_ref",
1377 ref_name = %String::from_utf8_lossy(&command.name),
1378 error = %err,
1379 "refusing one reference for its name; the rest of the push continues"
1380 );
1381 return Ok(Err(UnnameableRef {
1382 name: command.name.to_vec(),
1383 // git says `funny refname` and nothing else; gunnar's
1384 // validator already knows *which* rule was broken and the
1385 // client can only act on being told.
1386 //
1387 // **The rule and not the error.** `Error::InvalidRefName`
1388 // renders the offending name inside its own message, so
1389 // `format!("{err}")` here put the name in the `ng` line
1390 // twice — and the `ng` line is one pkt-line. A 60 000-byte
1391 // name then produced a 120 105-byte packet, `write_text`
1392 // refused it with `TooLong`, and the whole report failed to
1393 // write: the refusal path had its own way of dropping the
1394 // session, which is the exact defect this type exists to
1395 // remove. MEASURED 2026-08-06.
1396 reason: format!("funny refname: {err}"),
1397 position,
1398 deletes: command.new.is_null(),
1399 }));
1400 }
1401 };
1402 Ok(Ok(RefCommand {
1403 old: command.old,
1404 new: command.new,
1405 name,
1406 }))
1407 }
1408
1409 /// Connectivity, policy, then the reference transaction.
1410 fn apply(
1411 &self,
1412 commands: &[RefCommand],
1413 atomic: bool,
1414 already_refused: bool,
1415 gate: &dyn PushGate,
1416 facts: &PushFacts<'_>,
1417 ) -> Result<Vec<RefStatus>> {
1418 let mut statuses = vec![RefStatus::Ok; commands.len()];
1419 let _conn = crate::stage::enter("connectivity");
1420 tracing::info!(
1421 target: GIT_TARGET,
1422 event = "git.receive_pack.checking",
1423 commands = commands.len(),
1424 "checking that the push closes, before any reference moves"
1425 );
1426 let started = std::time::Instant::now();
1427
1428 // ── THE INDEX, BEFORE THE WALK AND NOT DURING IT (`S-0xx`) ─────────
1429 //
1430 // The znippy arm acks a push's *bytes* before its *oids* exist: a pack
1431 // entry does not carry an oid, so one is knowable only after its delta
1432 // chain has been applied, and doing 13.9 M of those is what the indexer
1433 // puts behind the ack. Everything below reads objects by oid.
1434 //
1435 // Without this call the walk's first `peel` fell into that backend's
1436 // read-time fallback, which resolves the whole pending pack **on this
1437 // thread and throws the rows away** — a second resolver beside the
1438 // indexer's, over the same objects, each holding its own O(pack) tables.
1439 // MEASURED on the rig 2026-08-30, gunnar v1.1.3-180-g01ce6c58,
1440 // torvalds/linux --mirror at 16 GB and at 8 GB:
1441 //
1442 // ```text
1443 // 07:43:49 git.receive_pack.checking commands=2874
1444 // 09:20:21 index.scanned "a reader scanned a pending pack for itself:
1445 // 13948244 objects in 5792442 ms"
1446 // ```
1447 //
1448 // 1h36m32s and 1h44m14s for the scan alone, and `git.receive_pack.checked`
1449 // never fired on either box: the clients sat unacked 2h14m30s and
1450 // 2h12m24s and the run ended only because the repository was deleted.
1451 // Stock git acks the same mirror in ~11 min, and it does it by running
1452 // `index-pack` first and `check_connected` second — which is exactly
1453 // this line and then the rest of this function.
1454 //
1455 // One call, before any read, so the resolve happens ONCE and everything
1456 // after it is a point lookup. A backend that indexes inside `put_pack`
1457 // (the gix arm) answers `Ok(0)` and pays nothing.
1458 match self.git.index_now() {
1459 Ok(0) => {}
1460 Ok(packs) => tracing::info!(
1461 target: GIT_TARGET,
1462 event = "git.receive_pack.indexed_for_check",
1463 packs = packs,
1464 elapsed_ms = started.elapsed().as_millis() as u64,
1465 "finished the deferred index before checking that the push closes"
1466 ),
1467 // **The push is REFUSED, and this used to be a warning it walked
1468 // past.** MEASURED on the rig 2026-08-30 at 8 GB, which is
1469 // what changed my mind: the backend's watchdog gave up by name
1470 // after 300 s (`index.gave_up ... 1.5G available, 1.6G needed`) —
1471 // exactly as designed, five minutes instead of the 1h45m park it
1472 // replaced — and then this arm logged a warning and went on to the
1473 // walk anyway. The walk's first read then fell into the very
1474 // read-time fallback `index_now` exists to keep it out of: a
1475 // whole-pack resolve on the connection thread, on a box that had
1476 // just said in writing that it cannot resolve this pack. The client
1477 // was back to hanging, one layer down.
1478 //
1479 // "Failing the push on this line would turn a slow index into a
1480 // refused mirror" — the old comment — mistook the error class. This
1481 // is not the backend being slow; it is the backend saying *I could
1482 // not make these objects addressable*. A check that cannot look
1483 // objects up cannot say whether the push closes, and the only
1484 // honest answers are "refused, here is why" or an unbounded wait.
1485 //
1486 // The backend's own sentence is carried through verbatim, because
1487 // it is the one that names what was available, what the floor is,
1488 // how far it got and what the operator can do. The bytes stay
1489 // durable and nothing partial is indexed, so the same push retried
1490 // on a bigger box is a no-op transfer and an ack.
1491 //
1492 // **A DIFFERENT event name from the ingest's**, deliberately.
1493 // `git.receive_pack.index_failed` already means *the pushed pack
1494 // could not be stored*; this one means *the pack is stored and
1495 // could not be indexed*. One name over two outcomes is a log
1496 // nobody can read.
1497 Err(err) => {
1498 tracing::warn!(
1499 target: GIT_TARGET,
1500 event = "git.receive_pack.index_for_check_failed",
1501 commands = commands.len(),
1502 elapsed_ms = started.elapsed().as_millis() as u64,
1503 error = %format_args!("{err:#}"),
1504 "the deferred index did not finish, so the push cannot be checked and is \
1505 refused; the pack's bytes are durable and nothing partial was indexed"
1506 );
1507 // **The ROOT cause on the `ng` line, the whole chain in the
1508 // log.** `{err:#}` prints outermost-first, and on the rig at
1509 // 8 GB the outermost frame is the absorber's own context —
1510 // "absorbing pack 0 at (0, 8723679817) — its bytes are durable
1511 // and it stays un-indexed; reads will keep falling back rather
1512 // than answer absent" — which is true for a READER and is not
1513 // what happened to this push, and which buried the sentence
1514 // that matters ("this box cannot index this pack: the memory
1515 // watchdog gave up (deadline) with 1.2G available against a
1516 // 1.6G floor") 150 characters into a one-line pkt.
1517 //
1518 // A `ng` line is read by a person at a terminal, once. It gets
1519 // the cause; the log keeps the chain.
1520 let reason = format!("the push cannot be checked: {}", err.root_cause());
1521 return Ok(commands
1522 .iter()
1523 .map(|command| {
1524 // A delete needs no object of this push to be
1525 // addressable, and refusing one because an unrelated
1526 // pack would not index is a refusal with no cause.
1527 if command.is_delete() {
1528 RefStatus::Ok
1529 } else {
1530 RefStatus::Rejected(reason.clone())
1531 }
1532 })
1533 .collect());
1534 }
1535 }
1536
1537 // The frontier is what the repository already pointed at. It is built
1538 // ONCE, not once per command: a push of a thousand references used to
1539 // peel every reference in the repository a thousand times.
1540 let snapshot = RefSnapshot::read(self.git)?;
1541 let mut frontier_values: Vec<ObjectId> = Vec::with_capacity(snapshot.rows().len());
1542 for row in snapshot.rows() {
1543 // Direct rows only, exactly as before: a symbolic reference points
1544 // at one of the others, so following it would put the same oid in
1545 // the frontier twice and add nothing.
1546 if let Some(bytes) = &row.oid {
1547 frontier_values.push(crate::graph::oid(bytes)?);
1548 }
1549 }
1550 let graph = Graph::new(self.git, self.hash_kind).with_budget(self.config.budget);
1551 let frontier = Frontier::new(&graph, &frontier_values)?;
1552
1553 // ── ONE walk for the whole push, not one per reference ──────────────
1554 //
1555 // `verify_connectivity` already takes a *slice* of tips and shares one
1556 // `seen` set across all of them, and `Frontier` is already built once
1557 // for the whole push. What was still per-command was the
1558 // call, so N references over one shared history walked that history N
1559 // times — the second axis the earlier fix never touched.
1560 //
1561 // A mirror push is exactly that shape and it is the ordinary case for
1562 // an import: `linux.git` is 13 816 503 objects under **2 872**
1563 // references on one mainline, so the union of their closures is the
1564 // closure of the newest and the per-command loop was doing it 2 872
1565 // times. MEASURED 2026-08-05 on the in-memory fixture
1566 // (`tests/connectivity_cost.rs`): 64 references over one 200-commit
1567 // history cost **54.2x** what pushing the newest alone costs, 33 376
1568 // object touches against 616. The ratio is the reference count, so at
1569 // 2 872 it is the reason a kernel-sized push writes its pack and its
1570 // index and then never reports.
1571 //
1572 // And in wall clock, on a real repository over the `fs` backend, with
1573 // every object already present and an empty pack so that the
1574 // connectivity walk is the only thing in the bracket. `small.git`,
1575 // 12 328 objects, sha1; N references taken down one first-parent line,
1576 // which is `linux.git`'s shape; 2026-08-05, `taskset -c 0-7`, best
1577 // of three, **box not idle** (load average 33–48):
1578 //
1579 // ```text
1580 // refs per-command batched ratio
1581 // 1 0.086 s 0.084 s 1.0x
1582 // 16 0.561 s 0.104 s 5.4x
1583 // 64 1.978 s 0.141 s 14.0x
1584 // 256 6.903 s 0.122 s 56.6x
1585 // 512 10.424 s 0.153 s 68.1x
1586 // ```
1587 //
1588 // The left column is linear in the reference count and the right one is
1589 // flat, which is the whole claim. `njord.git` (10 468 objects, one
1590 // 966 MB blob) is the same shape at 17.0x for 256 references.
1591 //
1592 // # Why the per-command walk is still here
1593 //
1594 // Because the event stream reports **per reference**, and one batched walk can only
1595 // say that *something* in the push does not close. So the batch is the
1596 // fast path and the per-command loop is the attribution path, entered
1597 // only when the batch has already failed. That keeps the common case
1598 // O(union) and pays O(sum) only on a push that is being refused
1599 // anyway — which is also the one case where an operator needs the
1600 // precision. git makes the cheaper choice here and refuses the whole
1601 // batch with one message (`check_connected` over the entire command
1602 // list, `receive-pack.c`); this keeps gunnar's finer report at a cost
1603 // that is only ever paid on failure.
1604 let new_tips: Vec<ObjectId> = commands
1605 .iter()
1606 .filter(|command| !command.is_delete())
1607 .map(|command| command.new)
1608 .collect();
1609 let batch = if new_tips.is_empty() {
1610 Ok(0)
1611 } else {
1612 verify_connectivity(&graph, &new_tips, &frontier)
1613 };
1614 match &batch {
1615 Ok(objects) => tracing::info!(
1616 target: GIT_TARGET,
1617 event = "git.receive_pack.checked",
1618 tips = new_tips.len(),
1619 objects = *objects,
1620 elapsed_ms = started.elapsed().as_millis() as u64,
1621 "connectivity verified"
1622 ),
1623 Err(err) => tracing::warn!(
1624 target: GIT_TARGET,
1625 event = "git.receive_pack.check_failed",
1626 tips = new_tips.len(),
1627 elapsed_ms = started.elapsed().as_millis() as u64,
1628 error = %err,
1629 "the push does not close; re-walking one reference at a time to say which"
1630 ),
1631 }
1632
1633 for (index, command) in commands.iter().enumerate() {
1634 // **A deletion skips the WALK, not the GATE.**
1635 //
1636 // `if command.is_delete() { continue; }` stood here, and the half
1637 // of it that is right is the walk: a deletion introduces no
1638 // objects, `command.new` is the zero oid, and there is nothing for
1639 // `verify_connectivity` to reach. The `continue` carried
1640 // `gate.check` out with it — so **every deletion bypassed the
1641 // pre-receive gate entirely**.
1642 //
1643 // What that made unenforceable is not a corner: `allow_delete` is
1644 // the field whose whole purpose is refusing this push, and
1645 // `allowed_principal_ids` and the ref-name denylist were bypassable
1646 // the same way. A branch could be protected against a force-push,
1647 // visibly and correctly, and deleted outright by anyone with WRITE
1648 // — which is a strictly worse outcome than the force-push beside
1649 // it, and was invisible behind it because the force-push guard
1650 // worked.
1651 //
1652 // `gunnar-policy`'s protection gate has always had a
1653 // `UpdateKind::Delete` arm with its own refusal message, and
1654 // `tests/protection.rs` exercises `allow_delete` directly. The gate
1655 // was written for this and simply never handed one.
1656 //
1657 // Found by `gunnar-ui/uitest`'s `a-protected-branch-refuses-a-deletion`
1658 // going red while the force-push row beside it passed; pinned in
1659 // `gunnar-cli/tests/policy_mirror.rs::a_protection_rule_refuses_a_real_branch_deletion`,
1660 // which drives stock git over real ssh.
1661 if !command.is_delete() && batch.is_err() {
1662 if let Err(err) = verify_connectivity(&graph, &[command.new], &frontier) {
1663 tracing::warn!(target: GIT_TARGET, ref_name = %command.name, error = %err, "push failed connectivity");
1664 statuses[index] =
1665 RefStatus::Rejected(format!("missing necessary objects: {err}"));
1666 continue;
1667 }
1668 }
1669 if let Err(reason) = gate.check(command, facts) {
1670 statuses[index] = RefStatus::Rejected(reason);
1671 }
1672 }
1673
1674 crate::stage::end(_conn);
1675 if atomic && (already_refused || statuses.iter().any(|s| !s.is_ok())) {
1676 // One refusal fails the batch. Every other reference is reported
1677 // as refused too, or the client is told a lie about what landed.
1678 // `already_refused` carries in the refusals that never became a
1679 // command at all — a reference whose *name* this server will not
1680 // have (see [`UnnameableRef`]).
1681 for status in statuses.iter_mut() {
1682 if status.is_ok() {
1683 *status = RefStatus::Rejected("atomic transaction failed".to_owned());
1684 }
1685 }
1686 return Ok(statuses);
1687 }
1688
1689 // `edit_for` peels a new annotated-tag tip **once, here on the write
1690 // path**, while the objects the push just delivered are warm and the
1691 // connectivity walk above has already proven them present, and hands the
1692 // terminal commit to the store as the ref row's peeled column. That is
1693 // the write half of the stored peel: with it stored, `advertise_from` reads the
1694 // peeled oid straight out of the row and never re-peels, so every later
1695 // clone, fetch and `ls-remote` pays zero object reads per tag instead of
1696 // one. The graph is the same one `verify_connectivity` just used.
1697 let edits: Vec<(usize, RefCas<'_>)> = commands
1698 .iter()
1699 .enumerate()
1700 .filter(|(index, _)| statuses[*index].is_ok())
1701 .map(|(index, command)| Ok((index, edit_for(command, &graph)?)))
1702 .collect::<Result<_>>()?;
1703
1704 let _txn = crate::stage::enter("ref_txn");
1705 tracing::info!(
1706 target: GIT_TARGET,
1707 event = "git.receive_pack.updating",
1708 edits = edits.len(),
1709 atomic,
1710 "applying the reference updates"
1711 );
1712 let started = std::time::Instant::now();
1713 if atomic {
1714 // ONE call. `put_refs_cas` applies every edit or none and names the
1715 // first whose expected value did not match; a loop with a break
1716 // leaves what it already applied applied, which is the thing
1717 // `--atomic` promises does not happen.
1718 let batch: Vec<RefCas<'_>> = edits
1719 .iter()
1720 .map(|(_, edit)| RefCas {
1721 name: edit.name.clone(),
1722 old: edit.old,
1723 new: edit.new,
1724 peeled: edit.peeled.clone(),
1725 })
1726 .collect();
1727 if let Err(err) = self.git.put_refs_cas(&batch) {
1728 let reason = format!("atomic transaction failed: {err:#}");
1729 for (index, _) in &edits {
1730 statuses[*index] = RefStatus::Rejected(reason.clone());
1731 }
1732 }
1733 } else {
1734 for (index, edit) in edits {
1735 // A **one-element `put_refs_cas`**, not `update_ref`: both are
1736 // the contract's compare-and-swap-one-reference call and a
1737 // single-edit atomic batch has identical single-ref semantics —
1738 // it applies that one ref or rejects it, raising the same typed
1739 // `RefRejection::Cas` a lost race needs. `update_ref` cannot
1740 // carry a peel (its signature is `name/old/new`), and `put_refs`
1741 // would lose the CAS; `put_refs_cas` keeps the CAS **and** takes
1742 // the `RefCas::peeled` this edit computed, so the non-atomic path
1743 // stores a tag's peel exactly as the atomic path above does
1744 // (one implementation: one writer for both, not a peel-losing
1745 // second one).
1746 // Per-ref independence is preserved because each edit is its own
1747 // call — a rejection on one does not touch the others.
1748 if let Err(err) = self.git.put_refs_cas(std::slice::from_ref(&edit)) {
1749 // ── THE TYPED REJECTION, RECOVERED ──────────────────────
1750 //
1751 // This used to render `{err:#}` and the comment here said
1752 // so: "A LOST COMPARE-AND-SWAP AND A BROKEN DISK ARE THE
1753 // SAME STRING HERE … Nothing may recover the distinction by
1754 // matching on this text; the fix is a typed rejection on
1755 // the contract."
1756 //
1757 // The contract had `RefRejection` all along
1758 // (`git-storage-trait`); `put_refs_cas` raised it and
1759 // `update_ref` raised a bare `bail!`. That is fixed, so the
1760 // distinction is a `downcast_ref` rather than a substring
1761 // match — and this is deliberately NOT parsing the message,
1762 // which the old comment forbade in as many words.
1763 //
1764 // A lost race is rendered in git's own words so a client
1765 // can act on it; anything else keeps the full chain,
1766 // because a broken disk deserves every line it has.
1767 statuses[index] = match err.downcast_ref::<RefRejection>() {
1768 Some(RefRejection::Cas {
1769 name,
1770 expected,
1771 actual,
1772 }) => RefStatus::Rejected(format!(
1773 "cannot lock ref '{name}': is at {} but expected {}",
1774 render(actual),
1775 render(expected)
1776 )),
1777 Some(RefRejection::Locked { name }) => RefStatus::Rejected(format!(
1778 "cannot lock ref '{name}': held by another writer, try again"
1779 )),
1780 _ => RefStatus::Rejected(format!("{err:#}")),
1781 };
1782 }
1783 }
1784 }
1785 tracing::info!(
1786 target: GIT_TARGET,
1787 event = "git.receive_pack.updated",
1788 // Counted off `statuses`, which is what the client is about to be
1789 // told, rather than off `edits`, which is only what was attempted.
1790 // A transaction that lost its compare-and-swap turns an edit into a
1791 // refusal, and a log reporting the attempt would report a reference
1792 // as moved that did not (measure, never assert).
1793 refs_ok = statuses.iter().filter(|s| s.is_ok()).count(),
1794 refs_rejected = statuses.iter().filter(|s| !s.is_ok()).count(),
1795 elapsed_ms = started.elapsed().as_millis() as u64,
1796 "the reference updates are applied"
1797 );
1798
1799 Ok(statuses)
1800 }
1801
1802 fn write_report(
1803 &self,
1804 out: &mut dyn Write,
1805 outcome: &PushOutcome,
1806 sideband: bool,
1807 ) -> Result<()> {
1808 // The lines come from the shared grammar (`gunnar-sendpack`), which is
1809 // the same code `gunnar-client` parses them with. What stays here is
1810 // the framing and the sideband, which belong to this transport.
1811 //
1812 // The two lists are merged back into the order the client sent, using
1813 // the position [`UnnameableRef`] carries. A client zips the report
1814 // against its own command list — `gunnar-client` does, and so does
1815 // `git push`'s per-ref output — so a report that silently reordered
1816 // would attribute a refusal to the wrong reference.
1817 let mut statuses: Vec<(usize, gunnar_sendpack::RefStatus)> = Vec::new();
1818 // The nameable commands sit at the positions the unnameable ones left
1819 // free, in order, which is enough to recover the original interleaving
1820 // without threading a position through `RefCommand` as well.
1821 let taken: std::collections::BTreeSet<usize> =
1822 outcome.unnameable.iter().map(|r| r.position).collect();
1823 let mut free = (0..).filter(|position| !taken.contains(position));
1824 for (command, status) in outcome.commands.iter().zip(&outcome.statuses) {
1825 let position = free.next().expect("the position sequence is infinite");
1826 statuses.push((
1827 position,
1828 match status {
1829 RefStatus::Ok => gunnar_sendpack::RefStatus::ok(command.name.as_bytes()),
1830 RefStatus::Rejected(reason) => {
1831 gunnar_sendpack::RefStatus::rejected(command.name.as_bytes(), reason)
1832 }
1833 },
1834 ));
1835 }
1836 for refused in &outcome.unnameable {
1837 statuses.push((
1838 refused.position,
1839 gunnar_sendpack::RefStatus::rejected(
1840 echoable_name(&refused.name, refused.reason.len()),
1841 &refused.reason,
1842 ),
1843 ));
1844 }
1845 statuses.sort_by_key(|(position, _)| *position);
1846 let statuses: Vec<gunnar_sendpack::RefStatus> =
1847 statuses.into_iter().map(|(_, status)| status).collect();
1848 let mut report = Vec::new();
1849 for line in gunnar_sendpack::report::lines(
1850 outcome.unpack.as_ref().copied().map_err(String::as_str),
1851 &statuses,
1852 ) {
1853 gunnar_pktline::write_text(&mut report, &line)?;
1854 }
1855 gunnar_pktline::write_flush(&mut report)?;
1856
1857 if sideband {
1858 // The notice first, on band 2, so `git push` prints it as
1859 // `remote: …` ABOVE the per-ref lines — where a person reads before
1860 // the summary — and separately framed so a client that ignores band
1861 // 2 still parses the report untouched.
1862 if let Some(notice) = &self.config.notice {
1863 let mut line = notice.clone();
1864 if !line.ends_with('\n') {
1865 line.push('\n');
1866 }
1867 wire_io::progress(out, true, &line)?;
1868 }
1869 // The whole report, its terminating flush-pkt included, rides band
1870 // 1; the connection is then ended by an *unframed* flush. That is
1871 // what `git-receive-pack` 2.53 puts on the wire (captured
1872 // 2026-08-03), and a client reading a demultiplexed stream cannot
1873 // see the difference between the two flushes any other way.
1874 wire_io::sideband(out, BAND_PACK, &report)?;
1875 wire_io::flush(out)?;
1876 } else {
1877 out.write_all(&report)?;
1878 }
1879 Ok(())
1880 }
1881}
1882
1883/// The most of a refused reference name that can go into an `ng` line without
1884/// the line outgrowing a pkt-line.
1885///
1886/// A refused name is the one name on the wire that is **not** bounded by
1887/// anything gunnar chose: it arrived because a client sent it, and the only
1888/// ceiling it ever passed is the command packet's own 65 516 bytes. Echoing it
1889/// whole into `ng <name> <reason>\n` therefore does not fit whenever the name is
1890/// within a few dozen bytes of that ceiling, and `write_text` refuses the packet
1891/// rather than emitting a header that lies. The report then does not get
1892/// written at all — the session dies on the refusal path, which is precisely the
1893/// failure [`UnnameableRef`] exists to remove.
1894///
1895/// So a name too big to echo is truncated and **says so**, rather than costing
1896/// the client the whole report. A name this long cannot match anything the
1897/// client will keep anyway: every legal name is at most
1898/// [`gunnar_store::MAX_REF_COMPONENT_BYTES`] per component.
1899fn echoable_name(name: &[u8], reason_len: usize) -> Vec<u8> {
1900 const MARKER: &[u8] = b"\xe2\x80\xa6[truncated]";
1901 // `ng ` + name + ` ` + reason + `\n`.
1902 let overhead = 3 + 1 + reason_len + 1;
1903 let budget = gunnar_pktline::MAX_PAYLOAD.saturating_sub(overhead);
1904 if name.len() <= budget {
1905 return name.to_vec();
1906 }
1907 let keep = budget.saturating_sub(MARKER.len());
1908 let mut out = name[..keep].to_vec();
1909 out.extend_from_slice(MARKER);
1910 out
1911}
1912
1913/// The rules `FullName::new` used to run, plus the one the contract adds.
1914///
1915/// Two failures with one shape, because both produce the same `ng` line and a
1916/// caller that had to tell them apart would be re-deriving the difference from
1917/// a message: the name is not a legal reference name, or it is legal and is not
1918/// UTF-8 and therefore cannot be a `RefRow::name`. See
1919/// [`ReceivePack::parse_command`] for why the second is a recorded narrowing
1920/// rather than a bug.
1921fn validate_ref_name(name: &[u8]) -> std::result::Result<String, String> {
1922 gunnar_store::refname::validate(name).map_err(|err| match err {
1923 // The validator renders the offending name inside its own message and
1924 // the `ng` line already carries the name, so only the *rule* is taken.
1925 // A 60 000-byte name once produced a 120 105-byte packet, `write_text`
1926 // refused it, and the whole report failed to write. MEASURED
1927 // 2026-08-06.
1928 gunnar_store::Error::InvalidRefName { reason, .. } => reason.to_owned(),
1929 other => other.to_string(),
1930 })?;
1931 String::from_utf8(name.to_vec())
1932 .map_err(|_| "a reference name gunnar can store must be UTF-8".to_owned())
1933}
1934
1935/// One command as a compare-and-swap edit, with a tag's peeled target attached.
1936///
1937/// The three shapes `PreviousValue`/`Change` used to spell out collapse into
1938/// two `Option`s, because that is what the contract says and it says the same
1939/// thing: `old: None` is *"must not exist"* (a create) and `new: None` is a
1940/// delete. A non-create carries `command.old`, which is the client's claim
1941/// about what the reference holds and the only thing that makes this a race
1942/// the server can lose safely.
1943///
1944/// The fourth field, `peeled`, is the one this fn computes rather than copies:
1945/// an annotated tag's terminal commit, so the store persists it and the
1946/// advertisement never re-peels. It needs the object graph, which is why
1947/// this takes `graph` and returns a `Result` — a peel is a store read.
1948fn edit_for<'a>(command: &'a RefCommand, graph: &Graph<'_>) -> Result<RefCas<'a>> {
1949 // The peeled column, filled only for a create/update whose new tip is an
1950 // annotated tag. `peel` follows the tag chain to the first non-tag object
1951 // (a commit, in the common case of one tag over a commit) and reports the
1952 // tags it walked through: `tags` is non-empty **iff** the tip really was a
1953 // tag object, which is the only case git's `packed-refs` writes a `^peeled`
1954 // line for. A delete has no tip; a branch head or a lightweight tag peels to
1955 // itself with no tag walked, so both carry `None` and the store keeps no
1956 // peel — matching packed-refs exactly. This is the one peel per new tag the
1957 // whole stored-peel fix costs, paid once at ingest instead of on every
1958 // advertisement.
1959 //
1960 // The oid is **owned** here (`RefCas::peeled` is `Option<Vec<u8>>`): unlike
1961 // `old`/`new`, which are slices of the command line the caller still holds,
1962 // the peel is derived from the store and borrows nothing.
1963 let peeled = if command.is_delete() {
1964 None
1965 } else {
1966 let peeled = graph.peel(&command.new)?;
1967 (!peeled.tags.is_empty()).then(|| peeled.id.as_bytes().to_vec())
1968 };
1969 Ok(RefCas {
1970 name: command.name.clone(),
1971 old: (!command.is_create()).then(|| command.old.as_bytes()),
1972 new: (!command.is_delete()).then(|| command.new.as_bytes()),
1973 peeled,
1974 })
1975}
1976
1977#[cfg(test)]
1978mod tests {
1979 use super::*;
1980
1981 /// **[`echoable_name`] is a fix, not a guard, and it is not reachable from
1982 /// the wire today. Both facts, with the arithmetic that decides them.**
1983 ///
1984 /// A command line is one pkt-line, so the longest reference name a client
1985 /// can physically send is `MAX_PAYLOAD - (40 + 1 + 40 + 1 + 1)` =
1986 /// **65 433** bytes. The `ng` line it comes back in is
1987 /// `"ng " + name + " " + reason + "\n"`, so the name fits whenever the
1988 /// reason is at most `65 511 - 65 433` = **78** bytes. Every reason
1989 /// [`ReceivePack::parse_command`] can produce is `"funny refname: "` plus
1990 /// one of the validator's fixed strings, and the longest of those makes
1991 /// **51**. 51 < 78, so no push can currently reach the truncation.
1992 ///
1993 /// That margin is 27 bytes of coincidence between two constants nobody
1994 /// holds together. One longer refusal string and the report becomes
1995 /// unwritable — `write_text` refuses the packet, `write_report` returns
1996 /// `Err`, and the session dies **on the refusal path**, which is the exact
1997 /// failure [`UnnameableRef`] exists to remove. So the truncation stays, and
1998 /// this is where it is proven, since the wire cannot prove it.
1999 #[test]
2000 fn a_refused_name_too_long_to_echo_is_truncated_rather_than_costing_the_report() {
2001 let name = vec![b'x'; gunnar_pktline::MAX_PAYLOAD - 40];
2002 let reason = "funny refname: ".to_owned() + &"y".repeat(200);
2003
2004 let echoed = echoable_name(&name, reason.len());
2005
2006 // The property that matters, asserted FIRST so a red run names the
2007 // consequence and not a proxy for it: the line frames.
2008 let mut line = b"ng ".to_vec();
2009 line.extend_from_slice(&echoed);
2010 line.push(b' ');
2011 line.extend_from_slice(reason.as_bytes());
2012 let mut out = Vec::new();
2013 gunnar_pktline::write_text(&mut out, &line).unwrap_or_else(|err| {
2014 panic!(
2015 "the `ng` line for a refused {}-byte name does not fit in a pkt-line ({err}), \
2016 so the whole report fails to write and the session dies on the refusal path",
2017 name.len()
2018 )
2019 });
2020 assert!(
2021 echoed.ends_with("…[truncated]".as_bytes()),
2022 "the name was cut to fit and the client is not told; a truncated name that \
2023 does not say so is a name the client cannot match against its own list"
2024 );
2025
2026 // And the acceptance half: a name that fits is not touched.
2027 let short = b"refs/heads/main".to_vec();
2028 assert_eq!(
2029 echoable_name(&short, reason.len()),
2030 short,
2031 "a name that fits was truncated anyway"
2032 );
2033 }
2034}