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