Skip to main content

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}