Skip to main content

runlimit_core/
counter.rs

1use std::fmt;
2
3use crate::{PolicyFingerprint, SubjectKey};
4
5/// The complete logical identity of one stored rate-limit counter.
6///
7/// Backends must use both the policy configuration fingerprint and opaque
8/// subject key when comparing, ordering, locking, or persisting counters.
9/// This storage key deliberately does not implement Serde traits.
10#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct CounterKey {
12    fingerprint: PolicyFingerprint,
13    subject: SubjectKey,
14}
15
16impl CounterKey {
17    /// Constructs a logical counter key from its fixed-width components.
18    pub const fn new(fingerprint: PolicyFingerprint, subject: SubjectKey) -> Self {
19        Self {
20            fingerprint,
21            subject,
22        }
23    }
24
25    /// Returns the policy configuration fingerprint.
26    pub const fn fingerprint(self) -> PolicyFingerprint {
27        self.fingerprint
28    }
29
30    /// Returns the opaque subject key.
31    pub const fn subject(self) -> SubjectKey {
32        self.subject
33    }
34
35    /// Returns the stable `fingerprint || subject` byte representation.
36    ///
37    /// This fixed-width encoding deliberately has no framing because both
38    /// components are exactly 32 bytes.
39    pub fn to_bytes(self) -> [u8; 64] {
40        let mut bytes = [0_u8; 64];
41        bytes[..32].copy_from_slice(self.fingerprint.as_bytes());
42        bytes[32..].copy_from_slice(self.subject.as_bytes());
43        bytes
44    }
45}
46
47impl fmt::Debug for CounterKey {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        formatter
50            .debug_struct("CounterKey")
51            .field("fingerprint", &self.fingerprint)
52            .field("subject", &self.subject)
53            .finish()
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use std::time::Duration;
60
61    use crate::{Check, FixedWindowPolicy, PolicyId, ScopeId, SubjectKey};
62
63    #[test]
64    fn fixed_width_encoding_is_fingerprint_then_subject() {
65        let policy = FixedWindowPolicy::new(
66            PolicyId::new("auth.login").unwrap(),
67            ScopeId::new("client").unwrap(),
68            8,
69            Duration::from_secs(60),
70        )
71        .unwrap();
72        let subject = SubjectKey::from_digest([0x5a; 32]);
73        let key = Check::new(&policy, subject).counter_key();
74        let bytes = key.to_bytes();
75
76        assert_eq!(&bytes[..32], policy.fingerprint().as_bytes());
77        assert_eq!(&bytes[32..], subject.as_bytes());
78        assert!(format!("{key:?}").contains("[REDACTED]"));
79        assert!(!format!("{key:?}").contains("5a5a"));
80    }
81}