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