Skip to main content

runlimit_core/
identifier.rs

1use std::{fmt, str::FromStr};
2
3use thiserror::Error;
4
5/// Maximum encoded length of a policy or scope identifier.
6pub const MAX_IDENTIFIER_LENGTH: usize = 128;
7
8fn validate(value: &str) -> Result<(), IdentifierError> {
9    if value.is_empty() {
10        return Err(IdentifierError::Empty);
11    }
12    if value.len() > MAX_IDENTIFIER_LENGTH {
13        return Err(IdentifierError::TooLong {
14            actual: value.len(),
15            maximum: MAX_IDENTIFIER_LENGTH,
16        });
17    }
18
19    for (index, character) in value.char_indices() {
20        if !(character.is_ascii_alphanumeric() || matches!(character, '-' | '.' | '_' | ':' | '/'))
21        {
22            return Err(IdentifierError::InvalidCharacter { index, character });
23        }
24    }
25
26    Ok(())
27}
28
29/// An invalid policy or scope identifier.
30///
31/// Identifiers are deliberately restricted to short ASCII tokens. This keeps
32/// storage keys portable and avoids visually confusable policy namespaces.
33#[derive(Clone, Debug, Error, Eq, PartialEq)]
34pub enum IdentifierError {
35    /// The identifier was empty.
36    #[error("identifier must not be empty")]
37    Empty,
38    /// The identifier exceeded [`MAX_IDENTIFIER_LENGTH`].
39    #[error("identifier is {actual} bytes; the maximum is {maximum}")]
40    TooLong {
41        /// Supplied byte length.
42        actual: usize,
43        /// Maximum accepted byte length.
44        maximum: usize,
45    },
46    /// The identifier contained a byte outside the accepted token alphabet.
47    #[error(
48        "identifier contains invalid character {character:?} at byte index {index}; \
49         use ASCII letters, digits, '-', '.', '_', ':', or '/'"
50    )]
51    InvalidCharacter {
52        /// Zero-based byte index of the invalid character.
53        index: usize,
54        /// Invalid character.
55        character: char,
56    },
57}
58
59macro_rules! identifier_type {
60    ($name:ident, $description:literal) => {
61        #[doc = $description]
62        ///
63        /// Values contain between 1 and 128 bytes and may use ASCII letters,
64        /// digits, `-`, `.`, `_`, `:`, and `/`. Identifiers are case-sensitive.
65        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
66        pub struct $name(Box<str>);
67
68        impl $name {
69            /// Validates and constructs an identifier.
70            ///
71            /// # Errors
72            ///
73            /// Returns an error when the value is empty, longer than 128
74            /// bytes, or contains a character outside the documented token
75            /// alphabet.
76            pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
77                let value = value.into();
78                validate(&value)?;
79                Ok(Self(value.into_boxed_str()))
80            }
81
82            /// Returns the identifier as a string slice.
83            pub fn as_str(&self) -> &str {
84                &self.0
85            }
86        }
87
88        impl fmt::Debug for $name {
89            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90                formatter
91                    .debug_tuple(stringify!($name))
92                    .field(&self.0)
93                    .finish()
94            }
95        }
96
97        impl fmt::Display for $name {
98            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99                formatter.write_str(self.as_str())
100            }
101        }
102
103        impl FromStr for $name {
104            type Err = IdentifierError;
105
106            fn from_str(value: &str) -> Result<Self, Self::Err> {
107                Self::new(value)
108            }
109        }
110
111        impl TryFrom<String> for $name {
112            type Error = IdentifierError;
113
114            fn try_from(value: String) -> Result<Self, Self::Error> {
115                Self::new(value)
116            }
117        }
118
119        impl TryFrom<&str> for $name {
120            type Error = IdentifierError;
121
122            fn try_from(value: &str) -> Result<Self, Self::Error> {
123                Self::new(value)
124            }
125        }
126
127        impl AsRef<str> for $name {
128            fn as_ref(&self) -> &str {
129                self.as_str()
130            }
131        }
132    };
133}
134
135identifier_type!(
136    PolicyId,
137    "A stable application-defined rate-limit policy identifier."
138);
139identifier_type!(
140    ScopeId,
141    "A stable application-defined scope within a rate-limit policy."
142);
143
144#[cfg(test)]
145mod tests {
146    use std::collections::BTreeSet;
147
148    use super::{IdentifierError, MAX_IDENTIFIER_LENGTH, PolicyId, ScopeId};
149
150    #[test]
151    fn accepts_portable_policy_and_scope_tokens() {
152        let policy = PolicyId::new("auth/login:v2").unwrap();
153        let scope: ScopeId = "client-ip_64".parse().unwrap();
154
155        assert_eq!(policy.as_str(), "auth/login:v2");
156        assert_eq!(scope.as_str(), "client-ip_64");
157        assert_eq!(policy.to_string(), "auth/login:v2");
158    }
159
160    #[test]
161    fn rejects_empty_identifiers() {
162        assert_eq!(PolicyId::new(""), Err(IdentifierError::Empty));
163        assert_eq!(ScopeId::new(""), Err(IdentifierError::Empty));
164    }
165
166    #[test]
167    fn rejects_long_identifiers() {
168        let value = "a".repeat(MAX_IDENTIFIER_LENGTH + 1);
169
170        assert_eq!(
171            PolicyId::new(value),
172            Err(IdentifierError::TooLong {
173                actual: MAX_IDENTIFIER_LENGTH + 1,
174                maximum: MAX_IDENTIFIER_LENGTH,
175            })
176        );
177    }
178
179    #[test]
180    fn rejects_whitespace_unicode_and_control_characters() {
181        assert_eq!(
182            PolicyId::new("auth login"),
183            Err(IdentifierError::InvalidCharacter {
184                index: 4,
185                character: ' ',
186            })
187        );
188        assert_eq!(
189            ScopeId::new("café"),
190            Err(IdentifierError::InvalidCharacter {
191                index: 3,
192                character: 'é',
193            })
194        );
195        assert!(matches!(
196            ScopeId::new("client\0ip"),
197            Err(IdentifierError::InvalidCharacter {
198                index: 6,
199                character: '\0',
200            })
201        ));
202    }
203
204    #[test]
205    fn identifiers_are_case_sensitive_and_orderable() {
206        let upper = PolicyId::new("Login").unwrap();
207        let lower = PolicyId::new("login").unwrap();
208        let values = BTreeSet::from([lower.clone(), upper.clone()]);
209
210        assert_ne!(upper, lower);
211        assert_eq!(values.len(), 2);
212    }
213}