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        ///
66        /// With the `serde` feature, this type uses a Serde string and applies
67        /// the same validation when deserializing.
68        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
69        pub struct $name(Box<str>);
70
71        impl $name {
72            /// Validates and constructs an identifier.
73            ///
74            /// # Errors
75            ///
76            /// Returns an error when the value is empty, longer than 128
77            /// bytes, or contains a character outside the documented token
78            /// alphabet.
79            pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
80                let value = value.into();
81                validate(&value)?;
82                Ok(Self(value.into_boxed_str()))
83            }
84
85            /// Returns the identifier as a string slice.
86            pub fn as_str(&self) -> &str {
87                &self.0
88            }
89        }
90
91        impl fmt::Debug for $name {
92            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93                formatter
94                    .debug_tuple(stringify!($name))
95                    .field(&self.0)
96                    .finish()
97            }
98        }
99
100        impl fmt::Display for $name {
101            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102                formatter.write_str(self.as_str())
103            }
104        }
105
106        impl FromStr for $name {
107            type Err = IdentifierError;
108
109            fn from_str(value: &str) -> Result<Self, Self::Err> {
110                Self::new(value)
111            }
112        }
113
114        impl TryFrom<String> for $name {
115            type Error = IdentifierError;
116
117            fn try_from(value: String) -> Result<Self, Self::Error> {
118                Self::new(value)
119            }
120        }
121
122        impl TryFrom<&str> for $name {
123            type Error = IdentifierError;
124
125            fn try_from(value: &str) -> Result<Self, Self::Error> {
126                Self::new(value)
127            }
128        }
129
130        impl AsRef<str> for $name {
131            fn as_ref(&self) -> &str {
132                self.as_str()
133            }
134        }
135
136        #[cfg(feature = "serde")]
137        impl serde::Serialize for $name {
138            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
139            where
140                S: serde::Serializer,
141            {
142                serializer.serialize_str(self.as_str())
143            }
144        }
145
146        #[cfg(feature = "serde")]
147        impl<'de> serde::Deserialize<'de> for $name {
148            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149            where
150                D: serde::Deserializer<'de>,
151            {
152                let value = <String as serde::Deserialize>::deserialize(deserializer)?;
153                Self::new(value).map_err(serde::de::Error::custom)
154            }
155        }
156    };
157}
158
159identifier_type!(
160    PolicyId,
161    "A stable application-defined rate-limit policy identifier."
162);
163identifier_type!(
164    ScopeId,
165    "A stable application-defined scope within a rate-limit policy."
166);
167
168#[cfg(test)]
169mod tests {
170    use std::collections::BTreeSet;
171
172    use super::{IdentifierError, MAX_IDENTIFIER_LENGTH, PolicyId, ScopeId};
173
174    #[test]
175    fn accepts_portable_policy_and_scope_tokens() {
176        let policy = PolicyId::new("auth/login:v2").unwrap();
177        let scope: ScopeId = "client-ip_64".parse().unwrap();
178
179        assert_eq!(policy.as_str(), "auth/login:v2");
180        assert_eq!(scope.as_str(), "client-ip_64");
181        assert_eq!(policy.to_string(), "auth/login:v2");
182    }
183
184    #[test]
185    fn rejects_empty_identifiers() {
186        assert_eq!(PolicyId::new(""), Err(IdentifierError::Empty));
187        assert_eq!(ScopeId::new(""), Err(IdentifierError::Empty));
188    }
189
190    #[test]
191    fn rejects_long_identifiers() {
192        let value = "a".repeat(MAX_IDENTIFIER_LENGTH + 1);
193
194        assert_eq!(
195            PolicyId::new(value),
196            Err(IdentifierError::TooLong {
197                actual: MAX_IDENTIFIER_LENGTH + 1,
198                maximum: MAX_IDENTIFIER_LENGTH,
199            })
200        );
201    }
202
203    #[test]
204    fn rejects_whitespace_unicode_and_control_characters() {
205        assert_eq!(
206            PolicyId::new("auth login"),
207            Err(IdentifierError::InvalidCharacter {
208                index: 4,
209                character: ' ',
210            })
211        );
212        assert_eq!(
213            ScopeId::new("café"),
214            Err(IdentifierError::InvalidCharacter {
215                index: 3,
216                character: 'é',
217            })
218        );
219        assert!(matches!(
220            ScopeId::new("client\0ip"),
221            Err(IdentifierError::InvalidCharacter {
222                index: 6,
223                character: '\0',
224            })
225        ));
226    }
227
228    #[test]
229    fn identifiers_are_case_sensitive_and_orderable() {
230        let upper = PolicyId::new("Login").unwrap();
231        let lower = PolicyId::new("login").unwrap();
232        let values = BTreeSet::from([lower.clone(), upper.clone()]);
233
234        assert_ne!(upper, lower);
235        assert_eq!(values.len(), 2);
236    }
237}