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}