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 FIXED_WINDOW_FINGERPRINT_DOMAIN: &[u8] = b"runlimit/fixed-window-policy/v1\0";
9const GCRA_FINGERPRINT_DOMAIN: &[u8] = b"runlimit/gcra-policy/v1\0";
10const MAX_EXACT_DOUBLE_INTEGER: u64 = 1_u64 << f64::MANTISSA_DIGITS;
11
12/// Largest quota or immediate capacity supported by built-in policies.
13///
14/// The portable ceiling is the largest positive value representable by the
15/// signed 64-bit counters used by persistent backends.
16pub const MAX_LIMIT: u64 = i64::MAX as u64;
17
18/// Largest whole-millisecond policy duration supported by built-in policies.
19///
20/// This deliberately conservative ceiling keeps the equivalent microsecond
21/// count in the consecutive-integer range of common backend time
22/// representations while still allowing durations of roughly 285 years.
23pub const MAX_WINDOW_MILLIS: u64 = MAX_EXACT_DOUBLE_INTEGER / 1_000;
24
25/// Largest policy duration supported by built-in policies.
26pub const MAX_WINDOW: Duration = Duration::from_millis(MAX_WINDOW_MILLIS);
27
28/// Whether quota exhaustion is enforced or reported in shadow mode.
29///
30/// This deployment flag is deliberately not part of a policy fingerprint.
31/// Switching a policy from [`QuotaMode::Shadow`] to [`QuotaMode::Enforce`]
32/// therefore keeps the counter state warmed while it was shadowed.
33#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
34#[cfg_attr(
35    feature = "serde",
36    derive(serde::Deserialize, serde::Serialize),
37    serde(rename_all = "snake_case")
38)]
39pub enum QuotaMode {
40    /// Quota exhaustion denies the operation.
41    #[default]
42    Enforce,
43    /// Quota exhaustion is reported but permits the operation to proceed.
44    Shadow,
45}
46
47/// Backend-independent policy metadata required to construct a check.
48///
49/// Storage backends remain free to support one specific policy algorithm by
50/// choosing it as [`crate::Limiter::Policy`]. Application adapters can be
51/// generic over this trait without assuming fixed-window behavior.
52pub trait RateLimitPolicy: fmt::Debug + Send + Sync {
53    /// Returns the application-defined policy identifier.
54    fn id(&self) -> &PolicyId;
55
56    /// Returns the application-defined policy scope.
57    fn scope(&self) -> &ScopeId;
58
59    /// Returns the quota replenished during [`Self::quota_period`].
60    fn quota(&self) -> u64;
61
62    /// Returns the period during which [`Self::quota`] is replenished.
63    fn quota_period(&self) -> Duration;
64
65    /// Returns the largest single cost and maximum immediately available
66    /// allowance supported by this policy.
67    fn capacity(&self) -> u64;
68
69    /// Returns the deterministic storage-key fingerprint.
70    fn fingerprint(&self) -> PolicyFingerprint;
71
72    /// Returns whether quota exhaustion is enforced or shadowed.
73    fn quota_mode(&self) -> QuotaMode;
74}
75
76/// A deterministic digest of a policy's identity, scope, and configuration.
77///
78/// Storage backends include this value in counter keys. Consequently, changing
79/// any storage-relevant policy configuration starts an independent counter
80/// instead of reinterpreting existing state.
81///
82/// This storage-key component deliberately does not implement Serde traits.
83#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
84pub struct PolicyFingerprint([u8; 32]);
85
86impl PolicyFingerprint {
87    /// Constructs a fingerprint from an already domain-separated digest.
88    ///
89    /// This is intended for third-party [`RateLimitPolicy`] implementations.
90    /// The digest must cover the algorithm identity and every
91    /// storage-relevant policy field. Deployment-only fields such as
92    /// [`QuotaMode`] should be excluded so a mode change reuses warmed state.
93    ///
94    /// Built-in policies derive their fingerprints automatically.
95    pub const fn from_digest(digest: [u8; 32]) -> Self {
96        Self(digest)
97    }
98
99    /// Returns the 32-byte SHA-256 fingerprint.
100    pub const fn as_bytes(&self) -> &[u8; 32] {
101        &self.0
102    }
103
104    /// Consumes the value and returns the 32-byte SHA-256 fingerprint.
105    pub const fn into_bytes(self) -> [u8; 32] {
106        self.0
107    }
108}
109
110impl fmt::Debug for PolicyFingerprint {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        formatter.write_str("PolicyFingerprint(")?;
113        write_hex(formatter, &self.0)?;
114        formatter.write_str(")")
115    }
116}
117
118impl fmt::Display for PolicyFingerprint {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write_hex(formatter, &self.0)
121    }
122}
123
124fn write_hex(formatter: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result {
125    for byte in bytes {
126        write!(formatter, "{byte:02x}")?;
127    }
128    Ok(())
129}
130
131/// An anchored fixed-window rate-limit policy.
132///
133/// A backend starts a window on the first allowed check for a storage key.
134/// Later allowed checks use that anchor until the full window has elapsed.
135/// This differs from fixed wall-clock boundaries such as calendar minutes.
136///
137/// Windows have exact whole-millisecond precision. A policy owns its
138/// application-defined identifier and scope so it can be reused by checks.
139///
140/// With the `serde` feature, the wire object contains `id`, `scope`, `limit`,
141/// `window_millis`, and `quota_mode`. The derived fingerprint is deliberately
142/// omitted and recomputed through [`FixedWindowPolicy::new`] when
143/// deserializing. An omitted `quota_mode` defaults to enforcement.
144#[derive(Clone, Debug, Eq, Hash, PartialEq)]
145pub struct FixedWindowPolicy {
146    id: PolicyId,
147    scope: ScopeId,
148    limit: NonZeroU64,
149    window_millis: NonZeroU64,
150    fingerprint: PolicyFingerprint,
151    quota_mode: QuotaMode,
152}
153
154impl FixedWindowPolicy {
155    /// Validates and constructs an anchored fixed-window policy.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if `limit` or `window` is zero, if `limit` exceeds
160    /// [`MAX_LIMIT`], if the window is not an exact whole number of
161    /// milliseconds, or if it exceeds [`MAX_WINDOW`].
162    pub fn new(
163        id: PolicyId,
164        scope: ScopeId,
165        limit: u64,
166        window: Duration,
167    ) -> Result<Self, PolicyError> {
168        let limit = NonZeroU64::new(limit).ok_or(PolicyError::ZeroLimit)?;
169        if limit.get() > MAX_LIMIT {
170            return Err(PolicyError::LimitTooLarge {
171                actual: limit.get(),
172                maximum: MAX_LIMIT,
173            });
174        }
175        let window_millis = validate_window(window)?;
176        let fingerprint = fingerprint(&id, &scope, limit, window_millis);
177
178        Ok(Self {
179            id,
180            scope,
181            limit,
182            window_millis,
183            fingerprint,
184            quota_mode: QuotaMode::Enforce,
185        })
186    }
187
188    /// Returns this policy with the requested quota deployment mode.
189    ///
190    /// The policy fingerprint is unchanged because the mode is not
191    /// storage-relevant.
192    #[must_use]
193    pub const fn with_quota_mode(mut self, quota_mode: QuotaMode) -> Self {
194        self.quota_mode = quota_mode;
195        self
196    }
197
198    /// Returns the application-defined policy identifier.
199    pub const fn id(&self) -> &PolicyId {
200        &self.id
201    }
202
203    /// Returns the application-defined policy scope.
204    pub const fn scope(&self) -> &ScopeId {
205        &self.scope
206    }
207
208    /// Returns the maximum cost allowed during one window.
209    pub const fn limit(&self) -> u64 {
210        self.limit.get()
211    }
212
213    /// Returns the anchored window duration.
214    pub const fn window(&self) -> Duration {
215        Duration::from_millis(self.window_millis.get())
216    }
217
218    /// Returns the anchored window as an exact, nonzero millisecond count.
219    pub const fn window_millis(&self) -> u64 {
220        self.window_millis.get()
221    }
222
223    /// Returns the deterministic configuration fingerprint.
224    pub const fn fingerprint(&self) -> PolicyFingerprint {
225        self.fingerprint
226    }
227
228    /// Returns whether quota exhaustion is enforced or shadowed.
229    pub const fn quota_mode(&self) -> QuotaMode {
230        self.quota_mode
231    }
232}
233
234impl RateLimitPolicy for FixedWindowPolicy {
235    fn id(&self) -> &PolicyId {
236        self.id()
237    }
238
239    fn scope(&self) -> &ScopeId {
240        self.scope()
241    }
242
243    fn quota(&self) -> u64 {
244        self.limit()
245    }
246
247    fn quota_period(&self) -> Duration {
248        self.window()
249    }
250
251    fn capacity(&self) -> u64 {
252        self.limit()
253    }
254
255    fn fingerprint(&self) -> PolicyFingerprint {
256        self.fingerprint()
257    }
258
259    fn quota_mode(&self) -> QuotaMode {
260        self.quota_mode()
261    }
262}
263
264#[cfg(feature = "serde")]
265#[derive(serde::Serialize)]
266struct FixedWindowPolicyRef<'a> {
267    id: &'a PolicyId,
268    scope: &'a ScopeId,
269    limit: u64,
270    window_millis: u64,
271    quota_mode: QuotaMode,
272}
273
274#[cfg(feature = "serde")]
275#[derive(serde::Deserialize)]
276#[serde(deny_unknown_fields)]
277struct FixedWindowPolicyWire {
278    id: PolicyId,
279    scope: ScopeId,
280    limit: u64,
281    window_millis: u64,
282    #[serde(default)]
283    quota_mode: QuotaMode,
284}
285
286#[cfg(feature = "serde")]
287impl serde::Serialize for FixedWindowPolicy {
288    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
289    where
290        S: serde::Serializer,
291    {
292        serde::Serialize::serialize(
293            &FixedWindowPolicyRef {
294                id: self.id(),
295                scope: self.scope(),
296                limit: self.limit(),
297                window_millis: self.window_millis(),
298                quota_mode: self.quota_mode(),
299            },
300            serializer,
301        )
302    }
303}
304
305#[cfg(feature = "serde")]
306impl<'de> serde::Deserialize<'de> for FixedWindowPolicy {
307    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
308    where
309        D: serde::Deserializer<'de>,
310    {
311        let wire = <FixedWindowPolicyWire as serde::Deserialize>::deserialize(deserializer)?;
312        Self::new(
313            wire.id,
314            wire.scope,
315            wire.limit,
316            Duration::from_millis(wire.window_millis),
317        )
318        .map(|policy| policy.with_quota_mode(wire.quota_mode))
319        .map_err(serde::de::Error::custom)
320    }
321}
322
323fn validate_window(window: Duration) -> Result<NonZeroU64, PolicyError> {
324    if window.is_zero() {
325        return Err(PolicyError::ZeroWindow);
326    }
327    if !window.subsec_nanos().is_multiple_of(1_000_000) {
328        return Err(PolicyError::WindowNotWholeMilliseconds);
329    }
330    if window > MAX_WINDOW {
331        return Err(PolicyError::WindowTooLarge {
332            actual: window,
333            maximum: MAX_WINDOW,
334        });
335    }
336
337    let millis =
338        u64::try_from(window.as_millis()).expect("the portable window maximum fits in u64");
339    NonZeroU64::new(millis).ok_or(PolicyError::ZeroWindow)
340}
341
342fn fingerprint(
343    id: &PolicyId,
344    scope: &ScopeId,
345    limit: NonZeroU64,
346    window_millis: NonZeroU64,
347) -> PolicyFingerprint {
348    let mut digest = Sha256::new();
349    digest.update(FIXED_WINDOW_FINGERPRINT_DOMAIN);
350    digest.update(id.as_str().as_bytes());
351    digest.update([0]);
352    digest.update(scope.as_str().as_bytes());
353    digest.update([0]);
354    digest.update(limit.get().to_be_bytes());
355    digest.update(window_millis.get().to_be_bytes());
356    PolicyFingerprint(digest.finalize().into())
357}
358
359/// A generic-cell-rate-algorithm policy.
360///
361/// `quota` units are replenished uniformly during `period`, while
362/// `burst_capacity` controls the maximum immediately available allowance. This
363/// avoids fixed-window boundary bursts while retaining constant-size state per
364/// storage key.
365#[derive(Clone, Debug, Eq, Hash, PartialEq)]
366pub struct GcraPolicy {
367    id: PolicyId,
368    scope: ScopeId,
369    quota: NonZeroU64,
370    period_millis: NonZeroU64,
371    burst_capacity: NonZeroU64,
372    fingerprint: PolicyFingerprint,
373    quota_mode: QuotaMode,
374}
375
376impl GcraPolicy {
377    /// Validates and constructs a GCRA policy.
378    ///
379    /// # Errors
380    ///
381    /// Returns an error when the quota or burst capacity is zero or exceeds
382    /// [`MAX_LIMIT`], or when the period is not a supported exact
383    /// whole-millisecond duration.
384    pub fn new(
385        id: PolicyId,
386        scope: ScopeId,
387        quota: u64,
388        period: Duration,
389        burst_capacity: u64,
390    ) -> Result<Self, GcraPolicyError> {
391        let quota = NonZeroU64::new(quota).ok_or(GcraPolicyError::ZeroQuota)?;
392        if quota.get() > MAX_LIMIT {
393            return Err(GcraPolicyError::QuotaTooLarge {
394                actual: quota.get(),
395                maximum: MAX_LIMIT,
396            });
397        }
398        let burst_capacity =
399            NonZeroU64::new(burst_capacity).ok_or(GcraPolicyError::ZeroBurstCapacity)?;
400        if burst_capacity.get() > MAX_LIMIT {
401            return Err(GcraPolicyError::BurstCapacityTooLarge {
402                actual: burst_capacity.get(),
403                maximum: MAX_LIMIT,
404            });
405        }
406        let period_millis = validate_window(period).map_err(GcraPolicyError::from)?;
407        let full_refill_millis = div_ceil_u128(
408            u128::from(burst_capacity.get()) * u128::from(period_millis.get()),
409            u128::from(quota.get()),
410        );
411        if full_refill_millis > u128::from(MAX_WINDOW_MILLIS) {
412            return Err(GcraPolicyError::RefillDurationTooLarge {
413                actual_millis: full_refill_millis,
414                maximum_millis: MAX_WINDOW_MILLIS,
415            });
416        }
417        let fingerprint = gcra_fingerprint(&id, &scope, quota, period_millis, burst_capacity);
418
419        Ok(Self {
420            id,
421            scope,
422            quota,
423            period_millis,
424            burst_capacity,
425            fingerprint,
426            quota_mode: QuotaMode::Enforce,
427        })
428    }
429
430    /// Returns this policy with the requested quota deployment mode.
431    #[must_use]
432    pub const fn with_quota_mode(mut self, quota_mode: QuotaMode) -> Self {
433        self.quota_mode = quota_mode;
434        self
435    }
436
437    /// Returns the application-defined policy identifier.
438    pub const fn id(&self) -> &PolicyId {
439        &self.id
440    }
441
442    /// Returns the application-defined policy scope.
443    pub const fn scope(&self) -> &ScopeId {
444        &self.scope
445    }
446
447    /// Returns the number of units replenished during one period.
448    pub const fn quota(&self) -> u64 {
449        self.quota.get()
450    }
451
452    /// Returns the replenishment period.
453    pub const fn period(&self) -> Duration {
454        Duration::from_millis(self.period_millis.get())
455    }
456
457    /// Returns the replenishment period as exact whole milliseconds.
458    pub const fn period_millis(&self) -> u64 {
459        self.period_millis.get()
460    }
461
462    /// Returns the maximum immediately available allowance.
463    pub const fn burst_capacity(&self) -> u64 {
464        self.burst_capacity.get()
465    }
466
467    /// Returns the deterministic configuration fingerprint.
468    pub const fn fingerprint(&self) -> PolicyFingerprint {
469        self.fingerprint
470    }
471
472    /// Returns whether quota exhaustion is enforced or shadowed.
473    pub const fn quota_mode(&self) -> QuotaMode {
474        self.quota_mode
475    }
476}
477
478impl RateLimitPolicy for GcraPolicy {
479    fn id(&self) -> &PolicyId {
480        self.id()
481    }
482
483    fn scope(&self) -> &ScopeId {
484        self.scope()
485    }
486
487    fn quota(&self) -> u64 {
488        self.quota()
489    }
490
491    fn quota_period(&self) -> Duration {
492        self.period()
493    }
494
495    fn capacity(&self) -> u64 {
496        self.burst_capacity()
497    }
498
499    fn fingerprint(&self) -> PolicyFingerprint {
500        self.fingerprint()
501    }
502
503    fn quota_mode(&self) -> QuotaMode {
504        self.quota_mode()
505    }
506}
507
508#[cfg(feature = "serde")]
509#[derive(serde::Serialize)]
510struct GcraPolicyRef<'a> {
511    id: &'a PolicyId,
512    scope: &'a ScopeId,
513    quota: u64,
514    period_millis: u64,
515    burst_capacity: u64,
516    quota_mode: QuotaMode,
517}
518
519#[cfg(feature = "serde")]
520#[derive(serde::Deserialize)]
521#[serde(deny_unknown_fields)]
522struct GcraPolicyWire {
523    id: PolicyId,
524    scope: ScopeId,
525    quota: u64,
526    period_millis: u64,
527    burst_capacity: u64,
528    #[serde(default)]
529    quota_mode: QuotaMode,
530}
531
532#[cfg(feature = "serde")]
533impl serde::Serialize for GcraPolicy {
534    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
535    where
536        S: serde::Serializer,
537    {
538        serde::Serialize::serialize(
539            &GcraPolicyRef {
540                id: self.id(),
541                scope: self.scope(),
542                quota: self.quota(),
543                period_millis: self.period_millis(),
544                burst_capacity: self.burst_capacity(),
545                quota_mode: self.quota_mode(),
546            },
547            serializer,
548        )
549    }
550}
551
552#[cfg(feature = "serde")]
553impl<'de> serde::Deserialize<'de> for GcraPolicy {
554    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
555    where
556        D: serde::Deserializer<'de>,
557    {
558        let wire = <GcraPolicyWire as serde::Deserialize>::deserialize(deserializer)?;
559        Self::new(
560            wire.id,
561            wire.scope,
562            wire.quota,
563            Duration::from_millis(wire.period_millis),
564            wire.burst_capacity,
565        )
566        .map(|policy| policy.with_quota_mode(wire.quota_mode))
567        .map_err(serde::de::Error::custom)
568    }
569}
570
571fn gcra_fingerprint(
572    id: &PolicyId,
573    scope: &ScopeId,
574    quota: NonZeroU64,
575    period_millis: NonZeroU64,
576    burst_capacity: NonZeroU64,
577) -> PolicyFingerprint {
578    let mut digest = Sha256::new();
579    digest.update(GCRA_FINGERPRINT_DOMAIN);
580    digest.update(id.as_str().as_bytes());
581    digest.update([0]);
582    digest.update(scope.as_str().as_bytes());
583    digest.update([0]);
584    digest.update(quota.get().to_be_bytes());
585    digest.update(period_millis.get().to_be_bytes());
586    digest.update(burst_capacity.get().to_be_bytes());
587    PolicyFingerprint(digest.finalize().into())
588}
589
590const fn div_ceil_u128(numerator: u128, denominator: u128) -> u128 {
591    numerator / denominator
592        + if numerator.is_multiple_of(denominator) {
593            0
594        } else {
595            1
596        }
597}
598
599/// An invalid fixed-window policy configuration.
600#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
601pub enum PolicyError {
602    /// The configured limit was zero.
603    #[error("fixed-window limit must be greater than zero")]
604    ZeroLimit,
605    /// The configured limit exceeded the portable backend maximum.
606    #[error("fixed-window limit {actual} exceeds portable maximum {maximum}")]
607    LimitTooLarge {
608        /// Supplied limit.
609        actual: u64,
610        /// Largest limit supported by every backend.
611        maximum: u64,
612    },
613    /// The configured window was zero.
614    #[error("fixed-window duration must be greater than zero")]
615    ZeroWindow,
616    /// The configured window had finer precision than a whole millisecond.
617    #[error("fixed-window duration must be an exact whole number of milliseconds")]
618    WindowNotWholeMilliseconds,
619    /// The configured window exceeded the portable backend maximum.
620    #[error("fixed-window duration {actual:?} exceeds portable maximum {maximum:?}")]
621    WindowTooLarge {
622        /// Supplied window.
623        actual: Duration,
624        /// Largest window supported by every backend.
625        maximum: Duration,
626    },
627}
628
629/// An invalid GCRA policy configuration.
630#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
631pub enum GcraPolicyError {
632    /// The replenishment quota was zero.
633    #[error("GCRA quota must be greater than zero")]
634    ZeroQuota,
635    /// The replenishment quota exceeded the portable backend maximum.
636    #[error("GCRA quota {actual} exceeds portable maximum {maximum}")]
637    QuotaTooLarge {
638        /// Supplied quota.
639        actual: u64,
640        /// Largest quota supported by every backend.
641        maximum: u64,
642    },
643    /// The burst capacity was zero.
644    #[error("GCRA burst capacity must be greater than zero")]
645    ZeroBurstCapacity,
646    /// The burst capacity exceeded the portable backend maximum.
647    #[error("GCRA burst capacity {actual} exceeds portable maximum {maximum}")]
648    BurstCapacityTooLarge {
649        /// Supplied burst capacity.
650        actual: u64,
651        /// Largest burst capacity supported by every backend.
652        maximum: u64,
653    },
654    /// The replenishment period was zero.
655    #[error("GCRA period must be greater than zero")]
656    ZeroPeriod,
657    /// The replenishment period had finer precision than a millisecond.
658    #[error("GCRA period must be an exact whole number of milliseconds")]
659    PeriodNotWholeMilliseconds,
660    /// The replenishment period exceeded the portable backend maximum.
661    #[error("GCRA period {actual:?} exceeds portable maximum {maximum:?}")]
662    PeriodTooLarge {
663        /// Supplied period.
664        actual: Duration,
665        /// Largest supported period.
666        maximum: Duration,
667    },
668    /// Filling the complete burst would take longer than the portable maximum.
669    #[error(
670        "GCRA full-refill duration {actual_millis}ms exceeds portable maximum {maximum_millis}ms"
671    )]
672    RefillDurationTooLarge {
673        /// Computed full-refill duration in milliseconds.
674        actual_millis: u128,
675        /// Largest supported full-refill duration in milliseconds.
676        maximum_millis: u64,
677    },
678}
679
680impl From<PolicyError> for GcraPolicyError {
681    fn from(error: PolicyError) -> Self {
682        match error {
683            PolicyError::ZeroWindow => Self::ZeroPeriod,
684            PolicyError::WindowNotWholeMilliseconds => Self::PeriodNotWholeMilliseconds,
685            PolicyError::WindowTooLarge { actual, maximum } => {
686                Self::PeriodTooLarge { actual, maximum }
687            }
688            PolicyError::ZeroLimit | PolicyError::LimitTooLarge { .. } => {
689                unreachable!("period validation cannot produce a limit error")
690            }
691        }
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use std::time::Duration;
698
699    use super::{
700        FixedWindowPolicy, GcraPolicy, GcraPolicyError, MAX_LIMIT, MAX_WINDOW, MAX_WINDOW_MILLIS,
701        PolicyError, QuotaMode, RateLimitPolicy,
702    };
703    use crate::{PolicyId, ScopeId};
704
705    fn policy(limit: u64, window: Duration) -> Result<FixedWindowPolicy, PolicyError> {
706        FixedWindowPolicy::new(
707            PolicyId::new("auth.login").unwrap(),
708            ScopeId::new("client").unwrap(),
709            limit,
710            window,
711        )
712    }
713
714    #[test]
715    fn accepts_nonzero_whole_millisecond_windows() {
716        let policy = policy(8, Duration::from_millis(60_001)).unwrap();
717
718        assert_eq!(policy.limit(), 8);
719        assert_eq!(policy.window(), Duration::from_millis(60_001));
720        assert_eq!(policy.window_millis(), 60_001);
721        assert_eq!(policy.id().as_str(), "auth.login");
722        assert_eq!(policy.scope().as_str(), "client");
723    }
724
725    #[test]
726    fn rejects_zero_limit_and_window() {
727        assert_eq!(
728            policy(0, Duration::from_secs(1)),
729            Err(PolicyError::ZeroLimit)
730        );
731        assert_eq!(policy(1, Duration::ZERO), Err(PolicyError::ZeroWindow));
732    }
733
734    #[test]
735    fn rejects_sub_millisecond_and_fractional_millisecond_windows() {
736        assert_eq!(
737            policy(1, Duration::from_nanos(1)),
738            Err(PolicyError::WindowNotWholeMilliseconds)
739        );
740        assert_eq!(
741            policy(1, Duration::from_micros(1_500)),
742            Err(PolicyError::WindowNotWholeMilliseconds)
743        );
744    }
745
746    #[test]
747    fn accepts_portable_upper_bounds() {
748        let policy = policy(MAX_LIMIT, MAX_WINDOW).unwrap();
749
750        assert_eq!(policy.limit(), MAX_LIMIT);
751        assert_eq!(policy.window(), MAX_WINDOW);
752        assert_eq!(policy.window_millis(), MAX_WINDOW_MILLIS);
753    }
754
755    #[test]
756    fn rejects_limit_above_portable_maximum() {
757        assert_eq!(
758            policy(MAX_LIMIT + 1, Duration::from_secs(1)),
759            Err(PolicyError::LimitTooLarge {
760                actual: MAX_LIMIT + 1,
761                maximum: MAX_LIMIT,
762            })
763        );
764    }
765
766    #[test]
767    fn rejects_window_above_portable_maximum() {
768        let actual = MAX_WINDOW + Duration::from_millis(1);
769
770        assert_eq!(
771            policy(1, actual),
772            Err(PolicyError::WindowTooLarge {
773                actual,
774                maximum: MAX_WINDOW,
775            })
776        );
777    }
778
779    #[test]
780    fn rejects_windows_far_beyond_portable_maximum() {
781        let actual = Duration::from_secs(u64::MAX);
782
783        assert_eq!(
784            policy(1, actual),
785            Err(PolicyError::WindowTooLarge {
786                actual,
787                maximum: MAX_WINDOW,
788            })
789        );
790    }
791
792    #[test]
793    fn fingerprint_is_deterministic() {
794        let first = policy(8, Duration::from_secs(60)).unwrap();
795        let second = policy(8, Duration::from_secs(60)).unwrap();
796
797        assert_eq!(first.fingerprint(), second.fingerprint());
798        assert_eq!(first.fingerprint().as_bytes().len(), 32);
799        assert_eq!(first.fingerprint().to_string().len(), 64);
800    }
801
802    #[test]
803    fn fingerprint_changes_with_every_storage_relevant_field() {
804        let baseline = policy(8, Duration::from_secs(60)).unwrap();
805        let different_limit = policy(9, Duration::from_secs(60)).unwrap();
806        let different_window = policy(8, Duration::from_secs(61)).unwrap();
807        let different_id = FixedWindowPolicy::new(
808            PolicyId::new("auth.signup").unwrap(),
809            ScopeId::new("client").unwrap(),
810            8,
811            Duration::from_secs(60),
812        )
813        .unwrap();
814        let different_scope = FixedWindowPolicy::new(
815            PolicyId::new("auth.login").unwrap(),
816            ScopeId::new("identity").unwrap(),
817            8,
818            Duration::from_secs(60),
819        )
820        .unwrap();
821
822        assert_ne!(baseline.fingerprint(), different_limit.fingerprint());
823        assert_ne!(baseline.fingerprint(), different_window.fingerprint());
824        assert_ne!(baseline.fingerprint(), different_id.fingerprint());
825        assert_ne!(baseline.fingerprint(), different_scope.fingerprint());
826    }
827
828    #[test]
829    fn quota_mode_does_not_change_fixed_window_storage_identity() {
830        let enforced = policy(8, Duration::from_secs(60)).unwrap();
831        let shadowed = enforced.clone().with_quota_mode(QuotaMode::Shadow);
832
833        assert_eq!(enforced.fingerprint(), shadowed.fingerprint());
834        assert_eq!(enforced.quota_mode(), QuotaMode::Enforce);
835        assert_eq!(shadowed.quota_mode(), QuotaMode::Shadow);
836        assert_eq!(RateLimitPolicy::capacity(&shadowed), 8);
837    }
838
839    #[test]
840    fn gcra_policy_exposes_uniform_replenishment_and_distinct_fingerprint() {
841        let id = PolicyId::new("api.read").unwrap();
842        let scope = ScopeId::new("account").unwrap();
843        let gcra =
844            GcraPolicy::new(id.clone(), scope.clone(), 10, Duration::from_secs(1), 20).unwrap();
845        let fixed = FixedWindowPolicy::new(id, scope, 10, Duration::from_secs(1)).unwrap();
846
847        assert_eq!(gcra.quota(), 10);
848        assert_eq!(gcra.period(), Duration::from_secs(1));
849        assert_eq!(gcra.burst_capacity(), 20);
850        assert_eq!(RateLimitPolicy::capacity(&gcra), 20);
851        assert_ne!(gcra.fingerprint(), fixed.fingerprint());
852        assert_eq!(
853            gcra.fingerprint(),
854            gcra.clone()
855                .with_quota_mode(QuotaMode::Shadow)
856                .fingerprint()
857        );
858    }
859
860    #[test]
861    fn gcra_fingerprint_tracks_every_storage_field_but_not_quota_mode() {
862        let make = |id: &str, scope: &str, quota, period_millis, burst_capacity| {
863            GcraPolicy::new(
864                PolicyId::new(id).unwrap(),
865                ScopeId::new(scope).unwrap(),
866                quota,
867                Duration::from_millis(period_millis),
868                burst_capacity,
869            )
870            .unwrap()
871        };
872        let baseline = make("api.read", "account", 10, 1_000, 20);
873
874        assert_eq!(
875            baseline.fingerprint(),
876            make("api.read", "account", 10, 1_000, 20).fingerprint()
877        );
878        assert_ne!(
879            baseline.fingerprint(),
880            make("api.write", "account", 10, 1_000, 20).fingerprint()
881        );
882        assert_ne!(
883            baseline.fingerprint(),
884            make("api.read", "client", 10, 1_000, 20).fingerprint()
885        );
886        assert_ne!(
887            baseline.fingerprint(),
888            make("api.read", "account", 11, 1_000, 20).fingerprint()
889        );
890        assert_ne!(
891            baseline.fingerprint(),
892            make("api.read", "account", 10, 1_001, 20).fingerprint()
893        );
894        assert_ne!(
895            baseline.fingerprint(),
896            make("api.read", "account", 10, 1_000, 21).fingerprint()
897        );
898        assert_eq!(
899            baseline.fingerprint(),
900            baseline
901                .clone()
902                .with_quota_mode(QuotaMode::Shadow)
903                .fingerprint()
904        );
905    }
906
907    #[test]
908    fn gcra_policy_rejects_invalid_portable_values() {
909        let make = |quota, period, burst| {
910            GcraPolicy::new(
911                PolicyId::new("api.read").unwrap(),
912                ScopeId::new("account").unwrap(),
913                quota,
914                period,
915                burst,
916            )
917        };
918
919        assert_eq!(
920            make(0, Duration::from_secs(1), 1),
921            Err(GcraPolicyError::ZeroQuota)
922        );
923        assert_eq!(
924            make(1, Duration::from_secs(1), 0),
925            Err(GcraPolicyError::ZeroBurstCapacity)
926        );
927        assert_eq!(
928            make(1, Duration::from_nanos(1), 1),
929            Err(GcraPolicyError::PeriodNotWholeMilliseconds)
930        );
931        assert!(matches!(
932            make(1, MAX_WINDOW, 2),
933            Err(GcraPolicyError::RefillDurationTooLarge { .. })
934        ));
935    }
936}