gunnar-ssh 2.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
//! **The plan gate: the front door's one question before the bytes.**
//!
//! `authorise_service` (see [`crate::server::Dispatch`]) is the ONE choke point
//! SSH and smart-HTTP share, and it is where a push is refused **before any
//! pack bytes**, which is the rule that makes this a front-door check and not a
//! `gunnar-policy` gate (a gate runs after the pack is read; that is a 1 GB
//! upload accepted and then refused). This crate does not know what a plan is,
//! what monetize is, or how a fact is signed; it asks the [`PlanGate`]s the
//! server hands in and does what the verdict says.
//!
//! # Two implementations, one chain (decided 2026-09-04)
//!
//! > two implementations: 1) the free lunch, basically checking disk has size
//! > before a new repo is created; 2) the monetize-bound one
//!
//! 1. [`crate::headroom::DiskHeadroomGate`] — **always compiled, no monetize
//!    dependency.** Refuses a push and a `Repos.Create` when the store root's
//!    filesystem would drop under a floor. The community binary has this one
//!    link.
//! 2. `gunnar-server::entitlement::EntitlementSlot` (`metered` only) — the
//!    ladder: Suspended, Retention, the pack quota. The decision is
//!    `monetize_embed::EntitlementCache`'s, one atomic load and a map lookup.
//!
//! [`crate::Control`] holds them as a **chain**, asked in order, disk first;
//! the first `Refuse` wins and is the line the client is told. A box that is
//! full is told it is full, not that its plan lapsed — the disk is the fact
//! nobody can pay their way past. A gate that always answers `Allow` is
//! invisible to a request that only pushes valid things (measured, not asserted), which is why
//! every gate's refusal has a test that sets the condition and watches the
//! push die before a byte.
//!
//! # What is asked, and when
//!
//! | request | asked | may be refused |
//! |---|---|---|
//! | `git-receive-pack`, LFS upload | [`PlanAction::Write`], then [`PlanGate::pack_quota`] and [`PlanGate::pack_ceiling`] | Suspended, Retention, over `caps["pack_bytes"]` — before the bytes, and (receive-pack only) DURING them |
//! | anonymous `git-upload-pack` / LFS download | [`PlanAction::AnonymousRead`] | Suspended, Retention |
//! | the owner's, or any authenticated reader's, `git-upload-pack` | nothing | **never** — data is never held hostage |
//!
//! Asked only for a request the grant check already admitted, so a refusal
//! here never tells a stranger anything about a repository they could not
//! already see; and asked AFTER the standby freeze, so a standby stays a
//! standby whatever the plan says.
//!
//! # The one answer that is not a verdict
//!
//! [`PlanGate::pack_ceiling`] is the exception to "the front door decides
//! before the bytes", and it is the exception because it has to be: a pack
//! header carries an **object count, not a byte count**, so the size of a push
//! is not knowable here. The gate therefore answers with a NUMBER rather than a
//! verdict, the number rides on [`crate::ServiceRequest::pack_ceiling`], and
//! the refusal happens in `gunnar-wire`'s receive loop the moment the count
//! passes it. That is still the front door's decision — it is just carried to
//! where the bytes are.

/// What the request is about to do to the namespace's data.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PlanAction {
    /// `git push`, LFS upload: anything that grows the tenant's data.
    Write,
    /// A stranger reading a public repository.
    AnonymousRead,
}

/// The gate's answer.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum PlanVerdict {
    /// Allowed, silently.
    Allow,
    /// Allowed, and the client is told `line` (Grace: `plan expired …; renew
    /// at …`). Delivered as a sideband progress line, so `git push` prints it
    /// as `remote: …` beside the report.
    Warn(String),
    /// Refused, with the line the client is told — it names the state, the
    /// date and the URL: `plan 10gb expired 2026-10-01: writes closed; renew
    /// at https://gunnar.rs/billing/team`.
    Refuse(String),
}

/// **A byte ceiling for one push, and the line the client is told past it.**
///
/// The twin of `gunnar_wire::receive_pack::PackCeiling`, which is where it is
/// enforced. Two declarations of two fields rather than one shared type,
/// deliberately: this crate does not depend on `gunnar-wire` and must not start
/// — the same boundary [`crate::ServiceRequest::git_protocol`] is a raw
/// `String` for. `gunnar-server` holds both and converts, which is
/// four lines at one seam (`transport.rs`) rather than a dependency edge
/// between two crates that have stayed apart on purpose. There is no logic here to duplicate: the deciding
/// is in the gate below, the enforcing is in the receive loop, and this is the
/// number travelling between them.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct PackCeiling {
    /// Packfile bytes past which the transfer is aborted mid-stream. A push of
    /// exactly this many bytes lands.
    pub bytes: u64,
    /// What the client is told when it trips — a whole refusal, naming the
    /// cap and where to pay, the same shape [`PlanVerdict::Refuse`] carries,
    /// because a tenant cut off mid-push must not be left reading "the pack is
    /// malformed".
    pub refusal: String,
}

/// One link of the front door's chain: "may this namespace do this, right now".
///
/// **Nanoseconds, no I/O, no lock held across I/O.** `verdict` is on the path
/// of every push and every anonymous clone; an implementation that reached a
/// network or a disk here would put a payment provider's uptime in front of
/// `git push`, which is the outage the metered plan exists to forbid.
/// `pack_quota` may read the disk (it is the namespace's byte count, or the
/// filesystem's free space), and is asked only for a write that grows data.
pub trait PlanGate: Send + Sync {
    /// The ladder's answer for `namespace` and `action`. An unknown namespace
    /// is Free, which is `Allow`.
    fn verdict(&self, namespace: &str, action: PlanAction) -> PlanVerdict;

    /// **Is the namespace already at its `caps["pack_bytes"]`?** `Refuse`
    /// names the cap, what is held and the URL; `Allow` for a namespace with
    /// no cap or room under it. Asked before a single pack byte is read, so a
    /// tenant at the ceiling learns it from the first line and not after the
    /// upload.
    fn pack_quota(&self, namespace: &str) -> PlanVerdict;

    /// **How many packfile bytes may this namespace still receive, right
    /// now?** `None` — the default, and every gate's answer until one has a
    /// reason — means no ceiling, and the pack is read to its end whatever its
    /// size.
    ///
    /// # The hole this closes, and why `pack_quota` could not
    ///
    /// A pack header carries an **object count, not a byte count**, so the
    /// size of a push is unknown at the moment it is authorised.
    /// [`PlanGate::pack_quota`] therefore answers about bytes ALREADY held and
    /// `gunnar_policy::gates::limits` answers about a pack ALREADY received —
    /// and a tenant one byte under a 10 GiB cap could send 400 GiB between
    /// them, because nothing in the receive loop was counting. This is the
    /// number that loop counts against; `gunnar-wire` cuts the stream past it
    /// and tells the client [`PackCeiling::refusal`].
    ///
    /// Asked at the same moment `pack_quota` is — a write that grows the
    /// tenant's data, after the grant check — and the **smallest** answer in
    /// the chain wins, which is the byte-wise spelling of the chain's own
    /// first-`Refuse`-wins rule.
    ///
    /// Cheap for the same reason `pack_quota` is allowed to be dear: it is
    /// asked once per push, not once per byte.
    fn pack_ceiling(&self, _namespace: &str) -> Option<PackCeiling> {
        None
    }

    /// **How many LFS bytes may this namespace still receive?** The same
    /// question [`PlanGate::pack_ceiling`] asks about a pack, one wire over, and
    /// `None` means the same thing: no cap, take what arrives.
    ///
    /// # Why the pack ceiling could not cover this
    ///
    /// A pack byte and an LFS byte land on the same filesystem and are billed
    /// at the same rate, but they are different METERS — `Usage.Read` reports
    /// `pack_bytes` and `lfs_bytes` separately and a signed fact caps them
    /// separately — and they arrive down different code paths.
    /// `gunnar_wire::receive_pack` counts pack bytes; the LFS session never sees
    /// that loop. Until this existed, `pack_ceiling` was computed for an LFS
    /// upload (the chain in `Dispatch::ask_plan` has always asked for one) and
    /// then **dropped on the floor**, so a tenant at their git ceiling could put
    /// 8 GiB at a time into LFS for as long as the operator's disk held out.
    ///
    /// # No slack, unlike the pack ceiling
    ///
    /// `pack_ceiling` is generous because a pack's wire bytes and its stored
    /// bytes are different numbers nobody knows in advance. An LFS object is
    /// stored raw and verified against a size declared up front, so the two are
    /// the same number and the answer here is exact. See
    /// [`gunnar_lfs::Allowance`].
    fn lfs_allowance(&self, _namespace: &str) -> Option<gunnar_lfs::Allowance> {
        None
    }

    /// **Does this gate also bind the primary's replication push?** A
    /// `Machine` principal is how a primary authenticates to its standby, and
    /// by default a gate is NOT asked about it: replication is the primary's
    /// copy arriving, not a tenant growing their data, and a plan that refused
    /// it would make a lapsed tenant's standby diverge from the primary. The
    /// disk gate answers `true`: a full standby must say so before the bytes,
    /// the same as a full primary — there is no plan to exempt a disk from.
    fn applies_to_machine(&self) -> bool {
        false
    }

    /// How this gate names itself in `Control`'s `Debug` and in the boot log.
    fn name(&self) -> &'static str;
}