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}