Skip to main content

kalamdb_commons/models/ids/
user_id.rs

1//! Type-safe wrapper for user identifiers.
2
3use 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
15/// Type-safe wrapper for user identifiers.
16///
17/// Ensures user IDs cannot be accidentally used where namespace IDs or table names
18/// are expected.
19/// Statics for cheap singleton construction.
20static 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/// Type-safe wrapper for user identifiers.
25///
26/// Stored as `Arc<str>` so `clone()` is a cheap atomic refcount increment
27/// rather than a heap allocation — critical for high-concurrency hot paths.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub struct UserId(Arc<str>);
31
32/// Error type for UserId validation failures
33#[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    /// Maximum user ID length accepted from external input.
46    pub const MAX_LENGTH: usize = 128;
47
48    /// Creates a new UserId from a string.
49    ///
50    /// # Panics
51    /// Panics if the ID contains path traversal characters. Use `try_new()` for fallible creation.
52    #[inline]
53    pub fn new(id: impl Into<String>) -> Self {
54        Self::try_new(id).expect("UserId contains invalid characters")
55    }
56
57    /// New anonymous UserId — cached singleton, clone is a free atomic increment.
58    #[inline]
59    pub fn anonymous() -> Self {
60        Self(ANON_USER_ID.get_or_init(|| Arc::from(AuthConstants::ANONYMOUS_USER_ID)).clone())
61    }
62
63    /// Creates a new UserId from a string, returning an error if validation fails.
64    ///
65    /// # Security
66    /// Validates that the ID does not contain path traversal characters and only uses
67    /// the canonical safe alphabet:
68    /// - `..` (parent directory)
69    /// - `/` or `\` (directory separators)
70    /// - Null bytes (`\0`)
71    /// - ASCII letters, digits, `_`, and `-`
72    ///
73    /// This prevents path traversal attacks when user IDs are used in storage paths.
74    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    /// Validates a user ID string for security.
81    fn validate_id(id: &str) -> Result<(), UserIdValidationError> {
82        // Check for empty or oversized IDs first.
83        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        // Check for path traversal patterns
94        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    /// Generates a new unique UserId using NanoID (21 URL-safe characters).
127    ///
128    /// Uses the default NanoID alphabet (`A-Za-z0-9_-`) which is safe for
129    /// storage paths, URLs, and database keys.
130    #[inline]
131    #[cfg(feature = "full")]
132    pub fn generate() -> Self {
133        Self(Arc::<str>::from(nanoid::nanoid!()))
134    }
135
136    /// Creates a UserId without validation (for internal use only).
137    ///
138    /// # Safety
139    /// This bypasses security validation. Only use for IDs that are known to be safe
140    /// (e.g., loaded from database, generated internally).
141    #[inline]
142    #[allow(dead_code)] // Reserved for internal use when loading from trusted sources
143    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    /// Returns the user ID as a string slice.
149    #[inline]
150    pub fn as_str(&self) -> &str {
151        &self.0
152    }
153
154    /// Consumes the wrapper and returns the inner String.
155    #[inline]
156    pub fn into_string(self) -> String {
157        String::from(&*self.0)
158    }
159
160    /// Creates a default 'root' user ID — cached singleton.
161    #[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    /// Creates a default 'system' user ID — cached singleton.
171    #[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    /// Is admin user?
181    #[inline]
182    pub fn is_admin(&self) -> bool {
183        self.as_str() == AuthConstants::DEFAULT_ROOT_USER_ID
184    }
185
186    /// Is anonymous user?
187    #[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    /// Converts a String into UserId.
201    ///
202    /// # Panics
203    /// Panics if the string contains path traversal characters.
204    fn from(s: String) -> Self {
205        Self::new(s)
206    }
207}
208
209impl From<&str> for UserId {
210    /// Converts a &str into UserId.
211    ///
212    /// # Panics
213    /// Panics if the string contains path traversal characters.
214    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        // This should panic on path traversal
334        let result = std::panic::catch_unwind(|| {
335            let _: UserId = "../etc/passwd".into();
336        });
337        assert!(result.is_err());
338    }
339}
340
341// KSerializable implementation for EntityStore support
342#[cfg(feature = "serialization")]
343impl crate::serialization::KSerializable for UserId {}