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)]
66 pub struct $name(Box<str>);
67
68 impl $name {
69 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 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}