gunnar_wire/lib.rs
1//! **The git smart protocol, server side.** `upload-pack` and `receive-pack`
2//! as state machines with no transport attached.
3//!
4//! This is the crate gunnar exists to have. `nornir-git` already speaks the
5//! *client* half of git-over-SSH against codeberg and GitHub — reused, not written; what
6//! does not exist anywhere in the constellation is a server that **answers**.
7//!
8//! # Transport-agnostic, on purpose
9//!
10//! Every entry point takes `&mut dyn BufRead` and `&mut dyn Write`. There is no
11//! socket, no async runtime, no SSH here. `gunnar-ssh` bridges a russh channel
12//! into those two traits; a test bridges a pipe, and `git`'s own `ext::`
13//! transport bridges a subprocess's stdio. The state machine cannot tell them
14//! apart, which is what makes it testable against the real `git` binary without
15//! an SSH server existing yet.
16//!
17//! # What is implemented
18//!
19//! | | protocol | notes |
20//! |---|---|---|
21//! | [`upload_pack`] | **v2**, with a **v0 fallback** | `ls-refs`, `fetch`, shallow, partial-clone filters, `ref-in-want` |
22//! | [`receive_pack`] | **v0** | the only protocol push has; `atomic`, `push-options`, `report-status-v2` |
23//! | [`fetch_pack`] | **v2** | the *client* seat of a fetch, and [`ls_remote`] |
24//!
25//! [`fetch_pack`] is the one that is not a server. It is here because it is the
26//! same conversation: it reuses this crate's framer, its capability tables, its
27//! advertised-ref vocabulary and — for the pack it receives — the very
28//! [`PackIngest`](receive_pack::PackIngest) a push already lands through, since
29//! a fetched pack and a pushed pack are the same bytes. Writing it against
30//! `gix-protocol` instead would have cost the appliance graph **+128 crates**
31//! and a curl or reqwest HTTP client; the module doc has the measurement.
32//!
33//! The v0 fallback is not a formality. `git` 2.53 negotiates v0 whenever
34//! `GIT_PROTOCOL` does not reach the server process. Measured 2026-08-03 over
35//! `ext::`, where git passes `GIT_EXT_SERVICE` but not `GIT_PROTOCOL`. A server
36//! without a v0 path would simply not answer those clients.
37//!
38//! # Two things this crate is careful about
39//!
40//! **The advertisement is generated from the parser's own table.** A v2 client
41//! will not send `filter` unless `filter` is advertised, so a server that
42//! parses more than it advertises has support that never activates and looks
43//! exactly like working code. [`caps`] makes that unexpressible rather than
44//! merely tested.
45//!
46//! **`object-format` comes from the repository's own hash kind**, carried into
47//! [`UploadPack::new`](upload_pack::UploadPack::new) and
48//! [`ReceivePack::new`](receive_pack::ReceivePack::new) from
49//! [`gunnar_store::RepoStore::hash_kind`], never from a default.
50//! Answering `sha1` to a sha256 repository yields an *empty* response rather
51//! than an error. That is the bug documented in `nornir-git`'s `ssh.rs` that
52//! cost a session, so a mismatch is rejected loudly here, and the interop tests run
53//! the whole suite against a sha256 fixture rather than inferring it from a
54//! sha1 green.
55//!
56//! # Example
57//!
58//! ```no_run
59//! use gunnar_wire::{ProtocolVersion, upload_pack::UploadPack};
60//! # fn run(git: &dyn gunnar_store::git_storage_trait::GitServe,
61//! # hash_kind: gunnar_store::gix_hash::Kind,
62//! # input: &mut dyn std::io::BufRead, output: &mut dyn std::io::Write)
63//! # -> gunnar_wire::Result<()> {
64//! let version = ProtocolVersion::from_git_protocol(std::env::var("GIT_PROTOCOL").ok().as_deref());
65//! UploadPack::new(git, hash_kind).serve(version, input, output)?;
66//! # Ok(()) }
67//! ```
68
69#![deny(missing_docs)]
70#![forbid(unsafe_code)]
71#![doc(
72 html_logo_url = "https://gunnar.rs/download/gunnar.webp",
73 html_favicon_url = "https://gunnar.rs/download/favicon.svg"
74)]
75
76// So `tests/common/mod.rs` can spell this crate's own types **one way** in both
77// contexts it is compiled in: as an external crate by every integration test,
78// and — through the `#[path]` below — inside the library's unit tests, where
79// `gunnar_wire::` would otherwise not resolve. That fixture used to dodge the
80// problem by being generic over `E: From<gunnar_pack::Error>` so it never had
81// to name a `gunnar-wire` type at all, and that dodge is precisely what kept
82// `gunnar-pack` a normal dependency of this crate. One line here, and the
83// fixture can say what it means. See `src/pack.rs`.
84extern crate self as gunnar_wire;
85
86pub mod caps;
87mod error;
88pub mod fetch_pack;
89pub mod filter;
90mod graph;
91pub mod limits;
92mod negotiate;
93pub mod pack;
94mod reachable;
95pub mod receive_pack;
96pub mod refs;
97pub mod shallow;
98mod stage;
99pub mod upload_pack;
100mod wire_io;
101
102/// The byte-level suites' repository fixture, borrowed by the unit tests.
103///
104/// `#[path]` into `tests/` rather than a second copy under `src/`: a fixture
105/// builder that could drift from the one every integration test in this crate
106/// runs against is exactly the duplication one implementation forbids, and the
107/// unit tests that assert on traversal cost need the same histories the
108/// protocol tests use. Declared here rather than beside its user because
109/// `#[path]` resolves through real directories, and `src/graph/` is not one.
110#[cfg(test)]
111#[path = "../tests/common/mod.rs"]
112mod common;
113
114pub use error::{Error, Result};
115pub use fetch_pack::{
116 ls_remote, ls_remote_any, Advertisement, FetchPack, FetchProgress, FetchReport, LocalRefs,
117 RefUpdatePlan,
118};
119pub use filter::Filter;
120
121/// How much of a conversation one call to a state machine gets to see.
122///
123/// # Why this exists, and why it is not a second implementation
124///
125/// Over SSH the client and the server hold **one connection** for the whole
126/// exchange: the advertisement, every `have`/`want` round and the packfile all
127/// travel down it, and the server's negotiation state lives in the stack frame
128/// that is serving it.
129///
130/// Smart HTTP has no such frame. Each `POST` is an independent request that may
131/// land on a different worker, so the client re-sends its whole request on
132/// every round and the server must answer **one round and stop**. That is git's
133/// `--stateless-rpc`, and it is the one behavioural difference between the two
134/// transports. Everything else — the framing, `ls-refs`, the negotiation, the
135/// pack writer — is identical, which is why `gunnar-http` wraps these state
136/// machines instead of restating them (one implementation).
137///
138/// The difference is exactly three things:
139///
140/// | | [`Connected`](Session::Connected) | [`StatelessRpc`](Session::StatelessRpc) |
141/// |---|---|---|
142/// | who writes the advertisement | this crate, inline | `GET /info/refs`, a separate call to [`upload_pack::UploadPack::advertise`] |
143/// | v2 commands per call | until the client stops | exactly one |
144/// | v0 `have` rounds per call | until the client sends `done` | one; a round with no `done` answers `ACK`/`NAK` and returns |
145///
146/// A `Session` is **not** a protocol version and does not interact with one:
147/// both v0 and v2 ride both transports.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum Session {
150 /// One connection carries the whole conversation. SSH, `ext::`, a pipe.
151 #[default]
152 Connected,
153 /// One call is one request and one response; the next round, if there is
154 /// one, arrives on a different connection. Smart HTTP.
155 StatelessRpc,
156}
157
158impl Session {
159 /// Does this session write its own advertisement before reading?
160 ///
161 /// Over HTTP it does not: the advertisement is a *different request*
162 /// (`GET /info/refs?service=…`), and a `POST` body that began with one
163 /// would put the whole ref list in front of every negotiation round.
164 pub fn advertises_inline(self) -> bool {
165 matches!(self, Session::Connected)
166 }
167
168 /// Must this call return after answering one round?
169 pub fn is_stateless(self) -> bool {
170 matches!(self, Session::StatelessRpc)
171 }
172}
173
174pub use graph::{objects_to_send, CONNECTIVITY_BUDGET, DEFAULT_BUDGET};
175pub use limits::{
176 Limits, WalkPool, DEFAULT_OBJECT_LIMIT_BYTES, DEFAULT_REQUEST_LIMIT_BYTES,
177 DEFAULT_WALK_LIMIT_BYTES, WALK_BYTES_PER_OBJECT,
178};
179pub use negotiate::Negotiation;
180pub use pack::{IndexedPack, PackVersion, WriteOutcome};
181pub use receive_pack::{
182 PackCeiling, PackIngest, PackLedger, PackReceipt, Preamble, PushFacts, PushGate, PushOutcome,
183 ReceivePack, RefCommand, RefStatus, GIT_TARGET,
184};
185pub use refs::RefSnapshot;
186pub use upload_pack::{FetchRequest, LsRefsRequest, UploadPack, UploadSummary, WantPolicy};
187pub use wire_io::{err_packet, TRANSPORT_BUFFER_BYTES};
188
189/// Which version of the smart protocol a connection speaks.
190///
191/// The server does not choose. Over SSH the client asks by sending
192/// `GIT_PROTOCOL=version=2` as an environment variable, and a client that
193/// cannot, or a transport that drops it, gets v0. `gunnar-ssh` reads the
194/// variable off the channel and passes the answer in.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum ProtocolVersion {
197 /// The original protocol: a reference advertisement, then `want`/`have`.
198 #[default]
199 V0,
200 /// Command-oriented, capability-negotiated protocol v2.
201 V2,
202}
203
204impl ProtocolVersion {
205 /// Parse the `GIT_PROTOCOL` environment variable.
206 ///
207 /// The value is a colon-separated list of `key=value`, of which gunnar
208 /// only cares about `version`. Anything that is not `version=2` is v0:
209 /// absent, malformed, `version=1`.
210 ///
211 /// Protocol **v1** is deliberately treated as v0 rather than rejected:
212 /// v1 is v0 preceded by a single `version 1` pkt-line, and `git`'s own
213 /// version discovery treats a missing one as v0 and carries on. Answering
214 /// a v1 request with a v0 advertisement therefore works, where refusing it
215 /// would break a client for no benefit.
216 pub fn from_git_protocol(value: Option<&str>) -> Self {
217 let Some(value) = value else {
218 return ProtocolVersion::V0;
219 };
220 for item in value.split(':') {
221 if let Some(version) = item.trim().strip_prefix("version=") {
222 if version.trim() == "2" {
223 return ProtocolVersion::V2;
224 }
225 }
226 }
227 ProtocolVersion::V0
228 }
229
230 /// The number as it appears in `version=<n>`.
231 pub fn number(&self) -> u8 {
232 match self {
233 ProtocolVersion::V0 => 0,
234 ProtocolVersion::V2 => 2,
235 }
236 }
237}