solid_pod_rs/security/rate_limit.rs
1//! Pluggable rate-limit primitive (Sprint 7 §6.1, ADR-057).
2//!
3//! The library exposes a transport-agnostic [`RateLimiter`] trait plus a
4//! reference in-process `LruRateLimiter` implementation. Consumer
5//! binders (actix-web, axum, tower) adapt the trait to their middleware
6//! surface — this crate never mounts routes itself (F7 boundary).
7//!
8//! ## Algorithm
9//!
10//! Sliding-window counter keyed by `(route, subject)`. Each bucket
11//! stores the monotonic `Instant` of every hit inside the current
12//! window. On each [`RateLimiter::check`]:
13//!
14//! 1. Prune entries older than `window`.
15//! 2. If the remaining count `>= max`, deny with
16//! `retry_after_secs = ceil(window - (now - oldest_hit))`.
17//! 3. Otherwise, record `now` and allow.
18//!
19//! ## Storage
20//!
21//! An LRU cache bounds memory under pathological key churn. The cache
22//! capacity defaults to `DEFAULT_LRU_CAPACITY` (4096). Entries that are
23//! evicted lose their history — a deliberate trade-off: the bound is
24//! hard, and real-world adversaries cannot force forgiveness of their
25//! own recent hits without also flushing their own bucket.
26//!
27//! ## Subject identity
28//!
29//! [`RateLimitSubject`] distinguishes per-IP (anonymous requests) from
30//! per-WebID (authenticated requests). Consumers SHOULD prefer WebID
31//! keying for authenticated endpoints: it is stable across NAT churn.
32//!
33//! ## Concurrency
34//!
35//! The limiter uses `parking_lot::Mutex` (already in the dep graph via
36//! `reqwest`). Contention is O(1) per check; the critical section is
37//! the prune-and-push on a single bucket.
38
39#[cfg(feature = "rate-limit")]
40use std::time::Duration;
41
42use async_trait::async_trait;
43
44/// Rate-limit subject — the entity whose quota is being counted.
45///
46/// Variants deliberately borrow `&str` / `&IpAddr` rather than owning;
47/// the limiter computes a canonical string key at check time and drops
48/// the borrow.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RateLimitSubject<'a> {
51 /// Anonymous client keyed by source IP.
52 Ip(std::net::IpAddr),
53 /// Authenticated client keyed by WebID URL.
54 WebId(&'a str),
55 /// Opaque caller-supplied key (e.g. API-key fingerprint).
56 Custom(&'a str),
57}
58
59#[cfg(feature = "rate-limit")]
60impl RateLimitSubject<'_> {
61 /// Canonical string representation, used as the bucket key.
62 fn canonical(&self) -> String {
63 match self {
64 RateLimitSubject::Ip(ip) => format!("ip:{ip}"),
65 RateLimitSubject::WebId(w) => format!("webid:{w}"),
66 RateLimitSubject::Custom(c) => format!("custom:{c}"),
67 }
68 }
69}
70
71/// Composite key for a limiter bucket. Bundles the logical route name
72/// with the subject; buckets never cross routes.
73#[derive(Debug, Clone)]
74pub struct RateLimitKey<'a> {
75 /// Logical route name (e.g. `pod_create`, `write`, `idp_credentials`).
76 pub route: &'a str,
77 /// Subject identity.
78 pub subject: RateLimitSubject<'a>,
79}
80
81#[cfg(feature = "rate-limit")]
82impl RateLimitKey<'_> {
83 fn canonical(&self) -> String {
84 format!("{}|{}", self.route, self.subject.canonical())
85 }
86}
87
88/// Outcome of a single [`RateLimiter::check`].
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum RateLimitDecision {
91 /// Request is permitted. The hit was recorded.
92 Allow,
93 /// Request exceeds the configured quota for this key. Caller
94 /// SHOULD return `429 Too Many Requests` with a `Retry-After`
95 /// header equal to `retry_after_secs`.
96 Deny {
97 /// Seconds the client should wait before retrying.
98 retry_after_secs: u64,
99 /// Configured maximum for this route.
100 limit: u32,
101 /// Configured window in seconds for this route.
102 window_secs: u64,
103 },
104}
105
106/// Transport-agnostic rate-limit contract.
107///
108/// Implementations MUST be `Send + Sync + 'static` so consumer binders
109/// can wrap them in a shared `Arc<dyn RateLimiter>` inside their
110/// middleware stack.
111#[async_trait]
112pub trait RateLimiter: Send + Sync + 'static {
113 /// Check and record a single hit for `key`. Returns
114 /// [`RateLimitDecision::Allow`] if the request is permitted (and
115 /// records the hit), or [`RateLimitDecision::Deny`] otherwise
116 /// (without recording).
117 async fn check(&self, key: &RateLimitKey<'_>) -> RateLimitDecision;
118}
119
120// -- reference implementation ---------------------------------------------
121
122#[cfg(feature = "rate-limit")]
123mod lru_impl {
124 use super::*;
125
126 use std::num::NonZeroUsize;
127 use std::time::Instant;
128
129 use lru::LruCache;
130 use parking_lot::Mutex;
131
132 /// Default LRU capacity. Bounds memory under key churn.
133 pub const DEFAULT_LRU_CAPACITY: usize = 4096;
134
135 /// Default policy when no per-route policy is supplied: 60 hits
136 /// per 60 s. Deliberately generous — binders SHOULD configure
137 /// tighter policies per route.
138 const DEFAULT_MAX: u32 = 60;
139 const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
140
141 /// Sliding-window bucket: the hit timestamps inside the current
142 /// window. Stored oldest-first so prune + retry-after are O(window).
143 #[derive(Debug, Default)]
144 struct SlidingWindow {
145 hits: Vec<Instant>,
146 }
147
148 impl SlidingWindow {
149 fn prune(&mut self, now: Instant, window: Duration) {
150 let cutoff = now.checked_sub(window);
151 match cutoff {
152 Some(c) => self.hits.retain(|t| *t > c),
153 None => self.hits.clear(),
154 }
155 }
156 }
157
158 /// LRU-cached sliding-window rate limiter.
159 pub struct LruRateLimiter {
160 buckets: Mutex<LruCache<String, SlidingWindow>>,
161 policies: Vec<RoutePolicy>,
162 default_policy: RoutePolicy,
163 }
164
165 #[derive(Debug, Clone)]
166 struct RoutePolicy {
167 route: String,
168 max: u32,
169 window: Duration,
170 }
171
172 impl LruRateLimiter {
173 /// Construct a limiter with no per-route policies and the
174 /// default fall-back (60 hits / 60 s).
175 pub fn new() -> Self {
176 Self::with_capacity_and_policies(DEFAULT_LRU_CAPACITY, Vec::new())
177 }
178
179 /// Construct a limiter with explicit per-route policies.
180 /// Routes not present in `policies` fall back to the default
181 /// (60 hits / 60 s).
182 ///
183 /// Panics if any `max` is zero or any `window` is zero — a zero
184 /// limit is non-sensical (would deny every request) and a zero
185 /// window would divide by zero when computing retry-after.
186 pub fn with_policy(policies: Vec<(String, u32, Duration)>) -> Self {
187 Self::with_capacity_and_policies(DEFAULT_LRU_CAPACITY, policies)
188 }
189
190 /// Construct with an explicit LRU capacity.
191 pub fn with_capacity_and_policies(
192 capacity: usize,
193 policies: Vec<(String, u32, Duration)>,
194 ) -> Self {
195 // SAFETY: 1 is non-zero, so this is infallible.
196 const ONE: NonZeroUsize = match NonZeroUsize::new(1) {
197 Some(v) => v,
198 None => unreachable!(),
199 };
200 let capacity = NonZeroUsize::new(capacity.max(1)).unwrap_or(ONE);
201
202 let policies = policies
203 .into_iter()
204 .map(|(route, max, window)| {
205 assert!(max > 0, "rate-limit max must be non-zero");
206 assert!(!window.is_zero(), "rate-limit window must be non-zero");
207 RoutePolicy { route, max, window }
208 })
209 .collect();
210
211 Self {
212 buckets: Mutex::new(LruCache::new(capacity)),
213 policies,
214 default_policy: RoutePolicy {
215 route: String::new(),
216 max: DEFAULT_MAX,
217 window: DEFAULT_WINDOW,
218 },
219 }
220 }
221
222 fn policy_for(&self, route: &str) -> &RoutePolicy {
223 self.policies
224 .iter()
225 .find(|p| p.route == route)
226 .unwrap_or(&self.default_policy)
227 }
228
229 /// Clock-injected form of [`RateLimiter::check`]: evaluate the
230 /// sliding window as if the request arrived at `now`, recording the
231 /// hit on an `Allow`.
232 ///
233 /// [`RateLimiter::check`] is exactly `check_at(key, Instant::now())`.
234 /// This entry point is public so callers — and tests — can drive the
235 /// window deterministically instead of sleeping through wall-clock
236 /// time, which is inherently racy under load or instrumentation
237 /// (coverage runs in particular).
238 ///
239 /// `now` is expected to be non-decreasing across calls for a given
240 /// key. A `now` that moves backwards is handled safely (saturating
241 /// arithmetic) but yields a window that has not advanced.
242 ///
243 /// # Examples
244 ///
245 /// ```
246 /// # #[cfg(feature = "rate-limit")] {
247 /// use std::net::{IpAddr, Ipv4Addr};
248 /// use std::time::{Duration, Instant};
249 /// use solid_pod_rs::security::rate_limit::{
250 /// LruRateLimiter, RateLimitDecision, RateLimitKey, RateLimitSubject,
251 /// };
252 ///
253 /// let limiter =
254 /// LruRateLimiter::with_policy(vec![("write".to_string(), 1, Duration::from_secs(60))]);
255 /// let key = RateLimitKey {
256 /// route: "write",
257 /// subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
258 /// };
259 ///
260 /// let t0 = Instant::now();
261 /// assert_eq!(limiter.check_at(&key, t0), RateLimitDecision::Allow);
262 /// // Second hit inside the window is denied — no sleeping required.
263 /// assert!(matches!(
264 /// limiter.check_at(&key, t0 + Duration::from_millis(1)),
265 /// RateLimitDecision::Deny { .. }
266 /// ));
267 /// // Once the window has fully elapsed the quota rolls over.
268 /// assert_eq!(
269 /// limiter.check_at(&key, t0 + Duration::from_secs(61)),
270 /// RateLimitDecision::Allow
271 /// );
272 /// # }
273 /// ```
274 #[must_use]
275 pub fn check_at(&self, key: &RateLimitKey<'_>, now: Instant) -> RateLimitDecision {
276 let policy = self.policy_for(key.route);
277 let canonical = key.canonical();
278
279 let mut buckets = self.buckets.lock();
280 let bucket = buckets.get_or_insert_mut(canonical, SlidingWindow::default);
281
282 bucket.prune(now, policy.window);
283
284 let window_secs = policy.window.as_secs().max(1);
285
286 if bucket.hits.len() as u32 >= policy.max {
287 // Retry-after = time until the oldest hit falls out of
288 // the window. Ceil to whole seconds so clients never
289 // retry slightly too early.
290 let oldest = bucket.hits.first().copied().unwrap_or(now);
291 let elapsed = now.saturating_duration_since(oldest);
292 let remaining = policy.window.saturating_sub(elapsed);
293 let retry_after_secs = ceil_secs(remaining).max(1);
294
295 return RateLimitDecision::Deny {
296 retry_after_secs,
297 limit: policy.max,
298 window_secs,
299 };
300 }
301
302 bucket.hits.push(now);
303 RateLimitDecision::Allow
304 }
305 }
306
307 impl Default for LruRateLimiter {
308 fn default() -> Self {
309 Self::new()
310 }
311 }
312
313 #[async_trait]
314 impl RateLimiter for LruRateLimiter {
315 async fn check(&self, key: &RateLimitKey<'_>) -> RateLimitDecision {
316 // Synchronous under the hood — the mutex section is O(1)
317 // amortised. Exposed async so future backends (Redis,
318 // sharded) slot in without a trait change.
319 self.check_at(key, Instant::now())
320 }
321 }
322
323 fn ceil_secs(d: Duration) -> u64 {
324 let whole = d.as_secs();
325 if d.subsec_nanos() > 0 {
326 whole.saturating_add(1)
327 } else {
328 whole
329 }
330 }
331
332 // --- unit tests ------------------------------------------------------
333
334 #[cfg(test)]
335 mod tests {
336 use super::*;
337 use std::net::{IpAddr, Ipv4Addr};
338
339 fn ip() -> RateLimitSubject<'static> {
340 RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))
341 }
342
343 #[test]
344 fn ceil_secs_rounds_up_fractional() {
345 assert_eq!(ceil_secs(Duration::from_millis(500)), 1);
346 assert_eq!(ceil_secs(Duration::from_secs(1)), 1);
347 assert_eq!(ceil_secs(Duration::from_millis(1500)), 2);
348 assert_eq!(ceil_secs(Duration::from_secs(0)), 0);
349 }
350
351 #[test]
352 fn default_policy_used_when_route_unknown() {
353 let limiter =
354 LruRateLimiter::with_policy(vec![("foo".into(), 1, Duration::from_secs(5))]);
355 let key = RateLimitKey {
356 route: "bar",
357 subject: ip(),
358 };
359 // First 60 should pass under the default policy.
360 let now = Instant::now();
361 for _ in 0..60 {
362 assert_eq!(limiter.check_at(&key, now), RateLimitDecision::Allow);
363 }
364 // 61st denies.
365 let d = limiter.check_at(&key, now);
366 assert!(matches!(d, RateLimitDecision::Deny { .. }));
367 }
368
369 #[test]
370 fn canonical_keys_separate_subjects() {
371 let a = RateLimitKey {
372 route: "r",
373 subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))),
374 };
375 let b = RateLimitKey {
376 route: "r",
377 subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 5))),
378 };
379 assert_ne!(a.canonical(), b.canonical());
380 }
381
382 #[test]
383 fn canonical_keys_separate_routes() {
384 let a = RateLimitKey {
385 route: "r1",
386 subject: ip(),
387 };
388 let b = RateLimitKey {
389 route: "r2",
390 subject: ip(),
391 };
392 assert_ne!(a.canonical(), b.canonical());
393 }
394 }
395}
396
397#[cfg(feature = "rate-limit")]
398pub use lru_impl::{LruRateLimiter, DEFAULT_LRU_CAPACITY};