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}