Skip to main content

choir_node/
limits.rs

1//! Request accounting and admission control (D33): who did what, and how
2//! much of it they may do.
3//!
4//! The node authenticates ([`crate::AuthTable`]) and authorizes per
5//! repository ([`crate::acl`]), and until this module existed it did
6//! nothing else. A second credential holder was invisible in the record —
7//! an incident left no trace of which credential caused it — and unbounded
8//! in consumption, because nothing counted requests at all.
9//!
10//! Two pieces, deliberately separate:
11//!
12//! - [`RequestLog`] writes one JSON object per served request. It is an
13//!   observation about this node, not part of the ordered history anyone
14//!   replays, so it lives beside the op log rather than in it — the same
15//!   reasoning that keeps `lag.jsonl` separate.
16//! - [`RateLimiter`] holds one token bucket per (user, [`Class`]) in
17//!   memory and answers "may this request proceed, and if not, when should
18//!   the caller come back".
19//!
20//! # What never reaches the log
21//!
22//! No header, no request body, no query string. [`Access::start`]
23//! truncates the path at `?` before the struct is even built, so a token
24//! smuggled into a query parameter cannot reach the file by any later
25//! path. This is the property that lets the file be handed to whoever is
26//! running an incident: a token that reaches a log is a token that has to
27//! be rotated.
28//!
29//! No client address either, and not by omission: [`Access`] has no field
30//! that could hold one, so there is no formatting decision anywhere that
31//! could begin writing one (D59). That is also why this file, rather than
32//! a proxy access log, is the record the deployment keeps.
33//!
34//! # Lock discipline
35//!
36//! Both locks are leaf locks held for arithmetic or one `write_all`, never
37//! across a socket write. [`Access::finish`] is called *after*
38//! `Request::respond` has returned, so the log mutex is acquired when the
39//! response is already on the wire and a slow reader cannot hold it. The
40//! rate check runs on the request's own thread after authentication, never
41//! on the accept loop, so a full bucket map cannot delay `accept`.
42//!
43//! # Examples
44//!
45//! ```
46//! use choir_node::limits::{Class, RateLimiter};
47//! use std::num::NonZeroU32;
48//!
49//! // Two API requests a minute, git unlimited.
50//! let limiter = RateLimiter::new(NonZeroU32::new(2), None);
51//! assert!(limiter.check("alice", Class::Api).is_none());
52//! assert!(limiter.check("alice", Class::Api).is_none());
53//!
54//! // The third is refused, and says how many seconds to wait.
55//! let retry = limiter.check("alice", Class::Api).expect("third is refused");
56//! assert!(retry >= 1);
57//!
58//! // Buckets are per user, and an unset ceiling limits nothing.
59//! assert!(limiter.check("bob", Class::Api).is_none());
60//! assert!(limiter.check("alice", Class::Git).is_none());
61//! ```
62//!
63//! The operator's guide to the ceilings this module enforces:
64//!
65#![doc = include_str!("../../../docs/operating/limits.md")]
66
67use std::collections::HashMap;
68use std::io::Write;
69use std::num::NonZeroU32;
70use std::path::PathBuf;
71use std::sync::Mutex;
72use std::time::{Duration, Instant};
73
74/// Which ceiling a request is charged against.
75///
76/// Two classes rather than one because the costs are unrelated: a clone is
77/// a single request that streams a whole pack, while a `POST
78/// /api/submit-batch` is a single request carrying many operations. One
79/// shared ceiling either throttles an ordinary fetch loop or leaves the
80/// operation path effectively unlimited, so the operator sets each
81/// independently and either may be left off.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum Class {
84    /// Everything the node answers itself: the platform API, the browser
85    /// page, the D30 repository-browsing pages, `llms.txt` and `sync.md`.
86    ///
87    /// Browsing pages shell out to `git` per request, so this bucket is
88    /// not free even though it never reaches `git http-backend`. That is
89    /// the reason to set a real number here rather than a huge one.
90    Api,
91    /// Git smart-HTTP: the requests handed to `git http-backend`.
92    Git,
93}
94
95/// The class a URL is charged to, mirroring the accept loop's own routing
96/// order (the API prefix is matched before the git fallback, so a path
97/// that contains both spellings lands in the same bucket the router will
98/// actually use).
99#[must_use]
100pub fn class_of(url: &str) -> Class {
101    if url.starts_with("/api/") {
102        return Class::Api;
103    }
104    if crate::repo_from_path(url).is_some() {
105        Class::Git
106    } else {
107        Class::Api
108    }
109}
110
111/// Per-user token buckets, one per (user, [`Class`]).
112///
113/// # Algorithm
114///
115/// A classic token bucket, refilled continuously rather than on a timer:
116/// capacity is one minute's allowance, tokens accrue at
117/// `per_minute / 60` per second, and each admitted request spends one.
118/// A bucket is refilled lazily when it is read, so there is no background
119/// thread and no periodic sweep — which is what lets this stay
120/// synchronous, allocation-light, and free on an idle node.
121///
122/// Capacity equal to a full minute means an agent may burst a minute's
123/// worth at once and then proceeds at the sustained rate, which is the
124/// shape real agent traffic has: a batch of work, then a wait.
125///
126/// # Bounds
127///
128/// The map holds one entry per (authenticated user, class) that has been
129/// seen. Usernames come from the operator's `--auth-file`, and the caller
130/// only consults the limiter for authenticated users, so the map is
131/// bounded by the credential count times two and cannot be grown by an
132/// unauthenticated caller.
133pub struct RateLimiter {
134    api: Option<NonZeroU32>,
135    git: Option<NonZeroU32>,
136    buckets: Mutex<HashMap<(String, Class), Bucket>>,
137}
138
139/// One user's allowance for one class.
140struct Bucket {
141    tokens: f64,
142    last: Instant,
143}
144
145impl RateLimiter {
146    /// A limiter with the given requests-per-minute ceilings. `None` for a
147    /// class means that class is not limited.
148    ///
149    /// The ceilings are [`NonZeroU32`] so that "limit to zero" — a value
150    /// that refuses every request forever and has no sensible
151    /// `Retry-After` — is not representable.
152    #[must_use]
153    pub fn new(api_per_minute: Option<NonZeroU32>, git_per_minute: Option<NonZeroU32>) -> Self {
154        Self {
155            api: api_per_minute,
156            git: git_per_minute,
157            buckets: Mutex::new(HashMap::new()),
158        }
159    }
160
161    /// Whether any class is limited at all.
162    #[must_use]
163    pub fn is_active(&self) -> bool {
164        self.api.is_some() || self.git.is_some()
165    }
166
167    /// Spends one token for `user` in `class`.
168    ///
169    /// `None` admits the request. `Some(seconds)` refuses it and is the
170    /// `Retry-After` the caller should answer with: the whole seconds
171    /// until one token has accrued, never less than one.
172    pub fn check(&self, user: &str, class: Class) -> Option<u64> {
173        self.check_at(user, class, Instant::now())
174    }
175
176    /// [`RateLimiter::check`] against a caller-supplied clock reading, so
177    /// refill can be proved without sleeping through it.
178    pub fn check_at(&self, user: &str, class: Class, now: Instant) -> Option<u64> {
179        let per_minute = match class {
180            Class::Api => self.api,
181            Class::Git => self.git,
182        }?;
183        let capacity = f64::from(per_minute.get());
184        let per_second = capacity / 60.0;
185
186        let mut buckets = self.buckets.lock().expect("rate bucket lock");
187        let bucket = buckets.entry((user.to_string(), class)).or_insert(Bucket {
188            tokens: capacity,
189            last: now,
190        });
191        // Saturating: a clock reading older than the last one (which
192        // `Instant` forbids, but a caller-supplied one does not) refills
193        // nothing rather than draining the bucket.
194        let elapsed = now.saturating_duration_since(bucket.last).as_secs_f64();
195        bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
196        bucket.last = now;
197        if bucket.tokens >= 1.0 {
198            bucket.tokens -= 1.0;
199            return None;
200        }
201        let wait = (1.0 - bucket.tokens) / per_second;
202        // Round up, and never answer "retry immediately": a client that
203        // obeys a zero would spin.
204        Some((wait.ceil() as u64).max(1))
205    }
206}
207
208/// The default rotation threshold: 32 MiB, so the two generations this
209/// keeps cost at most 64 MiB of disk.
210pub const DEFAULT_LOG_MAX_BYTES: u64 = 32 * 1024 * 1024;
211
212/// An append-only, size-bounded record of every request the node served.
213///
214/// # Format
215///
216/// One JSON object per line — the same shape as `ops.jsonl` and
217/// `lag.jsonl`, so the same tools read it — carrying `format_version`, the
218/// wall-clock time, the authenticated user, the method, the redacted path,
219/// the status, the response body size and the elapsed microseconds.
220///
221/// # Rotation
222///
223/// Size-bounded and single-generation. When the file passes
224/// `max_bytes` it is renamed to `<path>.1`, replacing any previous `.1`,
225/// and a fresh file is started. Disk is therefore bounded at roughly twice
226/// `max_bytes` with no timer, no cron entry and no external logrotate — a
227/// node that runs unattended for a year cannot fill its disk with this.
228/// The cost of that simplicity is stated plainly: exactly two generations
229/// are kept, so an operator who needs deeper history copies the file out
230/// on their own schedule.
231///
232/// # Durability
233///
234/// One unbuffered `write_all` per request, no `fsync`. An incident log
235/// that loses its tail to a buffer is worthless, and an `fsync` per
236/// request would put a disk round-trip in every response path — this is an
237/// observation, not the durable record.
238pub struct RequestLog {
239    sink: Mutex<Sink>,
240}
241
242/// The open file and what is known about it.
243struct Sink {
244    path: PathBuf,
245    file: std::fs::File,
246    written: u64,
247    max_bytes: u64,
248    /// A broken log must not break the node, but it must be said once
249    /// rather than once per request.
250    complained: bool,
251}
252
253impl RequestLog {
254    /// Opens `path` for append, creating it, and rotates at `max_bytes`.
255    ///
256    /// # Errors
257    ///
258    /// Propagates the failure to open the file. This is fatal at startup
259    /// by design: an operator who asked for a request log and did not get
260    /// one should find out before the node starts serving, not from the
261    /// absence of evidence during an incident.
262    pub fn open(path: PathBuf, max_bytes: u64) -> std::io::Result<Self> {
263        let file = std::fs::OpenOptions::new()
264            .create(true)
265            .append(true)
266            .open(&path)?;
267        let written = file.metadata().map(|m| m.len()).unwrap_or(0);
268        Ok(Self {
269            sink: Mutex::new(Sink {
270                path,
271                file,
272                written,
273                max_bytes,
274                complained: false,
275            }),
276        })
277    }
278
279    /// Appends one line, rotating first if the file is already over its
280    /// bound. Never panics and never propagates: a request that was served
281    /// is not un-served by a log failure.
282    fn append(&self, line: &str) {
283        let mut sink = self.sink.lock().expect("request log lock");
284        if sink.written >= sink.max_bytes {
285            let mut rotated = sink.path.clone().into_os_string();
286            rotated.push(".1");
287            // Rename then reopen. A failure at either step leaves the
288            // current file in place and is reported like any other write
289            // failure, so the worst case is an oversized log rather than a
290            // lost one.
291            match std::fs::rename(&sink.path, PathBuf::from(rotated)).and_then(|()| {
292                std::fs::OpenOptions::new()
293                    .create(true)
294                    .append(true)
295                    .open(&sink.path)
296            }) {
297                Ok(file) => {
298                    sink.file = file;
299                    sink.written = 0;
300                }
301                Err(e) => complain(&mut sink, &e),
302            }
303        }
304        match sink.file.write_all(line.as_bytes()) {
305            Ok(()) => sink.written += line.len() as u64,
306            Err(e) => complain(&mut sink, &e),
307        }
308    }
309}
310
311/// Says once that the request log is not working. The reason, never the
312/// path: that string names the operator's directories.
313fn complain(sink: &mut Sink, error: &std::io::Error) {
314    if !sink.complained {
315        sink.complained = true;
316        eprintln!("request log: writes are failing, so requests are going unrecorded: {error}");
317    }
318}
319
320/// The running totals behind the counters in `/metrics`.
321///
322/// Every gauge the node exported before these described a state it could
323/// read on demand -- readiness, free disk, ref agreement. Nothing
324/// described what had *happened*, so two of the alerts
325/// `docs/private-beta-runbook.md` calls critical, a spike in refused or
326/// failed requests and a breach of the latency gate, had no metric to
327/// fire from. These are that, and they are counters rather than gauges
328/// on purpose: an alert wants a rate, and a rate needs a number that
329/// only ever goes up.
330///
331/// Counted in [`Access::finish`], which every served request passes
332/// through, and counted **before** the request log is consulted --
333/// a node started without `--request-log` still has to be alertable.
334#[derive(Debug, Default)]
335pub struct Counters {
336    requests: std::sync::atomic::AtomicU64,
337    unauthorized: std::sync::atomic::AtomicU64,
338    throttled: std::sync::atomic::AtomicU64,
339    failed: std::sync::atomic::AtomicU64,
340    duration_us: std::sync::atomic::AtomicU64,
341}
342
343/// One reading of [`Counters`], taken field by field.
344///
345/// The fields are not read atomically with respect to each other, which
346/// is the ordinary bargain for a metrics scrape: a counter may be one
347/// request further along than the one beside it. An alert on a rate over
348/// a scrape interval cannot see the difference.
349#[derive(Debug, Clone, Copy)]
350pub struct CountersSnapshot {
351    /// Requests served, whatever their status.
352    pub requests: u64,
353    /// Requests refused as unauthenticated or forbidden (401, 403).
354    pub unauthorized: u64,
355    /// Requests refused by a rate limit or a quota (429).
356    pub throttled: u64,
357    /// Requests the node failed to serve (5xx, and I/O errors mid-write).
358    pub failed: u64,
359    /// Total time spent serving, microseconds. With `requests`, this is
360    /// the pair an average-latency alert needs.
361    pub duration_us: u64,
362}
363
364impl Counters {
365    /// Records one finished request.
366    fn record(&self, status: Option<u16>, elapsed: Duration) {
367        use std::sync::atomic::Ordering::Relaxed;
368        self.requests.fetch_add(1, Relaxed);
369        self.duration_us.fetch_add(
370            u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX),
371            Relaxed,
372        );
373        match status {
374            // A response the node never finished writing is a failure of
375            // the node, and is the case a status-code match would miss:
376            // the status was fine when it was chosen.
377            None => {
378                self.failed.fetch_add(1, Relaxed);
379            }
380            Some(401 | 403) => {
381                self.unauthorized.fetch_add(1, Relaxed);
382            }
383            Some(429) => {
384                self.throttled.fetch_add(1, Relaxed);
385            }
386            Some(code) if code >= 500 => {
387                self.failed.fetch_add(1, Relaxed);
388            }
389            Some(_) => {}
390        }
391    }
392
393    /// Reads every counter.
394    #[must_use]
395    pub fn snapshot(&self) -> CountersSnapshot {
396        use std::sync::atomic::Ordering::Relaxed;
397        CountersSnapshot {
398            requests: self.requests.load(Relaxed),
399            unauthorized: self.unauthorized.load(Relaxed),
400            throttled: self.throttled.load(Relaxed),
401            failed: self.failed.load(Relaxed),
402            duration_us: self.duration_us.load(Relaxed),
403        }
404    }
405}
406
407/// One request in flight: what it was, and when it started.
408///
409/// Built at the top of the request's thread and consumed by
410/// [`Access::finish`] after the response has been written, so the recorded
411/// duration covers the whole of the node's work including the socket
412/// write.
413pub struct Access {
414    started: Instant,
415    method: String,
416    path: String,
417    counters: std::sync::Arc<Counters>,
418}
419
420impl Access {
421    /// Captures the method and the path with its query string already
422    /// removed.
423    ///
424    /// The truncation happens here rather than at write time on purpose:
425    /// a query string that never enters the struct cannot leave it, so no
426    /// later edit to the formatting can leak a token that was passed as a
427    /// query parameter.
428    #[must_use]
429    pub fn start(request: &tiny_http::Request, counters: std::sync::Arc<Counters>) -> Self {
430        let url = request.url();
431        Self {
432            started: Instant::now(),
433            method: request.method().as_str().to_string(),
434            path: url.split('?').next().unwrap_or(url).to_string(),
435            counters,
436        }
437    }
438
439    /// The path this access will record, query string already stripped.
440    #[must_use]
441    pub fn path(&self) -> &str {
442        &self.path
443    }
444
445    /// Records the finished request. `outcome` is the status and response
446    /// body size when the response was written, or the I/O error that
447    /// stopped it — a truncated response is exactly the thing an incident
448    /// needs recorded, so it is logged rather than dropped.
449    ///
450    /// A `None` log skips the *line*, which is what a node started
451    /// without `--request-log` pays. The counters are bumped either way:
452    /// they are what `/metrics` exports, and a node nobody can alert on
453    /// is not an acceptable price for declining to keep a per-request
454    /// record.
455    pub fn finish(
456        self,
457        log: Option<&RequestLog>,
458        user: &str,
459        outcome: &std::io::Result<(u16, u64)>,
460    ) {
461        let elapsed = self.started.elapsed();
462        self.counters
463            .record(outcome.as_ref().ok().map(|(status, _)| *status), elapsed);
464        let Some(log) = log else {
465            return;
466        };
467        let at_unix_ms = std::time::SystemTime::now()
468            .duration_since(std::time::UNIX_EPOCH)
469            .unwrap_or(Duration::ZERO)
470            .as_millis();
471        let mut entry = serde_json::json!({
472            "format_version": 1,
473            "at_unix_ms": at_unix_ms,
474            "user": user,
475            "method": self.method,
476            "path": self.path,
477            "us": u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX),
478        });
479        match outcome {
480            Ok((status, bytes)) => {
481                entry["status"] = (*status).into();
482                entry["bytes"] = (*bytes).into();
483            }
484            Err(e) => {
485                // Status 0 = no complete response reached the client. The
486                // error *kind* only: the message of an I/O error is not
487                // guaranteed to be free of paths.
488                entry["status"] = 0.into();
489                entry["bytes"] = 0.into();
490                entry["write_error"] = format!("{:?}", e.kind()).into();
491            }
492        }
493        log.append(&format!("{entry}\n"));
494    }
495}
496
497/// The window the pre-auth counter is measured over.
498const PUBLIC_WINDOW: Duration = Duration::from_secs(60);
499
500/// Admission control for the routes that answer *before* a credential is
501/// checked (the invite link and the public landing page).
502///
503/// [`RateLimiter`] cannot do this job, and the reason is written into its
504/// own bounds note: its map holds one entry per key it has seen, which is
505/// safe only because its keys come from the operator's auth file. Keyed on
506/// something an anonymous caller chooses, an address or a header, the same
507/// map is an unbounded allocation driven by whoever is calling, which is
508/// the attack rather than the defence against it.
509///
510/// So this counter has no key at all. One number for the whole pre-auth
511/// surface, reset each window. It bounds the node's total pre-auth work no
512/// matter how many callers the traffic arrives from, its memory is one
513/// `u32` decided at startup, and there is nothing a caller can supply that
514/// makes it allocate or that buys them a second allowance.
515///
516/// **There is deliberately no per-client ceiling, here or at the proxy**
517/// (D59). Every key that would give one is either a client address, which
518/// this deployment does not handle at any layer, or something the caller
519/// chooses, which is the attack above. This type used to hold a 256-slot
520/// table hashed from the peer address; it was removed rather than left
521/// dormant, so the property is a fact about the code instead of a fact
522/// about where the node happens to be bound.
523///
524/// The cost is stated rather than hidden: a flood spends the node's whole
525/// pre-auth budget and the join page is unavailable to real invitees until
526/// the window rolls. That is an outage and never a disclosure, because the
527/// invite link is a bearer secret, `GET` never spends one, and every
528/// failure renders one byte-identical page (D57). Whoever can see the
529/// client can still limit per client; nothing in this deployment can.
530pub struct PublicLimiter {
531    /// Requests the whole pre-auth surface may spend per window.
532    global: u32,
533    counters: Mutex<PublicWindow>,
534}
535
536/// The counter for the window currently open.
537struct PublicWindow {
538    /// When the open window began. Reaching `PUBLIC_WINDOW` past this
539    /// resets the counter.
540    opened: Instant,
541    total: u32,
542}
543
544impl PublicLimiter {
545    /// A limiter allowing `global` requests per minute across the whole
546    /// pre-auth surface.
547    #[must_use]
548    pub fn new(global: u32) -> Self {
549        Self {
550            global,
551            counters: Mutex::new(PublicWindow {
552                opened: Instant::now(),
553                total: 0,
554            }),
555        }
556    }
557
558    /// Spends one request against the pre-auth allowance.
559    ///
560    /// `None` admits it. `Some(seconds)` refuses it and is the
561    /// `Retry-After` to answer with: whole seconds until the window rolls,
562    /// never less than one, so a client obeying it does not spin.
563    ///
564    /// Takes no argument describing the caller, which is the point: there
565    /// is no caller identity to take before a credential has been checked
566    /// that is not either a client address or a value the caller picked.
567    pub fn check(&self) -> Option<u64> {
568        self.check_at(Instant::now())
569    }
570
571    /// [`PublicLimiter::check`] against a caller-supplied clock, so the
572    /// window roll can be proved without sleeping through it.
573    pub fn check_at(&self, now: Instant) -> Option<u64> {
574        let mut window = self.counters.lock().expect("public limiter lock");
575        // Saturating, because a caller-supplied reading may predate the
576        // one before it; `Instant` forbids that but this signature does
577        // not, and an underflow here would roll the window every call.
578        if now.saturating_duration_since(window.opened) >= PUBLIC_WINDOW {
579            window.opened = now;
580            window.total = 0;
581        }
582        let remaining = PUBLIC_WINDOW.saturating_sub(now.saturating_duration_since(window.opened));
583        // Round up and never say "retry immediately".
584        let retry_after = u64::from(remaining.subsec_nanos() > 0) + remaining.as_secs();
585        // Checked before the counter moves, so `total` reads as
586        // "admitted this window" rather than "seen this window". That is a
587        // legibility choice, not a safety one, and the distinction is
588        // worth stating because the obvious justification is wrong here: a
589        // fixed window rolls on time, so counting refusals too would
590        // change no admission decision. It is `RateLimiter`, which refills
591        // continuously, where spending on a refusal would hold a bucket
592        // empty against its own refill.
593        if window.total >= self.global {
594            return Some(retry_after.max(1));
595        }
596        window.total = window.total.saturating_add(1);
597        None
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn a_bucket_admits_its_capacity_then_refuses() {
607        let limiter = RateLimiter::new(NonZeroU32::new(3), None);
608        let now = Instant::now();
609        for _ in 0..3 {
610            assert_eq!(limiter.check_at("alice", Class::Api, now), None);
611        }
612        let retry = limiter
613            .check_at("alice", Class::Api, now)
614            .expect("the fourth request in the same instant is refused");
615        // 3/minute is one token every 20 seconds.
616        assert_eq!(retry, 20);
617    }
618
619    #[test]
620    fn tokens_come_back_at_the_sustained_rate() {
621        let limiter = RateLimiter::new(NonZeroU32::new(60), None);
622        let start = Instant::now();
623        for _ in 0..60 {
624            assert_eq!(limiter.check_at("alice", Class::Api, start), None);
625        }
626        assert!(limiter.check_at("alice", Class::Api, start).is_some());
627        // 60/minute is one token a second; two seconds buys two requests
628        // and no more.
629        let later = start + Duration::from_secs(2);
630        assert_eq!(limiter.check_at("alice", Class::Api, later), None);
631        assert_eq!(limiter.check_at("alice", Class::Api, later), None);
632        assert!(limiter.check_at("alice", Class::Api, later).is_some());
633    }
634
635    #[test]
636    fn a_bucket_never_refills_past_a_minutes_allowance() {
637        let limiter = RateLimiter::new(NonZeroU32::new(5), None);
638        let start = Instant::now();
639        assert_eq!(limiter.check_at("alice", Class::Api, start), None);
640        // An hour of idleness does not buy an hour of burst.
641        let later = start + Duration::from_secs(3600);
642        for _ in 0..5 {
643            assert_eq!(limiter.check_at("alice", Class::Api, later), None);
644        }
645        assert!(limiter.check_at("alice", Class::Api, later).is_some());
646    }
647
648    #[test]
649    fn users_and_classes_do_not_share_a_bucket() {
650        let limiter = RateLimiter::new(NonZeroU32::new(1), NonZeroU32::new(1));
651        let now = Instant::now();
652        assert_eq!(limiter.check_at("alice", Class::Api, now), None);
653        assert!(limiter.check_at("alice", Class::Api, now).is_some());
654        // Alice's exhausted API bucket says nothing about her git bucket,
655        // and nothing at all about bob.
656        assert_eq!(limiter.check_at("alice", Class::Git, now), None);
657        assert_eq!(limiter.check_at("bob", Class::Api, now), None);
658    }
659
660    #[test]
661    fn an_unset_class_is_not_limited() {
662        let limiter = RateLimiter::new(None, NonZeroU32::new(1));
663        let now = Instant::now();
664        for _ in 0..1000 {
665            assert_eq!(limiter.check_at("alice", Class::Api, now), None);
666        }
667        assert!(!RateLimiter::new(None, None).is_active());
668        assert!(limiter.is_active());
669    }
670
671    #[test]
672    fn urls_are_charged_the_way_the_router_routes_them() {
673        assert_eq!(
674            class_of("/owner/repo.git/info/refs?service=git-upload-pack"),
675            Class::Git
676        );
677        assert_eq!(class_of("/owner/repo.git/git-receive-pack"), Class::Git);
678        assert_eq!(class_of("/api/view"), Class::Api);
679        assert_eq!(class_of("/api/log?from=0"), Class::Api);
680        assert_eq!(class_of("/"), Class::Api);
681        assert_eq!(class_of("/r/owner/repo/tree/main"), Class::Api);
682        assert_eq!(class_of("/llms.txt"), Class::Api);
683        // The API prefix wins, because the router matches it first.
684        assert_eq!(class_of("/api/x.git/y"), Class::Api);
685    }
686
687    #[test]
688    fn a_query_string_never_enters_the_record() {
689        // `Access` cannot be built without a `tiny_http::Request`, so the
690        // redaction is proved end to end in `tests/limits.rs` against a
691        // served node. What is checkable here is that the two agree on
692        // where the cut falls.
693        let url = "/api/log?from=0&token=SECRET";
694        assert_eq!(url.split('?').next(), Some("/api/log"));
695    }
696
697    #[test]
698    fn the_pre_auth_ceiling_admits_its_allowance_then_refuses_with_a_wait() {
699        let limiter = PublicLimiter::new(3);
700        let now = Instant::now();
701        for _ in 0..3 {
702            assert_eq!(limiter.check_at(now), None);
703        }
704        let retry = limiter
705            .check_at(now)
706            .expect("the fourth request in a window of three is refused");
707        assert!(
708            (1..=60).contains(&retry),
709            "a refusal must name a wait inside the window, said {retry}"
710        );
711    }
712
713    /// Named for what it proves. It does *not* prove that a refusal
714    /// leaves the allowance alone: this counter is a fixed window, so it
715    /// resets wholesale on time and every total at or above the ceiling
716    /// behaves alike. A mutation that spends on refusal passes this test,
717    /// and passed the version of it that keyed on a peer address too. The
718    /// property is real for [`RateLimiter`] and vacuous here.
719    #[test]
720    fn the_window_rolls_and_the_allowance_comes_back() {
721        let limiter = PublicLimiter::new(1);
722        let start = Instant::now();
723        assert_eq!(limiter.check_at(start), None);
724        for _ in 0..50 {
725            assert!(limiter.check_at(start).is_some());
726        }
727        assert_eq!(
728            limiter.check_at(start + PUBLIC_WINDOW),
729            None,
730            "the window rolled and the caller is still refused"
731        );
732    }
733}