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
12pub const MAX_LIMIT: u64 = i64::MAX as u64;
17
18pub const MAX_WINDOW_MILLIS: u64 = MAX_EXACT_DOUBLE_INTEGER / 1_000;
24
25pub const MAX_WINDOW: Duration = Duration::from_millis(MAX_WINDOW_MILLIS);
27
28#[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 #[default]
42 Enforce,
43 Shadow,
45}
46
47pub trait RateLimitPolicy: fmt::Debug + Send + Sync {
53 fn id(&self) -> &PolicyId;
55
56 fn scope(&self) -> &ScopeId;
58
59 fn quota(&self) -> u64;
61
62 fn quota_period(&self) -> Duration;
64
65 fn capacity(&self) -> u64;
68
69 fn fingerprint(&self) -> PolicyFingerprint;
71
72 fn quota_mode(&self) -> QuotaMode;
74}
75
76#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
84pub struct PolicyFingerprint([u8; 32]);
85
86impl PolicyFingerprint {
87 pub const fn from_digest(digest: [u8; 32]) -> Self {
96 Self(digest)
97 }
98
99 pub const fn as_bytes(&self) -> &[u8; 32] {
101 &self.0
102 }
103
104 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#[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 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 #[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 pub const fn id(&self) -> &PolicyId {
200 &self.id
201 }
202
203 pub const fn scope(&self) -> &ScopeId {
205 &self.scope
206 }
207
208 pub const fn limit(&self) -> u64 {
210 self.limit.get()
211 }
212
213 pub const fn window(&self) -> Duration {
215 Duration::from_millis(self.window_millis.get())
216 }
217
218 pub const fn window_millis(&self) -> u64 {
220 self.window_millis.get()
221 }
222
223 pub const fn fingerprint(&self) -> PolicyFingerprint {
225 self.fingerprint
226 }
227
228 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#[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 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 #[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 pub const fn id(&self) -> &PolicyId {
439 &self.id
440 }
441
442 pub const fn scope(&self) -> &ScopeId {
444 &self.scope
445 }
446
447 pub const fn quota(&self) -> u64 {
449 self.quota.get()
450 }
451
452 pub const fn period(&self) -> Duration {
454 Duration::from_millis(self.period_millis.get())
455 }
456
457 pub const fn period_millis(&self) -> u64 {
459 self.period_millis.get()
460 }
461
462 pub const fn burst_capacity(&self) -> u64 {
464 self.burst_capacity.get()
465 }
466
467 pub const fn fingerprint(&self) -> PolicyFingerprint {
469 self.fingerprint
470 }
471
472 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#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
601pub enum PolicyError {
602 #[error("fixed-window limit must be greater than zero")]
604 ZeroLimit,
605 #[error("fixed-window limit {actual} exceeds portable maximum {maximum}")]
607 LimitTooLarge {
608 actual: u64,
610 maximum: u64,
612 },
613 #[error("fixed-window duration must be greater than zero")]
615 ZeroWindow,
616 #[error("fixed-window duration must be an exact whole number of milliseconds")]
618 WindowNotWholeMilliseconds,
619 #[error("fixed-window duration {actual:?} exceeds portable maximum {maximum:?}")]
621 WindowTooLarge {
622 actual: Duration,
624 maximum: Duration,
626 },
627}
628
629#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
631pub enum GcraPolicyError {
632 #[error("GCRA quota must be greater than zero")]
634 ZeroQuota,
635 #[error("GCRA quota {actual} exceeds portable maximum {maximum}")]
637 QuotaTooLarge {
638 actual: u64,
640 maximum: u64,
642 },
643 #[error("GCRA burst capacity must be greater than zero")]
645 ZeroBurstCapacity,
646 #[error("GCRA burst capacity {actual} exceeds portable maximum {maximum}")]
648 BurstCapacityTooLarge {
649 actual: u64,
651 maximum: u64,
653 },
654 #[error("GCRA period must be greater than zero")]
656 ZeroPeriod,
657 #[error("GCRA period must be an exact whole number of milliseconds")]
659 PeriodNotWholeMilliseconds,
660 #[error("GCRA period {actual:?} exceeds portable maximum {maximum:?}")]
662 PeriodTooLarge {
663 actual: Duration,
665 maximum: Duration,
667 },
668 #[error(
670 "GCRA full-refill duration {actual_millis}ms exceeds portable maximum {maximum_millis}ms"
671 )]
672 RefillDurationTooLarge {
673 actual_millis: u128,
675 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}