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
11pub const MAX_LIMIT: u64 = i64::MAX as u64;
16
17pub const MAX_WINDOW_MILLIS: u64 = MAX_EXACT_DOUBLE_INTEGER / 1_000;
23
24pub const MAX_WINDOW: Duration = Duration::from_millis(MAX_WINDOW_MILLIS);
26
27#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
33pub struct PolicyFingerprint([u8; 32]);
34
35impl PolicyFingerprint {
36 pub const fn as_bytes(&self) -> &[u8; 32] {
38 &self.0
39 }
40
41 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#[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 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 pub const fn id(&self) -> &PolicyId {
120 &self.id
121 }
122
123 pub const fn scope(&self) -> &ScopeId {
125 &self.scope
126 }
127
128 pub const fn limit(&self) -> u64 {
130 self.limit.get()
131 }
132
133 pub const fn window(&self) -> Duration {
135 Duration::from_millis(self.window_millis.get())
136 }
137
138 pub const fn window_millis(&self) -> u64 {
140 self.window_millis.get()
141 }
142
143 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#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
187pub enum PolicyError {
188 #[error("fixed-window limit must be greater than zero")]
190 ZeroLimit,
191 #[error("fixed-window limit {actual} exceeds portable maximum {maximum}")]
193 LimitTooLarge {
194 actual: u64,
196 maximum: u64,
198 },
199 #[error("fixed-window duration must be greater than zero")]
201 ZeroWindow,
202 #[error("fixed-window duration must be an exact whole number of milliseconds")]
204 WindowNotWholeMilliseconds,
205 #[error("fixed-window duration {actual:?} exceeds portable maximum {maximum:?}")]
207 WindowTooLarge {
208 actual: Duration,
210 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}