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