Skip to main content

parse_rust_core/
object_id.rs

1//! `objectId` generation.
2//!
3//! The alphabet and length are part of the contract: clients make assumptions about both, and
4//! `allowCustomObjectId: false` makes the server validate incoming ids against
5//! `/^[a-zA-Z0-9]{1,}$/` (`SchemaController.js`).
6//!
7//! Upstream: `src/cryptoUtils.js`, `randomString` and `newObjectId`. The alphabet is uppercase,
8//! then lowercase, then digits, 62 characters, and the default size is 10.
9//!
10//! **Divergence, Tier 2, deliberate.** Upstream indexes the alphabet with `byte % 62`, which is
11//! biased because 256 is not a multiple of 62: the first 8 characters (`A` through `H`) come up
12//! about 25% more often than the rest. Upstream's own comment acknowledges this. The bias is not
13//! wire-visible (a client cannot tell a biased 10-char alphanumeric id from an unbiased one), and
14//! reproducing a weak RNG on purpose is worse than fixing it, so this uses rejection sampling.
15//! This is a deliberate, recorded difference from upstream rather than an oversight.
16
17use rand::RngCore;
18
19/// Uppercase, then lowercase, then digits. Order matters only for matching upstream's source;
20/// the set is what the client-visible contract depends on.
21const ALPHABET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
22
23/// Upstream's `newObjectId` default (`cryptoUtils.js`).
24pub const DEFAULT_OBJECT_ID_SIZE: usize = 10;
25
26/// A random alphanumeric string of `size` characters, uniformly distributed over the 62-char
27/// alphabet.
28///
29/// Uses rejection sampling rather than upstream's `byte % 62`. The largest multiple of 62 at or
30/// below 256 is 248, so bytes 248..=255 are rejected and redrawn. Expected redraws are about
31/// 3.2%, which is not worth a smarter scheme.
32pub fn random_string(size: usize) -> String {
33    let mut rng = rand::thread_rng();
34    let mut out = String::with_capacity(size);
35    let mut buf = [0u8; 64];
36    let mut have = 0usize;
37    let mut pos = 0usize;
38
39    while out.len() < size {
40        if pos == have {
41            rng.fill_bytes(&mut buf);
42            have = buf.len();
43            pos = 0;
44        }
45        let b = buf[pos];
46        pos += 1;
47        // 248 == 62 * 4. Anything at or above it would bias the low residues.
48        if b < 248 {
49            out.push(ALPHABET[(b % 62) as usize] as char);
50        }
51    }
52    out
53}
54
55/// A new `objectId`. Ten characters unless a size is given.
56pub fn new_object_id() -> String {
57    random_string(DEFAULT_OBJECT_ID_SIZE)
58}
59
60/// Does this string satisfy upstream's default `objectId` shape?
61///
62/// Mirrors `SchemaController`'s `autoIdRegEx`, `/^[a-zA-Z0-9]{1,}$/`. Note it has no upper
63/// bound: upstream accepts any length, so this must not impose one. With
64/// `allowCustomObjectId: true` the applicable pattern is `/^.{1,}$/` instead, which is a
65/// different check and belongs with the schema controller, not here.
66pub fn is_valid_auto_object_id(s: &str) -> bool {
67    !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric())
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use std::collections::HashSet;
74
75    #[test]
76    fn default_shape() {
77        let id = new_object_id();
78        assert_eq!(id.len(), 10);
79        assert!(
80            is_valid_auto_object_id(&id),
81            "{id} failed the upstream regex shape"
82        );
83    }
84
85    #[test]
86    fn alphabet_is_exactly_the_upstream_62() {
87        let set: HashSet<u8> = ALPHABET.iter().copied().collect();
88        assert_eq!(set.len(), 62, "alphabet has a duplicate");
89        for b in b'A'..=b'Z' {
90            assert!(set.contains(&b));
91        }
92        for b in b'a'..=b'z' {
93            assert!(set.contains(&b));
94        }
95        for b in b'0'..=b'9' {
96            assert!(set.contains(&b));
97        }
98    }
99
100    #[test]
101    fn validator_matches_the_regex_semantics() {
102        assert!(is_valid_auto_object_id("aA0"));
103        assert!(is_valid_auto_object_id("a"));
104        // No upper bound upstream, so none here.
105        assert!(is_valid_auto_object_id(&"a".repeat(500)));
106        assert!(!is_valid_auto_object_id(""));
107        assert!(!is_valid_auto_object_id("has-dash"));
108        assert!(!is_valid_auto_object_id("has space"));
109        assert!(!is_valid_auto_object_id("ünïcode"));
110    }
111
112    /// Not a randomness test, just a guard that rejection sampling did not silently truncate
113    /// the alphabet, which is the plausible failure mode of the bounds check.
114    #[test]
115    fn covers_the_whole_alphabet() {
116        let mut seen: HashSet<char> = HashSet::new();
117        for _ in 0..2000 {
118            seen.extend(random_string(32).chars());
119        }
120        assert_eq!(
121            seen.len(),
122            62,
123            "some alphabet characters were never produced"
124        );
125    }
126
127    #[test]
128    fn ids_are_not_repeating() {
129        let ids: HashSet<String> = (0..1000).map(|_| new_object_id()).collect();
130        assert_eq!(ids.len(), 1000, "collision in 1000 draws of a 62^10 space");
131    }
132}