choir_node/quota.rs
1//! Per-user quotas (D37): how much of the node one credential may hold.
2//!
3//! D33 gave the node a record of who did what ([`crate::limits::Access`])
4//! and a bound on how *often* anyone did it
5//! ([`crate::limits::RateLimiter`]). Neither bounds how *large* one
6//! request may be or how much durable state one user may accumulate, and
7//! a rate limit does not imply either: one push a minute is still an
8//! unbounded pack, and one workspace a minute is still unbounded disk.
9//!
10//! Two ceilings, sharing only the identity they key on — the
11//! authenticated username string, exactly as
12//! [`crate::limits::RateLimiter::check`] keys on it. Nothing here reads
13//! the auth file or asks any user store whether a name exists: the string
14//! arrives from the request's credentials and is used as an opaque key.
15//!
16//! # Where the push ceiling is enforced, and why it is there
17//!
18//! [`read_bounded`] runs on the request thread *before* `git
19//! http-backend` is spawned, which is the whole design rather than an
20//! implementation detail. Git applies no ref until the `pre-receive` hook
21//! exits zero, so a size check inside that hook would already have
22//! submitted ops for the refs it did reach — ops for refs git will never
23//! create — and would have to drive the compensating retraction pass
24//! `Node::create_repo` documents. Refusing before the CGI starts means no
25//! hook ran, no op was submitted, and the retraction path is not entered
26//! at all. The bound is on the transfer; the sequencer never learns the
27//! push was attempted.
28//!
29//! The cost is stated rather than hidden: an over-limit body is drained
30//! to a sink before the refusal is written, so the client always reads a
31//! `413` instead of a broken connection, and so `tiny_http`'s own reader
32//! does not try to swallow the remainder in one allocation on drop. The
33//! bytes still cross the network. What the ceiling buys is that they
34//! never reach memory beyond the ceiling, never reach `git`, and never
35//! reach the log.
36//!
37//! # Where the workspace ceiling gets its count, and why that survives a
38//! restart
39//!
40//! [`WorkspaceTally`] is not a new persisted file. It is a projection
41//! folded out of the op log during the replay the platform already
42//! performs at startup, keyed on the attribution channel each
43//! workspace-creating entry already carries in its signature-covered
44//! `channel` field. the build log left this item out of D33 calling a
45//! surviving tally "a persisted-state question"; it is one, and the
46//! answer is that the persisted state already exists and needed a reader
47//! rather than a writer. A restart rebuilds the tally from the same log
48//! that rebuilds the view, with no new format, no new file and no second
49//! durability barrier.
50//!
51//! # Examples
52//!
53//! ```
54//! use choir_node::quota::{channel_for, Quotas};
55//!
56//! // The tally's key is derived from the authenticated username and
57//! // nothing else.
58//! assert_eq!(channel_for("alice"), "git/alice");
59//!
60//! // Both ceilings are off unless the operator sets them.
61//! let none = Quotas::default();
62//! assert!(none.push_bytes.is_none() && none.workspaces.is_none());
63//! assert!(!none.is_active());
64//! ```
65
66use std::collections::BTreeMap;
67use std::io::Read;
68use std::num::{NonZeroU32, NonZeroU64};
69
70use choir_oplog::OpEntry;
71use choir_view::{OpKind, ViewOp};
72
73/// The attribution channel a request-authenticated user's operations are
74/// submitted under.
75///
76/// This must agree with the `attribution` that `crate::provision` stamps
77/// on a workspace operation, because that string is what
78/// [`WorkspaceTally`] counts. The agreement is pinned end to end *as
79/// well*: `tests/it/quotas.rs` creates a workspace as a named user
80/// through the real API and asserts the tally attributes it to that
81/// user, so a change here fails a test rather than silently zeroing
82/// everyone's count.
83///
84/// **Every path that turns an authenticated user into a channel calls
85/// this.** It used to be one of four spellings of the same `format!`,
86/// agreeing by test rather than by construction, which was adequate
87/// while the mapping was the identity function. It stops being adequate
88/// the moment the mapping is not: a channel is what
89/// [`choir_oplog::signing_hash`] covers and therefore what the log
90/// records forever, so a rule applied here and missed at one of the
91/// other three would write the real name on the path nobody checked
92/// while every path that was checked looked correct. One function is
93/// the only version of that guarantee a test cannot be wrong about.
94#[must_use]
95pub fn channel_for(user: &str) -> String {
96 format!("git/{user}")
97}
98
99/// The operator's per-user ceilings. Either may be left unset, and unset
100/// means unlimited — the same shape as the D33 rate-limit flags.
101#[derive(Debug, Clone, Copy, Default)]
102pub struct Quotas {
103 /// Maximum bytes in the body of one git smart-HTTP request.
104 ///
105 /// A per-request ceiling rather than a budget over time, because the
106 /// resource being bounded is the transfer and the unpack it feeds,
107 /// both of which are paid per request. That is also why this half
108 /// needs no persisted state, and why only the workspace half forced
109 /// a register row.
110 pub push_bytes: Option<NonZeroU64>,
111 /// Maximum workspaces one user may hold at once.
112 pub workspaces: Option<NonZeroU32>,
113}
114
115impl Quotas {
116 /// Whether either ceiling is set at all.
117 #[must_use]
118 pub fn is_active(&self) -> bool {
119 self.push_bytes.is_some() || self.workspaces.is_some()
120 }
121}
122
123/// What [`read_bounded`] found in a request body.
124#[derive(Debug)]
125pub enum Body {
126 /// The whole body, which was within the ceiling (or there was none).
127 Complete(Vec<u8>),
128 /// The body was larger than the ceiling. Nothing was handed on; the
129 /// remainder was drained so the client can read the refusal.
130 OverLimit {
131 /// The ceiling that was exceeded, in bytes.
132 limit: u64,
133 /// How large the body actually turned out to be, counted through
134 /// the drain so the refusal can name the real number rather than
135 /// "more than the limit".
136 size: u64,
137 },
138}
139
140/// Reads a request body, stopping at `limit` bytes.
141///
142/// `None` reads the whole body, which is what a node with no push ceiling
143/// does. With a ceiling, at most `limit + 1` bytes are ever held: the one
144/// extra byte is how "exactly at the ceiling" is told from "over it".
145///
146/// # Errors
147///
148/// Propagates a read failure from the socket. A failure *while draining*
149/// an over-limit body is deliberately not propagated — the client has
150/// already lost, and answering it beats reporting how it hung up.
151pub fn read_bounded(reader: &mut dyn Read, limit: Option<NonZeroU64>) -> std::io::Result<Body> {
152 let Some(limit) = limit.map(NonZeroU64::get) else {
153 let mut body = Vec::new();
154 reader.read_to_end(&mut body)?;
155 return Ok(Body::Complete(body));
156 };
157 let mut body = Vec::new();
158 // `take` bounds the allocation as well as the read: a lying
159 // Content-Length cannot make this reserve more than the ceiling.
160 reader.take(limit + 1).read_to_end(&mut body)?;
161 if body.len() as u64 <= limit {
162 return Ok(Body::Complete(body));
163 }
164 // Drain rather than drop. Dropping `tiny_http`'s content-length
165 // reader makes *it* swallow the remainder, in a single allocation the
166 // size of what is left, which is the allocation this ceiling exists
167 // to prevent. Copying to a sink uses a fixed buffer and counts what
168 // it discards, so the refusal can name the real size.
169 let drained = std::io::copy(reader, &mut std::io::sink()).unwrap_or(0);
170 Ok(Body::OverLimit {
171 limit,
172 size: body.len() as u64 + drained,
173 })
174}
175
176/// Which channel holds which workspace, folded from the op log.
177///
178/// # Why a map rather than a counter
179///
180/// A per-channel counter incremented and decremented alongside the view
181/// is a second copy of the same fact, and the two can drift — the failure
182/// this repository has hit before, where a guard passed while enforcing
183/// nothing. Holding only "workspace → the channel that created it" makes
184/// the count derived, so it cannot disagree with itself, and makes the
185/// stronger invariant checkable: this map's keys are exactly
186/// `View::workspaces`' keys, because the five operations that move one
187/// move the other.
188#[derive(Debug, Default)]
189pub struct WorkspaceTally {
190 owner_of: BTreeMap<String, String>,
191}
192
193impl WorkspaceTally {
194 /// Folds one admitted entry. Called on the writer thread for a live
195 /// operation and in the replay loop at startup — the same call in
196 /// both places, which is what makes a restart reproduce the tally
197 /// rather than approximate it.
198 pub fn observe(&mut self, entry: &OpEntry, op: &ViewOp) {
199 match &op.kind {
200 // The three that put a workspace into the view. `or_insert`
201 // rather than `insert`: a later checkpoint or a legacy head
202 // move does not transfer ownership to whoever moved it.
203 OpKind::SetWorkspaceHead { workspace, .. }
204 | OpKind::CreateChange { workspace, .. }
205 | OpKind::CheckpointChange { workspace, .. } => {
206 self.owner_of
207 .entry(workspace.clone())
208 .or_insert_with(|| entry.channel.clone());
209 }
210 // The two that take one out.
211 OpKind::DeleteWorkspace { workspace } | OpKind::ArchiveChange { workspace, .. } => {
212 self.owner_of.remove(workspace);
213 }
214 _ => {}
215 }
216 }
217
218 /// How many workspaces `channel` currently holds.
219 #[must_use]
220 pub fn held_by(&self, channel: &str) -> usize {
221 self.owner_of
222 .values()
223 .filter(|owner| *owner == channel)
224 .count()
225 }
226
227 /// Every workspace the tally is tracking, for the test that pins it
228 /// against `View::workspaces`.
229 pub fn workspaces(&self) -> impl Iterator<Item = &str> {
230 self.owner_of.keys().map(String::as_str)
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use choir_hash::ContentHash;
238
239 fn entry(channel: &str) -> OpEntry {
240 OpEntry {
241 format_version: 1,
242 parent: None,
243 seq: 0,
244 channel: channel.to_string(),
245 payload: Vec::new(),
246 witnesses: Vec::new(),
247 author_sig: None,
248 }
249 }
250
251 fn head(workspace: &str) -> ViewOp {
252 ViewOp::new(OpKind::SetWorkspaceHead {
253 workspace: workspace.to_string(),
254 commit: ContentHash::blake3(workspace.as_bytes()),
255 prev: None,
256 })
257 }
258
259 #[test]
260 fn a_workspace_is_counted_against_the_channel_that_created_it() {
261 let mut tally = WorkspaceTally::default();
262 tally.observe(&entry("git/alice"), &head("o/r/one"));
263 tally.observe(&entry("git/alice"), &head("o/r/two"));
264 tally.observe(&entry("git/bob"), &head("o/r/three"));
265 assert_eq!(tally.held_by("git/alice"), 2);
266 assert_eq!(tally.held_by("git/bob"), 1);
267 assert_eq!(tally.held_by("git/carol"), 0);
268 }
269
270 #[test]
271 fn a_later_head_move_does_not_transfer_the_workspace() {
272 // Otherwise a user could park their workspaces on someone else's
273 // count by touching them, and the ceiling would bound nobody.
274 let mut tally = WorkspaceTally::default();
275 tally.observe(&entry("git/alice"), &head("o/r/one"));
276 tally.observe(&entry("git/bob"), &head("o/r/one"));
277 assert_eq!(tally.held_by("git/alice"), 1);
278 assert_eq!(tally.held_by("git/bob"), 0);
279 }
280
281 #[test]
282 fn deleting_a_workspace_gives_the_allowance_back() {
283 let mut tally = WorkspaceTally::default();
284 tally.observe(&entry("git/alice"), &head("o/r/one"));
285 assert_eq!(tally.held_by("git/alice"), 1);
286 tally.observe(
287 &entry("git/alice"),
288 &ViewOp::new(OpKind::DeleteWorkspace {
289 workspace: "o/r/one".to_string(),
290 }),
291 );
292 assert_eq!(tally.held_by("git/alice"), 0);
293 assert_eq!(tally.workspaces().count(), 0);
294 }
295
296 #[test]
297 fn a_body_at_the_ceiling_passes_and_one_byte_over_does_not() {
298 let limit = NonZeroU64::new(8);
299 match read_bounded(&mut &b"12345678"[..], limit).expect("reads") {
300 Body::Complete(body) => assert_eq!(body.len(), 8),
301 other => panic!("exactly at the ceiling is not over it: {other:?}"),
302 }
303 match read_bounded(&mut &b"123456789"[..], limit).expect("reads") {
304 Body::OverLimit { limit, size } => {
305 assert_eq!(limit, 8);
306 // Counted through the drain, so the refusal names the
307 // real size rather than "more than eight".
308 assert_eq!(size, 9);
309 }
310 other => panic!("nine bytes is over a ceiling of eight: {other:?}"),
311 }
312 }
313
314 #[test]
315 fn no_ceiling_reads_the_whole_body() {
316 match read_bounded(&mut &b"12345678"[..], None).expect("reads") {
317 Body::Complete(body) => assert_eq!(body.len(), 8),
318 other => panic!("an unset ceiling limits nothing: {other:?}"),
319 }
320 }
321}