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}