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