Skip to main content

gunnar_ssh/
plan.rs

1//! **The plan gate: the front door's one question before the bytes.**
2//!
3//! `authorise_service` (see [`crate::server::Dispatch`]) is the ONE choke point
4//! SSH and smart-HTTP share, and it is where a push is refused **before any
5//! pack bytes**, which is the rule that makes this a front-door check and not a
6//! `gunnar-policy` gate (a gate runs after the pack is read; that is a 1 GB
7//! upload accepted and then refused). This crate does not know what a plan is,
8//! what monetize is, or how a fact is signed; it asks the [`PlanGate`]s the
9//! server hands in and does what the verdict says.
10//!
11//! # Two implementations, one chain (decided 2026-09-04)
12//!
13//! > two implementations: 1) the free lunch, basically checking disk has size
14//! > before a new repo is created; 2) the monetize-bound one
15//!
16//! 1. [`crate::headroom::DiskHeadroomGate`] — **always compiled, no monetize
17//!    dependency.** Refuses a push and a `Repos.Create` when the store root's
18//!    filesystem would drop under a floor. The community binary has this one
19//!    link.
20//! 2. `gunnar-server::entitlement::EntitlementSlot` (`metered` only) — the
21//!    ladder: Suspended, Retention, the pack quota. The decision is
22//!    `monetize_embed::EntitlementCache`'s, one atomic load and a map lookup.
23//!
24//! [`crate::Control`] holds them as a **chain**, asked in order, disk first;
25//! the first `Refuse` wins and is the line the client is told. A box that is
26//! full is told it is full, not that its plan lapsed — the disk is the fact
27//! nobody can pay their way past. A gate that always answers `Allow` is
28//! invisible to a request that only pushes valid things (measured, not asserted), which is why
29//! every gate's refusal has a test that sets the condition and watches the
30//! push die before a byte.
31//!
32//! # What is asked, and when
33//!
34//! | request | asked | may be refused |
35//! |---|---|---|
36//! | `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 |
37//! | anonymous `git-upload-pack` / LFS download | [`PlanAction::AnonymousRead`] | Suspended, Retention |
38//! | the owner's, or any authenticated reader's, `git-upload-pack` | nothing | **never** — data is never held hostage |
39//!
40//! Asked only for a request the grant check already admitted, so a refusal
41//! here never tells a stranger anything about a repository they could not
42//! already see; and asked AFTER the standby freeze, so a standby stays a
43//! standby whatever the plan says.
44//!
45//! # The one answer that is not a verdict
46//!
47//! [`PlanGate::pack_ceiling`] is the exception to "the front door decides
48//! before the bytes", and it is the exception because it has to be: a pack
49//! header carries an **object count, not a byte count**, so the size of a push
50//! is not knowable here. The gate therefore answers with a NUMBER rather than a
51//! verdict, the number rides on [`crate::ServiceRequest::pack_ceiling`], and
52//! the refusal happens in `gunnar-wire`'s receive loop the moment the count
53//! passes it. That is still the front door's decision — it is just carried to
54//! where the bytes are.
55
56/// What the request is about to do to the namespace's data.
57#[derive(Clone, Copy, PartialEq, Eq, Debug)]
58pub enum PlanAction {
59    /// `git push`, LFS upload: anything that grows the tenant's data.
60    Write,
61    /// A stranger reading a public repository.
62    AnonymousRead,
63}
64
65/// The gate's answer.
66#[derive(Clone, PartialEq, Eq, Debug)]
67pub enum PlanVerdict {
68    /// Allowed, silently.
69    Allow,
70    /// Allowed, and the client is told `line` (Grace: `plan expired …; renew
71    /// at …`). Delivered as a sideband progress line, so `git push` prints it
72    /// as `remote: …` beside the report.
73    Warn(String),
74    /// Refused, with the line the client is told — it names the state, the
75    /// date and the URL: `plan 10gb expired 2026-10-01: writes closed; renew
76    /// at https://gunnar.rs/billing/team`.
77    Refuse(String),
78}
79
80/// **A byte ceiling for one push, and the line the client is told past it.**
81///
82/// The twin of `gunnar_wire::receive_pack::PackCeiling`, which is where it is
83/// enforced. Two declarations of two fields rather than one shared type,
84/// deliberately: this crate does not depend on `gunnar-wire` and must not start
85/// — the same boundary [`crate::ServiceRequest::git_protocol`] is a raw
86/// `String` for. `gunnar-server` holds both and converts, which is
87/// four lines at one seam (`transport.rs`) rather than a dependency edge
88/// between two crates that have stayed apart on purpose. There is no logic here to duplicate: the deciding
89/// is in the gate below, the enforcing is in the receive loop, and this is the
90/// number travelling between them.
91#[derive(Clone, PartialEq, Eq, Debug)]
92pub struct PackCeiling {
93    /// Packfile bytes past which the transfer is aborted mid-stream. A push of
94    /// exactly this many bytes lands.
95    pub bytes: u64,
96    /// What the client is told when it trips — a whole refusal, naming the
97    /// cap and where to pay, the same shape [`PlanVerdict::Refuse`] carries,
98    /// because a tenant cut off mid-push must not be left reading "the pack is
99    /// malformed".
100    pub refusal: String,
101}
102
103/// One link of the front door's chain: "may this namespace do this, right now".
104///
105/// **Nanoseconds, no I/O, no lock held across I/O.** `verdict` is on the path
106/// of every push and every anonymous clone; an implementation that reached a
107/// network or a disk here would put a payment provider's uptime in front of
108/// `git push`, which is the outage the metered plan exists to forbid.
109/// `pack_quota` may read the disk (it is the namespace's byte count, or the
110/// filesystem's free space), and is asked only for a write that grows data.
111pub trait PlanGate: Send + Sync {
112    /// The ladder's answer for `namespace` and `action`. An unknown namespace
113    /// is Free, which is `Allow`.
114    fn verdict(&self, namespace: &str, action: PlanAction) -> PlanVerdict;
115
116    /// **Is the namespace already at its `caps["pack_bytes"]`?** `Refuse`
117    /// names the cap, what is held and the URL; `Allow` for a namespace with
118    /// no cap or room under it. Asked before a single pack byte is read, so a
119    /// tenant at the ceiling learns it from the first line and not after the
120    /// upload.
121    fn pack_quota(&self, namespace: &str) -> PlanVerdict;
122
123    /// **How many packfile bytes may this namespace still receive, right
124    /// now?** `None` — the default, and every gate's answer until one has a
125    /// reason — means no ceiling, and the pack is read to its end whatever its
126    /// size.
127    ///
128    /// # The hole this closes, and why `pack_quota` could not
129    ///
130    /// A pack header carries an **object count, not a byte count**, so the
131    /// size of a push is unknown at the moment it is authorised.
132    /// [`PlanGate::pack_quota`] therefore answers about bytes ALREADY held and
133    /// `gunnar_policy::gates::limits` answers about a pack ALREADY received —
134    /// and a tenant one byte under a 10 GiB cap could send 400 GiB between
135    /// them, because nothing in the receive loop was counting. This is the
136    /// number that loop counts against; `gunnar-wire` cuts the stream past it
137    /// and tells the client [`PackCeiling::refusal`].
138    ///
139    /// Asked at the same moment `pack_quota` is — a write that grows the
140    /// tenant's data, after the grant check — and the **smallest** answer in
141    /// the chain wins, which is the byte-wise spelling of the chain's own
142    /// first-`Refuse`-wins rule.
143    ///
144    /// Cheap for the same reason `pack_quota` is allowed to be dear: it is
145    /// asked once per push, not once per byte.
146    fn pack_ceiling(&self, _namespace: &str) -> Option<PackCeiling> {
147        None
148    }
149
150    /// **How many LFS bytes may this namespace still receive?** The same
151    /// question [`PlanGate::pack_ceiling`] asks about a pack, one wire over, and
152    /// `None` means the same thing: no cap, take what arrives.
153    ///
154    /// # Why the pack ceiling could not cover this
155    ///
156    /// A pack byte and an LFS byte land on the same filesystem and are billed
157    /// at the same rate, but they are different METERS — `Usage.Read` reports
158    /// `pack_bytes` and `lfs_bytes` separately and a signed fact caps them
159    /// separately — and they arrive down different code paths.
160    /// `gunnar_wire::receive_pack` counts pack bytes; the LFS session never sees
161    /// that loop. Until this existed, `pack_ceiling` was computed for an LFS
162    /// upload (the chain in `Dispatch::ask_plan` has always asked for one) and
163    /// then **dropped on the floor**, so a tenant at their git ceiling could put
164    /// 8 GiB at a time into LFS for as long as the operator's disk held out.
165    ///
166    /// # No slack, unlike the pack ceiling
167    ///
168    /// `pack_ceiling` is generous because a pack's wire bytes and its stored
169    /// bytes are different numbers nobody knows in advance. An LFS object is
170    /// stored raw and verified against a size declared up front, so the two are
171    /// the same number and the answer here is exact. See
172    /// [`gunnar_lfs::Allowance`].
173    fn lfs_allowance(&self, _namespace: &str) -> Option<gunnar_lfs::Allowance> {
174        None
175    }
176
177    /// **Does this gate also bind the primary's replication push?** A
178    /// `Machine` principal is how a primary authenticates to its standby, and
179    /// by default a gate is NOT asked about it: replication is the primary's
180    /// copy arriving, not a tenant growing their data, and a plan that refused
181    /// it would make a lapsed tenant's standby diverge from the primary. The
182    /// disk gate answers `true`: a full standby must say so before the bytes,
183    /// the same as a full primary — there is no plan to exempt a disk from.
184    fn applies_to_machine(&self) -> bool {
185        false
186    }
187
188    /// How this gate names itself in `Control`'s `Debug` and in the boot log.
189    fn name(&self) -> &'static str;
190}