Skip to main content

alkgit/
push.rs

1//! The receive-pack machinery (ADR-013 §3–11, as amended by ADR-020
2//! §1): the request grammar and the push state machine — the parse
3//! layer and the orchestration of one push invocation, mirroring
4//! [`parse_fetch_args`](crate::parse_fetch_args) /
5//! [`serve_fetch_round`](crate::serve_fetch_round).
6//!
7//! The grammar ([`parse_push_request`]) is substrate-agnostic: it
8//! consumes the pkt-line strings the substrates' readers produce — the
9//! same [`Request`](crate::Request)-shaped input the fetch parse
10//! takes, where `pre_lines` are the pkt-lines before the request's
11//! terminating flush (the command section: `<old> <new> <ref>` lines,
12//! caps NUL-attached on the first line only) and `args` are the
13//! pkt-lines after the command flush (the push-options section,
14//! present only when the push-options cap was negotiated). No wire
15//! writes; the parse stops at the command flush — the pack tail is
16//! the machine's (the raw-tail handoff).
17//!
18//! The machine ([`serve_receive_pack`]) is the ADR-013 §7 observed
19//! order over one parsed request, `parsed request → prepare →
20//! validate → CAS → report`, and it stays substrate-agnostic outside
21//! two tail disciplines ([`PackTailDiscipline`] — one core, the
22//! duplex peek and the stateless EOF twin; the stateless POST body's
23//! EOF is unambiguous, ADR-020 §1). Everything on a [`Session`]
24//! beyond the machine is transport-mapping shaped: a returned
25//! [`SubstrateError`] error-bands + closes on duplex and http-maps on
26//! stateless (ADR-005 §4, the entries' mapping).
27
28use std::collections::{HashMap, HashSet};
29use std::io;
30use std::sync::Arc;
31
32use futures::io::AsyncReadExt;
33use gix_hash::ObjectId;
34use tokio::sync::Semaphore;
35
36use crate::backend::{GitPackIngest, GitRefs};
37use crate::duplex::{RawTail, Request};
38use crate::fetch::{storage_to_session_error, ADVERTISED_OBJECT_FORMATS};
39use crate::limits::Limits;
40use crate::pack_source::{bridge_pack_source, PackSourceBridge};
41use crate::refname::validate_ref_name;
42use crate::registry::RepoRecord;
43use crate::seam::{PushOptions, RefOutcome, RefUpdate};
44use crate::substrate::SubstrateError;
45use crate::v2::PktLineWriter;
46
47/// The parsed receive-pack request (ADR-013 §3, ADR-020 §1):
48///
49/// - `commands` — the command lines (`<old> <new> <ref>`) resolved to
50///   the zero-id shapes: `old=zero` = create ([`RefUpdate::expected`]
51///   `None`), `new=zero` = delete ([`RefUpdate::new`] `None`), both
52///   non-zero = an ordinary update. **An empty command set is the
53///   client-side nothing-to-do — a distinct shape, not an error**
54///   (ADR-013 §3: the machine replies flush, no report; see
55///   [`PushRequest::is_nothing_to_do`]). Duplicate ref commands fail
56///   closed at parse.
57/// - `capabilities` — the client's selection (the parse accepts only
58///   the advertised set plus `quiet`/`agent`; unknown and duplicate
59///   capabilities fail closed).
60/// - `options` — the parsed push-options section ([`PushOptions`],
61///   the `(key, value)` pairs verbatim, repeated keys preserved);
62///   `None` when the cap was not negotiated (v1: the gate is off —
63///   never negotiated, the section is never parsed, the pack follows
64///   the command flush directly, ADR-013 §11).
65#[derive(Clone, Debug, Default, PartialEq, Eq)]
66pub struct PushRequest {
67    /// The command-derived ref updates, request order (the prepare/CAS
68    /// inputs; ADR-013 §6–7).
69    pub commands: Vec<RefUpdate>,
70    /// The client's capability selection (ADR-013 §2; the accepted
71    /// set is the advertised set plus `quiet`/`agent`).
72    pub capabilities: PushCapabilities,
73    /// The negotiated push-options section (ADR-013 §11) — `None`
74    /// until the gate opens.
75    pub options: Option<PushOptions>,
76}
77
78impl PushRequest {
79    /// The client-side nothing-to-do shape (ADR-013 §3): an immediate
80    /// flush with no commands — the machine replies flush, no report.
81    pub fn is_nothing_to_do(&self) -> bool {
82        self.commands.is_empty()
83    }
84}
85
86/// The client's capability selection (ADR-013 §2, the parse side of
87/// the served set): every bool is the corresponding cap having been
88/// selected on the request's first command line (NUL-attached caps).
89///
90/// - `quiet` — accepted, honored by omission (we never send progress;
91///   upstream clients select it habitually, push-captures.md §quiet
92///   semantics). Not advertised.
93/// - `agent` — recorded, uninterpreted.
94/// - `push_options` — the client selected `push-options` (only
95///   possible when the gate advertised it; v1: off, so selecting it
96///   fails closed).
97/// - The object-format check is validated at parse time against the
98///   pinned advertisement list (ADR-013 pins the push-side check to
99///   the fetch rule) and is not recorded here.
100#[derive(Clone, Debug, Default, PartialEq, Eq)]
101pub struct PushCapabilities {
102    /// `report-status` — the per-ref status report is wanted.
103    pub report_status: bool,
104    /// `report-status-v2` — the v2 report shape (identical for plain
105    /// pushes: option lines exist only when the server emits them,
106    /// ADR-013 §2).
107    pub report_status_v2: bool,
108    /// `delete-refs` — deletes are in scope (v1 allows deletes
109    /// unconditionally, ADR-013 §7).
110    pub delete_refs: bool,
111    /// `side-band-64k` — the report rides band-1 sideband chunks
112    /// (ADR-013 §8).
113    pub side_band_64k: bool,
114    /// `atomic` — the one-transaction CAS home makes atomic push
115    /// correct (ADR-013 §10).
116    pub atomic: bool,
117    /// `ofs-delta` — accepted, honored by omission (the server never
118    /// sends a report leg that depends on it).
119    pub ofs_delta: bool,
120    /// `push-options` — the client selected the negotiated cap
121    /// (v1: the gate is off, fail closed).
122    pub push_options: bool,
123    /// `quiet` — accepted, honored by omission.
124    pub quiet: bool,
125    /// `agent=<value>` — recorded, uninterpreted.
126    pub agent: Option<String>,
127}
128
129/// Parses a receive-pack request (ADR-013 §3, as amended by ADR-020
130/// §1; grammar-exact against push-captures.md §Request):
131///
132/// - **Command lines**: `<old> <new> <ref>` — two oids in the
133///   negotiated object format (sha1's 40 hex by default; the sha256
134///   format's 64 hex under the feature) and the ref name, one command
135///   per line. Capabilities ride NUL-attached on the **first command
136///   line only** (observed: a leading space after the NUL); a caps
137///   string on any later line fails closed. The ref name is parsed
138///   structurally only — per-ref validation is the machine's
139///   (ADR-013 §7's order, `validate_ref_name` after unpack).
140/// - **Shallow rejection up front** (ADR-013 §4): `shallow <sha>`
141///   lines anywhere before the pack are session errors — the clear
142///   pkt-line error the entries surface before any pack byte is read.
143/// - **The nothing-to-do shape** (ADR-013 §3): an immediate flush —
144///   [`PushRequest::is_nothing_to_do`], a distinct shape, not an error.
145/// - **The object-format check** (ADR-013 pins the push-side check to
146///   the fetch rule, transport.md §fetch): validated against the
147///   advertised list; absent assumes the pinned sha1.
148/// - **The push-options section** (ADR-013 §11): parsed only when the
149///   `push-options` cap was negotiated under the gate — bare pkt-lines
150///   parsed to [`PushOptions`] verbatim; when not negotiated the parse
151///   never consumes post-flush lines and any stray one fails closed
152///   (the flush boundary is authoritative: the section's absence must
153///   not be parsed as commands).
154///
155/// Unknown, duplicate, or out-of-grammar request lines are session
156/// errors — fail closed (a client selects only from our advertisement,
157/// so an unknown capability is a malformed or hostile request).
158///
159/// Contract stamp: ADR-013 §2–4, §11 (the request grammar, shallow
160/// policy, push-options); ADR-020 §1 (the parse stops at the command
161/// flush); push-captures.md §Request, §Advertisement.
162pub fn parse_push_request(
163    request: &Request,
164    push_options_negotiated: bool,
165) -> Result<PushRequest, SubstrateError> {
166    if request.pre_lines.is_empty() {
167        return Ok(PushRequest::default());
168    }
169
170    let mut commands: Vec<RefUpdate> = Vec::new();
171    let mut seen_refs: HashSet<String> = HashSet::new();
172    let mut capabilities = PushCapabilities::default();
173    let mut object_format: Option<&str> = None;
174
175    for (index, line) in request.pre_lines.iter().enumerate() {
176        let (command_text, caps_text) = match line.split_once('\0') {
177            Some((command_text, caps_text)) => {
178                if index != 0 {
179                    return Err(SubstrateError::Protocol(format!(
180                        "push: line {index} carries a caps string — caps ride the first command line only"
181                    )));
182                }
183                (command_text, caps_text)
184            }
185            None => (line.as_str(), ""),
186        };
187        if index == 0 {
188            parse_capabilities(
189                caps_text,
190                push_options_negotiated,
191                &mut capabilities,
192                &mut object_format,
193            )?;
194        }
195        if command_text == "shallow" || command_text.starts_with("shallow ") {
196            return Err(SubstrateError::Protocol(
197                "push: shallow update is not served — rejected up front (ADR-013 §4)".to_owned(),
198            ));
199        }
200        let command = parse_command(command_text, object_format_hex_size(object_format))?;
201        if !seen_refs.insert(command.name.clone()) {
202            return Err(SubstrateError::Protocol(format!(
203                "push: duplicate ref commands for {:?} in one push — fail closed",
204                command.name
205            )));
206        }
207        commands.push(command);
208    }
209
210    let options = if capabilities.push_options {
211        Some(parse_push_options(&request.args)?)
212    } else if !request.args.is_empty() {
213        return Err(SubstrateError::Protocol(
214            "push: post-command lines where no push-options section was negotiated — fail closed"
215                .to_owned(),
216        ));
217    } else {
218        None
219    };
220
221    Ok(PushRequest {
222        commands,
223        capabilities,
224        options,
225    })
226}
227
228/// Parses line 1's NUL-attached caps string (push-captures.md
229/// §Advertisement — the client's subset of the served set, observed
230/// with a leading space after the NUL): only the advertised set plus
231/// `quiet`/`agent` are accepted; unknown or duplicate capabilities
232/// fail closed. The `object-format=` value validates against the
233/// advertisement list (ADR-013 pins the push-side check to the fetch
234/// rule) and rides `object_format` out so the command oids' hex size
235/// follows the negotiated format.
236///
237/// Contract stamp: ADR-013 §2 (the served set), §4 (the object-format
238/// check rides the fetch rule); push-captures.md §Advertisement.
239fn parse_capabilities<'a>(
240    caps_text: &'a str,
241    push_options_negotiated: bool,
242    parsed: &mut PushCapabilities,
243    object_format: &mut Option<&'a str>,
244) -> Result<(), SubstrateError> {
245    for token in caps_text.split_whitespace() {
246        match token {
247            "report-status" => set_flag(&mut parsed.report_status, token)?,
248            "report-status-v2" => set_flag(&mut parsed.report_status_v2, token)?,
249            "delete-refs" => set_flag(&mut parsed.delete_refs, token)?,
250            "side-band-64k" => set_flag(&mut parsed.side_band_64k, token)?,
251            "atomic" => set_flag(&mut parsed.atomic, token)?,
252            "ofs-delta" => set_flag(&mut parsed.ofs_delta, token)?,
253            "quiet" => set_flag(&mut parsed.quiet, token)?,
254            "push-options" => {
255                if !push_options_negotiated {
256                    return Err(SubstrateError::Protocol(
257                        "push: push-options was not advertised — the selection is out of grammar"
258                            .to_owned(),
259                    ));
260                }
261                set_flag(&mut parsed.push_options, token)?;
262            }
263            other => {
264                if let Some(agent) = other.strip_prefix("agent=") {
265                    if agent.is_empty() {
266                        return Err(SubstrateError::Protocol(format!(
267                            "push: {other:?} is an invalid agent capability line"
268                        )));
269                    }
270                    if parsed.agent.is_some() {
271                        return Err(SubstrateError::Protocol(format!(
272                            "push: duplicate capability {other:?}"
273                        )));
274                    }
275                    parsed.agent = Some(agent.to_owned());
276                } else if let Some(format) = other.strip_prefix("object-format=") {
277                    if object_format.is_some() {
278                        return Err(SubstrateError::Protocol(
279                            "push: repeated object-format capability".to_owned(),
280                        ));
281                    }
282                    if !ADVERTISED_OBJECT_FORMATS.contains(&format) {
283                        return Err(SubstrateError::Protocol(format!(
284                            "push: object-format {format:?} was not advertised — mismatch rejected (transport.md §fetch)"
285                        )));
286                    }
287                    *object_format = Some(format);
288                } else {
289                    return Err(SubstrateError::Protocol(format!(
290                        "push: unknown capability {other:?} — fail closed"
291                    )));
292                }
293            }
294        }
295    }
296    Ok(())
297}
298
299/// Sets one boolean capability; a second occurrence is a duplicate —
300/// fail closed (the fetch parse's posture).
301fn set_flag(flag: &mut bool, token: &str) -> Result<(), SubstrateError> {
302    if *flag {
303        return Err(SubstrateError::Protocol(format!(
304            "push: duplicate capability {token:?}"
305        )));
306    }
307    *flag = true;
308    Ok(())
309}
310
311/// The oid hex size the request's negotiated object format carries:
312/// sha1's 40 (the pinned default when the cap is absent) or the
313/// sha256 format's 64 (reachable only when the feature is enabled —
314/// the advertisement list decides admission, not this size).
315fn object_format_hex_size(object_format: Option<&str>) -> usize {
316    if object_format == Some("sha256") {
317        64
318    } else {
319        40
320    }
321}
322
323/// Parses one command line `<old> <new> <ref>` (the upstream-faithful
324/// split: two oids, then the ref name is the rest of the line) into
325/// the zero-id shapes — `old=zero` = create
326/// ([`RefUpdate::expected`] `None`), `new=zero` = delete
327/// ([`RefUpdate::new`] `None`), both non-zero = an ordinary update.
328/// Bad hex, a missing field, an empty ref name, and the both-zero
329/// shape are session errors — fail closed.
330///
331/// Contract stamp: ADR-013 §3 (the command grammar, the zero-id
332/// create/delete shapes); push-captures.md §Request (deletes:
333/// old=current new=zero; creates: old=zero).
334fn parse_command(line: &str, hex_size: usize) -> Result<RefUpdate, SubstrateError> {
335    let mut fields = line.splitn(3, ' ');
336    let old_hex = fields.next().unwrap_or_default();
337    let new_hex = fields.next().unwrap_or_default();
338    let name = fields.next().unwrap_or_default();
339    if old_hex.is_empty() || new_hex.is_empty() || name.is_empty() {
340        return Err(SubstrateError::Protocol(format!(
341            "push: {line:?} is a malformed command line — <old> <new> <ref> expected"
342        )));
343    }
344    let old = parse_oid(old_hex, hex_size, "old", line)?;
345    let new = parse_oid(new_hex, hex_size, "new", line)?;
346    let update = match (old.is_null(), new.is_null()) {
347        (true, true) => {
348            return Err(SubstrateError::Protocol(format!(
349                "push: {line:?} carries the zero id on both sides — out of grammar"
350            )))
351        }
352        (true, false) => RefUpdate {
353            name: name.to_owned(),
354            expected: None,
355            new: Some(new),
356        },
357        (false, true) => RefUpdate {
358            name: name.to_owned(),
359            expected: Some(old),
360            new: None,
361        },
362        (false, false) => RefUpdate {
363            name: name.to_owned(),
364            expected: Some(old),
365            new: Some(new),
366        },
367    };
368    Ok(update)
369}
370
371/// Parses one oid of the negotiated hex size; a size mismatch or bad
372/// hex is a malformed command line — fail closed.
373fn parse_oid(hex: &str, size: usize, what: &str, line: &str) -> Result<ObjectId, SubstrateError> {
374    if hex.len() != size {
375        return Err(SubstrateError::Protocol(format!(
376            "push: {line:?} carries a malformed {what} oid {hex:?} — {size} hex characters expected"
377        )));
378    }
379    ObjectId::from_hex(hex.as_bytes()).map_err(|error| {
380        SubstrateError::Protocol(format!(
381            "push: {line:?} carries a malformed {what} oid {hex:?}: {error}"
382        ))
383    })
384}
385
386/// Parses the negotiated push-options section (ADR-013 §11): bare
387/// pkt-lines between the command flush and the pack, surfaced as the
388/// `(key, value)` pairs verbatim and un-interpreted (the upstream
389/// convention is `<key>=<value>`; a line without `=` is a key with an
390/// empty value), repeated keys preserved. An empty line is malformed —
391/// fail closed.
392///
393/// Contract stamp: ADR-013 §11 (the section's grammar; verbatim,
394/// un-interpreted); push-captures.md §Request.
395fn parse_push_options(lines: &[String]) -> Result<PushOptions, SubstrateError> {
396    let mut pairs = Vec::with_capacity(lines.len());
397    for line in lines {
398        if line.is_empty() {
399            return Err(SubstrateError::Protocol(
400                "push: an empty push-option line is out of grammar".to_owned(),
401            ));
402        }
403        let pair = match line.split_once('=') {
404            Some((key, value)) => (key.to_owned(), value.to_owned()),
405            None => (line.to_owned(), String::new()),
406        };
407        pairs.push(pair);
408    }
409    Ok(PushOptions { pairs })
410}
411
412/// The all-refs denial of an `unpack ng` leg (ADR-013 §8, the observed
413/// raw-stdio shape): a pack-level failure reports `ng <ref> unpacker
414/// error` for every ref — no per-ref reason survives a failed unpack.
415/// Constant because the reasons clients display must be stable.
416///
417/// Contract stamp: ADR-013 §8 (per-ref `unpacker error` on pack-level
418/// failure); push-captures.md §Raw stdio (corrupt pack), §Pack-read
419/// ordering (definitive unpack-first order).
420pub const UNPACKER_ERROR_REASON: &str = "unpacker error";
421
422/// The aborted-transaction denial of an atomic push (ADR-013 §10,
423/// observed live): on any denial of the whole-transaction CAS, the
424/// refs that would otherwise have succeeded report
425/// `ng <ref> atomic push failure` while the denied refs **keep their
426/// own reasons** (captured: `ng refs/heads/dev deletion prohibited` +
427/// `ng refs/heads/main atomic push failure` in one report). It maps
428/// twice — inside the CAS (the outcome data) and on the machine's
429/// name-validation abort branch — where the same upstream shape holds:
430/// a name the validation rejects keeps its `funny refname`,
431/// the valid updates report this.
432///
433/// Contract stamp: ADR-013 §10 (the atomic-abort mapping, verbatim);
434/// push-captures.md §Status report (the captured pair); ADR-013 §7 (a
435/// rejected refname keeps `funny refname`, the whole push aborts).
436pub const ATOMIC_PUSH_FAILURE_REASON: &str = "atomic push failure";
437
438const PACK_HEADER_BYTES: usize = 4;
439
440/// The pack-tail discipline the machine serves (ADR-020 §1 — one
441/// state-machine core, two tail disciplines): the two substrates
442/// differ only in how the pack's absence is knowable below the
443/// command flush.
444///
445/// - On a **duplex** transport the stream has no client-controlled
446///   EOF below the flush (the client keeps its write side open until
447///   the report — ADR-020 §3): the machine runs the **bounded peek**
448///   within [`Limits::packless_peek`], timeout-classifying it (the
449///   four ordering rules; rule (i) is already done by the grammar —
450///   the options section was consumed from the request before the
451///   tail ever surrenders), and after a packless report the tail
452///   drains to EOF so the session's close is clean, never a
453///   mid-stream RST (rule (iv)).
454/// - On the **stateless** POST body EOF is unambiguous: the same
455///   classification runs EOF-driven instead of timeout-driven (EOF at
456///   the tail's start = the zero-byte packless case; EOF after a
457///   partial header = rule (iii)'s malformed-push error), and no
458///   drain runs — the body is either consumed or ended.
459///
460/// Contract stamp: ADR-020 §1 (the four ordering rules verbatim), §3
461/// (the client's write-side-open shape); ADR-013 §3 (the tail's
462/// unframed raw handoff); ADR-009 (the peek is a named budget kind —
463/// `Limits::packless_peek` — never ambient behavior).
464#[derive(Clone, Copy, Debug, PartialEq, Eq)]
465pub enum PackTailDiscipline {
466    /// A duplex transport's tail: the bounded peek window
467    /// ([`Limits::packless_peek`]), drained after a packless report.
468    Duplex,
469    /// A stateless POST body's tail: EOF-driven, no drain.
470    Stateless,
471}
472
473impl PackTailDiscipline {
474    /// The peek's wall-clock bound for this discipline: the named
475    /// [`Limits::packless_peek`] window on duplex; unbounded (EOF
476    /// decides) on stateless.
477    fn peek_window(self, limits: &Limits) -> Option<std::time::Duration> {
478        match self {
479            PackTailDiscipline::Duplex => Some(limits.packless_peek),
480            PackTailDiscipline::Stateless => None,
481        }
482    }
483
484    /// Whether the packless path drains the tail after the report
485    /// (ADR-020 §1 rule (iv) — the duplex close's clean-close
486    /// discipline; the stateless body needs nothing).
487    fn drains_packless(self) -> bool {
488        matches!(self, PackTailDiscipline::Duplex)
489    }
490
491    /// The peek's classification name for the machine's error/trace
492    /// surface.
493    fn discipline_name(self) -> &'static str {
494        match self {
495            PackTailDiscipline::Duplex => "duplex",
496            PackTailDiscipline::Stateless => "stateless",
497        }
498    }
499}
500
501/// What the bounded peek observed below the command flush (ADR-020
502/// §1's classification, the machine's branch point).
503enum PackObservation {
504    /// A full [`PACK_HEADER_BYTES`]-byte header: ingestion follows,
505    /// the observed bytes prepend the bridged tail (the POC's
506    /// reassembly shape — the peeked bytes are the stream's first
507    /// bytes, never re-read or lost).
508    Header(Vec<u8>),
509    /// Zero pack bytes at the peek's boundary — the window timed out
510    /// (duplex; rule (ii)) or the stream ended (the EOF twin, both
511    /// disciplines): the packless, CAS-only path.
512    Packless,
513    /// A partial header byte string at the boundary: the malformed-
514    /// push session error — never a silent packless downgrade
515    /// (rule (iii)).
516    Malformed(usize),
517}
518
519/// The peek's boundary classification shared by the two twin
520/// disciplines (ADR-020 §1 rules (ii)/(iii)). Zero observed bytes is
521/// the packless path; anything partial is malformed — a half-sent
522/// pack cannot be re-interpreted as absence.
523fn packless_or_malformed(observed: usize) -> PackObservation {
524    if observed == 0 {
525        PackObservation::Packless
526    } else {
527        PackObservation::Malformed(observed)
528    }
529}
530
531/// Reads up to [`PACK_HEADER_BYTES`] bytes off the raw tail, bounded by
532/// `window` when the discipline carries one (the duplex peek's window
533/// bounds the whole gather, bytes observed so far survive the timeout
534/// into [`PackObservation::Malformed`]); an early stream end shortens
535/// the gather the same way on both disciplines — EOF with zero observed
536/// bytes is packless, EOF after a partial header is malformed (the
537/// client half-closing after a packless push is served without waiting
538/// the window out — ADR-020 §3's client shape). A tail io error is the
539/// session's terminal error.
540///
541/// The observed bytes are gathered byte-exactly and returned with the
542/// [`PackObservation::Header`] outcome, so the bridge prepends them
543/// with zero re-reads.
544///
545/// Contract stamp: ADR-020 §1 (the bounded peek, rules (ii)/(iii)),
546/// §3 (the client's waiting shape); ADR-009 (the named window — the
547/// only new budget kind of wave 3); push-captures.md (an ordinary
548/// push's pack header is immediate, a packless client already
549/// waiting).
550async fn peek_pack_header<R>(
551    tail: &mut RawTail<R>,
552    window: Option<std::time::Duration>,
553) -> Result<PackObservation, SubstrateError>
554where
555    R: futures::io::AsyncRead + Unpin,
556{
557    let deadline = window.map(|window| tokio::time::Instant::now() + window);
558    let mut observed = Vec::with_capacity(PACK_HEADER_BYTES);
559    let mut scratch = [0u8; 1];
560    loop {
561        if observed.len() == PACK_HEADER_BYTES {
562            return Ok(PackObservation::Header(observed));
563        }
564        let read = tail.read(&mut scratch);
565        let read = match deadline {
566            Some(deadline) => match tokio::time::timeout_at(deadline, read).await {
567                Ok(outcome) => outcome,
568                Err(_elapsed) => return Ok(packless_or_malformed(observed.len())),
569            },
570            None => read.await,
571        };
572        match read {
573            Ok(0) => return Ok(packless_or_malformed(observed.len())),
574            Ok(n) => observed.extend_from_slice(&scratch[..n]),
575            Err(error) => return Err(SubstrateError::Io(error)),
576        }
577    }
578}
579
580/// Drains the duplex tail to EOF after a packless report (ADR-020 §1
581/// rule (iv)): the client keeps its write side open until the report
582/// (§3) and closes after it, so the drain ends promptly — with the
583/// stream read dry the session's close is clean, never a mid-stream
584/// RST. A drain error is terminal (the transport broke closing). A
585/// client that never closes is the entries' wall clock's victim, not
586/// the machine's (the drain hangs on their session budget — the
587/// machine has no wall clock of its own, ADR-009's enforcement split).
588///
589/// Contract stamp: ADR-020 §1 rule (iv), §3; ADR-005 §4 (io terminal);
590/// ADR-009 (the wall clock is the entries').
591async fn drain_pack_tail<R>(tail: &mut RawTail<R>) -> Result<(), SubstrateError>
592where
593    R: futures::io::AsyncRead + Unpin,
594{
595    let mut scratch = vec![0u8; 8192];
596    loop {
597        match tail.read(&mut scratch).await {
598            Ok(0) => return Ok(()),
599            Ok(_) => continue,
600            Err(error) => return Err(SubstrateError::Io(error)),
601        }
602    }
603}
604
605/// Writes the status report when the client selected one (ADR-013 §8 —
606/// `report-status`/`report-status-v2`; the shape is identical for both,
607/// v2 adds option lines only when the server emits them, and v1 emits
608/// none). Without the selection: nothing — no report, no flush (real
609/// receive-pack's `report()` returns before any byte; the session ends
610/// quietly). The framing (band-1 wrapped or bare) is the report
611/// writer's, selected by the client's `side-band-64k`.
612async fn write_report_maybe(
613    writer: &mut impl PktLineWriter,
614    unpack: &Result<(), String>,
615    outcomes: &[RefOutcome],
616    sideband: bool,
617    wants_report: bool,
618) -> Result<(), SubstrateError> {
619    if !wants_report {
620        return Ok(());
621    }
622    crate::v0::write_status_report(writer, unpack, outcomes, sideband).await
623}
624
625/// Places the CAS's per-ref [`RefOutcome`]s over the request-order
626/// outcome slots the name validation left open (ADR-013 §8: the report
627/// is in request order; the reasons frame verbatim — a stale ref's
628/// server-chosen text and the atomic-abort mapping are the CAS's data,
629/// never rewritten). A valid slot the CAS's return carries no outcome
630/// for is fail-closed: the slot reports an error, never a silent ok.
631fn merge_cas_outcomes(outcomes: &mut [RefOutcome], cas: Vec<RefOutcome>) {
632    let mut by_name: HashMap<String, RefOutcome> = cas
633        .into_iter()
634        .map(|outcome| (outcome.name.clone(), outcome))
635        .collect();
636    for outcome in outcomes.iter_mut() {
637        if outcome.result.is_ok() {
638            *outcome = by_name.remove(&outcome.name).unwrap_or_else(|| RefOutcome {
639                name: outcome.name.clone(),
640                result: Err(format!(
641                    "the ref CAS returned no outcome for {}; fail closed",
642                    outcome.name
643                )),
644            });
645        }
646    }
647}
648
649/// The name-validate → CAS → report leg (ADR-013 §7–8, the observed
650/// unpack-first-then-per-ref order — this leg runs only after the
651/// unpack leg succeeded; ADR-020 §1's packless path runs it on the
652/// parsed updates with no prepare call):
653///
654/// 1. **Per-ref name validation** ([`validate_ref_name`], ADR-013
655///    §9): a rejected ref reports `ng <ref> funny refname` and is
656///    **excluded from the CAS call** — the backend never sees an
657///    invalid name (ADR-013 §9; the reason is the pinned observed
658///    text).
659/// 2. **The single CAS home**
660///    ([`GitRefs::apply_updates`](crate::GitRefs::apply_updates),
661///    ADR-013 §7): the valid updates in request order under one
662///    transaction; `atomic` is the client's selected cap (ADR-013
663///    §10). A `StorageError` is the session error (the
664///    storage-to-session mapping); denials are outcomes.
665/// 3. **The atomic abort's machine-side branch** (ADR-013 §10, the
666///    live-captured shape): when `atomic` was negotiated and the name
667///    validation rejected any ref, the transaction aborts whole — no
668///    CAS call runs at all (a partial apply is precisely what atomic
669///    forbids); the rejected refs keep `funny refname`, the valid ones
670///    report [`ATOMIC_PUSH_FAILURE_REASON`].
671///
672/// The v1 policy leg is CAS-only: deletes are allowed and non-FF
673/// updates are allowed (ADR-013 §7 — force-push is the client's
674/// explicit choice, CAS exact-match decides).
675///
676/// Contract stamp: ADR-013 §7 (the order, the CAS-only policy), §8
677/// (the report's request order + verbatim reasons), §9 (the name
678/// check's home, exclusion from CAS), §10 (the abort mapping);
679/// push-captures.md §CAS / race semantics, §Status report; ADR-020 §1
680/// (the packless path runs this on the parsed updates).
681async fn apply_and_report(
682    writer: &mut impl PktLineWriter,
683    updates: &[RefUpdate],
684    refs: Arc<dyn GitRefs>,
685    repo: &RepoRecord,
686    atomic: bool,
687    sideband: bool,
688    wants_report: bool,
689) -> Result<(), SubstrateError> {
690    let mut outcomes: Vec<RefOutcome> = Vec::with_capacity(updates.len());
691    let mut valid: Vec<RefUpdate> = Vec::with_capacity(updates.len());
692    let mut rejected = false;
693    for update in updates {
694        match validate_ref_name(&update.name) {
695            Ok(()) => {
696                outcomes.push(RefOutcome {
697                    name: update.name.clone(),
698                    result: Ok(()),
699                });
700                valid.push(update.clone());
701            }
702            Err(reason) => {
703                rejected = true;
704                outcomes.push(RefOutcome {
705                    name: update.name.clone(),
706                    result: Err(reason),
707                });
708            }
709        }
710    }
711    if atomic && rejected {
712        for outcome in &mut outcomes {
713            if outcome.result.is_ok() {
714                outcome.result = Err(ATOMIC_PUSH_FAILURE_REASON.to_owned());
715            }
716        }
717    } else if !valid.is_empty() {
718        let cas = refs
719            .apply_updates(repo, valid, atomic)
720            .await
721            .map_err(storage_to_session_error)?;
722        merge_cas_outcomes(&mut outcomes, cas);
723    }
724    write_report_maybe(writer, &Ok(()), &outcomes, sideband, wants_report).await
725}
726
727/// Serves one receive-pack invocation (ADR-013 §3–8, as amended by
728/// ADR-020 §1) — the push state machine, the substrate-agnostic core
729/// the serving entries call with the parsed request's raw tail.
730///
731/// The tail is the raw-tail handoff the substrate surrenders after the
732/// command flush
733/// ([`Session::take_raw_tail`](crate::Session::take_raw_tail) on
734/// duplex, [`StatelessRequest::take_raw_tail`](crate::StatelessRequest::take_raw_tail)
735/// on stateless) — the pack streams there unframed, byte-exactly
736/// continuing the flush (ADR-013 §3). `request` is the substrates'
737/// parsed request shape: the command lines in `pre_lines`, the
738/// push-options section (only when negotiated) in `args`.
739///
740/// The orchestration, in ADR-013 §7's observed order:
741///
742/// - **Up-front rejections**: the grammar runs first ([`parse_push_request`])
743///   — a shallow line (ADR-013 §4), an object-format mismatch (§2/§4),
744///   a duplicate or out-of-grammar line, or a bad command are session
745///   errors before any pack byte is read or any ref is heard of. The
746///   **nothing-to-do shape** (an immediate flush, ADR-013 §3) replies
747///   flush with no report — no peek, no CAS.
748/// - **The pack-arrival branch** ([`PackTailDiscipline`]): the bounded
749///   peek classifies the tail — a full header proceeds to ingestion
750///   (the peeked bytes prepend the bridged tail, zero loss); zero
751///   bytes at the boundary (window timeout, or EOF) is the **packless,
752///   CAS-only path** — the parsed updates skip prepare, go straight to
753///   the validate-→-CAS leg (ADR-020 §1 rule (ii), applied per ADR-013
754///   §7; the client stays open until the report); a partial header is
755///   the **malformed-push session error** (rule (iii) — a half-sent
756///   pack is never a silently-downgraded absence). The duplex packless
757///   path drains the tail after the report (rule (iv)).
758/// - **The ordinary path**: under the **pipeline-concurrency permit**
759///   (ADR-009's enforcement point — the shared semaphore the fetch
760///   machine's `generate` wraps too; exhaustion delays the call, a
761///   closed semaphore is the 503-mapped session error class), the
762///   bridge's blocking end carries the pack into
763///   [`GitPackIngest::prepare`](crate::GitPackIngest::prepare) on
764///   `spawn_blocking` under `Handle::block_on` (the machine owns the
765///   blocking-thread posture, backend.md §concurrency model; the
766///   wave-2 relay pattern) — a `StorageError` from prepare is the
767///   session error, an `unpack` ng leg continues to the report.
768///   The relay is joined after the ingestion finished with the end
769///   (the break-before-join discipline), then the wire-side fault is
770///   consulted: a budget breach or a transport failure on the pack
771///   stream fails the session closed (ADR-009 — the client-fault
772///   class), never a report over a broken stream.
773/// - **The unpack-ng leg**: `unpack ng <reason>` + **all refs
774///   `ng <ref> unpacker error`** — the observed raw-stdio shape —
775///   with no CAS call (ADR-013 §8).
776/// - **The CAS leg** (the `apply_and_report` leg): per-ref name validation,
777///   then the single `apply_updates` call, then the report —
778///   `unpack ok` + the per-ref outcomes framed verbatim in request
779///   order through the report writer (sideband-selected framing,
780///   ADR-013 §8). Report-free when the client selected neither
781///   `report-status` cap (real receive-pack's shape).
782///
783/// Substrate-agnostic per convention 15: the writer is the
784/// [`PktLineWriter`](crate::v2::PktLineWriter) surface, the tail is the
785/// surrender state's raw reader — no duplex- or http-specific type
786/// appears below the two disciplines; no cross-request state is held.
787///
788/// Contract stamp: ADR-013 §3–§11 (the state machine this implements),
789/// ADR-020 §1 (the bounded peek's four rules, the packless CAS path),
790/// §2 (the ingestion composition lives impl-side; the machine calls
791/// the trait), §3 (the client's write-side-open shape), ADR-009 (the
792/// permit, the pack-size relay budget, the peek window, fail-closed
793/// breaches), ADR-005 (the substrate owns the report framing);
794/// push-captures.md §Pack-read ordering (the definitive order),
795/// §Status report, §CAS / race semantics; core/w3-push-machine (this
796/// wave).
797#[allow(clippy::too_many_arguments)]
798pub async fn serve_receive_pack<R>(
799    writer: &mut impl PktLineWriter,
800    request: &Request,
801    tail: RawTail<R>,
802    push_options_negotiated: bool,
803    refs: Arc<dyn GitRefs>,
804    ingest: Arc<dyn GitPackIngest>,
805    repo: &RepoRecord,
806    limits: &Limits,
807    tail_discipline: PackTailDiscipline,
808    pipeline: Arc<Semaphore>,
809) -> Result<(), SubstrateError>
810where
811    R: futures::io::AsyncRead + Unpin + Send + 'static,
812{
813    let parsed = parse_push_request(request, push_options_negotiated)?;
814
815    let wants_report = parsed.capabilities.report_status || parsed.capabilities.report_status_v2;
816    let sideband = parsed.capabilities.side_band_64k;
817    let atomic = parsed.capabilities.atomic;
818
819    if parsed.is_nothing_to_do() {
820        return writer.write_flush().await;
821    }
822
823    let mut tail = tail;
824    let observation = peek_pack_header(&mut tail, tail_discipline.peek_window(limits)).await?;
825    match observation {
826        PackObservation::Packless => {
827            apply_and_report(
828                writer,
829                &parsed.commands,
830                refs,
831                repo,
832                atomic,
833                sideband,
834                wants_report,
835            )
836            .await?;
837            if tail_discipline.drains_packless() {
838                drain_pack_tail(&mut tail).await?;
839            }
840            Ok(())
841        }
842        PackObservation::Malformed(observed) => Err(SubstrateError::Protocol(format!(
843            "push: only {observed} of {PACK_HEADER_BYTES} pack-header bytes reached the peek window ({}) — a half-sent pack cannot be re-interpreted as absence: malformed push, session error (ADR-020 §1 rule (iii))",
844            tail_discipline.discipline_name()
845        ))),
846        PackObservation::Header(header) => {
847            use futures::io::AsyncReadExt as _;
848
849            let permit = pipeline.clone().acquire_owned().await.map_err(|_| {
850                SubstrateError::Protocol("the pipeline-concurrency semaphore is closed".to_owned())
851            })?;
852            let PackSourceBridge { end, fault, relay } = bridge_pack_source(
853                futures::io::Cursor::new(header).chain(tail),
854                limits,
855            );
856            let handle = tokio::runtime::Handle::current();
857            let owned_repo = repo.clone();
858            let owned_limits = *limits;
859            let updates = parsed.commands;
860            let push_options = parsed.options;
861            let prepared = tokio::task::spawn_blocking(move || {
862                let _permit = permit;
863                handle.block_on(ingest.prepare(
864                    &owned_repo,
865                    updates,
866                    Box::new(end),
867                    push_options,
868                    owned_limits,
869                ))
870            })
871            .await
872            .map_err(|join| {
873                SubstrateError::Io(io::Error::other(format!(
874                    "the pack ingestion task failed: {join}"
875                )))
876            })?
877            .map_err(storage_to_session_error)?;
878            if let Some(failure) = fault.take_failure() {
879                // A wire-side pack failure (a budget breach — the
880                // client-fault class; a transport failure — terminal)
881                // fails the session closed, never a report over the
882                // broken stream (ADR-009's fail-closed behavior).
883                return Err(failure);
884            }
885
886            match &prepared.unpack {
887                Ok(()) => {
888                    apply_and_report(
889                        writer,
890                        &prepared.updates,
891                        refs,
892                        repo,
893                        atomic,
894                        sideband,
895                        wants_report,
896                    )
897                    .await?
898                }
899                Err(_) => {
900                    let outcomes: Vec<RefOutcome> = prepared
901                        .updates
902                        .iter()
903                        .map(|update| RefOutcome {
904                            name: update.name.clone(),
905                            result: Err(UNPACKER_ERROR_REASON.to_owned()),
906                        })
907                        .collect();
908                    write_report_maybe(writer, &prepared.unpack, &outcomes, sideband, wants_report)
909                        .await?
910                }
911            }
912
913            // The report is out, the blocking end is gone with its
914            // task: the relay ends on its next send or at the tail's
915            // EOF — which on both disciplines comes only after the
916            // report (a healthy client's tail EOFs when the client
917            // closes past it, ADR-020 §3) — so the join is the
918            // machine's last act; the pre-report join of the wave-2
919            // pattern's join-half would deadlock exactly that healthy
920            // shape. A client that never closes parks the relay here:
921            // the entries' wall clock is its bound (ADR-009).
922            relay.await.map_err(|join| {
923                SubstrateError::Io(io::Error::other(format!(
924                    "the pack-source relay task failed: {join}"
925                )))
926            })?;
927            Ok(())
928        }
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935    use crate::duplex::Request;
936
937    fn zero() -> String {
938        "0".repeat(40)
939    }
940
941    fn hex_oid(b: u8) -> String {
942        let mut hex = String::with_capacity(40);
943        for _ in 0..20 {
944            hex.push_str(&format!("{b:02x}"));
945        }
946        hex
947    }
948
949    fn request(pre_lines: &[&str], args: &[&str]) -> Request {
950        Request {
951            command: "git-receive-pack".to_owned(),
952            pre_lines: pre_lines.iter().map(|l| l.to_string()).collect(),
953            args: args.iter().map(|l| l.to_string()).collect(),
954        }
955    }
956
957    fn parse(
958        request: &Request,
959        push_options_negotiated: bool,
960    ) -> Result<PushRequest, SubstrateError> {
961        parse_push_request(request, push_options_negotiated)
962    }
963
964    fn protocol_error(result: Result<PushRequest, SubstrateError>) -> String {
965        match result {
966            Err(SubstrateError::Protocol(message)) => message,
967            Ok(_) => panic!("expected a protocol error"),
968            Err(other) => panic!("expected a protocol error, got {other:?}"),
969        }
970    }
971
972    #[test]
973    fn ordinary_commands_parse_into_the_zero_id_shapes() {
974        let parsed = parse(
975            &request(
976                &[
977                    &format!(
978                        "{} {} refs/heads/create\0report-status ofs-delta",
979                        zero(),
980                        hex_oid(0x0a)
981                    ),
982                    &format!("{} {} refs/heads/update", hex_oid(0x1b), hex_oid(0x2c)),
983                    &format!("{} {} refs/heads/delete", hex_oid(0x3d), zero()),
984                ],
985                &[],
986            ),
987            false,
988        )
989        .unwrap();
990        assert_eq!(
991            parsed.commands,
992            vec![
993                RefUpdate {
994                    name: "refs/heads/create".to_owned(),
995                    expected: None,
996                    new: Some(ObjectId::from([0x0a; 20]))
997                },
998                RefUpdate {
999                    name: "refs/heads/update".to_owned(),
1000                    expected: Some(ObjectId::from([0x1b; 20])),
1001                    new: Some(ObjectId::from([0x2c; 20]))
1002                },
1003                RefUpdate {
1004                    name: "refs/heads/delete".to_owned(),
1005                    expected: Some(ObjectId::from([0x3d; 20])),
1006                    new: None,
1007                }
1008            ]
1009        );
1010        assert!(parsed.capabilities.report_status);
1011        assert!(parsed.capabilities.ofs_delta);
1012        assert!(!parsed.capabilities.report_status_v2);
1013        assert_eq!(parsed.options, None);
1014        assert!(!parsed.is_nothing_to_do());
1015    }
1016
1017    #[test]
1018    fn the_capture_shape_caps_selection_parses() {
1019        // push-captures.md §Advertisement: a leading space after the NUL.
1020        let parsed = parse(
1021            &request(
1022                &[&format!(
1023                    "{} {} refs/heads/dev\0 report-status-v2 side-band-64k quiet object-format=sha1 agent=git/2.43.0",
1024                    zero(),
1025                    hex_oid(0x0a)
1026                )],
1027                &[],
1028            ),
1029            false,
1030        )
1031        .unwrap();
1032        assert!(parsed.capabilities.report_status_v2);
1033        assert!(parsed.capabilities.side_band_64k);
1034        assert!(parsed.capabilities.quiet);
1035        assert!(!parsed.capabilities.ofs_delta);
1036        assert_eq!(parsed.capabilities.agent.as_deref(), Some("git/2.43.0"));
1037    }
1038
1039    #[test]
1040    fn an_immediate_flush_is_the_nothing_to_do_shape() {
1041        let parsed = parse(&request(&[], &[]), false).unwrap();
1042        assert!(parsed.is_nothing_to_do());
1043        assert!(parsed.commands.is_empty());
1044        assert_eq!(parsed.capabilities, PushCapabilities::default());
1045        assert_eq!(parsed.options, None);
1046    }
1047
1048    #[test]
1049    fn caps_ride_the_first_command_line_only() {
1050        let message = protocol_error(parse(
1051            &request(
1052                &[
1053                    &format!(
1054                        "{} {} refs/heads/main\0report-status",
1055                        zero(),
1056                        hex_oid(0x0a)
1057                    ),
1058                    &format!(
1059                        "{} {} refs/heads/other\0quiet",
1060                        hex_oid(0x1b),
1061                        hex_oid(0x2c)
1062                    ),
1063                ],
1064                &[],
1065            ),
1066            false,
1067        ));
1068        assert!(message.contains("first command line"), "{message}");
1069    }
1070
1071    #[test]
1072    fn unknown_and_duplicate_caps_and_empty_agent_fail_closed() {
1073        let message = protocol_error(parse(
1074            &request(
1075                &[&format!(
1076                    "{} {} refs/heads/main\0push-cert",
1077                    zero(),
1078                    hex_oid(0x0a)
1079                )],
1080                &[],
1081            ),
1082            false,
1083        ));
1084        assert!(message.contains("unknown capability"), "{message}");
1085        let message = protocol_error(parse(
1086            &request(
1087                &[&format!(
1088                    "{} {} refs/heads/main\0report-status report-status",
1089                    zero(),
1090                    hex_oid(0x0a)
1091                )],
1092                &[],
1093            ),
1094            false,
1095        ));
1096        assert!(message.contains("duplicate capability"), "{message}");
1097        let message = protocol_error(parse(
1098            &request(
1099                &[&format!(
1100                    "{} {} refs/heads/main\0agent=",
1101                    zero(),
1102                    hex_oid(0x0a)
1103                )],
1104                &[],
1105            ),
1106            false,
1107        ));
1108        assert!(message.contains("agent"), "{message}");
1109    }
1110
1111    #[test]
1112    fn the_object_format_mismatch_is_rejected_and_drives_the_oid_size() {
1113        let message = protocol_error(parse(
1114            &request(
1115                &[&format!(
1116                    "{} {} refs/heads/main\0object-format=sha1024",
1117                    zero(),
1118                    hex_oid(0x0a)
1119                )],
1120                &[],
1121            ),
1122            false,
1123        ));
1124        assert!(message.contains("object-format"), "{message}");
1125        // A sha1 request carrying 64-hex oids is out of grammar, even
1126        // where from_hex would parse it (the sha256 feature's shape).
1127        let long = hex_oid(0x0a).repeat(2)[..64].to_owned();
1128        let message = protocol_error(parse(
1129            &request(
1130                &[&format!(
1131                    "{} {} refs/heads/main\0object-format=sha1",
1132                    zero(),
1133                    long
1134                )],
1135                &[],
1136            ),
1137            false,
1138        ));
1139        assert!(message.contains("40 hex"), "{message}");
1140    }
1141
1142    #[cfg(feature = "sha256")]
1143    #[test]
1144    fn the_advertised_sha256_format_is_accepted_under_the_feature() {
1145        let long = "0".repeat(64);
1146        let new = hex_oid(0x0a).repeat(2)[..64].to_owned();
1147        let parsed = parse(
1148            &request(
1149                &[&format!(
1150                    "{long} {new} refs/heads/main\0object-format=sha256"
1151                )],
1152                &[],
1153            ),
1154            false,
1155        )
1156        .unwrap();
1157        match &parsed.commands[0] {
1158            RefUpdate {
1159                expected: None,
1160                new: Some(new),
1161                ..
1162            } => assert_eq!(new.as_bytes().len(), 32),
1163            other => panic!("expected a create, got {other:?}"),
1164        }
1165    }
1166
1167    #[test]
1168    fn shallow_lines_are_rejected_up_front() {
1169        let shallow = String::from("shallow ") + &hex_oid(0x0a);
1170        for lines in [
1171            vec![
1172                shallow.clone(),
1173                format!("{} {} refs/heads/main", zero(), hex_oid(0x0a)),
1174            ],
1175            vec![
1176                format!("{} {} refs/heads/main", zero(), hex_oid(0x0a)),
1177                shallow,
1178            ],
1179            vec![String::from("shallow")],
1180        ] {
1181            let borrowed: Vec<&str> = lines.iter().map(String::as_str).collect();
1182            let message = protocol_error(parse(&request(&borrowed, &[]), false));
1183            assert!(message.contains("shallow"), "{message}");
1184        }
1185    }
1186
1187    #[test]
1188    fn malformed_command_lines_fail_closed() {
1189        for line in [
1190            format!("{} {} ", zero(), hex_oid(0x0a)),
1191            format!("{} refs/heads/short", zero()),
1192            "zz zz refs/heads/badhex".to_owned(),
1193            "\0report-status".to_owned(),
1194            format!("{} {} refs/heads/main", zero(), String::from("0")),
1195        ] {
1196            let message = protocol_error(parse(&request(&[&line], &[]), false));
1197            assert!(
1198                message.contains("malformed") || message.contains("carries the zero id"),
1199                "{message}"
1200            );
1201        }
1202    }
1203
1204    #[test]
1205    fn duplicate_ref_commands_fail_closed() {
1206        let message = protocol_error(parse(
1207            &request(
1208                &[
1209                    &format!("{} {} refs/heads/main", zero(), hex_oid(0x0a)),
1210                    &format!("{} {} refs/heads/main", hex_oid(0x1b), hex_oid(0x2c)),
1211                ],
1212                &[],
1213            ),
1214            false,
1215        ));
1216        assert!(message.contains("duplicate ref commands"), "{message}");
1217    }
1218
1219    #[test]
1220    fn the_push_options_section_parses_only_when_negotiated() {
1221        let command = format!(
1222            "{} {} refs/heads/main\0report-status push-options",
1223            zero(),
1224            hex_oid(0x0a)
1225        );
1226        let commands = [command.as_str()];
1227
1228        // Not advertised (the gate is off): selecting the cap fails closed.
1229        let message = protocol_error(parse(&request(&commands, &[]), false));
1230        assert!(message.contains("not advertised"), "{message}");
1231
1232        // Negotiated: the section parses to the verbatim pairs,
1233        // repeated keys preserved.
1234        let parsed = parse(
1235            &request(&commands, &["private=yes", "bare-option", "private=no"]),
1236            true,
1237        )
1238        .unwrap();
1239        assert_eq!(
1240            parsed.options,
1241            Some(PushOptions {
1242                pairs: vec![
1243                    ("private".to_owned(), "yes".to_owned()),
1244                    ("bare-option".to_owned(), String::new()),
1245                    ("private".to_owned(), "no".to_owned()),
1246                ],
1247            })
1248        );
1249
1250        // Negotiated: the section may be empty (the flush-only shape).
1251        let parsed = parse(&request(&commands, &[]), true).unwrap();
1252        assert_eq!(parsed.options, Some(PushOptions::default()));
1253
1254        // Not negotiated: the parse never consumes post-flush lines —
1255        // a stray one fails closed (never parsed as commands).
1256        let message = protocol_error(parse(
1257            &request(
1258                &[&format!(
1259                    "{} {} refs/heads/main\0report-status",
1260                    zero(),
1261                    hex_oid(0x0a)
1262                )],
1263                &["private=yes"],
1264            ),
1265            false,
1266        ));
1267        assert!(message.contains("fail closed"), "{message}");
1268
1269        // An empty push-option line is out of grammar.
1270        let message = protocol_error(parse(&request(&commands, &[""]), true));
1271        assert!(message.contains("empty push-option"), "{message}");
1272    }
1273}