Skip to main content

zenith_session/
docid.rs

1//! Document-identity minting: 128-bit ULID encoded as 26 Crockford base-32 chars.
2//! Deterministic under injected Clock + Rng for testability.
3
4use crate::adapter::{Clock, Rng};
5use crate::error::SessionError;
6use std::time::UNIX_EPOCH;
7
8/// Crockford base-32 alphabet (excludes I, L, O, U). 32 symbols.
9const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
10
11/// Encode a 128-bit value as the canonical 26-character ULID string
12/// (big-endian, top character carries the high 2 padding bits so it is 0-7).
13fn encode_crockford(val: u128) -> String {
14    let mut out = [0u8; 26];
15    for (i, slot) in out.iter_mut().enumerate() {
16        let idx = ((val >> (5 * (25 - i))) & 0x1f) as usize;
17        // idx is always < 32, so indexing CROCKFORD is in-bounds; use get to avoid any panic path.
18        *slot = match CROCKFORD.get(idx) {
19            Some(c) => *c,
20            None => b'0',
21        };
22    }
23    // out is ASCII by construction.
24    String::from_utf8(out.into()).unwrap_or_default()
25}
26
27/// Mint a fresh ULID document id: 48-bit millisecond timestamp (from `clock`)
28/// in the high bits, 80 bits of randomness (from `rng`) in the low bits.
29pub fn mint_ulid(clock: &impl Clock, rng: &impl Rng) -> Result<String, SessionError> {
30    let millis = clock
31        .now()
32        .duration_since(UNIX_EPOCH)
33        .map_err(|e| SessionError::new(format!("system clock is before the unix epoch: {e}")))?
34        .as_millis();
35    let time48 = millis & 0xFFFF_FFFF_FFFF;
36
37    let mut rand_bytes = [0u8; 10]; // 80 bits
38    rng.fill_bytes(&mut rand_bytes)?;
39    let mut rand80: u128 = 0;
40    for b in rand_bytes {
41        rand80 = (rand80 << 8) | u128::from(b);
42    }
43
44    let val = (time48 << 80) | rand80;
45    Ok(encode_crockford(val))
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use crate::adapter::{FakeClock, FakeRng};
52    use std::time::{Duration, UNIX_EPOCH};
53
54    fn crockford_chars() -> &'static [u8] {
55        CROCKFORD
56    }
57
58    #[test]
59    fn mints_26_char_string() {
60        let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(1));
61        let rng = FakeRng(0);
62        let id = mint_ulid(&clock, &rng).unwrap();
63        assert_eq!(id.len(), 26);
64        for ch in id.bytes() {
65            assert!(crockford_chars().contains(&ch), "unexpected char: {ch}");
66        }
67    }
68
69    #[test]
70    fn is_deterministic_under_fakes() {
71        let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(1));
72        let rng = FakeRng(0);
73        let first = mint_ulid(&clock, &rng).unwrap();
74        let second = mint_ulid(&clock, &rng).unwrap();
75        assert_eq!(first, second);
76    }
77
78    #[test]
79    fn different_time_changes_prefix() {
80        let clock1 = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
81        let clock2 = FakeClock(UNIX_EPOCH + Duration::from_millis(2000));
82        let rng = FakeRng(0x00);
83        let id1 = mint_ulid(&clock1, &rng).unwrap();
84        let id2 = mint_ulid(&clock2, &rng).unwrap();
85        // First 10 chars encode the timestamp — they must differ.
86        assert_ne!(&id1[..10], &id2[..10]);
87        // Last 16 chars encode the randomness — FakeRng is identical, so they must match.
88        assert_eq!(&id1[10..], &id2[10..]);
89    }
90
91    #[test]
92    fn different_rng_changes_suffix() {
93        let clock = FakeClock(UNIX_EPOCH + Duration::from_millis(1000));
94        let rng0 = FakeRng(0x00);
95        let rng1 = FakeRng(0xFF);
96        let id0 = mint_ulid(&clock, &rng0).unwrap();
97        let id1 = mint_ulid(&clock, &rng1).unwrap();
98        // First 10 chars encode the timestamp — same clock, so they must match.
99        assert_eq!(&id0[..10], &id1[..10]);
100        // Last 16 chars encode the randomness — different RNGs, so they must differ.
101        assert_ne!(&id0[10..], &id1[10..]);
102    }
103
104    #[test]
105    fn known_vector() {
106        // FakeClock at UNIX_EPOCH exactly → millis = 0.
107        // FakeRng(0x00) → all rand bytes are 0.
108        // val = 0 → all 26 Crockford chars are '0'.
109        let clock = FakeClock(UNIX_EPOCH);
110        let rng = FakeRng(0x00);
111        let id = mint_ulid(&clock, &rng).unwrap();
112        assert_eq!(id, "00000000000000000000000000");
113    }
114
115    #[test]
116    fn clock_before_epoch_errors() {
117        if let Some(before_epoch) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
118            let clock = FakeClock(before_epoch);
119            let rng = FakeRng(0x00);
120            assert!(mint_ulid(&clock, &rng).is_err());
121        }
122        // If checked_sub returns None on this platform, the assertion is skipped gracefully.
123    }
124}