Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-http/src/service.rs

//! One HTTP request, answered.
//!
//! ```text
//!  route  →  authenticate  →  Dispatch::authorise_service  →  GitTransport
//!  (ACL sandbox) (token)       (the ONE ACL call)             (gunnar-wire)
//! ```
//!
//! # The status codes, and why each one is that one
//!
//! | | |
//! |---|---|
//! | **401** + `WWW-Authenticate: Basic` | nothing was presented and the request is not allowed anonymously. Also what a *wrong* credential gets. This is what makes `git` prompt. |
//! | **403** | credentials over cleartext, and the dumb protocol. Retrying will not help, and a 401 would invite the client to send the password again. |
//! | **404** | authenticated, and the repository either does not exist or is not theirs. **The two are one answer**, exactly as over SSH: a caller who could tell them apart could enumerate private repositories one guess at a time. |
//! | **503** | the identity table or the catalog could not be read. A fault, never reported as a refusal. |
//!
//! The 401-before-404 order is what keeps the indistinguishability honest: an
//! *unauthenticated* request gets 401 whether the repository is private,
//! public-but-anonymous-disabled, or absent, so the response says nothing about
//! which.
//!
//! # Errors after the first byte
//!
//! Once the response head is out, there is no status code left to send. A
//! transport failure mid-pack therefore ends the body early and is logged
//! server-side — which is what `git http-backend` does too, and what `git`
//! reports as an unexpected disconnect. Everything that *can* be decided before
//! the head is: routing, authentication and the grant check all happen first.

use std::sync::Arc;

use bytes::Bytes;
use http::{header, HeaderValue, Method, Request, Response, StatusCode};
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;

use gunnar_core::observe::{RequestId, ServerMetrics, TARGET_GIT};
use gunnar_core::AuthTable;
use gunnar_ssh::seam::{Delivery, GitTransport, ServiceRequest};
// **The admission gate is `gunnar-ssh`'s, reused — not a second one.**
// `Admission`/`AdmissionGuard`/`Refusal` are the same types the SSH front
// acquires a permit from, `Metered` is the same byte-meter that feeds the
// memory ledger, and `OpClass::for_ssh_service` classifies an HTTP transfer by
// exactly the wire service it carries. `gunnar-http` already depends on
// `gunnar-ssh` for the seam, so this door costs no new edge.
use gunnar_ssh::admission::Metered;
use gunnar_ssh::governor::OpClass;
use gunnar_ssh::{Admission, AdmissionGuard, Refusal};
use tracing::Instrument as _;

/// The header every response carries the request id in.
///
/// Lower-case and `x-`-prefixed: it is gunnar's own, not a registered one, and
/// a client that does not know it ignores it. `git` prints response headers
/// under `GIT_CURL_VERBOSE=1`, which is how an operator reads it off a failing
/// clone without a proxy in the middle.
pub const REQUEST_ID_HEADER: header::HeaderName =
    header::HeaderName::from_static("x-gunnar-request-id");
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::GitService;

use crate::auth::{self, AuthError, AuthenticateError};
use crate::body::{self, HalfDuplex, NoBody, RequestBody, Streamed};
use crate::lfs;
use crate::route::{self, RouteError};
use crate::HttpConfig;

/// A request body, in whichever shape its endpoint needs.
///
/// The two JSON endpoints have theirs read and bounded before the grant check
/// (their content selects the grant); an object transfer keeps hyper's stream,
/// because an LFS object is a video and must never be collected.
enum Bodied {
    /// Already read, bounded by [`lfs::MAX_BATCH_REQUEST_BYTES`].
    Json(Vec<u8>),
    /// Still on the socket.
    Raw(Incoming),
}

/// The response body type: one box so a fixed message and a streamed packfile
/// are the same type.
pub type Body = BoxBody<Bytes, std::io::Error>;

/// Everything one listener needs to answer a request.
#[derive(Clone)]
pub struct GitHttp {
    dispatch: Dispatch,
    transport: Arc<dyn GitTransport>,
    /// The LFS object store seam, or `None` on a listener that serves git over
    /// HTTP and no LFS.
    ///
    /// An `Option` rather than a default no-op implementation, because a no-op
    /// would answer every batch `200 {"objects":[]}` and a clone would report
    /// success with pointer files on disk — the exact failure this surface was
    /// built to end. `None` answers `501`, which git-lfs reports.
    lfs: Option<Arc<dyn gunnar_ssh::seam::LfsTransport>>,
    config: HttpConfig,
    metrics: Arc<ServerMetrics>,
    /// **The one admission gate this listener routes every git transfer
    /// through** (one implementation).
    ///
    /// Before this field the smart-HTTP front had no ceiling at all: 64
    /// concurrent clients got 64 slots and nothing was ever refused, so an
    /// HTTP-fronted deployment had zero backpressure and no counter to see it
    /// on. It is a `gunnar_ssh::Admission` and not a second design because the
    /// two transports must count against **one** server-wide limit, **one**
    /// per-principal share and **one** memory budget — the binary hands the SSH
    /// listener's own `Arc` in with [`GitHttp::with_admission`] so it is
    /// literally the same gate. A listener built without that call still gets a
    /// safe default (the shipped SSH ceilings), never an unmetered one.
    admission: Arc<Admission>,
}

impl GitHttp {
    /// Serve `dispatch`'s repositories through `transport`.
    pub fn new(dispatch: Dispatch, transport: Arc<dyn GitTransport>, config: HttpConfig) -> Self {
        GitHttp {
            dispatch,
            transport,
            lfs: None,
            config,
            metrics: Arc::new(ServerMetrics::new()),
            // The shipped SSH defaults, so a bare `gunnar-http` listener is
            // never the unmetered one the HTTP front used to be. The binary overrides this
            // with the SSH listener's own gate (`with_admission`), which is the
            // only way the two fronts share a limit rather than each holding
            // half of it.
            admission: Arc::new(Admission::with_memory_budget(
                Some(gunnar_ssh::config::DEFAULT_MAX_CONCURRENT_TRANSFERS),
                Some(gunnar_ssh::config::DEFAULT_MAX_TRANSFERS_PER_PRINCIPAL),
                gunnar_ssh::governor::DEFAULT_MEMORY_BUDGET_BYTES,
            )),
        }
    }

    /// Route this listener's git transfers through an **existing** admission
    /// gate — the SSH listener's own (one implementation).
    ///
    /// This is the wiring that makes SSH and HTTP one server rather than two: a
    /// transfer over either transport takes a slot from the same server-wide
    /// limit, is held to the same per-principal share, and reserves against the
    /// same memory budget. Without it a `--http-bind` appliance would serve up
    /// to its whole configured concurrency **again**, on top of SSH, which is
    /// the doubled ceiling nobody would have chosen.
    pub fn with_admission(mut self, admission: Arc<Admission>) -> Self {
        self.admission = admission;
        self
    }

    /// What the admission gate has done since this listener started — the
    /// visibility half of the shared gate, the mirror of
    /// [`gunnar_ssh::BoundServer::admission`].
    ///
    /// A ceiling whose saturation cannot be read is the same undiagnosable "the
    /// server is slow" the SSH front carried before it had a ceiling; `admitted`,
    /// `refused_*`, `in_flight` and `peak_in_flight` are what an operator, or a
    /// test, reads instead. When the gate is shared with SSH this counts both
    /// transports, which is the point.
    pub fn admission(&self) -> gunnar_ssh::AdmissionStats {
        self.admission.stats()
    }

    /// Serve Git LFS over HTTP out of `lfs`.
    ///
    /// Without this the LFS URLs answer `501`. It is a separate call rather
    /// than a fourth parameter of [`GitHttp::new`] so that `gunnar-http`'s own
    /// tests can build a listener with no object store at all and assert what
    /// that one says.
    pub fn with_lfs(mut self, lfs: Arc<dyn gunnar_ssh::seam::LfsTransport>) -> Self {
        self.lfs = Some(lfs);
        self
    }

    /// Count into the **server's** counters rather than a private set.
    ///
    /// The binary mints one [`ServerMetrics`] and hands the same `Arc` to the
    /// SSH listener, the wire transport and here, so `connections_accepted` is
    /// one reading of one server and not one per transport.
    pub fn with_metrics(mut self, metrics: Arc<ServerMetrics>) -> Self {
        self.metrics = metrics;
        self
    }

    /// The identity table, for authentication.
    fn auth(&self) -> &AuthTable {
        self.dispatch.control().auth()
    }

    /// Answer one request, under a span that names it.
    ///
    /// # Every line this request emits carries one id, and so does the answer
    ///
    /// SSH has had `ssh.connection` with a `conn` since the connection span landed: a
    /// [`RequestId`] on a span, `principal` and `repo` declared `Empty` and
    /// recorded as they become known, so no call site repeats a field and
    /// `conn=<id>` greps a whole transfer out of a log. HTTP had **nothing**.
    /// Its lines named a principal here, a path there and a repository
    /// somewhere else, the `git.upload_pack.served` line that `gunnar-cli`
    /// emits for an HTTP transfer named none of them, and there was no way at
    /// all to tie two of them together — let alone to tie any of them to the
    /// `500` a client was looking at.
    ///
    /// So this is the same scheme, not a second one (one implementation): the same
    /// `RequestId`, the same `Empty`-then-`record` shape, the same targets.
    ///
    /// **One span per REQUEST, not per connection.** HTTP/1.1 keep-alive
    /// serves many requests down one socket — a `GET /info/refs` and the
    /// `POST` that follows it, and often a refusal before either — and a
    /// connection-scoped id would file all of them under one identifier,
    /// which is the opposite of what an operator is trying to do.
    ///
    /// The id also goes back on the wire, as `x-gunnar-request-id`, on **every
    /// response including every refusal**. That is the half that makes it
    /// worth anything: an operator holding a client's `403` can now name the
    /// line in the server's log, which is precisely what the counters record said they
    /// could not do.
    pub async fn call(
        &self,
        request: Request<Incoming>,
        peer: Option<std::net::SocketAddr>,
    ) -> Response<Body> {
        let id = RequestId::next();
        let span = tracing::info_span!(
            target: TARGET_GIT,
            "http.request",
            req = %id,
            method = %request.method(),
            path = request.uri().path(),
            peer = tracing::field::Empty,
            principal = tracing::field::Empty,
            repo = tracing::field::Empty,
        );
        if let Some(peer) = peer {
            span.record("peer", tracing::field::display(peer));
        }
        let mut response = self
            .serve_one(request, &span, id)
            .instrument(span.clone())
            .await;
        // Stamped here, once, on the way out. Every arm below — every refusal,
        // every stream — goes through this return, so no arm can forget it and
        // a new one cannot be added without it.
        response.headers_mut().insert(
            REQUEST_ID_HEADER,
            HeaderValue::from_str(&id.to_string()).expect("a request id is ascii hex"),
        );
        response
    }

    /// **Who is asking.** One authority, shared by the git and LFS surfaces,
    /// so the two cannot disagree about a credential.
    ///
    /// `Err` is the finished refusal, already counted (boxed: a response is
    /// several times the size of the answer).
    fn who(
        &self,
        request: &Request<Incoming>,
    ) -> Result<(auth::Credential, gunnar_core::Principal, Option<String>), Box<Response<Body>>>
    {
        let header_value = |name: &str| {
            request
                .headers()
                .get(name)
                .and_then(|value| value.to_str().ok())
        };
        let credential = auth::credential(header_value(header::AUTHORIZATION.as_str()))
            .map_err(|err| Box::new(self.refuse_auth(err.into())))?;
        let over_tls = auth::over_tls(
            self.config.cleartext,
            header_value(auth::FORWARDED_PROTO),
            self.config.tls.is_some(),
        );
        let (principal, token) = auth::principal_for(
            self.auth(),
            &credential,
            self.config.allow_anonymous,
            over_tls,
        )
        .map_err(|err| Box::new(self.refuse_auth(err)))?;
        Ok((credential, principal, token))
    }

    /// The request itself, with the span already entered.
    async fn serve_one(
        &self,
        request: Request<Incoming>,
        span: &tracing::Span,
        id: RequestId,
    ) -> Response<Body> {
        let method = request.method().clone();
        let path = request.uri().path().to_owned();
        let query = request.uri().query().map(str::to_owned);

        // The liveness probe answers before anything else: no credential, no
        // repository, nothing but "the HTTP listener is up".
        if route::is_health(&method, &path) {
            return status(StatusCode::OK, "ok");
        }

        let route = match route::resolve(&method, &path, query.as_deref()) {
            Ok(route) => route,
            Err(err) => return self.refuse_route(&method, &path, err),
        };

        // ── who ────────────────────────────────────────────────────────────
        let (credential, principal, token) = match self.who(&request) {
            Ok(who) => who,
            Err(refusal) => return *refusal,
        };
        // Recorded on the span, not repeated at each call site: from here on
        // every line this request emits carries the principal, including the
        // ones inside `gunnar-cli`'s transport that know nothing about HTTP.
        span.record("principal", tracing::field::display(&principal.id));
        span.record("repo", tracing::field::display(route.repo.key()));
        if principal.is_anonymous() {
            self.metrics.add_connections_anonymous(1);
        }
        if let Some(hash) = &token {
            // Best effort: a table that cannot be written must not stop a
            // request that has already been authenticated against it.
            //
            // 🚨 THIS IS THE SERVING TIER'S CONCURRENCY CEILING. MEASURED on
            // the reference desktop 2026-08-11, one `gunnar-server` pinned to a 4-CPU cpuset,
            // `ls-refs` over smart HTTP (the request path with no packfile
            // behind it), the same binary in both arms and the only difference
            // whether this line runs:
            //
            // | concurrent | with the touch | without | server CPU used |
            // |---|---|---|---|
            // |  1 |  236 req/s |  378 req/s | 0.85 → 0.96 of 4 |
            // | 16 |  573 req/s | 1416 req/s | 1.94 → 3.74 of 4 |
            // | 64 |  819 req/s | 1484 req/s | 2.59 → 3.84 of 4 |
            //
            // The mechanism is not this call's own cost, it is what it
            // contends on. `last_used` lives in ONE control document
            // (`refs/gunnar/auth`) shared by every principal and every token,
            // so [`gunnar_core::AuthTable::touch_token`] is a
            // read-modify-compare-and-swap that every authenticated request in
            // the process aims at the same pointer. At 16 concurrent requests
            // the server's own `store.doc_txn` log recorded **2 370 lost
            // compare-and-swaps across 1 600 requests** (1.48 retries each),
            // each one re-serialising the whole document and writing a fresh
            // blob, and **56 of those 1 600 exhausted `MAX_ATTEMPTS` and lost
            // the update silently** — this is `let _ =`, so a dropped
            // `last_used` says nothing to anybody. Commit latency was p50 2 ms,
            // p99 19 ms, max 31 ms for a metadata touch.
            //
            // Everything else on the request path measured flat: per-request
            // futex, `pread64` and `mprotect` counts do not grow from 1 to 16
            // clients, the `Mutex<HandleCache>` costs nothing (16 clients on 16
            // distinct repositories ran within 4% of 16 on one), and the pack
            // path itself saturates 96% of the CPUs it is given. This line is
            // the one place where CPU stops converting into throughput.
            //
            // It barely shows on a clone — 1.7 ms against ~120 ms of packfile,
            // and the fetch arm A/B'd inside noise — but time-to-first-byte at
            // 16 concurrent fetches went 1.4–2.1 ms → 9.7–56.6 ms, and a forge's
            // real traffic is small requests, not clones.
            //
            // FIXED 2026-08-12 by granularity, not removal:
            // `AuthTable::touch_token` now skips the CAS for a token whose
            // `last_used` is fresher than `LAST_USED_GRANULARITY` (60 s), so
            // the write leaves the steady-state request path — the measurement
            // that forced it is `gunnar.multiuser_scaling`, where 102 users
            // plateaued at 82 ops/s with 862 of ~1700 requests dropping the
            // update in one window. `touch_key` — the SSH twin — still has no
            // caller, so the two transports still do not agree about whether a
            // credential's use is recorded.
            //
            // A failed touch is best-effort but no longer silent: the request
            // has authenticated either way, and the debug line is what ties a
            // `store.doc_txn` give-up in the log to this caller.
            if let Err(err) = self.auth().touch_token(hash, gunnar_core::now_ms()) {
                tracing::debug!(%err, "last_used was not recorded for this request");
            }
        }

        // The LFS surface leaves here: it is the same authentication and, a few
        // lines further in, the *same* `authorise_service` call, but the
        // service it asks about cannot be known until the request body has been
        // read, so it cannot share the next paragraph.
        if let route::Kind::Lfs(endpoint) = route.kind.clone() {
            return self
                .serve_lfs(request, &route, endpoint, &principal, &credential, id)
                .await;
        }
        let (service, advertisement) = match route.kind {
            route::Kind::Advertisement(service) => (service, true),
            route::Kind::Rpc(service) => (service, false),
            // Handled above and returned; matched here rather than with a
            // wildcard so a new `Kind` is a compile error and not a 500.
            route::Kind::Lfs(_) => unreachable!("the lfs surface returned above"),
        };

        // ── may they ───────────────────────────────────────────────────────
        //
        // `authorise_service` is `gunnar-ssh`'s decision, entered from a URL
        // instead of an exec line. There is no second ACL here and there must
        // never be one.
        let service_request =
            match self
                .dispatch
                .authorise_service(&principal, None, service, route.repo.clone())
            {
                Ok(request) => request,
                Err(err) => return self.refuse_dispatch(&principal, &credential, err),
            };

        self.metrics.add_connections_accepted(1);
        let git_protocol = request
            .headers()
            .get("git-protocol")
            .and_then(|value| value.to_str().ok())
            .map(str::to_owned);
        let service_request = service_request
            // **Who this push will be attributed to** (GUN-014). `None` is
            // passed to `authorise_service` above for the key fingerprint and
            // is correct — HTTP authenticates with a token and there is no key
            // — but it left `RefUpdated`, `RefDeleted` and the attestation
            // with an empty fingerprint and no other field naming the
            // credential, while the code that reads it states an empty value
            // can only mean the anonymous read path. A PREFIX of the token's
            // hash, never the token and never its whole hash: enough to join
            // two pushes to one credential and to `Access.ListKeys`, and not
            // enough to be a credential itself if the log leaks.
            .with_credential(token.as_ref().map(|hash| {
                let hash = hash.as_str();
                format!("token:{}", &hash[..12.min(hash.len())])
            }))
            .with_git_protocol(git_protocol)
            .with_delivery(if advertisement {
                Delivery::Advertisement
            } else {
                Delivery::StatelessRpc
            });

        // ── the explicit ceiling, on the HTTP front door ───────────────────
        //
        // After authorisation, deliberately and for the same reason the SSH
        // path gates after it (`gunnar_ssh::server`): "the server is
        // busy" is a fact about the server, and telling it to a caller who may
        // not touch this repository would leak that the repository exists. The
        // order also means the gate meters transfers gunnar was going to serve.
        //
        // This is the SAME gate the SSH transfer takes a permit from — the same
        // server-wide limit, the same per-principal share, the same memory
        // budget — so a git transfer over HTTP counts against SSH's ceilings
        // and not a second set (one implementation). It **refuses** over the limit rather
        // than queueing; the response is a real `429 Too Many Requests`, which
        // a git client and a proxy both read as transient, so the caller is
        // told to come back instead of watching a socket go quiet.
        //
        // Each smart-HTTP request is its own transfer and takes its own slot:
        // the advertisement (`GET info/refs`, which is also `ls-remote`) and
        // the `POST` rpc are separate requests, and both do real work behind
        // the seam, so both are metered — exactly as the single SSH
        // `git-upload-pack` exec that carries both is.
        let class = OpClass::for_ssh_service(service);
        let guard =
            match self
                .admission
                .try_admit(&principal.id, &service_request.repo.namespace, class)
            {
                Ok(guard) => guard,
                Err(refusal) => return self.refuse_admission(&principal, service, refusal),
            };

        if advertisement {
            self.advertise(service_request, guard).await
        } else {
            self.rpc(request, service_request, guard).await
        }
    }

    /// The finished `429` for a transfer the admission gate would not admit,
    /// with the same counters on the log line the SSH refusal files.
    ///
    /// `429 Too Many Requests`, not `503`: on this listener `503` already means
    /// "a table could not be read" — a fault, never a refusal — and a
    /// saturation shed is the opposite, a healthy server saying *not right now*.
    /// `Retry-After` says so in the client's own vocabulary. The body is the
    /// gate's own `client_message`, so a human running `git clone` with
    /// `GIT_CURL_VERBOSE=1` reads the same sentence the log recorded.
    fn refuse_admission(
        &self,
        principal: &gunnar_core::Principal,
        service: GitService,
        refusal: Refusal,
    ) -> Response<Body> {
        // No separate `ServerMetrics` bump: the refusal counters live in the
        // admission gate itself (`AdmissionStats::refused_*`), which is exactly
        // where the SSH path reads them from too — one meter for one gate, not
        // a second tally that has to be kept in step with the first.
        let stats = self.admission.stats();
        tracing::warn!(
            target: TARGET_GIT,
            event = "http.transfer.refused",
            // Which gate, not just "a gate": the anonymous ceiling fires
            // with the box nearly empty, and a line that still said
            // `admission-gate-full` there would send an operator to raise a
            // limit that was never reached. See `gunnar_ssh::Refusal::reason`.
            reason = refusal.reason(),
            %service,
            principal = %principal.id,
            %refusal,
            in_flight = stats.in_flight,
            peak_in_flight = stats.peak_in_flight,
            // The GOVERNED limit in force at the refusal, not the configured
            // one — the same number the SSH refusal reports, for the same
            // reason: an operator who read one number from a config file and a
            // different one from a log would conclude the gate was broken.
            limit = stats.limit,
            reserved_bytes = stats.reserved_bytes,
            refused_total = stats.refused(),
            "smart-HTTP transfer REFUSED by the admission gate"
        );
        // A PAUSE is not load: 503 with a Retry-After in minutes, so a proxy
        // or a CI runner backs off for the growth's whole window instead of
        // hammering a 429 every second. Everything else is the gate being
        // full, which is the 429 it always was.
        let (code, retry_after) = match refusal {
            Refusal::Paused { .. } => (StatusCode::SERVICE_UNAVAILABLE, "120"),
            _ => (StatusCode::TOO_MANY_REQUESTS, "1"),
        };
        let mut response = status(code, &refusal.client_message());
        response
            .headers_mut()
            .insert(header::RETRY_AFTER, HeaderValue::from_static(retry_after));
        response
    }

    /// **Git LFS over HTTP**, from the same principal the git URLs produced.
    ///
    /// ```text
    ///  route  →  authenticate  →  read the body  →  Dispatch::authorise_service  →  the store
    ///  ACL       token             (batch only)     the SAME ACL call              gunnar-lfs
    /// ```
    ///
    /// The body is read **before** the grant check and not after, and that is
    /// forced rather than chosen: a batch's direction lives in its JSON
    /// (`"operation"`), and the direction is what decides whether the caller
    /// needs Read or Write. Nothing is read from a *store* before the check,
    /// which is the property that matters; what a bad body can cost an
    /// unauthenticated caller is one bounded read and a `400`, and the answer
    /// is the same whether or not the repository exists.
    ///
    /// Refusals here are the LFS batch API's error document rather than
    /// `text/plain`, because that is what a client parses — but the *decision*
    /// and the *sentence* are `gunnar-ssh`'s, unchanged. `401` carries
    /// `LFS-Authenticate`, which is the header the specification names, beside
    /// the `WWW-Authenticate` git's own credential helper looks for.
    async fn serve_lfs(
        &self,
        request: Request<Incoming>,
        route: &route::Route,
        endpoint: route::Lfs,
        principal: &gunnar_core::Principal,
        credential: &auth::Credential,
        id: RequestId,
    ) -> Response<Body> {
        // A server built without a `LfsTransport` says so, rather than 404ing
        // as though the URL were wrong. git-lfs turns 501 into "this endpoint
        // does not do LFS" and stops, which is the true statement.
        let Some(transport) = self.lfs.as_ref() else {
            tracing::debug!(
                target: TARGET_GIT,
                event = "http.lfs.unserved",
                repo = %route.repo.key(),
                "an lfs request arrived at a listener with no lfs backend wired"
            );
            return lfs::error_with_id(
                StatusCode::NOT_IMPLEMENTED,
                "this gunnar serves git over HTTP but has no LFS object store attached \
                 to its HTTP listener"
                    .to_owned(),
                id,
            );
        };

        let method = request.method().clone();
        let headers = request.headers().clone();
        let json_endpoint = matches!(endpoint, route::Lfs::Batch | route::Lfs::Verify);
        if json_endpoint && !lfs::accepts_lfs_json(&headers) {
            return lfs::error_with_id(
                StatusCode::NOT_ACCEPTABLE,
                format!("this endpoint answers `{}` only", lfs::LFS_CONTENT_TYPE),
                id,
            );
        }

        // Read the JSON body, if this endpoint has one. Bounded on the way in.
        let (parts, incoming) = request.into_parts();
        let body = if json_endpoint {
            match lfs::read_bounded(incoming, lfs::MAX_BATCH_REQUEST_BYTES).await {
                Ok(bytes) => Bodied::Json(bytes),
                Err(message) => return lfs::error_with_id(StatusCode::BAD_REQUEST, message, id),
            }
        } else {
            Bodied::Raw(incoming)
        };

        // The direction, and therefore the grant. `service_for` is the one
        // place it is decided; see its own test for `PUT` never being read.
        let batch = match &body {
            Bodied::Json(bytes) if matches!(endpoint, route::Lfs::Batch) => {
                match lfs::parse_batch(bytes) {
                    Ok(parsed) => Some(parsed),
                    Err(message) => {
                        return lfs::error_with_id(StatusCode::BAD_REQUEST, message, id)
                    }
                }
            }
            _ => None,
        };
        let service = lfs::service_for(
            &endpoint,
            &method,
            batch.as_ref().and_then(lfs::operation_of),
        );

        // ── may they ───────────────────────────────────────────────────────
        //
        // The same call, with the same arguments, that the two git verbs make
        // three paragraphs up. `GitService::LfsTransfer(operation)` is the same
        // enum value `gunnar_ssh::command::parse` produces for
        // `git-lfs-transfer <path> <operation>`, so the directional grant SSH
        // enforces is enforced here by the same table row.
        let service_request =
            match self
                .dispatch
                .authorise_service(principal, None, service, route.repo.clone())
            {
                Ok(request) => request,
                Err(err) => return self.refuse_lfs_dispatch(principal, credential, err, id),
            };
        self.metrics.add_connections_accepted(1);

        let context = lfs::Context {
            transport,
            request: service_request,
            endpoint: lfs::endpoint_for(
                self.config.external_base.as_deref(),
                self.config.tls.is_some(),
                parts.uri.authority().map(|a| a.as_str()),
                &headers,
                &route.base,
            ),
            authorization: headers
                .get(header::AUTHORIZATION)
                .and_then(|value| value.to_str().ok())
                .map(str::to_owned),
            stream_window: self.config.stream_window,
            id,
            headers,
        };

        match (endpoint, body) {
            (route::Lfs::Batch, _) => {
                lfs::batch(context, batch.expect("a batch route parsed its body")).await
            }
            (route::Lfs::Verify, Bodied::Json(bytes)) => match lfs::parse_verify(&bytes) {
                Ok(parsed) => lfs::verify(context, parsed).await,
                Err(message) => lfs::error(&context, StatusCode::BAD_REQUEST, message),
            },
            (route::Lfs::Object(oid), Bodied::Raw(incoming)) => {
                if method == Method::PUT {
                    lfs::upload(context, &oid, incoming).await
                } else {
                    lfs::download(context, &oid).await
                }
            }
            // `read_bounded` runs exactly for the two JSON endpoints and the
            // raw body survives for the object one, so these cannot pair up
            // differently. Matched rather than wildcarded so a future endpoint
            // has to say which it is.
            (route::Lfs::Verify, Bodied::Raw(_)) | (route::Lfs::Object(_), Bodied::Json(_)) => {
                let _ = parts;
                lfs::error(
                    &context,
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "the request body was read in the wrong shape for this endpoint".to_owned(),
                )
            }
        }
    }

    /// `GET /<path>/info/refs?service=…`.
    ///
    /// The `# service=` preamble belongs to **v0 only**. Protocol v2 answers
    /// this request with the capability advertisement directly, with no
    /// preamble at all — captured from `git http-backend` 2.53.0 on 2026-08-03,
    /// where the v0 response opens `001e# service=git-upload-pack` + flush and
    /// the v2 response opens `000eversion 2`.
    ///
    /// **`git` tolerates the preamble on a v2 response**, measured by sending
    /// one deliberately: `remote-curl.c`'s `discover_refs` consumes a leading
    /// `# service=` line whatever version follows it, and the clone succeeds.
    /// So this is not a bug being avoided; it is thirty bytes of divergence
    /// from the reference server that some *other* client would be entitled to
    /// disagree about. It is asserted on the bytes in
    /// `gunnar-cli/tests/http_transport.rs`, because no `git clone` test could
    /// ever fail on it.
    async fn advertise(&self, request: ServiceRequest, guard: AdmissionGuard) -> Response<Body> {
        let verb = request.service.verb();
        let content_type = format!("application/x-{verb}-advertisement");
        let version =
            gunnar_wire::ProtocolVersion::from_git_protocol(request.git_protocol.as_deref());

        let (body, mut writer) = Streamed::pair(self.config.stream_window);
        if version == gunnar_wire::ProtocolVersion::V0 {
            use tokio::io::AsyncWriteExt as _;
            let mut preamble = Vec::new();
            gunnar_pktline::write_text(&mut preamble, format!("# service={verb}").as_bytes())
                .expect("a Vec does not fail to write");
            gunnar_pktline::write_flush(&mut preamble).expect("a Vec does not fail to write");
            if writer.write_all(&preamble).await.is_err() {
                return status(StatusCode::SERVICE_UNAVAILABLE, "the client went away");
            }
        }

        self.spawn_transport(request, HalfDuplex::new(NoBody, writer), guard);
        self.streaming(content_type, body)
    }

    /// `POST /<path>/git-upload-pack` or `…/git-receive-pack`.
    async fn rpc(
        &self,
        http: Request<Incoming>,
        request: ServiceRequest,
        guard: AdmissionGuard,
    ) -> Response<Body> {
        let verb = request.service.verb();
        let content_type = format!("application/x-{verb}-result");

        let gzipped = http
            .headers()
            .get(header::CONTENT_ENCODING)
            .and_then(|value| value.to_str().ok())
            .is_some_and(|value| value.eq_ignore_ascii_case("gzip"));

        let (_, incoming) = http.into_parts();
        let read = if gzipped {
            // **Bounded on the way IN** (GUN-005). This was
            // `incoming.collect()`, which accumulates the whole body with no
            // ceiling, and the comment here claimed a bound that belonged to
            // the next step: `gunzip`'s limit is on the INFLATED size, so the
            // step that allocated had none. `Content-Encoding: gzip` plus
            // `Transfer-Encoding: chunked` plus arbitrarily many bytes — which
            // need not be valid gzip, since nothing inflates them until the
            // read has finished — was remote memory exhaustion at 1:1
            // bandwidth cost, anonymous on a public repository, against a
            // process meant to run as PID 1.
            //
            // In memory is still right, and only ever on this path: `git`
            // gzips a request body it has already buffered and never a
            // packfile. `read_bounded` is the reader the LFS
            // endpoints already use (one implementation), and `gunzip`'s
            // output ceiling stays as the second line.
            let collected =
                match lfs::read_bounded(incoming, crate::body::MAX_GZIPPED_REQUEST_INPUT_BYTES)
                    .await
                {
                    Ok(collected) => collected,
                    Err(why) => return status(StatusCode::PAYLOAD_TOO_LARGE, &why),
                };
            match body::gunzip(&collected) {
                Ok(inflated) => RequestBody::buffered(inflated),
                Err(err) => {
                    return status(
                        StatusCode::BAD_REQUEST,
                        &format!("the gzipped request body could not be read: {err}"),
                    )
                }
            }
        } else {
            RequestBody::streaming(incoming)
        };

        let (body, writer) = Streamed::pair(self.config.stream_window);
        self.spawn_transport(request, HalfDuplex::new(read, writer), guard);
        self.streaming(content_type, body)
    }

    /// Hand the stream to the seam and let it run.
    ///
    /// Spawned rather than awaited, for the same reason `gunnar-ssh` spawns it:
    /// the response head has to go out before the transport has finished, or
    /// nothing is streaming.
    ///
    /// **`.instrument(…)` is not decoration.** `tokio::spawn` starts a task
    /// with no span at all — the current span is thread-local and does not
    /// cross the spawn — so without this the ENTIRE transfer, every line
    /// `gunnar-wire` and `gunnar-cli`'s transport emit, would fall outside
    /// `http.request` and carry no `req` at all. That is the same trap
    /// `gunnar-cli`'s `WireTransport::serve` documents for `spawn_blocking`,
    /// and the transfer is the part an operator most wants correlated.
    fn spawn_transport<S: gunnar_ssh::seam::DuplexStream + 'static>(
        &self,
        request: ServiceRequest,
        stream: S,
        guard: AdmissionGuard,
    ) {
        let transport = Arc::clone(&self.transport);
        let service = request.service;
        let repo = request.repo.key();
        let principal = request.principal.id.clone();
        let span = tracing::Span::current();
        // ── the admission permit is held for the WHOLE transfer ─────────────
        //
        // The guard was taken in `serve_one`, before the response head went
        // out, and it releases its slot, its per-principal count and its memory
        // reservation on drop — so it must live exactly as long as the transfer
        // does, which is this spawned task. `Metered` wraps the same duplex the
        // seam was already going to be handed (no second copy of the bytes) and
        // charges the memory ledger as the pack moves, exactly as the SSH path
        // does; `entered_service` files the one sojourn sample the governor's
        // controller reads, so an HTTP transfer is not `unmeasured_sojourn`.
        let admitted = Arc::new(guard);
        let stream = Metered::new(stream, Arc::clone(&admitted));
        // ── the SAME standing fans the SSH path uses ────────────────────────
        //
        // `gunnar-http` holds its own `Arc<dyn GitTransport>` and calls
        // `serve()` itself, so wiring only `gunnar-ssh`'s `Connection::exec`
        // left every HTTP transfer on tokio's blocking pool — which is most of
        // the `population_*` and `multiuser_scaling` load, i.e. exactly the
        // arms a concurrency change is judged by. `pipeline::fans()` is process
        // -wide precisely so both transports queue into one place and one limit.
        let class = OpClass::for_ssh_service(service);
        let handle = tokio::runtime::Handle::current();
        let body = async move {
            // The FIRST line, as on the SSH path: everything between the permit
            // and here was the transfer WAITING (the fan handoff), and this is
            // what makes that wait a governed sojourn sample instead of an
            // invisible queue.
            admitted.entered_service();
            if let Err(err) = transport.serve(request, Box::new(stream)).await {
                // After the head is out there is no status left to send, so
                // this is the only place the failure is visible. It must not be
                // silent (measured, not asserted): a truncated pack and a completed one look the
                // same from the server's side otherwise.
                tracing::warn!(
                    target: TARGET_GIT,
                    event = "http.transfer.failed",
                    %service,
                    %repo,
                    %principal,
                    %err,
                    "a smart-HTTP transfer ended early"
                );
            }
        }
        .instrument(span);

        if let Err(refused) =
            gunnar_ssh::pipeline::fans().submit(class, Box::new(move || handle.block_on(body)))
        {
            // The head is already out on this path, so there is no status left
            // to send — the same asymmetry the failure arm above documents.
            // Saying it in the log is the only honest option left.
            tracing::warn!(
                target: TARGET_GIT,
                event = "http.transfer.refused",
                class = ?class,
                %refused,
                "the standing fan for this class refused the transfer"
            );
        }
    }

    fn streaming(&self, content_type: String, body: Streamed) -> Response<Body> {
        let mut response = Response::new(BodyExt::boxed(body));
        let headers = response.headers_mut();
        headers.insert(
            header::CONTENT_TYPE,
            HeaderValue::from_str(&content_type).expect("a git content type is ascii"),
        );
        // git's own http-backend sends all three, and they matter: a caching
        // proxy that served a stale advertisement would hand a client object
        // ids the server no longer has.
        headers.insert(
            header::CACHE_CONTROL,
            HeaderValue::from_static("no-cache, max-age=0, must-revalidate"),
        );
        headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
        headers.insert(
            header::EXPIRES,
            HeaderValue::from_static("Fri, 01 Jan 1980 00:00:00 GMT"),
        );
        response
    }

    // ── refusals ───────────────────────────────────────────────────────────

    fn refuse_route(&self, method: &Method, path: &str, err: RouteError) -> Response<Body> {
        tracing::debug!(
            target: TARGET_GIT,
            event = "http.route.refused",
            %method,
            path,
            %err,
            "not a smart-HTTP git request"
        );
        let code = match err {
            RouteError::Dumb => StatusCode::FORBIDDEN,
            _ => StatusCode::NOT_FOUND,
        };
        status(code, &err.to_string())
    }

    fn refuse_auth(&self, err: AuthenticateError) -> Response<Body> {
        self.metrics.add_connections_refused(1);
        match err {
            AuthenticateError::Store(err) => {
                tracing::error!(
                    target: TARGET_GIT,
                    event = "http.auth.fault",
                    error = format!("{err:#}"),
                    "the identity table could not be read"
                );
                status(
                    StatusCode::SERVICE_UNAVAILABLE,
                    "the gunnar control plane is unavailable; try again",
                )
            }
            AuthenticateError::Auth(auth @ AuthError::Cleartext) => {
                tracing::warn!(
                    target: TARGET_GIT,
                    event = "http.auth.cleartext",
                    "a credential was presented over a plain connection and refused"
                );
                status(StatusCode::FORBIDDEN, &auth.to_string())
            }
            AuthenticateError::Auth(auth) => {
                // `warn`, not `debug`, and the same level the SSH door uses for
                // the same event (`ssh.auth.refused`,
                // `gunnar-ssh/src/server.rs:702`).
                //
                // At `debug` this was the one refusal in the whole server that
                // a production log could not see: `http.auth.fault` is `error`,
                // `http.auth.cleartext` is `warn`, and an ordinary bad token —
                // the only one an attacker produces, thousands of times, while
                // guessing — was invisible at the shipped default. The counter
                // moved (`add_connections_refused` above) but the heartbeat that
                // prints it runs once a minute and names no principal, no path
                // and no peer, so nothing said *which* door was being tried.
                tracing::warn!(
                    target: TARGET_GIT,
                    event = "http.auth.refused",
                    reason = %auth,
                    "authentication failed"
                );
                unauthorized(&auth.to_string())
            }
        }
    }

    /// The ACL said no, or the catalog could not answer.
    ///
    /// **An unauthenticated caller gets 401, not 404**, whatever the reason,
    /// and that is the whole of the indistinguishability property: a stranger
    /// who probes `/team/does-not-exist.git` and `/team/private.git` gets the
    /// identical response, so the server has told them nothing. Once a
    /// credential *has* been accepted, 404 is safe and more useful, because the
    /// caller already knows they are somebody.
    fn refuse_dispatch(
        &self,
        principal: &gunnar_core::Principal,
        credential: &auth::Credential,
        err: DispatchError,
    ) -> Response<Body> {
        self.metrics.add_connections_refused(1);
        if let DispatchError::Store(store) = &err {
            tracing::error!(
                target: TARGET_GIT,
                event = "http.dispatch.fault",
                error = format!("{store:#}"),
                "the control plane could not answer"
            );
            return status(
                StatusCode::SERVICE_UNAVAILABLE,
                "the gunnar control plane is unavailable; try again",
            );
        }
        tracing::info!(
            target: TARGET_GIT,
            event = "http.dispatch.denied",
            principal = %principal.id,
            reason = %err,
            "refused"
        );
        // `client_message` is `gunnar-ssh`'s wording, reused rather than
        // rewritten: SSH and HTTP say the same sentence for the same refusal.
        let message = err
            .client_message()
            .unwrap_or_else(|| "repository not found, or you do not have access".to_owned());
        if matches!(credential, auth::Credential::None) {
            unauthorized(&message)
        } else {
            status(StatusCode::NOT_FOUND, &message)
        }
    }

    /// The ACL said no to an LFS request.
    ///
    /// The same decision and the same sentence as [`GitHttp::refuse_dispatch`]
    /// — including the 401-before-404 rule that keeps a private repository
    /// indistinguishable from an absent one — rendered as the batch API's error
    /// document, because that is what a client on this surface parses.
    fn refuse_lfs_dispatch(
        &self,
        principal: &gunnar_core::Principal,
        credential: &auth::Credential,
        err: DispatchError,
        id: RequestId,
    ) -> Response<Body> {
        self.metrics.add_connections_refused(1);
        if let DispatchError::Store(store) = &err {
            tracing::error!(
                target: TARGET_GIT,
                event = "http.lfs.dispatch.fault",
                error = format!("{store:#}"),
                "the control plane could not answer"
            );
            return lfs::error_with_id(
                StatusCode::SERVICE_UNAVAILABLE,
                "the gunnar control plane is unavailable; try again".to_owned(),
                id,
            );
        }
        tracing::info!(
            target: TARGET_GIT,
            event = "http.lfs.dispatch.denied",
            principal = %principal.id,
            reason = %err,
            "refused"
        );
        let message = err
            .client_message()
            .unwrap_or_else(|| "repository not found, or you do not have access".to_owned());
        if matches!(credential, auth::Credential::None) {
            let mut response = lfs::error_with_id(StatusCode::UNAUTHORIZED, message, id);
            let headers = response.headers_mut();
            // `LFS-Authenticate` is the header the batch API names for a 401.
            // `WWW-Authenticate` is what git's credential helper looks for, and
            // without it `git lfs` reports the refusal instead of prompting.
            // Both, because they are read by different code in the same client.
            headers.insert(
                header::HeaderName::from_static("lfs-authenticate"),
                HeaderValue::from_static("Basic realm=\"gunnar\""),
            );
            headers.insert(
                header::WWW_AUTHENTICATE,
                HeaderValue::from_static("Basic realm=\"gunnar\""),
            );
            response
        } else {
            lfs::error_with_id(StatusCode::NOT_FOUND, message, id)
        }
    }
}

/// A 401 that makes `git` ask for a password.
///
/// Without the `WWW-Authenticate` header a client has no way to know that
/// authenticating is what would help, and `git clone` reports the refusal
/// instead of prompting.
fn unauthorized(message: &str) -> Response<Body> {
    let mut response = status(StatusCode::UNAUTHORIZED, message);
    response.headers_mut().insert(
        header::WWW_AUTHENTICATE,
        HeaderValue::from_static("Basic realm=\"gunnar\""),
    );
    response
}

/// A plain-text response.
fn status(code: StatusCode, message: &str) -> Response<Body> {
    let body = Full::new(Bytes::from(format!("{message}\n")))
        .map_err(|never| match never {})
        .boxed();
    let mut response = Response::new(body);
    *response.status_mut() = code;
    response.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("text/plain; charset=utf-8"),
    );
    response
}

/// The two verbs, for a caller that wants to name one.
pub const SERVED: &[GitService] = &[GitService::UploadPack, GitService::ReceivePack];