Skip to main content

uselesskey_core_base62/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Base62 generation primitives for test fixtures.
4//!
5//! Provides deterministic, RNG-driven generation of base62 strings without
6//! modulo bias under normal RNG behavior.
7
8use rand_core::RngCore;
9
10/// Base62 alphabet used by fixture generators.
11pub const BASE62_ALPHABET: &[u8; 62] =
12    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
13
14const ACCEPT_MAX: u8 = 248; // 62 * 4; accept 0..=247 for unbiased mod 62
15
16/// Generate a random base62 string of the requested length.
17///
18/// Uses rejection sampling to avoid modulo bias for normal RNG outputs.
19/// Includes a deterministic bounded fallback path to avoid hangs with
20/// pathological RNGs that never emit acceptable bytes.
21pub fn random_base62(rng: &mut impl RngCore, len: usize) -> String {
22    let mut out = String::with_capacity(len);
23    let mut buf = [0u8; 64];
24
25    while out.len() < len {
26        rng.fill_bytes(&mut buf);
27        let before = out.len();
28
29        for &b in &buf {
30            if b < ACCEPT_MAX {
31                out.push(BASE62_ALPHABET[(b % 62) as usize] as char);
32                if out.len() == len {
33                    break;
34                }
35            }
36        }
37
38        if out.len() == before {
39            for &b in &buf {
40                out.push(BASE62_ALPHABET[(b as usize) % 62] as char);
41                if out.len() == len {
42                    break;
43                }
44            }
45        }
46    }
47
48    out
49}
50
51#[cfg(test)]
52mod tests {
53    use super::{BASE62_ALPHABET, random_base62};
54    use rand_chacha::ChaCha20Rng;
55    use rand_core::{RngCore, SeedableRng};
56
57    #[test]
58    fn generates_requested_length() {
59        let mut rng = ChaCha20Rng::from_seed([1u8; 32]);
60        assert_eq!(random_base62(&mut rng, 0).len(), 0);
61        assert_eq!(random_base62(&mut rng, 73).len(), 73);
62    }
63
64    #[test]
65    fn uses_only_base62_chars() {
66        let mut rng = ChaCha20Rng::from_seed([2u8; 32]);
67        let value = random_base62(&mut rng, 256);
68        assert!(value.bytes().all(|b| BASE62_ALPHABET.contains(&b)));
69    }
70
71    #[test]
72    fn deterministic_for_seeded_rng() {
73        let seed = [7u8; 32];
74        let a = random_base62(&mut ChaCha20Rng::from_seed(seed), 96);
75        let b = random_base62(&mut ChaCha20Rng::from_seed(seed), 96);
76        assert_eq!(a, b);
77    }
78
79    #[test]
80    fn fallback_path_terminates_for_constant_rng() {
81        struct ConstantRng;
82
83        impl RngCore for ConstantRng {
84            fn next_u32(&mut self) -> u32 {
85                u32::from_le_bytes([255, 255, 255, 255])
86            }
87
88            fn next_u64(&mut self) -> u64 {
89                u64::from_le_bytes([255, 255, 255, 255, 255, 255, 255, 255])
90            }
91
92            fn fill_bytes(&mut self, dest: &mut [u8]) {
93                dest.fill(255);
94            }
95
96            fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
97                self.fill_bytes(dest);
98                Ok(())
99            }
100        }
101
102        let mut rng = ConstantRng;
103        let value = random_base62(&mut rng, 32);
104        assert_eq!(value.len(), 32);
105        assert!(value.chars().all(|c| c.is_ascii_alphanumeric()));
106    }
107
108    #[test]
109    fn output_uses_diverse_alphabet() {
110        // Catches `% 62` → `/ 62` mutation: division would yield only
111        // indices 0–3, producing at most 4 distinct characters.
112        let mut rng = ChaCha20Rng::from_seed([42u8; 32]);
113        let out = random_base62(&mut rng, 256);
114        let unique: std::collections::HashSet<char> = out.chars().collect();
115        assert!(
116            unique.len() > 10,
117            "expected diverse output, got {} unique chars",
118            unique.len()
119        );
120    }
121
122    #[test]
123    fn fallback_path_uses_modulo_not_division() {
124        // All bytes = 255 → rejected by accept path → fallback runs.
125        // Correct: (255 % 62) = 7 → alphabet[7] = 'H'.
126        // Mutation / 62: (255 / 62) = 4 → alphabet[4] = 'E'.
127        struct AllMaxRng;
128
129        impl RngCore for AllMaxRng {
130            fn next_u32(&mut self) -> u32 {
131                u32::MAX
132            }
133
134            fn next_u64(&mut self) -> u64 {
135                u64::MAX
136            }
137
138            fn fill_bytes(&mut self, dest: &mut [u8]) {
139                dest.fill(255);
140            }
141
142            fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
143                self.fill_bytes(dest);
144                Ok(())
145            }
146        }
147
148        let mut rng = AllMaxRng;
149        let out = random_base62(&mut rng, 4);
150        // 255 % 62 = 7, BASE62_ALPHABET[7] = 'H'
151        assert!(
152            out.chars().all(|c| c == 'H'),
153            "expected all 'H' from fallback, got {out}"
154        );
155    }
156}