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}