Skip to main content

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    /// Append the canonical subject representation (`ip:…`, `webid:…`,
62    /// `custom:…`) to `out`, without allocating.
63    ///
64    /// This used to return an owned `String` that
65    /// `RateLimitKey::canonical` immediately embedded in a second
66    /// `format!` — one extra heap allocation on every
67    /// `RateLimiter::check`. The bucket key is rebuilt on every check,
68    /// so the hot path now appends in place instead.
69    fn write_canonical(&self, out: &mut String) {
70        match self {
71            RateLimitSubject::Ip(ip) => {
72                out.push_str("ip:");
73                // `IpAddr`'s `Display` writes digit groups straight into
74                // `out`; routing it through `format_args!` avoids the
75                // intermediate `ip.to_string()` allocation.
76                let _ = std::fmt::Write::write_fmt(out, format_args!("{ip}"));
77            }
78            RateLimitSubject::WebId(w) => {
79                out.push_str("webid:");
80                out.push_str(w);
81            }
82            RateLimitSubject::Custom(c) => {
83                out.push_str("custom:");
84                out.push_str(c);
85            }
86        }
87    }
88}
89
90/// Composite key for a limiter bucket. Bundles the logical route name
91/// with the subject; buckets never cross routes.
92#[derive(Debug, Clone)]
93pub struct RateLimitKey<'a> {
94    /// Logical route name (e.g. `pod_create`, `write`, `idp_credentials`).
95    pub route: &'a str,
96    /// Subject identity.
97    pub subject: RateLimitSubject<'a>,
98}
99
100#[cfg(feature = "rate-limit")]
101impl RateLimitKey<'_> {
102    fn canonical(&self) -> String {
103        // Exactly one allocation per check: size the key up front
104        // (`1` = the `|` separator; `45` = widest `IpAddr` text form,
105        // a full IPv6 literal), then append in place.
106        let subject_cap = match &self.subject {
107            RateLimitSubject::Ip(_) => "ip:".len() + 45,
108            RateLimitSubject::WebId(w) => "webid:".len() + w.len(),
109            RateLimitSubject::Custom(c) => "custom:".len() + c.len(),
110        };
111        let mut key = String::with_capacity(self.route.len() + 1 + subject_cap);
112        key.push_str(self.route);
113        key.push('|');
114        self.subject.write_canonical(&mut key);
115        key
116    }
117}
118
119/// Outcome of a single [`RateLimiter::check`].
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum RateLimitDecision {
122    /// Request is permitted. The hit was recorded.
123    Allow,
124    /// Request exceeds the configured quota for this key. Caller
125    /// SHOULD return `429 Too Many Requests` with a `Retry-After`
126    /// header equal to `retry_after_secs`.
127    Deny {
128        /// Seconds the client should wait before retrying.
129        retry_after_secs: u64,
130        /// Configured maximum for this route.
131        limit: u32,
132        /// Configured window in seconds for this route.
133        window_secs: u64,
134    },
135}
136
137/// Transport-agnostic rate-limit contract.
138///
139/// Implementations MUST be `Send + Sync + 'static` so consumer binders
140/// can wrap them in a shared `Arc<dyn RateLimiter>` inside their
141/// middleware stack.
142#[async_trait]
143pub trait RateLimiter: Send + Sync + 'static {
144    /// Check and record a single hit for `key`. Returns
145    /// [`RateLimitDecision::Allow`] if the request is permitted (and
146    /// records the hit), or [`RateLimitDecision::Deny`] otherwise
147    /// (without recording).
148    async fn check(&self, key: &RateLimitKey<'_>) -> RateLimitDecision;
149}
150
151// -- reference implementation ---------------------------------------------
152
153#[cfg(feature = "rate-limit")]
154mod lru_impl {
155    use super::*;
156
157    use std::num::NonZeroUsize;
158    use std::time::Instant;
159
160    use lru::LruCache;
161    use parking_lot::Mutex;
162
163    /// Default LRU capacity. Bounds memory under key churn.
164    pub const DEFAULT_LRU_CAPACITY: usize = 4096;
165
166    /// Default policy when no per-route policy is supplied: 60 hits
167    /// per 60 s. Deliberately generous — binders SHOULD configure
168    /// tighter policies per route.
169    const DEFAULT_MAX: u32 = 60;
170    const DEFAULT_WINDOW: Duration = Duration::from_secs(60);
171
172    /// Sliding-window bucket: the hit timestamps inside the current
173    /// window. Stored oldest-first so prune + retry-after are O(window).
174    #[derive(Debug, Default)]
175    struct SlidingWindow {
176        hits: Vec<Instant>,
177    }
178
179    impl SlidingWindow {
180        fn prune(&mut self, now: Instant, window: Duration) {
181            let cutoff = now.checked_sub(window);
182            match cutoff {
183                Some(c) => self.hits.retain(|t| *t > c),
184                None => self.hits.clear(),
185            }
186        }
187    }
188
189    /// LRU-cached sliding-window rate limiter.
190    pub struct LruRateLimiter {
191        buckets: Mutex<LruCache<String, SlidingWindow>>,
192        policies: Vec<RoutePolicy>,
193        default_policy: RoutePolicy,
194    }
195
196    #[derive(Debug, Clone)]
197    struct RoutePolicy {
198        route: String,
199        max: u32,
200        window: Duration,
201    }
202
203    impl LruRateLimiter {
204        /// Construct a limiter with no per-route policies and the
205        /// default fall-back (60 hits / 60 s).
206        pub fn new() -> Self {
207            Self::with_capacity_and_policies(DEFAULT_LRU_CAPACITY, Vec::new())
208        }
209
210        /// Construct a limiter with explicit per-route policies.
211        /// Routes not present in `policies` fall back to the default
212        /// (60 hits / 60 s).
213        ///
214        /// Panics if any `max` is zero or any `window` is zero — a zero
215        /// limit is non-sensical (would deny every request) and a zero
216        /// window would divide by zero when computing retry-after.
217        pub fn with_policy(policies: Vec<(String, u32, Duration)>) -> Self {
218            Self::with_capacity_and_policies(DEFAULT_LRU_CAPACITY, policies)
219        }
220
221        /// Construct with an explicit LRU capacity.
222        pub fn with_capacity_and_policies(
223            capacity: usize,
224            policies: Vec<(String, u32, Duration)>,
225        ) -> Self {
226            // SAFETY: 1 is non-zero, so this is infallible.
227            const ONE: NonZeroUsize = match NonZeroUsize::new(1) {
228                Some(v) => v,
229                None => unreachable!(),
230            };
231            let capacity = NonZeroUsize::new(capacity.max(1)).unwrap_or(ONE);
232
233            let policies = policies
234                .into_iter()
235                .map(|(route, max, window)| {
236                    assert!(max > 0, "rate-limit max must be non-zero");
237                    assert!(!window.is_zero(), "rate-limit window must be non-zero");
238                    RoutePolicy { route, max, window }
239                })
240                .collect();
241
242            Self {
243                buckets: Mutex::new(LruCache::new(capacity)),
244                policies,
245                default_policy: RoutePolicy {
246                    route: String::new(),
247                    max: DEFAULT_MAX,
248                    window: DEFAULT_WINDOW,
249                },
250            }
251        }
252
253        fn policy_for(&self, route: &str) -> &RoutePolicy {
254            self.policies
255                .iter()
256                .find(|p| p.route == route)
257                .unwrap_or(&self.default_policy)
258        }
259
260        /// Clock-injected form of [`RateLimiter::check`]: evaluate the
261        /// sliding window as if the request arrived at `now`, recording the
262        /// hit on an `Allow`.
263        ///
264        /// [`RateLimiter::check`] is exactly `check_at(key, Instant::now())`.
265        /// This entry point is public so callers — and tests — can drive the
266        /// window deterministically instead of sleeping through wall-clock
267        /// time, which is inherently racy under load or instrumentation
268        /// (coverage runs in particular).
269        ///
270        /// `now` is expected to be non-decreasing across calls for a given
271        /// key. A `now` that moves backwards is handled safely (saturating
272        /// arithmetic) but yields a window that has not advanced.
273        ///
274        /// # Examples
275        ///
276        /// ```
277        /// # #[cfg(feature = "rate-limit")] {
278        /// use std::net::{IpAddr, Ipv4Addr};
279        /// use std::time::{Duration, Instant};
280        /// use solid_pod_rs::security::rate_limit::{
281        ///     LruRateLimiter, RateLimitDecision, RateLimitKey, RateLimitSubject,
282        /// };
283        ///
284        /// let limiter =
285        ///     LruRateLimiter::with_policy(vec![("write".to_string(), 1, Duration::from_secs(60))]);
286        /// let key = RateLimitKey {
287        ///     route: "write",
288        ///     subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
289        /// };
290        ///
291        /// let t0 = Instant::now();
292        /// assert_eq!(limiter.check_at(&key, t0), RateLimitDecision::Allow);
293        /// // Second hit inside the window is denied — no sleeping required.
294        /// assert!(matches!(
295        ///     limiter.check_at(&key, t0 + Duration::from_millis(1)),
296        ///     RateLimitDecision::Deny { .. }
297        /// ));
298        /// // Once the window has fully elapsed the quota rolls over.
299        /// assert_eq!(
300        ///     limiter.check_at(&key, t0 + Duration::from_secs(61)),
301        ///     RateLimitDecision::Allow
302        /// );
303        /// # }
304        /// ```
305        #[must_use]
306        pub fn check_at(&self, key: &RateLimitKey<'_>, now: Instant) -> RateLimitDecision {
307            let policy = self.policy_for(key.route);
308            let canonical = key.canonical();
309
310            let mut buckets = self.buckets.lock();
311            let bucket = buckets.get_or_insert_mut(canonical, SlidingWindow::default);
312
313            bucket.prune(now, policy.window);
314
315            let window_secs = policy.window.as_secs().max(1);
316
317            if bucket.hits.len() as u32 >= policy.max {
318                // Retry-after = time until the oldest hit falls out of
319                // the window. Ceil to whole seconds so clients never
320                // retry slightly too early.
321                let oldest = bucket.hits.first().copied().unwrap_or(now);
322                let elapsed = now.saturating_duration_since(oldest);
323                let remaining = policy.window.saturating_sub(elapsed);
324                let retry_after_secs = ceil_secs(remaining).max(1);
325
326                return RateLimitDecision::Deny {
327                    retry_after_secs,
328                    limit: policy.max,
329                    window_secs,
330                };
331            }
332
333            bucket.hits.push(now);
334            RateLimitDecision::Allow
335        }
336    }
337
338    impl Default for LruRateLimiter {
339        fn default() -> Self {
340            Self::new()
341        }
342    }
343
344    #[async_trait]
345    impl RateLimiter for LruRateLimiter {
346        async fn check(&self, key: &RateLimitKey<'_>) -> RateLimitDecision {
347            // Synchronous under the hood — the mutex section is O(1)
348            // amortised. Exposed async so future backends (Redis,
349            // sharded) slot in without a trait change.
350            self.check_at(key, Instant::now())
351        }
352    }
353
354    fn ceil_secs(d: Duration) -> u64 {
355        let whole = d.as_secs();
356        if d.subsec_nanos() > 0 {
357            whole.saturating_add(1)
358        } else {
359            whole
360        }
361    }
362
363    // --- unit tests ------------------------------------------------------
364
365    #[cfg(test)]
366    mod tests {
367        use super::*;
368        use std::net::{IpAddr, Ipv4Addr};
369
370        fn ip() -> RateLimitSubject<'static> {
371            RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))
372        }
373
374        #[test]
375        fn ceil_secs_rounds_up_fractional() {
376            assert_eq!(ceil_secs(Duration::from_millis(500)), 1);
377            assert_eq!(ceil_secs(Duration::from_secs(1)), 1);
378            assert_eq!(ceil_secs(Duration::from_millis(1500)), 2);
379            assert_eq!(ceil_secs(Duration::from_secs(0)), 0);
380        }
381
382        #[test]
383        fn default_policy_used_when_route_unknown() {
384            let limiter =
385                LruRateLimiter::with_policy(vec![("foo".into(), 1, Duration::from_secs(5))]);
386            let key = RateLimitKey {
387                route: "bar",
388                subject: ip(),
389            };
390            // First 60 should pass under the default policy.
391            let now = Instant::now();
392            for _ in 0..60 {
393                assert_eq!(limiter.check_at(&key, now), RateLimitDecision::Allow);
394            }
395            // 61st denies.
396            let d = limiter.check_at(&key, now);
397            assert!(matches!(d, RateLimitDecision::Deny { .. }));
398        }
399
400        #[test]
401        fn canonical_keys_separate_subjects() {
402            let a = RateLimitKey {
403                route: "r",
404                subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))),
405            };
406            let b = RateLimitKey {
407                route: "r",
408                subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 5))),
409            };
410            assert_ne!(a.canonical(), b.canonical());
411        }
412
413        #[test]
414        fn canonical_keys_separate_routes() {
415            let a = RateLimitKey {
416                route: "r1",
417                subject: ip(),
418            };
419            let b = RateLimitKey {
420                route: "r2",
421                subject: ip(),
422            };
423            assert_ne!(a.canonical(), b.canonical());
424        }
425
426        #[test]
427        fn canonical_key_format_is_exact() {
428            // Regression witness for the single-allocation key builder:
429            // the key feeds the LRU, so a silent format drift would
430            // orphan every live bucket (quota reset) instead of failing
431            // loudly. Byte format pinned for all three subject kinds,
432            // both IP families included.
433            let webid = RateLimitKey {
434                route: "write",
435                subject: RateLimitSubject::WebId("https://alice.example/webid#me"),
436            };
437            assert_eq!(
438                webid.canonical(),
439                "write|webid:https://alice.example/webid#me"
440            );
441
442            let ip_v4 = RateLimitKey {
443                route: "read",
444                subject: RateLimitSubject::Ip(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 7))),
445            };
446            assert_eq!(ip_v4.canonical(), "read|ip:192.0.2.7");
447
448            let ip_v6 = RateLimitKey {
449                route: "read",
450                subject: RateLimitSubject::Ip(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)),
451            };
452            assert_eq!(ip_v6.canonical(), "read|ip:::1");
453
454            let custom = RateLimitKey {
455                route: "idp_credentials",
456                subject: RateLimitSubject::Custom("keyfp:9f2a"),
457            };
458            assert_eq!(custom.canonical(), "idp_credentials|custom:keyfp:9f2a");
459        }
460    }
461}
462
463#[cfg(feature = "rate-limit")]
464pub use lru_impl::{LruRateLimiter, DEFAULT_LRU_CAPACITY};