Skip to main content

runlimit_core/
policy.rs

1use std::{fmt, num::NonZeroU64, time::Duration};
2
3use sha2::{Digest, Sha256};
4use thiserror::Error;
5
6use crate::{PolicyId, ScopeId};
7
8const FINGERPRINT_DOMAIN: &[u8] = b"runlimit/fixed-window-policy/v1\0";
9const MAX_EXACT_DOUBLE_INTEGER: u64 = 1_u64 << f64::MANTISSA_DIGITS;
10
11/// Largest fixed-window quota supported by every Runlimit backend.
12///
13/// The portable ceiling is the largest positive value representable by the
14/// signed 64-bit counters used by persistent backends.
15pub const MAX_LIMIT: u64 = i64::MAX as u64;
16
17/// Largest whole-millisecond window supported by every Runlimit backend.
18///
19/// This deliberately conservative ceiling keeps the equivalent microsecond
20/// count in the consecutive-integer range of common backend time
21/// representations while still allowing windows of roughly 285 years.
22pub const MAX_WINDOW_MILLIS: u64 = MAX_EXACT_DOUBLE_INTEGER / 1_000;
23
24/// Largest fixed-window duration supported by every Runlimit backend.
25pub const MAX_WINDOW: Duration = Duration::from_millis(MAX_WINDOW_MILLIS);
26
27/// A deterministic digest of a policy's identity, scope, and configuration.
28///
29/// Storage backends include this value in counter keys. Consequently, changing
30/// a limit or window starts an independent counter instead of reinterpreting
31/// state created under the old configuration.
32#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
33pub struct PolicyFingerprint([u8; 32]);
34
35impl PolicyFingerprint {
36    /// Returns the 32-byte SHA-256 fingerprint.
37    pub const fn as_bytes(&self) -> &[u8; 32] {
38        &self.0
39    }
40
41    /// Consumes the value and returns the 32-byte SHA-256 fingerprint.
42    pub const fn into_bytes(self) -> [u8; 32] {
43        self.0
44    }
45}
46
47impl fmt::Debug for PolicyFingerprint {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        formatter.write_str("PolicyFingerprint(")?;
50        write_hex(formatter, &self.0)?;
51        formatter.write_str(")")
52    }
53}
54
55impl fmt::Display for PolicyFingerprint {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write_hex(formatter, &self.0)
58    }
59}
60
61fn write_hex(formatter: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result {
62    for byte in bytes {
63        write!(formatter, "{byte:02x}")?;
64    }
65    Ok(())
66}
67
68/// An anchored fixed-window rate-limit policy.
69///
70/// A backend starts a window on the first allowed check for a storage key.
71/// Later allowed checks use that anchor until the full window has elapsed.
72/// This differs from fixed wall-clock boundaries such as calendar minutes.
73///
74/// Windows have exact whole-millisecond precision. A policy owns its
75/// application-defined identifier and scope so it can be reused by checks.
76#[derive(Clone, Debug, Eq, Hash, PartialEq)]
77pub struct FixedWindowPolicy {
78    id: PolicyId,
79    scope: ScopeId,
80    limit: NonZeroU64,
81    window_millis: NonZeroU64,
82    fingerprint: PolicyFingerprint,
83}
84
85impl FixedWindowPolicy {
86    /// Validates and constructs an anchored fixed-window policy.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if `limit` or `window` is zero, if `limit` exceeds
91    /// [`MAX_LIMIT`], if the window is not an exact whole number of
92    /// milliseconds, or if it exceeds [`MAX_WINDOW`].
93    pub fn new(
94        id: PolicyId,
95        scope: ScopeId,
96        limit: u64,
97        window: Duration,
98    ) -> Result<Self, PolicyError> {
99        let limit = NonZeroU64::new(limit).ok_or(PolicyError::ZeroLimit)?;
100        if limit.get() > MAX_LIMIT {
101            return Err(PolicyError::LimitTooLarge {
102                actual: limit.get(),
103                maximum: MAX_LIMIT,
104            });
105        }
106        let window_millis = validate_window(window)?;
107        let fingerprint = fingerprint(&id, &scope, limit, window_millis);
108
109        Ok(Self {
110            id,
111            scope,
112            limit,
113            window_millis,
114            fingerprint,
115        })
116    }
117
118    /// Returns the application-defined policy identifier.
119    pub const fn id(&self) -> &PolicyId {
120        &self.id
121    }
122
123    /// Returns the application-defined policy scope.
124    pub const fn scope(&self) -> &ScopeId {
125        &self.scope
126    }
127
128    /// Returns the maximum cost allowed during one window.
129    pub const fn limit(&self) -> u64 {
130        self.limit.get()
131    }
132
133    /// Returns the anchored window duration.
134    pub const fn window(&self) -> Duration {
135        Duration::from_millis(self.window_millis.get())
136    }
137
138    /// Returns the anchored window as an exact, nonzero millisecond count.
139    pub const fn window_millis(&self) -> u64 {
140        self.window_millis.get()
141    }
142
143    /// Returns the deterministic configuration fingerprint.
144    pub const fn fingerprint(&self) -> PolicyFingerprint {
145        self.fingerprint
146    }
147}
148
149fn validate_window(window: Duration) -> Result<NonZeroU64, PolicyError> {
150    if window.is_zero() {
151        return Err(PolicyError::ZeroWindow);
152    }
153    if !window.subsec_nanos().is_multiple_of(1_000_000) {
154        return Err(PolicyError::WindowNotWholeMilliseconds);
155    }
156    if window > MAX_WINDOW {
157        return Err(PolicyError::WindowTooLarge {
158            actual: window,
159            maximum: MAX_WINDOW,
160        });
161    }
162
163    let millis =
164        u64::try_from(window.as_millis()).expect("the portable window maximum fits in u64");
165    NonZeroU64::new(millis).ok_or(PolicyError::ZeroWindow)
166}
167
168fn fingerprint(
169    id: &PolicyId,
170    scope: &ScopeId,
171    limit: NonZeroU64,
172    window_millis: NonZeroU64,
173) -> PolicyFingerprint {
174    let mut digest = Sha256::new();
175    digest.update(FINGERPRINT_DOMAIN);
176    digest.update(id.as_str().as_bytes());
177    digest.update([0]);
178    digest.update(scope.as_str().as_bytes());
179    digest.update([0]);
180    digest.update(limit.get().to_be_bytes());
181    digest.update(window_millis.get().to_be_bytes());
182    PolicyFingerprint(digest.finalize().into())
183}
184
185/// An invalid fixed-window policy configuration.
186#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
187pub enum PolicyError {
188    /// The configured limit was zero.
189    #[error("fixed-window limit must be greater than zero")]
190    ZeroLimit,
191    /// The configured limit exceeded the portable backend maximum.
192    #[error("fixed-window limit {actual} exceeds portable maximum {maximum}")]
193    LimitTooLarge {
194        /// Supplied limit.
195        actual: u64,
196        /// Largest limit supported by every backend.
197        maximum: u64,
198    },
199    /// The configured window was zero.
200    #[error("fixed-window duration must be greater than zero")]
201    ZeroWindow,
202    /// The configured window had finer precision than a whole millisecond.
203    #[error("fixed-window duration must be an exact whole number of milliseconds")]
204    WindowNotWholeMilliseconds,
205    /// The configured window exceeded the portable backend maximum.
206    #[error("fixed-window duration {actual:?} exceeds portable maximum {maximum:?}")]
207    WindowTooLarge {
208        /// Supplied window.
209        actual: Duration,
210        /// Largest window supported by every backend.
211        maximum: Duration,
212    },
213}
214
215#[cfg(test)]
216mod tests {
217    use std::time::Duration;
218
219    use super::{FixedWindowPolicy, MAX_LIMIT, MAX_WINDOW, MAX_WINDOW_MILLIS, PolicyError};
220    use crate::{PolicyId, ScopeId};
221
222    fn policy(limit: u64, window: Duration) -> Result<FixedWindowPolicy, PolicyError> {
223        FixedWindowPolicy::new(
224            PolicyId::new("auth.login").unwrap(),
225            ScopeId::new("client").unwrap(),
226            limit,
227            window,
228        )
229    }
230
231    #[test]
232    fn accepts_nonzero_whole_millisecond_windows() {
233        let policy = policy(8, Duration::from_millis(60_001)).unwrap();
234
235        assert_eq!(policy.limit(), 8);
236        assert_eq!(policy.window(), Duration::from_millis(60_001));
237        assert_eq!(policy.window_millis(), 60_001);
238        assert_eq!(policy.id().as_str(), "auth.login");
239        assert_eq!(policy.scope().as_str(), "client");
240    }
241
242    #[test]
243    fn rejects_zero_limit_and_window() {
244        assert_eq!(
245            policy(0, Duration::from_secs(1)),
246            Err(PolicyError::ZeroLimit)
247        );
248        assert_eq!(policy(1, Duration::ZERO), Err(PolicyError::ZeroWindow));
249    }
250
251    #[test]
252    fn rejects_sub_millisecond_and_fractional_millisecond_windows() {
253        assert_eq!(
254            policy(1, Duration::from_nanos(1)),
255            Err(PolicyError::WindowNotWholeMilliseconds)
256        );
257        assert_eq!(
258            policy(1, Duration::from_micros(1_500)),
259            Err(PolicyError::WindowNotWholeMilliseconds)
260        );
261    }
262
263    #[test]
264    fn accepts_portable_upper_bounds() {
265        let policy = policy(MAX_LIMIT, MAX_WINDOW).unwrap();
266
267        assert_eq!(policy.limit(), MAX_LIMIT);
268        assert_eq!(policy.window(), MAX_WINDOW);
269        assert_eq!(policy.window_millis(), MAX_WINDOW_MILLIS);
270    }
271
272    #[test]
273    fn rejects_limit_above_portable_maximum() {
274        assert_eq!(
275            policy(MAX_LIMIT + 1, Duration::from_secs(1)),
276            Err(PolicyError::LimitTooLarge {
277                actual: MAX_LIMIT + 1,
278                maximum: MAX_LIMIT,
279            })
280        );
281    }
282
283    #[test]
284    fn rejects_window_above_portable_maximum() {
285        let actual = MAX_WINDOW + Duration::from_millis(1);
286
287        assert_eq!(
288            policy(1, actual),
289            Err(PolicyError::WindowTooLarge {
290                actual,
291                maximum: MAX_WINDOW,
292            })
293        );
294    }
295
296    #[test]
297    fn rejects_windows_far_beyond_portable_maximum() {
298        let actual = Duration::from_secs(u64::MAX);
299
300        assert_eq!(
301            policy(1, actual),
302            Err(PolicyError::WindowTooLarge {
303                actual,
304                maximum: MAX_WINDOW,
305            })
306        );
307    }
308
309    #[test]
310    fn fingerprint_is_deterministic() {
311        let first = policy(8, Duration::from_secs(60)).unwrap();
312        let second = policy(8, Duration::from_secs(60)).unwrap();
313
314        assert_eq!(first.fingerprint(), second.fingerprint());
315        assert_eq!(first.fingerprint().as_bytes().len(), 32);
316        assert_eq!(first.fingerprint().to_string().len(), 64);
317    }
318
319    #[test]
320    fn fingerprint_changes_with_every_storage_relevant_field() {
321        let baseline = policy(8, Duration::from_secs(60)).unwrap();
322        let different_limit = policy(9, Duration::from_secs(60)).unwrap();
323        let different_window = policy(8, Duration::from_secs(61)).unwrap();
324        let different_id = FixedWindowPolicy::new(
325            PolicyId::new("auth.signup").unwrap(),
326            ScopeId::new("client").unwrap(),
327            8,
328            Duration::from_secs(60),
329        )
330        .unwrap();
331        let different_scope = FixedWindowPolicy::new(
332            PolicyId::new("auth.login").unwrap(),
333            ScopeId::new("identity").unwrap(),
334            8,
335            Duration::from_secs(60),
336        )
337        .unwrap();
338
339        assert_ne!(baseline.fingerprint(), different_limit.fingerprint());
340        assert_ne!(baseline.fingerprint(), different_window.fingerprint());
341        assert_ne!(baseline.fingerprint(), different_id.fingerprint());
342        assert_ne!(baseline.fingerprint(), different_scope.fingerprint());
343    }
344}