Skip to main content

gunnar_ssh/
seam.rs

1//! **The one remaining seam: temporary, and marked so.**
2//!
3//! # What used to be here, and why it is gone
4//!
5//! This module carried three traits, because `gunnar-core` (the ACL model) and
6//! `gunnar-wire` (the git wire protocol) were being written in parallel with this crate and
7//! blocking on either would have meant writing no server at all.
8//!
9//! **`gunnar-core` has landed, so two of them are deleted.** `AuthDirectory` and
10//! `RepoResolver` are now [`crate::control::Control`] over the real
11//! [`AuthTable`](gunnar_core::AuthTable) and [`RepoCatalog`](gunnar_core::RepoCatalog),
12//! and their test doubles went with them. They were not thin adapters worth
13//! keeping: both returned `Option`, and `gunnar-core` deliberately returns
14//! `Result` because the table lives in storage and a read can fail. Adapting one
15//! to the other means choosing an answer for the failure case, and the
16//! safe-looking choice (`None`) turns a storage fault into a silent permission
17//! denial. That is precisely what the ACL model says not to do, and it is what the
18//! seam was doing.
19//!
20//! **`gunnar-wire` has not landed**, so [`GitTransport`] stays exactly as it
21//! was. It is the seam the protocol state machines plug into, and wiring it is
22//! a separate job.
23//!
24//! | trait | delegates to | status |
25//! |---|---|---|
26//! | ~~`AuthDirectory`~~ | `gunnar_core::AuthTable` | **deleted** |
27//! | ~~`RepoResolver`~~ | `gunnar_core::RepoCatalog` | **deleted** |
28//! | [`GitTransport`] | `gunnar-wire`'s `upload_pack` / `receive_pack` | still a seam |
29//!
30//! # The test double
31//!
32//! [`BannerTransport`] is a **test double, not a backend**. It exists so this
33//! crate's tests can prove the SSH machinery end to end without `gunnar-wire`.
34//! Nothing outside a test should construct it.
35
36use std::future::Future;
37use std::pin::Pin;
38use std::sync::Mutex;
39
40use gunnar_core::{Fingerprint, Level, Principal, Repo};
41use tokio::io::{AsyncRead, AsyncWrite};
42
43use crate::command::GitService;
44
45/// A bidirectional byte stream: what an SSH channel is once the exec has been
46/// accepted.
47///
48/// Blanket-implemented, so `Box<dyn DuplexStream>` accepts russh's
49/// `ChannelStream` and, in a test, a `tokio::io::DuplexStream`.
50pub trait DuplexStream: AsyncRead + AsyncWrite + Unpin + Send {}
51
52impl<T: AsyncRead + AsyncWrite + Unpin + Send> DuplexStream for T {}
53
54/// Which part of a git conversation one call to the seam carries.
55///
56/// # Why an SSH crate names an HTTP shape
57///
58/// This module is **the seam**, not the SSH transport: it is the join every
59/// front end plugs the protocol state machines into, and `gunnar-http` plugs
60/// into this one rather than growing a second copy of it. That is the whole
61/// reason the crate docs above call `GitTransport` "the seam" and not "the SSH
62/// bridge".
63///
64/// The alternative was a second trait in `gunnar-http` with its own
65/// `ServiceRequest`, its own authorisation call and its own join to
66/// `gunnar-wire` — which is exactly the shape that produces two answers to
67/// *"may this principal push"*. Sharing one seam costs an enum with three
68/// variants and no dependencies; `gunnar-http` linking `gunnar-ssh` adds
69/// nothing to any build, because a gunnar server that speaks HTTP also speaks
70/// SSH and `gunnar-ssh` was already there.
71///
72/// If a build ever wants HTTP **without** SSH, this module and
73/// [`crate::server::Dispatch`] move to their own crate and nothing else
74/// changes. That build does not exist today, and inventing the crate for it
75/// now would be inventing a boundary before there is anything on both sides of
76/// it.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum Delivery {
79    /// One connection carries the advertisement, the negotiation and the pack.
80    /// This is SSH, and it is what `gunnar-wire`'s `Session::Connected` means.
81    #[default]
82    Session,
83    /// Smart HTTP `GET /<path>/info/refs?service=…`: write the advertisement
84    /// and stop. Nothing is read from the client.
85    Advertisement,
86    /// Smart HTTP `POST /<path>/<service>`: one stateless-rpc round.
87    StatelessRpc,
88}
89
90/// Everything the wire needs to know about the session it is serving.
91#[derive(Debug, Clone)]
92pub struct ServiceRequest {
93    /// Which service the client asked for.
94    pub service: GitService,
95    /// The repository, as the catalog holds it, carrying the hash kind the wire
96    /// has to echo in `object-format` (the git wire protocol) and the default branch.
97    ///
98    /// When [`Self::prospective`] is set this is the repository the
99    /// first-repository door **would** create — the same value
100    /// `RepoCatalog::create` would write — and it is not in the catalog yet.
101    pub repo: Repo,
102    /// **The first-repository door is open for this push, and nothing has
103    /// been written.** Only ever `true` for [`GitService::ReceivePack`] or an
104    /// LFS upload (`GitService::LfsTransfer(Upload)`, 2026-09-28) to a
105    /// repository that does not exist, by a principal who may create it. An
106    /// LFS upload's repository is made when its first object arrives. The
107    /// transport advertises `repo` as an empty repository, reads the client's
108    /// commands, checks the hash, and only then asks
109    /// `Control::admit_push_create` to write the row and makes the store. A
110    /// client that hangs up at the advertisement leaves no trace.
111    pub prospective: bool,
112    /// Who is asking. Carried into the push attestation and the
113    /// event stream.
114    pub principal: Principal,
115    /// The level the principal was found to hold, which the wire may need for
116    /// finer decisions than "may push at all".
117    pub access: Level,
118    /// **The key this session actually authenticated with**.
119    ///
120    /// `None` means no key did, the anonymous read-only path, which
121    /// is a real state and not a missing value, so it is an `Option` rather
122    /// than an empty string.
123    ///
124    /// The server computed this in `auth_publickey` and looked the principal up
125    /// by it, then used to drop it. Downstream, `gunnar-cli`'s push attestation
126    /// and its `RefUpdated` / `RefDeleted` events had to *guess* it back out of
127    /// the identity table, and could only do so when the principal held exactly
128    /// one key, so a real deploy account with two keys published an empty
129    /// fingerprint, and the audit trail could not answer "which key pushed
130    /// this".
131    ///
132    /// It is a **parameter of [`crate::Dispatch::authorise`]**, not something
133    /// attached afterwards like [`ServiceRequest::with_git_protocol`], and the
134    /// difference is deliberate: an attach-after-the-fact field can be
135    /// forgotten, and a forgotten one is silently `None`, which is exactly the
136    /// shape of the bug this closes. The compiler asks for it instead.
137    /// `git_protocol` is the other way round for the opposite reason: it comes
138    /// from the *client*, and must not be able to reach the grant check.
139    pub key_fingerprint: Option<Fingerprint>,
140    /// The raw value of the client's `GIT_PROTOCOL` environment variable, if it
141    /// sent one and the server's allowlist accepted it (see
142    /// [`crate::server::ENV_ALLOWLIST`]).
143    ///
144    /// **Raw, not parsed**, and deliberately: the grammar is
145    /// `gunnar-wire`'s (`ProtocolVersion::from_git_protocol`), and this crate
146    /// does not depend on `gunnar-wire`. Parsing it here would be a second
147    /// implementation of a version negotiation that already exists, and the two
148    /// could disagree about `version=1` (one implementation).
149    ///
150    /// `None` means the client sent nothing, which is v0. It is an `Option`
151    /// rather than a defaulted `ProtocolVersion` so that "the client asked for
152    /// v0" and "the client never got to ask" stay distinguishable in a log.
153    pub git_protocol: Option<String>,
154    /// Which part of the conversation this call carries.
155    ///
156    /// [`Delivery::Session`] for SSH, always. Attached afterwards like
157    /// [`ServiceRequest::git_protocol`] rather than taken as a parameter of
158    /// [`crate::server::Dispatch::authorise`], and unlike the key fingerprint
159    /// that is safe here for a specific reason: **forgetting it fails loudly.**
160    /// An HTTP `POST` served as a `Session` writes a ref advertisement in front
161    /// of the negotiation, and `git` rejects that on the first packet. The
162    /// fingerprint's failure mode is the opposite — a forgotten one is silently
163    /// `None` and shows up months later as an empty field in an audit trail —
164    /// which is why that one is a parameter and this one is not.
165    pub delivery: Delivery,
166    /// **Which credential authenticated this request**, as the audit trail
167    /// should name it: `key:SHA256:…` or `token:<hash prefix>`.
168    ///
169    /// `None` means a caller that presented no credential at all — the
170    /// anonymous read path — and it is the ONLY thing `None` means here.
171    ///
172    /// It exists because [`Self::key_fingerprint`] cannot mean that (GUN-014).
173    /// HTTP authenticates with a **token**, so there is no key to put in that
174    /// field and it is `None` on every HTTP request, `git-receive-pack`
175    /// included — while the code that consumes it states that an empty
176    /// fingerprint has "one reason only — the anonymous, read-only path… That
177    /// path cannot push". A push therefore reached `RefUpdated`, `RefDeleted`
178    /// and the attestation with an empty fingerprint and no other field to say
179    /// who had made it.
180    ///
181    /// A builder and not a parameter, unlike the fingerprint, and the reason
182    /// is the opposite of that one's: the SSH front door does not set it at
183    /// all. `key_fingerprint` is already the answer there, and deriving
184    /// `key:<fp>` in one place (`transport::session_credential`) is one
185    /// implementation rather than two front doors spelling the same prefix.
186    pub credential: Option<String>,
187    /// **The gate chain's warning line**: a Grace tenant's push is allowed and
188    /// told `plan expired <date>; renew at <url>`. The transport puts it on the
189    /// sideband so `git push` prints `remote: …`. `None` for every other state,
190    /// for every read, and on a community build (whose one gate never warns).
191    pub plan_notice: Option<String>,
192    /// **The gate chain's byte ceiling for this push**, the smallest any gate
193    /// asked for. The transport hands it to `gunnar-wire`, which counts the
194    /// pack off the wire and cuts the transfer past it — see
195    /// [`crate::PackCeiling`] and [`crate::PlanGate::pack_ceiling`].
196    ///
197    /// `None` for every read, for every write on a namespace with no byte cap,
198    /// and on a community build (whose one gate asks for no ceiling), and
199    /// `None` then means what it has always meant: read the pack to its end.
200    ///
201    /// Decided in `authorise_service` rather than looked up in the transport
202    /// for the same reason [`ServiceRequest::plan_notice`] is: the gate chain
203    /// lives on the front door's `Control`, and the transport has never been
204    /// able to see it.
205    ///
206    /// # What reads it today, said plainly
207    ///
208    /// **The receive path, and only the receive path.** The chain asks for a
209    /// ceiling wherever it asks `pack_quota`, which includes an LFS upload — and
210    /// the LFS transfer ignores this field, deliberately. What LFS needed was
211    /// its own cut against its own meter (`lfs_bytes`, not `pack_bytes`), and
212    /// since 2026-09-07 it has one: [`ServiceRequest::lfs_allowance`]. Half a
213    /// ceiling carried here would have been the twin of a limit that fires on
214    /// one transport and not the other. A `Machine` principal is never asked at
215    /// all ([`crate::PlanGate::applies_to_machine`]), so a primary's replication
216    /// push is never cut by a tenant's plan.
217    pub pack_ceiling: Option<crate::plan::PackCeiling>,
218    /// **Where this push stands in the namespace's ledger of pack bytes** — the
219    /// value the `pack_bytes` counter had when
220    /// [`crate::server::Dispatch::ask_plan`] measured, taken BEFORE any gate
221    /// walked the disk. `Some` exactly when [`Self::pack_ceiling`] is.
222    ///
223    /// `gunnar-server` binds the two together and hands the result to
224    /// `gunnar-wire`, whose receive loop charges the ledger for every chunk
225    /// before it takes it — so the ceiling is the room the whole namespace has
226    /// left, not the room each connection is separately told it has.
227    ///
228    /// # Why this is a field beside the ceiling and not a field INSIDE it
229    ///
230    /// The LFS wire folded its mark into the number
231    /// ([`gunnar_lfs::NamespaceAllowance`]) because there the two are used
232    /// together by one callee. Here they are not: the number goes into
233    /// `gunnar_wire::PackCeiling`, in a crate that cannot name a
234    /// `gunnar_lfs::Mark` and must not learn to, and the mark goes into the
235    /// same struct as a `dyn gunnar_wire::PackLedger`. They are joined at
236    /// `gunnar_server::transport`, the seam that already exists for exactly
237    /// this conversion, so folding them together here would be folding them for
238    /// the journey and nothing else.
239    ///
240    /// The other half of the reason is whose fact it is. [`crate::PackCeiling`]
241    /// is what a [`crate::PlanGate`] ANSWERED, and `PlanGate` is a public trait
242    /// anyone may implement; the mark is the SESSION's standing in a counter
243    /// this crate owns, taken before any gate is consulted and existing whether
244    /// or not one answers. Putting it in the gate's return type would have
245    /// asked every implementor to produce something only the dispatch can, and
246    /// changed the type of a field the server's tests read.
247    pub pack_mark: Option<gunnar_lfs::Mark>,
248    /// **The gate chain's LFS room for this transfer**, the smallest any gate
249    /// admitted — the twin of [`ServiceRequest::pack_ceiling`] one wire over.
250    /// The transport hands it to `gunnar_lfs::Session` (SSH) and to
251    /// [`LfsAccess`] (smart HTTP), and an upload past it is refused 507 with the
252    /// plan's own sentence, before the object's bytes.
253    ///
254    /// `None` for every read, for every namespace with no `lfs_bytes` cap, and
255    /// on a community build — and `None` means what the pack ceiling's `None`
256    /// means: take what arrives.
257    ///
258    /// The pack ceiling would not have done: they are different meters on the
259    /// signed fact, they are reported separately by `Usage.Read`, and one is
260    /// counted in a receive loop while the other is declared up front. See
261    /// [`crate::PlanGate::lfs_allowance`].
262    ///
263    /// # Why a `NamespaceAllowance` and not the plain `gunnar_lfs::Allowance`
264    ///
265    /// Because a plain one is a number measured for ONE transfer, and two
266    /// transfers that started together measured the same "before". This one
267    /// carries, beside the number, where the transfer stands in the namespace's
268    /// ledger of bytes admitted since — so the door counts what the other
269    /// connections are already doing. `gunnar-server` still decides the number
270    /// (`PlanGate::lfs_allowance` returns the plain `Allowance` and is
271    /// unchanged); [`crate::server::Dispatch::ask_plan`] binds it to the
272    /// ledger, which is also the one place that can guarantee the mark is taken
273    /// BEFORE the disk is walked. See `gunnar_lfs::ledger`.
274    pub lfs_allowance: Option<gunnar_lfs::NamespaceAllowance>,
275    /// **Set when the client is gone and the transfer must stop** at its next
276    /// safe point (`crate::channel::serve_until_session_ends`). A transport
277    /// whose git work runs off the async task checks it there, so the work
278    /// ends before the fan worker serving it is given back.
279    pub cancel: crate::channel::Cancel,
280}
281
282impl ServiceRequest {
283    /// Attach the client's `GIT_PROTOCOL` value.
284    ///
285    /// Separate from the authorisation decision on purpose: what a client may
286    /// do is decided from its key and the catalog, and an environment variable
287    /// must never be able to reach that decision. This carries a *hint about
288    /// framing* to the wire, nothing more.
289    pub fn with_git_protocol(mut self, value: Option<String>) -> Self {
290        self.git_protocol = value;
291        self
292    }
293
294    /// Say which part of the conversation this call carries. See
295    /// [`Delivery`].
296    pub fn with_delivery(mut self, delivery: Delivery) -> Self {
297        self.delivery = delivery;
298        self
299    }
300
301    /// Name the credential this request authenticated with. See
302    /// [`ServiceRequest::credential`].
303    pub fn with_credential(mut self, credential: Option<String>) -> Self {
304        self.credential = credential;
305        self
306    }
307}
308
309/// What went wrong while serving.
310#[derive(Debug, thiserror::Error)]
311pub enum TransportError {
312    /// The protocol state machine refused the request.
313    #[error("protocol error: {0}")]
314    Protocol(String),
315    /// The stream failed.
316    #[error("i/o error: {0}")]
317    Io(#[from] std::io::Error),
318    /// Not wired yet. **This is the honest answer until `gunnar-wire` lands**,
319    /// and it must never quietly become a successful empty response: a clone
320    /// that returns no objects and exits zero looks exactly like a working
321    /// server serving an empty repository (measured, not asserted).
322    #[error("not implemented: {0}; the wire seam is not connected to gunnar-wire yet")]
323    Unimplemented(&'static str),
324    /// The transfer was refused **before a single byte reached the client**, and
325    /// the string is the sentence the client is owed.
326    ///
327    /// This variant exists because the other three could not carry one. A
328    /// transfer that failed after the advertisement went out cannot be answered
329    /// with a fresh pkt-line — the client is mid-pack and the framing belongs to
330    /// `gunnar-wire`'s sideband, which is what `UploadPack::report_error` is
331    /// for. A transfer that failed *before* the first write can be answered, and
332    /// until this variant existed it was not: the session logged the whole
333    /// reason and sent the client an exit status and no words, which git renders
334    /// as `the remote end hung up unexpectedly`.
335    ///
336    /// So the variant is the *fact* "nothing has been written yet", not a
337    /// severity. Only a construction site that knows that may use it, which is
338    /// why it is a distinct variant rather than a flag on
339    /// [`TransportError::Protocol`].
340    #[error("{0}")]
341    Refused(String),
342}
343
344impl TransportError {
345    /// What the client is told, or `None` when the client cannot be told
346    /// anything.
347    ///
348    /// Named after [`crate::server::DispatchError::client_message`] and
349    /// `gunnar_wire::Error::client_message` because it is the same contract in a
350    /// third place (one implementation): a refusal the operator can see and the user cannot
351    /// is half a fix.
352    ///
353    /// `None` for [`TransportError::Protocol`] and [`TransportError::Io`]
354    /// **on purpose**, and it is the conservative answer rather than the
355    /// convenient one: those two are raised from inside a transfer that may
356    /// already have written an advertisement, a `NAK` or half a pack, and a raw
357    /// pkt-line appended to that stream is corruption, not an error message.
358    pub fn client_message(&self) -> Option<String> {
359        match self {
360            TransportError::Protocol(_) | TransportError::Io(_) => None,
361            // Nothing has been served, by definition of the variant, so the
362            // client can be told — and must be, because the alternative is a
363            // clone that exits non-zero with no reason at all.
364            TransportError::Unimplemented(_) => Some(self.to_string()),
365            TransportError::Refused(message) => Some(message.clone()),
366        }
367    }
368}
369
370/// An authorised repository's LFS object store, and what it will accept.
371///
372/// Returned by [`LfsTransport::open_lfs`], and deliberately not a session: LFS
373/// over HTTP is four independent requests (`batch`, `GET`, `PUT`, `verify`),
374/// each authorised on its own, so there is nothing for a session to hold
375/// between them. What the two surfaces share is the *store* and the *limits*,
376/// which is exactly what this carries.
377pub struct LfsAccess {
378    /// Where the objects are. `Arc` because the caller runs the actual I/O on a
379    /// blocking thread and has to own it there.
380    pub store: std::sync::Arc<dyn gunnar_lfs::LfsStore>,
381    /// The quota and the batch cap in force, from the **same**
382    /// `WireConfig::lfs` the SSH session is built with. A second home for these
383    /// is how 8 GiB comes to be enforced on one transport and not the other.
384    pub limits: gunnar_lfs::Limits,
385    /// **The tenant's remaining LFS room**, decided by the same
386    /// [`crate::PlanGate`] chain that decided
387    /// [`crate::ServiceRequest::pack_ceiling`] for a push, and `None` wherever a
388    /// push would get no ceiling: a community build, an uncapped namespace, a
389    /// download.
390    ///
391    /// Beside [`Self::limits`] rather than inside it because they are different
392    /// kinds of number. `limits` is the operator's, one set for the whole box,
393    /// `Copy`, and comes off the command line; this is one namespace's plan,
394    /// re-measured per transfer, and carries a sentence built from a signed
395    /// entitlement fact. A tenant who fills it is told to buy more; a client
396    /// that trips `limits` is told the box does not take objects that big.
397    pub allowance: Option<gunnar_lfs::NamespaceAllowance>,
398}
399
400impl std::fmt::Debug for LfsAccess {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        f.debug_struct("LfsAccess")
403            .field("limits", &self.limits)
404            .finish_non_exhaustive()
405    }
406}
407
408/// Open the LFS object store of a repository a caller has **already been
409/// authorised for**.
410///
411/// # Why this is a seam and not a store handed to `gunnar-http` directly
412///
413/// The same reason [`GitTransport`] is. `gunnar-http` knows HTTP and the LFS
414/// batch API; it must not know that a repository is a directory, that the
415/// objects live under `<git_dir>/lfs/`, or that opening one can take a lock.
416/// Those are `gunnar-cli`'s, which owns `ServerStores` and the one
417/// `WireConfig` both transports are configured from.
418///
419/// # `request` is the output of the ACL, not an input to it
420///
421/// The parameter is a [`ServiceRequest`], which can only be got out of
422/// [`crate::server::Dispatch::authorise_service`]. So an implementation cannot
423/// be called without the grant check having already happened, and there is no
424/// second authorisation path — the same property `GitTransport` has, held the
425/// same way. `request.service` is `GitService::LfsTransfer(operation)` and the
426/// operation in it is the one that demanded Read or Write.
427///
428/// # Blocking
429///
430/// Implementations do filesystem work. The caller runs them on a blocking
431/// thread; nothing here is async, for the same reason `gunnar_lfs::Session` is
432/// not.
433pub trait LfsTransport: Send + Sync + 'static {
434    /// The object store for `request`'s repository.
435    fn open_lfs(&self, request: &ServiceRequest) -> Result<LfsAccess, TransportError>;
436}
437
438/// Serve `git-upload-pack` / `git-receive-pack` over an accepted channel.
439///
440/// This is `gunnar-wire`'s entry point seen from the SSH side. The returned
441/// future owns the stream for the whole of the transfer; the server spawns it
442/// rather than awaiting it inline, because russh dispatches handler calls from
443/// the session's own read loop and awaiting there would stop the loop that has
444/// to deliver the client's bytes.
445pub trait GitTransport: Send + Sync + 'static {
446    /// Serve one request to completion.
447    fn serve<'a>(
448        &'a self,
449        request: ServiceRequest,
450        stream: Box<dyn DuplexStream>,
451    ) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + 'a>>;
452}
453
454/// A [`GitTransport`] that writes one line describing the request it was given,
455/// then reads the client out. **A test double.**
456///
457/// It exists to prove a specific thing that a mock recording a call cannot: that
458/// the *byte stream* reached the seam, in both directions, after a real SSH
459/// handshake. The line it writes names the service and the repository, so a test
460/// asserting on it is asserting on applied output rather than on a flag someone
461/// set (measured, not asserted).
462#[derive(Debug, Default)]
463pub struct BannerTransport {
464    served: Mutex<Vec<String>>,
465    git_protocols: Mutex<Vec<Option<String>>>,
466    settle_after_release: bool,
467    fail_at_the_end: bool,
468    /// Set by [`BannerTransport::refusing`]: the transport writes **nothing** and
469    /// returns [`TransportError::Refused`], which is the shape of every
470    /// post-authorisation refusal — a held store lock, a repository that will not
471    /// open, an unwired service.
472    refuse_before_writing: Option<String>,
473    /// Set by [`BannerTransport::holding`]: the transport writes its banner and
474    /// then does not return until the paired [`TransportHold`] is released.
475    hold: Option<tokio::sync::watch::Receiver<bool>>,
476}
477
478/// The release lever of [`BannerTransport::holding`].
479///
480/// Dropping it releases every held transfer, so a test that panics mid-way
481/// cannot wedge a server task forever.
482#[derive(Debug)]
483pub struct TransportHold {
484    tx: tokio::sync::watch::Sender<bool>,
485}
486
487impl TransportHold {
488    /// Let every held transfer finish.
489    pub fn release(&self) {
490        let _ = self.tx.send(true);
491    }
492}
493
494impl Drop for TransportHold {
495    fn drop(&mut self) {
496        self.release();
497    }
498}
499
500impl BannerTransport {
501    /// A transport that has served nothing yet.
502    pub fn new() -> Self {
503        Self::default()
504    }
505
506    /// The same transport, but it **drops the stream and then waits** before it
507    /// returns.
508    ///
509    /// This is the red half of the exit-status ordering guard, and it is worth
510    /// spelling out why it is not a contrivance.
511    ///
512    /// russh's `Channel::into_stream()` wraps the channel in
513    /// `ChannelCloseOnDrop`, whose `Drop` **spawns a task** that sends
514    /// `ChannelMsg::Close`. A server that hands the transport such a stream and
515    /// sends `exit-status` afterwards is in a race with that task: the ordinary
516    /// path wins it on a current-thread runtime (the send takes an available
517    /// permit without yielding) and loses it often enough on a multi-threaded
518    /// one to make a *successful* push report failure (measured 2026-08-03,
519    /// `ssh` exit 255 with a complete 244-byte advertisement).
520    ///
521    /// A test on the ordinary path therefore proves nothing: it is green with
522    /// the bug present. This mode gives the spawned task the scheduling it needs
523    /// to run, which turns "usually" into "always" **in the failing direction
524    /// only**. A server that closes nothing on drop is unaffected by any amount
525    /// of waiting here, so a green under this transport is not a timing
526    /// accident; it is the absence of the close.
527    pub fn settling_after_it_releases_the_stream() -> Self {
528        BannerTransport {
529            settle_after_release: true,
530            ..Self::default()
531        }
532    }
533
534    /// A transport whose transfers **stay in flight** until the returned
535    /// [`TransportHold`] is released.
536    ///
537    /// This is what makes the admission gate testable at all. A limit on
538    /// *concurrent* transfers cannot be reached by a transport that finishes
539    /// instantly: a hundred sequential clones never put two in flight together,
540    /// and a test written against the ordinary banner would be asserting on
541    /// scheduling luck. Here the first N transfers are genuinely simultaneous
542    /// and stay that way until the test says otherwise.
543    ///
544    /// The banner is written **before** the wait, so a client knows its transfer
545    /// was admitted rather than merely accepted.
546    pub fn holding() -> (Self, TransportHold) {
547        let (tx, rx) = tokio::sync::watch::channel(false);
548        (
549            BannerTransport {
550                hold: Some(rx),
551                ..Self::default()
552            },
553            TransportHold { tx },
554        )
555    }
556
557    /// A transport that refuses **without writing a byte**, the way a transfer
558    /// whose repository will not open does.
559    ///
560    /// Distinct from [`BannerTransport::that_then_fails`] on the one axis that
561    /// matters: that one writes its banner first, so the client has already had
562    /// output and the framing is no longer the session's to add to. This one has
563    /// written nothing, which is the *only* condition under which the session may
564    /// still hand the client a reason.
565    pub fn refusing(message: impl Into<String>) -> Self {
566        Self {
567            refuse_before_writing: Some(message.into()),
568            ..Self::default()
569        }
570    }
571
572    /// …and then reports that the transfer failed.
573    ///
574    /// The other direction of the same guard: a **non-zero** status has to
575    /// survive the stream being released too. Without this, the exit-status
576    /// assertions could all be satisfied by a server that hard-coded zero.
577    pub fn that_then_fails(mut self) -> Self {
578        self.fail_at_the_end = true;
579        self
580    }
581
582    /// Everything it has served, as `"<verb> <ns>/<repo> <principal-id>"`.
583    pub fn served(&self) -> Vec<String> {
584        self.served.lock().expect("banner transport lock").clone()
585    }
586
587    /// The `GIT_PROTOCOL` value that arrived with each served request, in the
588    /// same order as [`BannerTransport::served`].
589    ///
590    /// Recorded separately from the banner so that a test can assert *what the
591    /// wire would negotiate with* without changing the byte the existing
592    /// end-to-end assertions read back over SSH.
593    pub fn git_protocols(&self) -> Vec<Option<String>> {
594        self.git_protocols
595            .lock()
596            .expect("banner transport lock")
597            .clone()
598    }
599
600    /// The line this transport writes for `request`.
601    pub fn banner(request: &ServiceRequest) -> String {
602        format!(
603            "{} {} {}\n",
604            request.service.verb(),
605            request.repo.key(),
606            request.principal.id
607        )
608    }
609}
610
611impl GitTransport for BannerTransport {
612    fn serve<'a>(
613        &'a self,
614        request: ServiceRequest,
615        mut stream: Box<dyn DuplexStream>,
616    ) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + 'a>> {
617        Box::pin(async move {
618            use tokio::io::AsyncWriteExt;
619            // Before the banner, before `served` is recorded: a refusal that had
620            // already written or already counted itself would not be the thing
621            // under test.
622            if let Some(message) = &self.refuse_before_writing {
623                return Err(TransportError::Refused(message.clone()));
624            }
625            let banner = BannerTransport::banner(&request);
626            self.served
627                .lock()
628                .expect("banner transport lock")
629                .push(banner.trim_end().to_string());
630            self.git_protocols
631                .lock()
632                .expect("banner transport lock")
633                .push(request.git_protocol.clone());
634            stream.write_all(banner.as_bytes()).await?;
635            stream.flush().await?;
636            if let Some(hold) = &self.hold {
637                // Wait, still holding the stream, which is what an admitted,
638                // in-flight transfer looks like to the admission gate.
639                // A transfer told to stop (its client gone past the grace)
640                // stops, as the real transport does at its next safe point.
641                let mut hold = hold.clone();
642                let released = async {
643                    while !*hold.borrow_and_update() {
644                        if hold.changed().await.is_err() {
645                            break;
646                        }
647                    }
648                };
649                tokio::select! {
650                    () = released => {}
651                    () = request.cancel.cancelled() => {
652                        return Err(TransportError::Io(std::io::Error::new(
653                            std::io::ErrorKind::ConnectionAborted,
654                            "the banner double was told to stop: its client is gone",
655                        )));
656                    }
657                }
658            }
659            stream.shutdown().await?;
660            if self.settle_after_release {
661                // Explicit, so the drop happens here rather than at the end of
662                // the block, and anything it spawned has somewhere to run.
663                drop(stream);
664                for _ in 0..16 {
665                    tokio::task::yield_now().await;
666                }
667                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
668            }
669            if self.fail_at_the_end {
670                return Err(TransportError::Protocol(
671                    "the banner double was asked to report a failed transfer".to_string(),
672                ));
673            }
674            Ok(())
675        })
676    }
677}