runlimit_core/
identifier.rs1use std::{fmt, str::FromStr};
2
3use thiserror::Error;
4
5pub 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#[derive(Clone, Debug, Error, Eq, PartialEq)]
34pub enum IdentifierError {
35 #[error("identifier must not be empty")]
37 Empty,
38 #[error("identifier is {actual} bytes; the maximum is {maximum}")]
40 TooLong {
41 actual: usize,
43 maximum: usize,
45 },
46 #[error(
48 "identifier contains invalid character {character:?} at byte index {index}; \
49 use ASCII letters, digits, '-', '.', '_', ':', or '/'"
50 )]
51 InvalidCharacter {
52 index: usize,
54 character: char,
56 },
57}
58
59macro_rules! identifier_type {
60 ($name:ident, $description:literal) => {
61 #[doc = $description]
62 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
69 pub struct $name(Box<str>);
70
71 impl $name {
72 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 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}