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}