Skip to main content

gunnar_wire/
caps.rs

1//! Capabilities, and the table that keeps the advertisement honest.
2//!
3//! # The bug this module exists to make impossible
4//!
5//! A protocol-v2 client will not send an argument unless the corresponding
6//! **feature** appears in the value of the capability it belongs to. A server
7//! that parses `filter <spec>` but advertises `fetch=shallow wait-for-done` has
8//! partial-clone support that can never activate. Worse, it looks *exactly*
9//! like working code: no error is raised, no test fails, the client simply
10//! never exercises the path. Stock `git` 2.53 does this deliberately
11//! (`uploadpack.allowFilter` defaults off), which is why the shape is easy to
12//! copy by accident.
13//!
14//! So the advertisement is not written down anywhere. There is one table
15//! ([`FETCH_ARGUMENTS`], [`LS_REFS_ARGUMENTS`]) that says, per argument, which
16//! feature gates it, and both the **parser** and the **advertisement** are
17//! derived from it. Adding an argument to the parser without advertising its
18//! feature is not something you can forget to do; it is something you cannot
19//! express. That is a fix by construction rather than a guard watching two
20//! copies agree (one implementation), and `tests/advertisement.rs` still asserts the
21//! resulting invariant from the outside, red-first.
22//!
23//! # The other bug, which the paragraph above did NOT prevent
24//!
25//! That paragraph was true of the **v2 arguments** and of nothing else, and it
26//! was read as covering everything — an early audit
27//! said outright that *"advertised but not honoured is not something this
28//! codebase can express"*. It was expressible in three other places, and it was
29//! expressed:
30//!
31//! * `upload_pack_v0_capabilities` was a hand-written `vec![]`,
32//! * `receive_pack_capabilities` was a second one, and
33//! * the **v2 capability names** were a third, in `upload_pack.rs`.
34//!
35//! `"push-options".to_owned()` sat in the second of those. It was advertised,
36//! `receive_pack::ReceivePack::serve` parsed the section, and the only consumer
37//! anywhere in the tree was `.len()` in a log field. A client that ran
38//! `git push -o ci.skip` was told on the wire that the server speaks push
39//! options and the option went nowhere — a wrong answer on the wire, not a
40//! missing feature.
41//!
42//! [`ADVERTISED`] closes it the same way: **one place decides what is
43//! advertised, for all three surfaces**, every row carries a [`Honoured`]
44//! saying where it is honoured, and `tests/advertisement.rs` checks each claim
45//! against the source with comments stripped — because
46//! `contains("push-options")` is satisfied by the paragraph explaining
47//! push-options two lines above the call site.
48
49use gix_hash::Kind;
50
51/// The product half of the `agent=` capability.
52pub const PRODUCT: &str = "gunnar";
53
54/// The `agent=` value gunnar advertises: `gunnar/<crate version>`.
55pub fn default_agent() -> String {
56    format!("{PRODUCT}/{}", env!("CARGO_PKG_VERSION"))
57}
58
59/// git's name for a hash algorithm, as it appears in `object-format=`.
60pub fn object_format_name(kind: Kind) -> &'static str {
61    match kind {
62        Kind::Sha1 => "sha1",
63        Kind::Sha256 => "sha256",
64        // `gix_hash::Kind` is `#[non_exhaustive]`. A hash gunnar has never
65        // heard of cannot be advertised as one it has, so it gets a name no
66        // client will match and `check_object_format` rejects every request
67        // against it. Loudly wrong beats quietly wrong.
68        _ => "unsupported",
69    }
70}
71
72/// The inverse of [`object_format_name`].
73pub fn object_format_from_name(name: &str) -> Option<Kind> {
74    match name {
75        "sha1" => Some(Kind::Sha1),
76        "sha256" => Some(Kind::Sha256),
77        _ => None,
78    }
79}
80
81/// Whether an argument carries a value after its name.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Value {
84    /// The whole line is the argument name: `done`, `thin-pack`.
85    None,
86    /// A single space, then the value: `want <oid>`, `ref-prefix <p>`.
87    Required,
88}
89
90/// One argument a protocol-v2 command accepts.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct Argument {
93    /// The name as it appears on the wire.
94    pub name: &'static str,
95    /// Whether a value follows.
96    pub value: Value,
97    /// The feature that must appear in the capability's advertised value
98    /// before a client is allowed to send this argument, or `None` for the
99    /// arguments every v2 client may always send.
100    pub feature: Option<&'static str>,
101}
102
103const fn plain(name: &'static str, value: Value) -> Argument {
104    Argument {
105        name,
106        value,
107        feature: None,
108    }
109}
110
111const fn gated(name: &'static str, value: Value, feature: &'static str) -> Argument {
112    Argument {
113        name,
114        value,
115        feature: Some(feature),
116    }
117}
118
119/// Every argument `command=ls-refs` accepts.
120pub const LS_REFS_ARGUMENTS: &[Argument] = &[
121    plain("symrefs", Value::None),
122    plain("peel", Value::None),
123    plain("ref-prefix", Value::Required),
124    gated("unborn", Value::None, "unborn"),
125];
126
127/// Every argument `command=fetch` accepts.
128///
129/// The four `deepen*` forms and `shallow` are all gated on the single
130/// `shallow` feature, exactly as git defines it: advertising `shallow` is a
131/// promise to accept all five, which is why they are implemented rather than
132/// rejected.
133pub const FETCH_ARGUMENTS: &[Argument] = &[
134    plain("want", Value::Required),
135    plain("have", Value::Required),
136    plain("done", Value::None),
137    plain("thin-pack", Value::None),
138    plain("no-progress", Value::None),
139    plain("include-tag", Value::None),
140    plain("ofs-delta", Value::None),
141    gated("shallow", Value::Required, "shallow"),
142    gated("deepen", Value::Required, "shallow"),
143    gated("deepen-relative", Value::None, "shallow"),
144    gated("deepen-since", Value::Required, "shallow"),
145    gated("deepen-not", Value::Required, "shallow"),
146    gated("filter", Value::Required, "filter"),
147    gated("want-ref", Value::Required, "ref-in-want"),
148    gated("wait-for-done", Value::None, "wait-for-done"),
149];
150
151/// Look an argument up by name.
152pub fn argument(table: &'static [Argument], name: &str) -> Option<&'static Argument> {
153    table.iter().find(|a| a.name == name)
154}
155
156/// The features of `table`, in first-seen order and without duplicates.
157///
158/// This is the only place an advertised feature list comes from.
159pub fn features(table: &'static [Argument]) -> Vec<&'static str> {
160    let mut out: Vec<&'static str> = Vec::new();
161    for arg in table {
162        if let Some(feature) = arg.feature {
163            if !out.contains(&feature) {
164                out.push(feature);
165            }
166        }
167    }
168    out
169}
170
171/// Render `name=<features>` for a v2 capability, or bare `name` when the
172/// command has no gated arguments at all.
173pub fn capability(name: &str, table: &'static [Argument]) -> String {
174    let features = features(table);
175    if features.is_empty() {
176        name.to_owned()
177    } else {
178        format!("{name}={}", features.join(" "))
179    }
180}
181
182/// What a client may turn off in the fetch capability.
183///
184/// `filter` is the one capability an operator plausibly wants to decline
185/// (serving a partial clone means serving arbitrary blob subsets), so it is a
186/// knob rather than a constant. Declining it removes the feature from the
187/// advertisement **and** makes the parser reject the argument, together, in
188/// [`fetch_capability`] and [`crate::upload_pack::FetchRequest::parse`].
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct Offered {
191    /// Whether `filter` is advertised and accepted.
192    pub filter: bool,
193}
194
195impl Default for Offered {
196    fn default() -> Self {
197        Offered { filter: true }
198    }
199}
200
201impl Offered {
202    /// Is `feature` on offer?
203    pub fn has(&self, feature: &str) -> bool {
204        match feature {
205            "filter" => self.filter,
206            _ => true,
207        }
208    }
209
210    /// May `argument` be sent by a client, given what is on offer?
211    pub fn accepts(&self, argument: &Argument) -> bool {
212        argument.feature.is_none_or(|f| self.has(f))
213    }
214}
215
216// ════════════════════════════════════════════════════════════════════════════
217// The advertisement — ONE table, all three surfaces
218// ════════════════════════════════════════════════════════════════════════════
219
220/// Where the code that honours a capability lives.
221///
222/// Every entry in every advertisement table carries one of these, and
223/// `tests/advertisement.rs` reads them: the named file, **with its comments
224/// stripped**, must contain the named code. Comments are stripped because
225/// `contains("push-options")` is satisfied by the paragraph explaining
226/// push-options two lines above the call — a guard that a doc comment can
227/// satisfy is a guard that survives the deletion of the thing it guards.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum Honoured {
230    /// Read by code in `file` containing the substring `code`.
231    ///
232    /// `file` is relative to the workspace root.
233    By {
234        /// Workspace-relative path.
235        file: &'static str,
236        /// A substring of the line that reads it, e.g. `peer.has("atomic")`.
237        code: &'static str,
238    },
239    /// Read by a public item of a crate gunnar **depends on**, not by a file in
240    /// this tree.
241    ///
242    /// This variant exists because the other one could not tell the truth here.
243    /// `ofs-delta`'s obligation is discharged by `gunnar-pack`, which lived at
244    /// `../edda/crates/gunnar-pack` and was cited as a workspace-relative PATH —
245    /// a path that only resolves on a machine with an edda checkout beside
246    /// gunnar. From a fresh clone (and, since 2026-09-09, from every clone, since
247    /// the crate is a crates.io dependency) the citation named a file that is not
248    /// there, and the guard failed for the absence of a sibling rather than for
249    /// anything about gunnar.
250    ///
251    /// A path was the wrong shape for the claim anyway. What is being asserted is
252    /// that a named ITEM exists in a named CRATE, and the compiler can assert
253    /// that exactly — `advertisement.rs` carries a `use` of every item cited this
254    /// way, so the guard is a build error rather than a substring search, and it
255    /// holds whether the crate resolves from the registry or from a patched
256    /// sibling checkout.
257    ByDependency {
258        /// The crate as cargo names it, e.g. `gunnar-pack`.
259        krate: &'static str,
260        /// The public item that does the work, e.g. `index_thin_pack`.
261        item: &'static str,
262    },
263    /// There is nothing to do, and here is the argument for why.
264    ///
265    /// The reason is a sentence, not a marker: this variant is the escape hatch
266    /// and it has to cost something to use. Today exactly three capabilities
267    /// use it, and each says what makes it vacuous rather than unimplemented.
268    Vacuously(&'static str),
269}
270
271/// What follows a capability's name on the wire.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum CapValue {
274    /// Nothing: the name alone, `atomic`.
275    Bare,
276    /// `=sha1` / `=sha256`, from the repository's hash.
277    ObjectFormat,
278    /// `=gunnar/1.1.0`, from the caller's agent string.
279    Agent,
280    /// `=<features>`, generated from a v2 argument table by [`features`].
281    ///
282    /// A bare name when the table gates nothing, which is what
283    /// [`capability`] already does.
284    Features(&'static [Argument]),
285}
286
287/// One capability gunnar advertises, and the thing that honours it.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub struct Capability {
290    /// The name as it appears on the wire, without any `=value`.
291    pub name: &'static str,
292    /// What follows the name.
293    pub value: CapValue,
294    /// The [`Offered`] knob that can withdraw this capability, if any.
295    ///
296    /// `None` means it is always advertised. `Some(feature)` means it appears
297    /// only when [`Offered::has`] says so — and the parser must refuse the
298    /// argument in the same breath, which is what `Offered` exists to make
299    /// impossible to get wrong.
300    pub offered: Option<&'static str>,
301    /// Where it is honoured.
302    pub honoured: Honoured,
303    /// The [`WantPolicy`] this row is a promise about, if any.
304    ///
305    /// `None` for every capability whose truth does not depend on what a
306    /// client is allowed to `want` — which is all of them but the two
307    /// `allow-*-sha1-in-want` rows.
308    ///
309    /// This exists because the drift `Honoured` catches runs one way only. A
310    /// row honoured by nothing is caught by the guard; a **policy honoured by
311    /// no row** is not, because there is no row to look at. `WantPolicy` was
312    /// exactly that: enforced by `check_wants` on every request and named in no
313    /// advertisement, so a stock client refused the request on its own side and
314    /// the server never heard it. See [`WantPolicy::advertises`].
315    pub requires: Option<crate::upload_pack::WantPolicy>,
316}
317
318const fn cap(name: &'static str, code: &'static str, file: &'static str) -> Capability {
319    Capability {
320        name,
321        value: CapValue::Bare,
322        offered: None,
323        honoured: Honoured::By { file, code },
324        requires: None,
325    }
326}
327
328/// The file both push-side capability readers live in.
329const RECEIVE: &str = "crates/gunnar-wire/src/receive_pack.rs";
330/// The file the fetch-side capability readers live in.
331const UPLOAD: &str = "crates/gunnar-wire/src/upload_pack.rs";
332
333/// `agent=`, which is the same row on all three surfaces.
334const AGENT: Capability = Capability {
335    name: "agent",
336    value: CapValue::Agent,
337    offered: None,
338    honoured: Honoured::Vacuously(
339        "an identifier for the peer's logs. There is no behaviour attached to \
340         it at either end; git itself only records it.",
341    ),
342    requires: None,
343};
344
345/// `object-format=`, which is the same row on all three surfaces.
346const OBJECT_FORMAT: Capability = Capability {
347    name: "object-format",
348    value: CapValue::ObjectFormat,
349    offered: None,
350    honoured: Honoured::By {
351        file: "crates/gunnar-wire/src/caps.rs",
352        code: "pub fn check_object_format",
353    },
354    requires: None,
355};
356
357/// Every capability `git-upload-pack` advertises in **protocol v0**.
358///
359/// Plain `multi_ack` is deliberately absent: it is an ACK mode with its own
360/// semantics that nothing here implements, and advertising a negotiation richer
361/// than the one implemented is the same class of bug this module exists to
362/// prevent. It is absent by *not being in this table*, which is now the only
363/// way to be absent.
364///
365/// `multi_ack_detailed` and `no-done` **are** here, and the comment on the first
366/// row says why: without them stock git negotiates in single-ACK, which has no
367/// correct answer over stateless-rpc and cannot finish a fetch over smart HTTP.
368pub const UPLOAD_PACK_V0: &[Capability] = &[
369    // ── the ACK mode, and why these two are not optional ────────────────────
370    //
371    // This table used to carry neither, on the reasoning that gunnar negotiated
372    // in single-ACK mode and that advertising a richer ACK protocol than the
373    // one implemented is the bug this module exists to prevent. The reasoning
374    // was sound and the conclusion was wrong, and stock git rather than an
375    // argument is what decided it.
376    //
377    // A v0 client picks its ACK mode from what the server offers. Offered
378    // neither, git 2.53.0 falls back to single-ACK, in which a bare
379    // `ACK <oid>` is TERMINAL: it means "negotiation is over, a packfile
380    // follows". Over a CONNECTED session that can be served correctly, and
381    // `b9fee55` did exactly that — git's rule is on `have_obj.nr`, so nothing
382    // more goes out once the ACK has, and the surplus `NAK` that used to sit in
383    // the pipe is gone. That fix is real and the `else` arm in
384    // `serve_v0_session` still carries it.
385    //
386    // Over **stateless-rpc there is no correct answer at all**, which is the
387    // part a connected fix cannot reach. The round ends with the HTTP response,
388    // so the client takes the terminal `ACK`, opens a FRESH `POST` carrying
389    // `want` and `done` and no `have`s, and reads that response as a raw
390    // sideband packfile — while the server, seeing a request with no commons,
391    // correctly opens it with `NAK`. There is nothing else it could send: the
392    // request genuinely has no common `have` in it. git demuxes the `N` as a
393    // band number and dies with `fetch-pack: protocol error: bad band #78`.
394    //
395    // MEASURED 2026-08-05, git 2.53.0, a corpus of 12 pushes cut back a
396    // third: `git fetch http://…` exited **128 against `b9fee55` itself**, on
397    // both sha1 and sha256, and 0 against `git http-backend` on the identical
398    // repository. The advertisements differed in these two names and in nothing
399    // else that changes the client's behaviour. That is why the mode is now
400    // implemented rather than declined — not because the earlier fix was wrong.
401    //
402    // `no-done` is what stateless-rpc is really after: it lets the server send
403    // `ACK <oid> ready`, the terminal `ACK` and the packfile in ONE response
404    // rather than making the client spend a second HTTP request to say `done`.
405    //
406    // Plain `multi_ack` is still absent, and deliberately: it is a third ACK
407    // mode with its own semantics that nothing here implements, so it is not
408    // advertised. A client too old to know `multi_ack_detailed` gets
409    // single-ACK, which is correct on the connected sessions it can reach.
410    cap(
411        "multi_ack_detailed",
412        r#"peer.has("multi_ack_detailed")"#,
413        UPLOAD,
414    ),
415    cap("no-done", r#"peer.has("no-done")"#, UPLOAD),
416    cap("side-band-64k", r#"peer.has("side-band-64k")"#, UPLOAD),
417    // The **v0** reader, not the v2 argument arm. This row cited
418    // `"thin-pack" => out.thin_pack = true`, which is the v2 parser, while v0
419    // read the capability nowhere at all — so every v0 fetch was served as a
420    // whole pack against an advertisement promising otherwise (found and fixed
421    // in `222f7b2`: 1.343x the bytes on a real incremental). A v0 row must cite
422    // a v0 consumer, or the guard is satisfied by the other protocol's code.
423    cap("thin-pack", r#"peer.has("thin-pack")"#, UPLOAD),
424    cap("ofs-delta", r#"peer.has("ofs-delta")"#, UPLOAD),
425    cap("no-progress", r#"peer.has("no-progress")"#, UPLOAD),
426    cap("include-tag", r#"peer.has("include-tag")"#, UPLOAD),
427    // Again the **v0** arms (`parse_v0_shallow_line`), not the v2 ones. Both
428    // exist and both are real, but a v0 row citing the v2 parser would stay
429    // green if the v0 half were deleted — which is exactly what happened to
430    // `thin-pack` above.
431    cap("shallow", r#""shallow" => request"#, UPLOAD),
432    cap("deepen-since", "request.shallow.since =", UPLOAD),
433    cap("deepen-not", r#""deepen-not" => request"#, UPLOAD),
434    // The capability, not the line arm (GHUNT-1): a row citing the line parser stayed green
435    // while no v0 client could reach it.
436    cap("deepen-relative", r#"peer.has("deepen-relative")"#, UPLOAD),
437    Capability {
438        name: "filter",
439        value: CapValue::Bare,
440        // The one capability an operator plausibly declines. Withdrawing it
441        // removes it from HERE and makes the parser refuse the argument, in
442        // one place, which is what `Offered` is for.
443        offered: Some("filter"),
444        honoured: Honoured::By {
445            file: UPLOAD,
446            code: r#""filter" => out.filter"#,
447        },
448        requires: None,
449    },
450    // ── the two rows that were missing, and the failure they hid ────────────
451    //
452    // `check_wants` has always enforced a `WantPolicy`, and the v0
453    // advertisement said nothing about it. `fetch-pack.c` will not send a
454    // `want` for an unadvertised object unless one of these two names was in
455    // the advertisement, so the request was refused **client-side** — no
456    // server error, no log line, nothing to notice. That is the inverted form
457    // of the drift the rest of this table catches: not a promise nothing keeps,
458    // but something kept that was never promised.
459    //
460    // MEASURED 2026-08-05, stock git 2.53.0 over `ext::`, one commit two
461    // steps behind `main`'s tip:
462    //
463    // | request | v0 | v2 |
464    // |---|---|---|
465    // | `fetch <oid>` of a reachable non-tip commit | `Server does not allow request for unadvertised object` | 6 objects, OK |
466    // | `clone --filter=blob:none`, then check out | **4 refusals, checkout failed** | OK |
467    //
468    // The second row is the one that matters: a partial clone fetches its blobs
469    // back by bare oid. v2 has no such capability at all (`upload_pack_v2` sets
470    // `ALLOW_ANY_SHA1` unconditionally), which is why every v2 arm was green
471    // while every v0 arm was broken.
472    //
473    // Both are honoured by `check_wants`, and deliberately by the *same* line:
474    // there is one policy decision, not two, and a row citing a second site
475    // would go green on code that agreed with it by accident.
476    Capability {
477        name: "allow-reachable-sha1-in-want",
478        value: CapValue::Bare,
479        offered: None,
480        honoured: Honoured::By {
481            file: UPLOAD,
482            code: "fn check_wants",
483        },
484        requires: Some(crate::upload_pack::WantPolicy::ReachableFromRefs),
485    },
486    Capability {
487        name: "allow-tip-sha1-in-want",
488        value: CapValue::Bare,
489        offered: None,
490        honoured: Honoured::By {
491            file: UPLOAD,
492            code: "fn check_wants",
493        },
494        // git sets `ALLOW_TIP_SHA1` on its own or as part of `ALLOW_ANY_SHA1`,
495        // and gunnar has no separate "tips only, plus hidden ones" policy —
496        // `AdvertisedTips` already serves every tip it advertises, so the
497        // capability would say nothing. Only `Any` is a promise beyond
498        // reachability.
499        requires: Some(crate::upload_pack::WantPolicy::Any),
500    },
501    OBJECT_FORMAT,
502    AGENT,
503];
504
505/// Every capability `git-receive-pack` advertises. Push has only v0.
506pub const RECEIVE_PACK: &[Capability] = &[
507    cap("report-status", r#"peer.has("report-status")"#, RECEIVE),
508    cap(
509        "report-status-v2",
510        r#"peer.has("report-status-v2")"#,
511        RECEIVE,
512    ),
513    Capability {
514        name: "delete-refs",
515        value: CapValue::Bare,
516        offered: None,
517        // Not `peer.has("delete-refs")`, and that is correct rather than a
518        // gap: the capability tells the CLIENT it may send a delete command,
519        // and the server's obligation is to carry one out. The code that does
520        // is the honouring.
521        honoured: Honoured::By {
522            file: RECEIVE,
523            code: "if command.is_delete()",
524        },
525        requires: None,
526    },
527    cap("side-band-64k", r#"peer.has("side-band-64k")"#, RECEIVE),
528    Capability {
529        name: "quiet",
530        value: CapValue::Bare,
531        offered: None,
532        honoured: Honoured::Vacuously(
533            "`quiet` asks receive-pack to suppress its progress reporting. \
534             gunnar's receive-pack emits no progress on that connection at \
535             all, so there is nothing to suppress and nothing to read the \
536             capability for. Vacuously honoured, and recorded as such rather \
537             than counted as a reader.",
538        ),
539        requires: None,
540    },
541    cap("atomic", r#"peer.has("atomic")"#, RECEIVE),
542    Capability {
543        name: "ofs-delta",
544        value: CapValue::Bare,
545        offered: None,
546        // The push side is the RECEIVER of ofs-deltas: advertising it tells
547        // the client it may send them, and the obligation is to RESOLVE them.
548        // There is no `peer.has("ofs-delta")` on this surface and there should
549        // not be one — the consumer is the indexer that completes the chains.
550        honoured: Honoured::ByDependency {
551            krate: "gunnar-pack",
552            item: "index_thin_pack",
553        },
554        requires: None,
555    },
556    cap("push-options", r#"peer.has("push-options")"#, RECEIVE),
557    OBJECT_FORMAT,
558    AGENT,
559];
560
561/// Every capability the **protocol v2** advertisement carries.
562///
563/// `ls-refs` and `fetch` carry generated feature lists; the others are their
564/// own row. This is what `UploadPack::write_capability_advertisement` writes,
565/// rather than a fourth hand-written list beside the table — which is what it
566/// was until 2026-08-05, in a different file from the two above.
567pub const UPLOAD_PACK_V2: &[Capability] = &[
568    AGENT,
569    Capability {
570        name: "ls-refs",
571        value: CapValue::Features(LS_REFS_ARGUMENTS),
572        offered: None,
573        honoured: Honoured::By {
574            file: UPLOAD,
575            code: "impl LsRefsRequest",
576        },
577        requires: None,
578    },
579    Capability {
580        name: "fetch",
581        value: CapValue::Features(FETCH_ARGUMENTS),
582        offered: None,
583        honoured: Honoured::By {
584            file: UPLOAD,
585            code: "impl FetchRequest",
586        },
587        requires: None,
588    },
589    Capability {
590        name: "server-option",
591        value: CapValue::Bare,
592        offered: None,
593        honoured: Honoured::Vacuously(
594            "protocol v2 says a server SHOULD ignore server options it does \
595             not understand, and gunnar defines none. The lines are accepted \
596             into `PeerCapabilities` rather than rejected, so a client that \
597             sends one is not broken by it. This is NOT the `push-options` \
598             case: git hands push options to `pre-receive`, so discarding \
599             those was a wrong answer, while discarding server options is \
600             what the specification asks for.",
601        ),
602        requires: None,
603    },
604    OBJECT_FORMAT,
605];
606
607/// Every surface, so a guard can walk all of them without naming three
608/// constants and going stale when a fourth appears.
609pub const ADVERTISED: &[(&str, &[Capability])] = &[
610    ("upload-pack v2", UPLOAD_PACK_V2),
611    ("upload-pack v0", UPLOAD_PACK_V0),
612    ("receive-pack", RECEIVE_PACK),
613];
614
615/// Render one capability's wire spelling, or `None` if it is not on offer.
616///
617/// `want_policy` is a parameter and not a default because the advertisement
618/// genuinely depends on it, and a default would be the drift this whole table
619/// exists to prevent: the two `allow-*-sha1-in-want` rows are promises about
620/// what `check_wants` will serve, and a server that renders them from anything
621/// other than the policy it judges by is advertising a different server.
622pub fn render(
623    cap: &Capability,
624    hash_kind: Kind,
625    agent: &str,
626    offered: Offered,
627    want_policy: crate::upload_pack::WantPolicy,
628) -> Option<String> {
629    if let Some(feature) = cap.offered {
630        if !offered.has(feature) {
631            return None;
632        }
633    }
634    if let Some(required) = cap.requires {
635        if !want_policy.advertises(required) {
636            return None;
637        }
638    }
639    Some(match cap.value {
640        CapValue::Bare => cap.name.to_owned(),
641        CapValue::ObjectFormat => format!("{}={}", cap.name, object_format_name(hash_kind)),
642        CapValue::Agent => format!("{}={agent}", cap.name),
643        CapValue::Features(table) => {
644            // `Offered` also gates individual v2 features, so this is the same
645            // filter `fetch_capability` applies — one writer, not two.
646            let features: Vec<&str> = features(table)
647                .into_iter()
648                .filter(|f| offered.has(f))
649                .collect();
650            if features.is_empty() {
651                cap.name.to_owned()
652            } else {
653                format!("{}={}", cap.name, features.join(" "))
654            }
655        }
656    })
657}
658
659/// The wire lines for a whole surface, in table order.
660///
661/// **This is the only thing that decides what gunnar advertises.** Before
662/// 2026-08-05 the v2 list was generated from the argument table while v0 and
663/// receive-pack were hand-written `vec![]`s in this file and the v2 capability
664/// names were a fourth hand-written list in `upload_pack.rs` — so
665/// `push-options` was a bare string literal that nothing else in the tree had
666/// to agree with, and it was advertised, parsed and discarded for the whole of
667/// v1. An early audit claimed that *"advertised but
668/// not honoured is not something this codebase can express"*; that was true of
669/// the v2 arguments and false of the other three lists. Now it is true of all
670/// of them, because there is nowhere else to write one down.
671pub fn advertise(
672    table: &'static [Capability],
673    hash_kind: Kind,
674    agent: &str,
675    offered: Offered,
676    want_policy: crate::upload_pack::WantPolicy,
677) -> Vec<String> {
678    table
679        .iter()
680        .filter_map(|cap| render(cap, hash_kind, agent, offered, want_policy))
681        .collect()
682}
683
684/// The `fetch=…` capability line for what is on offer.
685pub fn fetch_capability(offered: Offered) -> String {
686    let features: Vec<&str> = features(FETCH_ARGUMENTS)
687        .into_iter()
688        .filter(|f| offered.has(f))
689        .collect();
690    if features.is_empty() {
691        "fetch".to_owned()
692    } else {
693        format!("fetch={}", features.join(" "))
694    }
695}
696
697/// The `ls-refs=…` capability line.
698pub fn ls_refs_capability() -> String {
699    capability("ls-refs", LS_REFS_ARGUMENTS)
700}
701
702/// The capabilities `git-upload-pack` advertises in **protocol v0**, where
703/// they ride on the first reference line rather than in their own section.
704///
705/// `multi_ack` and `multi_ack_detailed` are deliberately absent: gunnar
706/// negotiates in single-ACK mode, which every git since 2005 still speaks, and
707/// advertising a richer ACK protocol than the one implemented is the same
708/// class of bug this module exists to prevent.
709pub fn upload_pack_v0_capabilities(
710    hash_kind: Kind,
711    agent: &str,
712    offered: Offered,
713    want_policy: crate::upload_pack::WantPolicy,
714) -> Vec<String> {
715    advertise(UPLOAD_PACK_V0, hash_kind, agent, offered, want_policy)
716}
717
718/// The capabilities `git-receive-pack` advertises.
719pub fn receive_pack_capabilities(hash_kind: Kind, agent: &str) -> Vec<String> {
720    // `WantPolicy` decides what a client may *fetch*; push has no `want` at
721    // all, so no row in `RECEIVE_PACK` carries a `requires` and the value here
722    // cannot change a byte. Passed as the default rather than given its own
723    // parameter, because a knob that cannot move is one more thing to keep
724    // agreeing about.
725    advertise(
726        RECEIVE_PACK,
727        hash_kind,
728        agent,
729        Offered::default(),
730        crate::upload_pack::WantPolicy::default(),
731    )
732}
733
734/// The capability set a v0 peer sent on its first line, parsed.
735///
736/// Values are kept as `Option<String>` so `object-format=sha256` and a bare
737/// `atomic` are the same shape.
738#[derive(Debug, Clone, Default, PartialEq, Eq)]
739pub struct PeerCapabilities {
740    entries: Vec<(String, Option<String>)>,
741}
742
743impl PeerCapabilities {
744    /// Parse a space-separated capability string.
745    ///
746    /// Leading and repeated spaces are tolerated: git's own `receive-pack`
747    /// client emits `…\0 report-status-v2 …` with a leading space, which a
748    /// naive `split(' ')` turns into a phantom empty capability.
749    pub fn parse(text: &str) -> Self {
750        let entries = text
751            .split(' ')
752            .filter(|s| !s.is_empty())
753            .map(|item| match item.split_once('=') {
754                Some((k, v)) => (k.to_owned(), Some(v.to_owned())),
755                None => (item.to_owned(), None),
756            })
757            .collect();
758        PeerCapabilities { entries }
759    }
760
761    /// Is this capability present, with or without a value?
762    pub fn has(&self, name: &str) -> bool {
763        self.entries.iter().any(|(k, _)| k == name)
764    }
765
766    /// The value of `name=<value>`, if it was sent with one.
767    pub fn value(&self, name: &str) -> Option<&str> {
768        self.entries
769            .iter()
770            .find(|(k, _)| k == name)
771            .and_then(|(_, v)| v.as_deref())
772    }
773
774    /// Every capability name, in the order sent.
775    pub fn names(&self) -> impl Iterator<Item = &str> {
776        self.entries.iter().map(|(k, _)| k.as_str())
777    }
778}
779
780/// Check a peer's `object-format` against the repository's actual hash kind.
781///
782/// The whole point of object-format negotiation: answering `sha1` to a sha256 repository does not
783/// fail, it returns an *empty* response, so the mismatch has to be caught here
784/// and turned into an error.
785pub fn check_object_format(ours: Kind, theirs: Option<&str>) -> crate::Result<()> {
786    let Some(theirs) = theirs else {
787        return Ok(());
788    };
789    if object_format_from_name(theirs) == Some(ours) {
790        Ok(())
791    } else {
792        Err(crate::Error::ObjectFormatMismatch {
793            ours: object_format_name(ours),
794            theirs: theirs.to_owned(),
795        })
796    }
797}
798
799// ════════════════════════════════════════════════════════════════════════════
800// The census: every serving-side policy, and what on the wire declares it
801// ════════════════════════════════════════════════════════════════════════════
802//
803// # Why a second table
804//
805// [`ADVERTISED`] answers *"does everything gunnar promises have a consumer?"*.
806// It cannot answer the inverse — *"does everything gunnar enforces have a
807// promise?"* — because the inverse has nothing to walk. A row nothing reads is
808// visible; **a policy no row names** is not there at all.
809//
810// That inverse has produced four wrong answers in this tree in a single day,
811// and the sharpest of them, `WantPolicy` (`8f4e6e0`), was invisible in the most
812// complete way available: `check_wants` enforced it on every request, no
813// capability named it, so stock git refused the `want` on its **own** side and
814// gunnar never received the request. No error, no log line, no failing test —
815// only a user whose partial clone could not check out.
816//
817// So every field of every serving-side `Config`, plus every field of
818// [`Offered`], is listed here with a verdict, and
819// `tests/advertisement.rs::every_serving_policy_is_declared_somewhere` reads the
820// **struct definitions out of the source** and refuses to pass while one of
821// them is missing from this list. Adding a knob that changes what a client gets
822// is therefore not something you can forget to classify; it is something that
823// does not compile past the test suite.
824
825/// How a serving-side policy reaches the peer.
826///
827/// The three arms are not degrees of quality — they are three different
828/// contracts, and picking the wrong one is the defect. `WantPolicy` was
829/// implicitly treated as [`Self::Refuses`] and was in fact silent: the refusal
830/// happened inside the *client*, which is the one place a server's error can
831/// never reach.
832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub enum Declared {
834    /// Named on the wire as this capability, so a peer can see it **before it
835    /// asks** and shape its request around it.
836    ///
837    /// Required whenever the peer's behaviour depends on knowing: git's client
838    /// will not send `filter`, `thin-pack` or a `want` for an unadvertised
839    /// object unless the corresponding capability was advertised.
840    As(&'static str),
841    /// Not on the wire, and it does not have to be, because when it applies the
842    /// peer gets an **error that says so**.
843    ///
844    /// This arm is a claim about something arriving, so it carries the tests
845    /// that watched it arrive. "It refuses" is only a safe answer if the
846    /// refusal is observable at the far end; a limit that silently truncates a
847    /// pack, or that is enforced inside the client, is not this arm.
848    Refuses {
849        /// `<workspace-relative file>::<test fn>`, one or more, each of which
850        /// asserts the peer sees the refusal. Checked to exist.
851        seen_by: &'static [&'static str],
852        /// What the peer actually sees.
853        reason: &'static str,
854    },
855    /// The peer gets an error that says so — and **nothing in this repository has
856    /// watched it arrive**.
857    ///
858    /// Split out of [`Refuses`](Self::Refuses) on 2026-09-09, because `limits` was
859    /// using that arm while citing three tests at
860    /// `../edda/crates/storage-git-gix/parked-gix-2/gunnar-wire-gix/tests/fetch_memory.rs`.
861    /// Two things were wrong with that and only one of them was the sibling path:
862    /// `parked-gix-2` is quarantined salvage, named by neither edda's `include`
863    /// list nor any published tarball, so the citation resolved on exactly one
864    /// machine and nowhere else. The guard "checks the citation exists", and what
865    /// it was checking was one developer's disk.
866    ///
867    /// So the uncovered case gets its own name instead of borrowing the covered
868    /// one's. The reason must say what the peer sees AND what would cover it —
869    /// this arm is an admission, and an admission that does not name its way out
870    /// becomes furniture.
871    RefusesUncovered(&'static str),
872    /// Nothing a peer can observe, and here is the argument for why.
873    ///
874    /// The escape hatch, and it has to cost a sentence — the same rule
875    /// [`Honoured::Vacuously`] carries, for the same reason.
876    Invisible(&'static str),
877}
878
879/// One field of a serving-side configuration struct, and its verdict.
880#[derive(Debug, Clone, Copy, PartialEq, Eq)]
881pub struct Policy {
882    /// The field's name, exactly as the struct spells it.
883    pub field: &'static str,
884    /// How it reaches the peer.
885    pub declared: Declared,
886}
887
888/// A struct whose fields are serving-side policy, with a verdict on every one.
889#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890pub struct PolicySurface {
891    /// The surface's name, for a failure message.
892    pub surface: &'static str,
893    /// Workspace-relative path to the file the struct is written in.
894    pub file: &'static str,
895    /// The struct's opening line, verbatim, so the guard can find it.
896    pub declaration: &'static str,
897    /// Every field, in any order. The guard asserts this set equals the set in
898    /// the source — in **both** directions, so a stale entry fails too.
899    pub fields: &'static [Policy],
900}
901
902/// Every configuration surface that decides what a peer gets.
903pub const SERVER_POLICIES: &[PolicySurface] = &[
904    PolicySurface {
905        surface: "upload-pack",
906        file: "crates/gunnar-wire/src/upload_pack.rs",
907        declaration: "pub struct Config {",
908        fields: &[
909            Policy {
910                field: "agent",
911                declared: Declared::As("agent"),
912            },
913            Policy {
914                field: "offered",
915                // The struct is one field wide and that field is `filter`;
916                // `Offered` has its own row below, so this is not the place the
917                // claim is checked.
918                declared: Declared::As("filter"),
919            },
920            Policy {
921                field: "want_policy",
922                // The defect of 2026-08-05. Before `8f4e6e0` this was enforced
923                // by `check_wants` and named by nothing, and the failure landed
924                // inside the client where no server error could reach.
925                declared: Declared::As("allow-reachable-sha1-in-want"),
926            },
927            Policy {
928                field: "budget",
929                declared: Declared::Refuses {
930                    seen_by: &["crates/gunnar-cli/tests/wire_config.rs::\
931                         a_traversal_budget_of_one_refuses_a_clone_rather_than_truncating_it"],
932                    reason: "the walk stops at the ceiling and the request is REFUSED, so the \
933                             client's `git clone` fails with the server's message. A budget \
934                             that truncated the pack instead would be a wrong answer and would \
935                             belong in no arm of this enum — which is why the test asserts the \
936                             clone failed AND that no repository was left behind, rather than \
937                             asserting an exit code.",
938                },
939            },
940            Policy {
941                field: "limits",
942                declared: Declared::RefusesUncovered(
943                    "UNCOVERED HERE, and the arm now says so rather than citing a path. All \
944                     three ceilings raise a named error — `ObjectTooLarge`, `RequestTooLarge`, \
945                     `WalkTooLarge` — that travels to the client as an `ERR` pkt-line or on \
946                     band 3, and an object over the ceiling is REFUSED and not skipped, \
947                     because a pack whose closure does not hold is a corrupt clone rather \
948                     than a refused one; `limits.rs` argues that at length. The tests that \
949                     watched two of the three arrive \
950                     (`a_blob_over_the_ceiling_is_refused_by_name`, \
951                     `a_v0_have_flood_is_refused_at_the_ceiling`, \
952                     `a_v2_argument_flood_is_refused_at_the_ceiling`) are PARKED in edda's \
953                     `storage-git-gix/parked-gix-2/` — quarantined salvage, in no published \
954                     tarball and in no clone of this repository — because their fixture \
955                     opened an FsStore and the packfile engine is amputated. THE WAY OUT is \
956                     a MemStore fixture, which is gix-free and already in gunnar-store: port \
957                     the three tests onto it, here, and this field goes back to `Refuses` \
958                     with citations that resolve for everyone. The third ceiling, \
959                     `walk_bytes`, IS covered — `walk_limit.rs` reads the sentence off the \
960                     wire — and is a separate field for that reason.",
961                ),
962            },
963            Policy {
964                field: "walk_pool",
965                declared: Declared::Refuses {
966                    seen_by: &["crates/gunnar-wire/tests/walk_limit.rs::\
967                         a_full_pool_is_a_different_sentence_from_a_request_that_will_never_fit"],
968                    reason: "the server-wide memory ceiling, and the one policy here whose \
969                             answer depends on what OTHER clients are doing. It refuses with \
970                             `WalkPoolFull`, which is deliberately a different sentence from \
971                             `WalkTooLarge`: this one says the machine is busy and a retry will \
972                             work, and the other says the request will never fit. A client that \
973                             cannot tell them apart retries the first forever and gives up on \
974                             the second. Nothing is advertised, because nothing a client could \
975                             be told before it asks would still be true when it did — the \
976                             answer is a property of the moment.",
977                },
978            },
979        ],
980    },
981    PolicySurface {
982        surface: "receive-pack",
983        file: "crates/gunnar-wire/src/receive_pack.rs",
984        declaration: "pub struct Config {",
985        fields: &[
986            Policy {
987                field: "agent",
988                declared: Declared::As("agent"),
989            },
990            Policy {
991                field: "budget",
992                declared: Declared::Refuses {
993                    seen_by: &[
994                        "crates/gunnar-wire/tests/protocol.rs::\
995                         the_connectivity_budget_refuses_a_push_rather_than_accepting_it_unchecked",
996                        "crates/gunnar-wire/tests/protocol.rs::\
997                         a_mixed_push_refuses_only_the_reference_that_does_not_close",
998                    ],
999                    reason: "the connectivity walk's ceiling. Exceeding it, like failing the \
1000                             walk itself, turns into a per-reference `RefStatus::Rejected` \
1001                             carrying the reason, which the status report puts in front of \
1002                             `git push`, and the reference does not move. The first test sets \
1003                             the budget to 1 over six unreachable commits and reads the status \
1004                             report; the second is what says the refusal is per reference and \
1005                             not per push. The first was written for this entry: until then \
1006                             the only citation available proved the reporting mechanism and \
1007                             never set a budget, which is a claim standing next to its \
1008                             evidence rather than on it.",
1009                },
1010            },
1011            Policy {
1012                field: "pack_ceiling",
1013                declared: Declared::Refuses {
1014                    seen_by: &[
1015                        "crates/gunnar-wire/tests/pack_ceiling.rs::\
1016                         a_push_over_its_ceiling_is_cut_mid_transfer",
1017                        "crates/gunnar-wire/tests/pack_ceiling.rs::\
1018                         a_push_of_exactly_the_ceiling_lands",
1019                        "crates/gunnar-server/tests/pack_ceiling_store.rs::\
1020                         a_cut_push_stores_nothing_and_leaves_the_repository_usable",
1021                        "crates/gunnar-wire/tests/concurrent_pack_ceiling.rs::\
1022                         simultaneous_pushes_are_not_each_admitted_against_the_same_measurement",
1023                    ],
1024                    reason: "the byte ceiling a push is held to WHILE it arrives, and the one \
1025                             policy here that a client cannot be told before it asks — because \
1026                             the thing it bounds is not knowable before it asks. A pack header \
1027                             carries an object count, not a byte count, so neither side knows \
1028                             the size of a push at negotiation time; there is no number to put \
1029                             in a capability and nothing a client could do differently if there \
1030                             were. So it refuses, and the refusal is the whole surface: the \
1031                             read is cut one byte past the ceiling, `unpack <reason>` carries \
1032                             the setter's own sentence (which names the ceiling and why \
1033                             — never the indexer's opinion of a truncated stream), \
1034                             and every reference is reported `ng`. The first citation is the \
1035                             one that matters and it asserts BYTES, not a verdict: a server \
1036                             that read the whole 400 GiB and then refused would satisfy any \
1037                             assertion about the report and is a different bug. The second is \
1038                             the boundary — a push of exactly the ceiling LANDS — and the \
1039                             third is the same cut against the real znippy engine, which is \
1040                             where `nothing was stored` costs something to be true. The \
1041                             fourth is the number's OTHER half, added 2026-09-08: the \
1042                             ceiling is one namespace's room and not one connection's, so \
1043                             eight pushes that measured it together spend it once between \
1044                             them (`PackCeiling::ledger`). Without it three green citations \
1045                             stood over a limit N simultaneous pushes could spend N times.",
1046                },
1047            },
1048            Policy {
1049                field: "notice",
1050                declared: Declared::Invisible(
1051                    "It is not a policy: the push it rides on is ALLOWED. `notice` is one \
1052                     human-readable line put on the progress band (`remote: …`) beside a \
1053                     report that is otherwise byte-for-byte what it would have been — the \
1054                     metered appliance's `plan expired <date>; renew at <url>` for a tenant in \
1055                     Grace. Nothing about the request has to be shaped around knowing it, so \
1056                     `As` would be a capability no client would read; nothing is refused, so \
1057                     `Refuses` would have no arrival to cite. The three things that make \
1058                     `Invisible` the honest arm rather than the convenient one, all readable \
1059                     at `receive_pack.rs:1445-1455`: it is sent ONLY inside the `if sideband` \
1060                     arm, so a client that negotiated no sideband gets the bare report and \
1061                     never a line where the report should be; it is framed separately on band \
1062                     2, so a client that ignores band 2 parses the report untouched; and it is \
1063                     written BEFORE the report on band 1, which is presentation order for a \
1064                     person and nothing a parser depends on. Its absence is exactly the \
1065                     behaviour that shipped before it existed. If it ever gates, blocks or \
1066                     shortens a push, it stops being this arm.",
1067                ),
1068            },
1069        ],
1070    },
1071    PolicySurface {
1072        surface: "what is on offer",
1073        file: "crates/gunnar-wire/src/caps.rs",
1074        declaration: "pub struct Offered {",
1075        fields: &[Policy {
1076            field: "filter",
1077            declared: Declared::As("filter"),
1078        }],
1079    },
1080];