kalamdb_commons/models/ids/
user_id.rs1use std::{
4 fmt,
5 sync::{Arc, OnceLock},
6};
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11use crate::{constants::AuthConstants, StorageKey};
12
13static ANON_USER_ID: OnceLock<Arc<str>> = OnceLock::new();
19static ROOT_USER_ID: OnceLock<Arc<str>> = OnceLock::new();
20static SYSTEM_USER_ID_STATIC: OnceLock<Arc<str>> = OnceLock::new();
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub struct UserId(Arc<str>);
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct UserIdValidationError(pub String);
33
34impl std::fmt::Display for UserIdValidationError {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "{}", self.0)
37 }
38}
39
40impl std::error::Error for UserIdValidationError {}
41
42impl UserId {
43 pub const MAX_LENGTH: usize = 128;
45
46 #[inline]
51 pub fn new(id: impl Into<String>) -> Self {
52 Self::try_new(id).expect("UserId contains invalid characters")
53 }
54
55 #[inline]
57 pub fn anonymous() -> Self {
58 Self(ANON_USER_ID.get_or_init(|| Arc::from(AuthConstants::ANONYMOUS_USER_ID)).clone())
59 }
60
61 pub fn try_new(id: impl Into<String>) -> Result<Self, UserIdValidationError> {
73 let id = id.into();
74 Self::validate_id(&id)?;
75 Ok(Self(Arc::<str>::from(id)))
76 }
77
78 fn validate_id(id: &str) -> Result<(), UserIdValidationError> {
80 if id.is_empty() {
82 return Err(UserIdValidationError("User ID cannot be empty".to_string()));
83 }
84 if id.len() > Self::MAX_LENGTH {
85 return Err(UserIdValidationError(format!(
86 "User ID cannot exceed {} characters",
87 Self::MAX_LENGTH
88 )));
89 }
90
91 if id.contains("..") {
93 return Err(UserIdValidationError(
94 "User ID cannot contain '..' (path traversal)".to_string(),
95 ));
96 }
97 if id.contains('/') {
98 return Err(UserIdValidationError(
99 "User ID cannot contain '/' (directory separator)".to_string(),
100 ));
101 }
102 if id.contains('\\') {
103 return Err(UserIdValidationError(
104 "User ID cannot contain '\\' (directory separator)".to_string(),
105 ));
106 }
107 if id.contains('\0') {
108 return Err(UserIdValidationError("User ID cannot contain null bytes".to_string()));
109 }
110
111 if !id.is_ascii()
112 || !id
113 .bytes()
114 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
115 {
116 return Err(UserIdValidationError(
117 "User ID can only contain ASCII letters, digits, '_' and '-'".to_string(),
118 ));
119 }
120
121 Ok(())
122 }
123
124 #[inline]
129 #[cfg(feature = "full")]
130 pub fn generate() -> Self {
131 Self(Arc::<str>::from(nanoid::nanoid!()))
132 }
133
134 #[inline]
140 #[allow(dead_code)] pub(crate) fn new_unchecked(id: impl Into<String>) -> Self {
142 let s: String = id.into();
143 Self(Arc::<str>::from(s))
144 }
145
146 #[inline]
148 pub fn as_str(&self) -> &str {
149 &self.0
150 }
151
152 #[inline]
154 pub fn into_string(self) -> String {
155 String::from(&*self.0)
156 }
157
158 #[inline]
160 pub fn root() -> Self {
161 Self(
162 ROOT_USER_ID
163 .get_or_init(|| Arc::from(AuthConstants::DEFAULT_ROOT_USER_ID))
164 .clone(),
165 )
166 }
167
168 #[inline]
170 pub fn system() -> Self {
171 Self(
172 SYSTEM_USER_ID_STATIC
173 .get_or_init(|| Arc::from(AuthConstants::DEFAULT_SYSTEM_USER_ID))
174 .clone(),
175 )
176 }
177
178 #[inline]
180 pub fn is_admin(&self) -> bool {
181 self.as_str() == AuthConstants::DEFAULT_ROOT_USER_ID
182 }
183
184 #[inline]
186 pub fn is_anonymous(&self) -> bool {
187 self.as_str() == AuthConstants::ANONYMOUS_USER_ID
188 }
189}
190
191impl fmt::Display for UserId {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 write!(f, "{}", self.0)
194 }
195}
196
197impl From<String> for UserId {
198 fn from(s: String) -> Self {
203 Self::new(s)
204 }
205}
206
207impl From<&str> for UserId {
208 fn from(s: &str) -> Self {
213 Self::new(s.to_string())
214 }
215}
216
217impl AsRef<str> for UserId {
218 fn as_ref(&self) -> &str {
219 &self.0
220 }
221}
222
223impl AsRef<[u8]> for UserId {
224 fn as_ref(&self) -> &[u8] {
225 self.0.as_bytes()
226 }
227}
228
229impl StorageKey for UserId {
230 fn storage_key(&self) -> Vec<u8> {
231 self.0.as_bytes().to_vec()
232 }
233
234 fn from_storage_key(bytes: &[u8]) -> Result<Self, String> {
235 String::from_utf8(bytes.to_vec())
236 .map(|s| UserId(Arc::<str>::from(s)))
237 .map_err(|e| e.to_string())
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_valid_user_id() {
247 let user = UserId::try_new("alice123");
248 assert!(user.is_ok());
249 assert_eq!(user.unwrap().as_str(), "alice123");
250 }
251
252 #[test]
253 fn test_user_id_with_underscores_and_dashes() {
254 let user = UserId::try_new("user_123-test");
255 assert!(user.is_ok());
256 }
257
258 #[test]
259 fn test_path_traversal_double_dot_blocked() {
260 let user = UserId::try_new("../../../etc/passwd");
261 assert!(user.is_err());
262 assert!(user.unwrap_err().0.contains("path traversal"));
263 }
264
265 #[test]
266 fn test_path_traversal_forward_slash_blocked() {
267 let user = UserId::try_new("user/subdir");
268 assert!(user.is_err());
269 assert!(user.unwrap_err().0.contains("directory separator"));
270 }
271
272 #[test]
273 fn test_path_traversal_backslash_blocked() {
274 let user = UserId::try_new("user\\subdir");
275 assert!(user.is_err());
276 assert!(user.unwrap_err().0.contains("directory separator"));
277 }
278
279 #[test]
280 fn test_null_byte_blocked() {
281 let user = UserId::try_new("user\0hidden");
282 assert!(user.is_err());
283 assert!(user.unwrap_err().0.contains("null bytes"));
284 }
285
286 #[test]
287 fn test_empty_user_id_blocked() {
288 let user = UserId::try_new("");
289 assert!(user.is_err());
290 assert!(user.unwrap_err().0.contains("empty"));
291 }
292
293 #[test]
294 fn test_user_id_too_long_blocked() {
295 let user = UserId::try_new("a".repeat(UserId::MAX_LENGTH + 1));
296 assert!(user.is_err());
297 assert!(user.unwrap_err().0.contains("cannot exceed"));
298 }
299
300 #[test]
301 fn test_user_id_with_disallowed_ascii_characters_blocked() {
302 for invalid in [
303 "user name",
304 "user\nname",
305 "user\tname",
306 "user'name",
307 "user;drop",
308 ] {
309 let user = UserId::try_new(invalid);
310 assert!(user.is_err(), "expected '{}' to be rejected", invalid.escape_debug());
311 assert!(user.unwrap_err().0.contains("ASCII letters"));
312 }
313 }
314
315 #[test]
316 fn test_user_id_with_hidden_unicode_blocked() {
317 let user = UserId::try_new("user\u{200B}hidden");
318 assert!(user.is_err());
319 assert!(user.unwrap_err().0.contains("ASCII letters"));
320 }
321
322 #[test]
323 #[should_panic(expected = "invalid characters")]
324 fn test_new_panics_on_invalid() {
325 let _ = UserId::new("../evil");
326 }
327
328 #[test]
329 fn test_from_string_panics_on_invalid() {
330 let result = std::panic::catch_unwind(|| {
332 let _: UserId = "../etc/passwd".into();
333 });
334 assert!(result.is_err());
335 }
336}
337
338#[cfg(feature = "serialization")]
340impl crate::serialization::KSerializable for UserId {}