Skip to main content

codlet_core/store/
ratelimit.rs

1//! Rate-limit policy and storage trait (RFC-008).
2//!
3//! Short human-friendly codes must be protected against online guessing.
4//! codlet's rate-limit model is:
5//!
6//! 1. The **host** computes a [`RateLimitKey`] from a trustworthy source
7//!    (e.g. a verified client IP from a trusted proxy header, or a
8//!    scope+purpose combination).
9//! 2. codlet checks the key **before** the expensive lookup.
10//! 3. On a failed redemption, codlet records the failure.
11//! 4. On a successful redemption, the caller may clear the failures.
12//!
13//! codlet never parses network headers. Trustworthiness of the key is the
14//! host's responsibility (RFC-008 §6).
15
16use std::future::Future;
17use std::time::Duration;
18
19use crate::store::error::StoreError;
20
21/// A rate-limit dimension key supplied by the host (RFC-008 §4).
22///
23/// The key should be derived from a trustworthy, non-spoofable signal.
24/// It must never be the raw plaintext code or a user-display identifier.
25/// The recommended shape is `HMAC(purpose || 0x00 || ip_or_scope)` or a
26/// stable fingerprint that the host can compute without codlet.
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub struct RateLimitKey(String);
29
30impl RateLimitKey {
31    /// Wrap a pre-computed key string.
32    #[must_use]
33    pub fn new(key: impl Into<String>) -> Self {
34        Self(key.into())
35    }
36
37    /// Borrow the key string.
38    #[must_use]
39    pub fn as_str(&self) -> &str {
40        &self.0
41    }
42
43    /// A privacy-safe fingerprint of the key, safe to include in audit events
44    /// and metrics labels (RFC-012 §10.3). Currently the first 8 characters
45    /// of the key; adapters may override with a hashed prefix.
46    #[must_use]
47    pub fn fingerprint(&self) -> &str {
48        let end = self
49            .0
50            .char_indices()
51            .nth(8)
52            .map(|(i, _)| i)
53            .unwrap_or(self.0.len());
54        &self.0[..end]
55    }
56}
57
58/// Behaviour when the rate-limit store is unavailable (RFC-008 §4).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum RateLimitUnavailable {
61    /// Allow the operation to proceed; log the store error internally.
62    /// Appropriate when rate limiting is a defence-in-depth layer and
63    /// availability is preferred over strict enforcement.
64    #[default]
65    FailOpen,
66    /// Deny the operation. Appropriate when rate limiting is a hard
67    /// requirement and availability is secondary.
68    FailClosed,
69    /// Allow until the counter reaches `n` above the normal threshold,
70    /// then deny. A compromise for services with intermittent store issues.
71    SoftDenyAfterThreshold(u32),
72}
73
74/// Rate-limit policy (RFC-008 §4).
75#[derive(Debug, Clone)]
76pub struct RateLimitPolicy {
77    /// Maximum number of recorded failures within `window` before blocking.
78    pub max_failures: u32,
79    /// Rolling window over which failures are counted.
80    pub window: Duration,
81    /// What to do when the rate-limit store is unreachable.
82    pub unavailable: RateLimitUnavailable,
83}
84
85impl RateLimitPolicy {
86    /// Sensible default: 10 failures in 5 minutes, fail-open.
87    /// Matches the source service's `10 failures / 5 min / IP` policy.
88    #[must_use]
89    pub fn default_invite() -> Self {
90        Self {
91            max_failures: 10,
92            window: Duration::from_secs(5 * 60),
93            unavailable: RateLimitUnavailable::FailOpen,
94        }
95    }
96
97    /// Whether a given failure count is at or over the threshold.
98    #[must_use]
99    pub fn is_exceeded(&self, failures: u32) -> bool {
100        failures >= self.max_failures
101    }
102}
103
104/// The result of a rate-limit check.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum RateLimitOutcome {
107    /// The key is within the policy limit; proceed with the operation.
108    Allow,
109    /// The key has exceeded the policy limit; deny the operation.
110    Deny,
111}
112
113/// Rate-limit storage (RFC-008 §4).
114///
115/// Implementations record failure counts within a rolling window keyed by
116/// [`RateLimitKey`]. All methods are infallible from the caller's perspective;
117/// backend errors are handled per [`RateLimitUnavailable`].
118pub trait RateLimitStore {
119    /// Check whether the key is within the policy limit **before** an
120    /// operation. Does not mutate state.
121    fn check(
122        &self,
123        key: &RateLimitKey,
124        policy: &RateLimitPolicy,
125    ) -> impl Future<Output = Result<RateLimitOutcome, StoreError>>;
126
127    /// Record a failure for the given key within the current window.
128    fn record_failure(
129        &self,
130        key: &RateLimitKey,
131        policy: &RateLimitPolicy,
132    ) -> impl Future<Output = Result<(), StoreError>>;
133
134    /// Clear all failure counters for the given key (called after a
135    /// successful redemption so legitimate users are not locked out).
136    fn clear_failures(&self, key: &RateLimitKey) -> impl Future<Output = Result<(), StoreError>>;
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn default_policy_thresholds() {
145        let p = RateLimitPolicy::default_invite();
146        assert_eq!(p.max_failures, 10);
147        assert!(!p.is_exceeded(9));
148        assert!(p.is_exceeded(10));
149        assert!(p.is_exceeded(11));
150    }
151
152    #[test]
153    fn fingerprint_is_prefix_not_full_key() {
154        let k = RateLimitKey::new("abcdefghijklmnop");
155        assert_eq!(k.fingerprint(), "abcdefgh");
156        let short = RateLimitKey::new("ab");
157        assert_eq!(short.fingerprint(), "ab");
158    }
159
160    #[test]
161    fn key_roundtrips() {
162        let k = RateLimitKey::new("test-key");
163        assert_eq!(k.as_str(), "test-key");
164    }
165}