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 when the rate-limit store is unavailable.
62    /// Appropriate when rate limiting is defence-in-depth and availability is
63    /// preferred over strict enforcement.
64    #[default]
65    FailOpen,
66    /// Deny the operation when the rate-limit store is unavailable.
67    /// Use when rate limiting is a hard security requirement and the host
68    /// prefers a temporary outage over a weakened rate-limit guarantee.
69    FailClosed,
70}
71
72/// Rate-limit policy (RFC-008 §4).
73#[derive(Debug, Clone)]
74pub struct RateLimitPolicy {
75    /// Maximum number of recorded failures within `window` before blocking.
76    pub max_failures: u32,
77    /// Rolling window over which failures are counted.
78    pub window: Duration,
79    /// What to do when the rate-limit store is unreachable.
80    pub unavailable: RateLimitUnavailable,
81}
82
83impl RateLimitPolicy {
84    /// Sensible default: 10 failures in 5 minutes, fail-open.
85    /// Matches the source service's `10 failures / 5 min / IP` policy.
86    #[must_use]
87    pub fn default_invite() -> Self {
88        Self {
89            max_failures: 10,
90            window: Duration::from_secs(5 * 60),
91            unavailable: RateLimitUnavailable::FailOpen,
92        }
93    }
94
95    /// Whether a given failure count is at or over the threshold.
96    #[must_use]
97    pub fn is_exceeded(&self, failures: u32) -> bool {
98        failures >= self.max_failures
99    }
100}
101
102/// The result of a rate-limit check.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum RateLimitOutcome {
105    /// The key is within the policy limit; proceed with the operation.
106    Allow,
107    /// The key has exceeded the policy limit; deny the operation.
108    Deny,
109}
110
111/// Rate-limit storage (RFC-008 §4).
112///
113/// Implementations record failure counts within a rolling window keyed by
114/// [`RateLimitKey`]. All methods are infallible from the caller's perspective;
115/// backend errors are handled per [`RateLimitUnavailable`].
116pub trait RateLimitStore {
117    /// Check whether the key is within the policy limit **before** an
118    /// operation. Does not mutate state.
119    fn check(
120        &self,
121        key: &RateLimitKey,
122        policy: &RateLimitPolicy,
123    ) -> impl Future<Output = Result<RateLimitOutcome, StoreError>>;
124
125    /// Record a failure for the given key within the current window.
126    fn record_failure(
127        &self,
128        key: &RateLimitKey,
129        policy: &RateLimitPolicy,
130    ) -> impl Future<Output = Result<(), StoreError>>;
131
132    /// Clear all failure counters for the given key (called after a
133    /// successful redemption so legitimate users are not locked out).
134    fn clear_failures(&self, key: &RateLimitKey) -> impl Future<Output = Result<(), StoreError>>;
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn default_policy_thresholds() {
143        let p = RateLimitPolicy::default_invite();
144        assert_eq!(p.max_failures, 10);
145        assert!(!p.is_exceeded(9));
146        assert!(p.is_exceeded(10));
147        assert!(p.is_exceeded(11));
148    }
149
150    #[test]
151    fn fingerprint_is_prefix_not_full_key() {
152        let k = RateLimitKey::new("abcdefghijklmnop");
153        assert_eq!(k.fingerprint(), "abcdefgh");
154        let short = RateLimitKey::new("ab");
155        assert_eq!(short.fingerprint(), "ab");
156    }
157
158    #[test]
159    fn key_roundtrips() {
160        let k = RateLimitKey::new("test-key");
161        assert_eq!(k.as_str(), "test-key");
162    }
163}