Skip to main content

bento_kit/id/
session.rs

1//! Session / user / DB-key ID helpers.
2//!
3//! Direct ports of the corresponding functions in
4//! [`du-node-utils/lib/id.js`](https://github.com/imcooder/du-node-utils/blob/main/lib/id.js):
5//! `setSessionPrefix`, `generateSessionId`, `makeUidPostfix`,
6//! `makeUserId`, `parseUserid`, `makeDbKey`.
7
8use std::sync::{Mutex, OnceLock};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rand::Rng;
12
13use super::random::generate_random_string;
14
15const SESSION_PREFIX_DEFAULT_LEN: usize = 12;
16const SESSION_ID_RANDOM_SUFFIX_LEN: usize = 5;
17
18fn session_prefix_storage() -> &'static Mutex<String> {
19    static PREFIX: OnceLock<Mutex<String>> = OnceLock::new();
20    PREFIX.get_or_init(|| Mutex::new(generate_random_string(SESSION_PREFIX_DEFAULT_LEN)))
21}
22
23/// Set a custom session prefix used by [`generate_session_id`].
24///
25/// Empty inputs are ignored (matching the Node library, which silently
26/// drops falsy / non-string values). Once set, the prefix persists for
27/// the rest of the process.
28///
29/// ```
30/// use bento_kit::id::{generate_session_id, set_session_prefix};
31/// set_session_prefix("myapp");
32/// let sid = generate_session_id();
33/// assert!(sid.starts_with("myapp"));
34/// ```
35pub fn set_session_prefix(prefix: &str) {
36    if prefix.is_empty() {
37        return;
38    }
39    let mut guard = session_prefix_storage()
40        .lock()
41        .expect("session-prefix mutex poisoned");
42    *guard = prefix.to_string();
43}
44
45/// Generate a session ID in the same format as Node's `generateSessionId`:
46/// `<prefix><13-digit ms timestamp><5-char base36 suffix>`.
47///
48/// The prefix is set via [`set_session_prefix`]; before any explicit set,
49/// it defaults to a random 12-char base36 string generated once per
50/// process.
51pub fn generate_session_id() -> String {
52    let prefix = session_prefix_storage()
53        .lock()
54        .expect("session-prefix mutex poisoned")
55        .clone();
56    let ms = SystemTime::now()
57        .duration_since(UNIX_EPOCH)
58        .map(|d| d.as_millis())
59        .unwrap_or(0);
60    let suffix = generate_random_string(SESSION_ID_RANDOM_SUFFIX_LEN);
61    format!("{prefix}{ms}{suffix}")
62}
63
64/// Generate a UID postfix: hex of `(unix_ms * 1000 + random_in_0..1000)`.
65///
66/// Direct port of `id.makeUidPostfix()`.
67///
68/// ```
69/// let p = bento_kit::id::make_uid_postfix();
70/// assert!(p.chars().all(|c| c.is_ascii_hexdigit()));
71/// ```
72pub fn make_uid_postfix() -> String {
73    let ms: u128 = SystemTime::now()
74        .duration_since(UNIX_EPOCH)
75        .map(|d| d.as_millis())
76        .unwrap_or(0);
77    let r: u128 = rand::thread_rng().gen_range(0..1000);
78    format!("{:x}", ms * 1000 + r)
79}
80
81/// Build a user ID in the form `connect.<appid>.<uid>.<cuid>`.
82///
83/// Direct port of `id.makeUserId(appid, uid, cuid)`.
84pub fn make_user_id(appid: &str, uid: &str, cuid: &str) -> String {
85    format!("connect.{appid}.{uid}.{cuid}")
86}
87
88/// Build a database key in the form `connect.<uid>`.
89///
90/// Direct port of `id.makeDbKey(uid)`.
91pub fn make_db_key(uid: &str) -> String {
92    format!("connect.{uid}")
93}
94
95/// Result of [`parse_user_id`].
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct UserId {
98    /// The full input string.
99    pub id: String,
100    /// Application ID component (second segment).
101    pub appid: String,
102    /// User ID component (third segment).
103    pub uid: String,
104    /// Client ID component (fourth segment).
105    pub cuid: String,
106}
107
108/// Parse a user ID produced by [`make_user_id`]. Returns `None` if the
109/// string has fewer than four `.`-separated segments.
110///
111/// Mirrors `id.parseUserid(userId)`.
112///
113/// ```
114/// use bento_kit::id::parse_user_id;
115/// let parsed = parse_user_id("connect.app1.user42.client99").unwrap();
116/// assert_eq!(parsed.appid, "app1");
117/// assert_eq!(parsed.uid, "user42");
118/// assert_eq!(parsed.cuid, "client99");
119/// ```
120pub fn parse_user_id(user_id: &str) -> Option<UserId> {
121    let items: Vec<&str> = user_id.split('.').collect();
122    if items.len() < 4 {
123        return None;
124    }
125    Some(UserId {
126        id: user_id.to_string(),
127        appid: items[1].to_string(),
128        uid: items[2].to_string(),
129        cuid: items[3].to_string(),
130    })
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    // The session prefix is process-global, so any tests that touch
138    // `set_session_prefix` must run serially against each other. cargo
139    // runs tests in parallel by default; this mutex serializes them.
140    fn prefix_test_lock() -> &'static Mutex<()> {
141        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
142        LOCK.get_or_init(|| Mutex::new(()))
143    }
144
145    fn lock_prefix_state<'a>() -> std::sync::MutexGuard<'a, ()> {
146        // Recover from poisoning so a panic in one test doesn't cascade.
147        prefix_test_lock().lock().unwrap_or_else(|e| e.into_inner())
148    }
149
150    #[test]
151    fn session_id_default_prefix_shape() {
152        let _g = lock_prefix_state();
153        let sid = generate_session_id();
154        assert!(sid.len() >= SESSION_PREFIX_DEFAULT_LEN + 13 + SESSION_ID_RANDOM_SUFFIX_LEN);
155    }
156
157    #[test]
158    fn session_id_with_custom_prefix() {
159        let _g = lock_prefix_state();
160        set_session_prefix("test_prefix_alpha");
161        let sid = generate_session_id();
162        assert!(sid.starts_with("test_prefix_alpha"));
163    }
164
165    #[test]
166    fn session_id_empty_prefix_is_ignored() {
167        let _g = lock_prefix_state();
168        set_session_prefix("known_prefix_beta");
169        set_session_prefix("");
170        let sid = generate_session_id();
171        assert!(sid.starts_with("known_prefix_beta"));
172    }
173
174    #[test]
175    fn session_ids_are_unique() {
176        let a = generate_session_id();
177        let b = generate_session_id();
178        assert_ne!(a, b);
179    }
180
181    #[test]
182    fn uid_postfix_is_valid_hex() {
183        let p = make_uid_postfix();
184        assert!(!p.is_empty());
185        assert!(p.chars().all(|c| c.is_ascii_hexdigit()));
186    }
187
188    #[test]
189    fn uid_postfix_increases_over_time() {
190        // Postfix is monotonic-ish: ms*1000 + rand[0..1000) means same ms
191        // can produce out-of-order pairs by the rand component, but across
192        // a sleep of >1ms the ms part dominates and ordering is monotonic.
193        let a = u128::from_str_radix(&make_uid_postfix(), 16).unwrap();
194        std::thread::sleep(std::time::Duration::from_millis(2));
195        let b = u128::from_str_radix(&make_uid_postfix(), 16).unwrap();
196        assert!(a < b);
197    }
198
199    #[test]
200    fn make_user_id_format() {
201        assert_eq!(make_user_id("a", "u", "c"), "connect.a.u.c");
202    }
203
204    #[test]
205    fn parse_user_id_round_trip() {
206        let s = make_user_id("app1", "user42", "client99");
207        let parsed = parse_user_id(&s).unwrap();
208        assert_eq!(parsed.id, s);
209        assert_eq!(parsed.appid, "app1");
210        assert_eq!(parsed.uid, "user42");
211        assert_eq!(parsed.cuid, "client99");
212    }
213
214    #[test]
215    fn parse_user_id_rejects_too_few_segments() {
216        assert!(parse_user_id("connect.a.b").is_none());
217        assert!(parse_user_id("nope").is_none());
218        assert!(parse_user_id("").is_none());
219    }
220
221    #[test]
222    fn parse_user_id_accepts_extra_trailing_segments() {
223        // Node implementation only checks for >=4 segments; extras are ignored.
224        let parsed = parse_user_id("connect.app1.uid1.cid1.extra").unwrap();
225        assert_eq!(parsed.appid, "app1");
226        assert_eq!(parsed.uid, "uid1");
227        assert_eq!(parsed.cuid, "cid1");
228    }
229
230    #[test]
231    fn make_db_key_format() {
232        assert_eq!(make_db_key("user42"), "connect.user42");
233    }
234}