Skip to main content

runlimit_core/
check.rs

1use thiserror::Error;
2
3use crate::{CounterKey, FixedWindowPolicy, SubjectKey};
4
5/// One proposed quota charge against a policy and opaque subject.
6///
7/// A newly constructed check has cost 1. Custom costs are validated against
8/// the referenced policy so a backend never receives a zero-cost or
9/// intrinsically impossible check.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub struct Check<'a> {
12    policy: &'a FixedWindowPolicy,
13    subject: SubjectKey,
14    cost: u64,
15}
16
17impl<'a> Check<'a> {
18    /// Constructs a check with the default cost of 1.
19    pub const fn new(policy: &'a FixedWindowPolicy, subject: SubjectKey) -> Self {
20        Self {
21            policy,
22            subject,
23            cost: 1,
24        }
25    }
26
27    /// Validates and constructs a check with a custom cost.
28    ///
29    /// # Errors
30    ///
31    /// Returns an error when `cost` is zero or exceeds the policy limit.
32    pub fn with_cost(
33        policy: &'a FixedWindowPolicy,
34        subject: SubjectKey,
35        cost: u64,
36    ) -> Result<Self, CheckError> {
37        validate_cost(policy, cost)?;
38        Ok(Self {
39            policy,
40            subject,
41            cost,
42        })
43    }
44
45    /// Returns this check with a validated custom cost.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error when `cost` is zero or exceeds the policy limit.
50    pub fn try_with_cost(mut self, cost: u64) -> Result<Self, CheckError> {
51        validate_cost(self.policy, cost)?;
52        self.cost = cost;
53        Ok(self)
54    }
55
56    /// Returns the policy evaluated by this check.
57    pub const fn policy(&self) -> &'a FixedWindowPolicy {
58        self.policy
59    }
60
61    /// Returns the opaque subject key evaluated by this check.
62    pub const fn subject(&self) -> SubjectKey {
63        self.subject
64    }
65
66    /// Returns the complete logical identity of the stored counter.
67    pub const fn counter_key(&self) -> CounterKey {
68        CounterKey::new(self.policy.fingerprint(), self.subject)
69    }
70
71    /// Returns the nonzero quota cost.
72    pub const fn cost(&self) -> u64 {
73        self.cost
74    }
75}
76
77fn validate_cost(policy: &FixedWindowPolicy, cost: u64) -> Result<(), CheckError> {
78    if cost == 0 {
79        return Err(CheckError::ZeroCost);
80    }
81    if cost > policy.limit() {
82        return Err(CheckError::CostExceedsLimit {
83            cost,
84            limit: policy.limit(),
85        });
86    }
87    Ok(())
88}
89
90/// An invalid check cost.
91#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
92pub enum CheckError {
93    /// The requested cost was zero.
94    #[error("check cost must be greater than zero")]
95    ZeroCost,
96    /// The requested cost exceeded the referenced policy's limit.
97    #[error("check cost ({cost}) exceeds the policy limit ({limit})")]
98    CostExceedsLimit {
99        /// Requested cost.
100        cost: u64,
101        /// Maximum cost accepted by the policy.
102        limit: u64,
103    },
104}
105
106#[cfg(test)]
107mod tests {
108    use std::time::Duration;
109
110    use super::{Check, CheckError};
111    use crate::{FixedWindowPolicy, PolicyId, ScopeId, SubjectKey};
112
113    fn policy() -> FixedWindowPolicy {
114        FixedWindowPolicy::new(
115            PolicyId::new("auth.login").unwrap(),
116            ScopeId::new("client").unwrap(),
117            8,
118            Duration::from_secs(60),
119        )
120        .unwrap()
121    }
122
123    #[test]
124    fn defaults_to_one_unit_of_cost() {
125        let policy = policy();
126        let subject = SubjectKey::from_digest([1; 32]);
127        let check = Check::new(&policy, subject);
128
129        assert_eq!(check.policy(), &policy);
130        assert_eq!(check.subject(), subject);
131        assert_eq!(check.cost(), 1);
132    }
133
134    #[test]
135    fn accepts_cost_up_to_and_including_the_limit() {
136        let policy = policy();
137        let subject = SubjectKey::from_digest([2; 32]);
138
139        assert_eq!(Check::with_cost(&policy, subject, 3).unwrap().cost(), 3);
140        assert_eq!(
141            Check::new(&policy, subject)
142                .try_with_cost(policy.limit())
143                .unwrap()
144                .cost(),
145            policy.limit()
146        );
147    }
148
149    #[test]
150    fn rejects_zero_cost() {
151        let policy = policy();
152
153        assert_eq!(
154            Check::with_cost(&policy, SubjectKey::from_digest([3; 32]), 0),
155            Err(CheckError::ZeroCost)
156        );
157    }
158
159    #[test]
160    fn rejects_cost_above_policy_limit() {
161        let policy = policy();
162
163        assert_eq!(
164            Check::with_cost(&policy, SubjectKey::from_digest([4; 32]), 9),
165            Err(CheckError::CostExceedsLimit { cost: 9, limit: 8 })
166        );
167    }
168}