Skip to main content

uselesskey_token/srp/
base62.rs

1//! Base62 generation primitives for test fixtures.
2//!
3//! Provides deterministic, seed-driven generation of base62 strings without
4//! modulo bias under normal RNG behavior.
5
6use rand_chacha10::ChaCha20Rng;
7use rand_core10::{Rng, SeedableRng};
8use uselesskey_core::Seed;
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 deterministic base62 string from the provided seed.
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(seed: Seed, len: usize) -> String {
22    let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
23    random_base62_with_rng(&mut rng, len)
24}
25
26fn random_base62_with_rng(rng: &mut impl Rng, len: usize) -> String {
27    let mut out = String::with_capacity(len);
28    let mut buf = [0u8; 64];
29
30    while out.len() < len {
31        rng.fill_bytes(&mut buf);
32        let before = out.len();
33
34        for &b in &buf {
35            if b < ACCEPT_MAX {
36                out.push(BASE62_ALPHABET[(b % 62) as usize] as char);
37                if out.len() == len {
38                    break;
39                }
40            }
41        }
42
43        if out.len() == before {
44            for &b in &buf {
45                out.push(BASE62_ALPHABET[(b as usize) % 62] as char);
46                if out.len() == len {
47                    break;
48                }
49            }
50        }
51    }
52
53    out
54}
55
56#[cfg(test)]
57mod tests {
58    use super::{BASE62_ALPHABET, random_base62, random_base62_with_rng};
59    use rand_chacha10::ChaCha20Rng;
60    use rand_core10::{Infallible, SeedableRng, TryRng};
61    use uselesskey_core::Seed;
62
63    #[test]
64    fn generates_requested_length() {
65        assert_eq!(random_base62(Seed::new([1u8; 32]), 0).len(), 0);
66        assert_eq!(random_base62(Seed::new([1u8; 32]), 73).len(), 73);
67    }
68
69    #[test]
70    fn uses_only_base62_chars() {
71        let value = random_base62(Seed::new([2u8; 32]), 256);
72        assert!(value.bytes().all(|b| BASE62_ALPHABET.contains(&b)));
73    }
74
75    #[test]
76    fn deterministic_for_seeded_rng() {
77        let seed = [7u8; 32];
78        let a = random_base62(Seed::new(seed), 96);
79        let b = random_base62(Seed::new(seed), 96);
80        assert_eq!(a, b);
81    }
82
83    #[test]
84    fn fallback_path_terminates_for_constant_rng() {
85        struct ConstantRng;
86
87        impl TryRng for ConstantRng {
88            type Error = Infallible;
89
90            fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
91                Ok(u32::from_le_bytes([255, 255, 255, 255]))
92            }
93
94            fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
95                Ok(u64::from_le_bytes([255, 255, 255, 255, 255, 255, 255, 255]))
96            }
97
98            fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> {
99                self.fill_bytes(dest);
100                Ok(())
101            }
102        }
103
104        impl ConstantRng {
105            fn fill_bytes(&mut self, dest: &mut [u8]) {
106                dest.fill(255);
107            }
108        }
109
110        let mut rng = ConstantRng;
111        let value = random_base62_with_rng(&mut rng, 32);
112        assert_eq!(value.len(), 32);
113        assert!(value.chars().all(|c| c.is_ascii_alphanumeric()));
114    }
115
116    #[test]
117    fn output_uses_diverse_alphabet() {
118        // Catches `% 62` → `/ 62` mutation: division would yield only
119        // indices 0–3, producing at most 4 distinct characters.
120        let mut rng = ChaCha20Rng::from_seed([42u8; 32]);
121        let out = random_base62_with_rng(&mut rng, 256);
122        let unique: std::collections::HashSet<char> = out.chars().collect();
123        assert!(
124            unique.len() > 10,
125            "expected diverse output, got {} unique chars",
126            unique.len()
127        );
128    }
129
130    #[test]
131    fn fallback_path_uses_modulo_not_division() {
132        // All bytes = 255 → rejected by accept path → fallback runs.
133        // Correct: (255 % 62) = 7 → alphabet[7] = 'H'.
134        // Mutation / 62: (255 / 62) = 4 → alphabet[4] = 'E'.
135        struct AllMaxRng;
136
137        impl TryRng for AllMaxRng {
138            type Error = Infallible;
139
140            fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
141                Ok(u32::MAX)
142            }
143
144            fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
145                Ok(u64::MAX)
146            }
147
148            fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> {
149                self.fill_bytes(dest);
150                Ok(())
151            }
152        }
153
154        impl AllMaxRng {
155            fn fill_bytes(&mut self, dest: &mut [u8]) {
156                dest.fill(255);
157            }
158        }
159
160        let mut rng = AllMaxRng;
161        let out = random_base62_with_rng(&mut rng, 4);
162        // 255 % 62 = 7, BASE62_ALPHABET[7] = 'H'
163        assert!(
164            out.chars().all(|c| c == 'H'),
165            "expected all 'H' from fallback, got {out}"
166        );
167    }
168
169    #[test]
170    fn acceptance_boundary_rejects_248_and_keeps_batch_semantics() {
171        struct BoundaryRng {
172            fills: usize,
173        }
174
175        impl TryRng for BoundaryRng {
176            type Error = Infallible;
177
178            fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
179                Ok(0)
180            }
181
182            fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
183                Ok(0)
184            }
185
186            fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> {
187                self.fill_bytes(dest);
188                Ok(())
189            }
190        }
191
192        impl BoundaryRng {
193            fn fill_bytes(&mut self, dest: &mut [u8]) {
194                dest.fill(255);
195                if self.fills == 0 {
196                    dest[0] = 0;
197                    dest[1] = 247;
198                    dest[2] = 248;
199                } else {
200                    dest[0] = 1;
201                }
202                self.fills += 1;
203            }
204        }
205
206        let mut rng = BoundaryRng { fills: 0 };
207        let out = random_base62_with_rng(&mut rng, 3);
208
209        assert_eq!(out, "A9B");
210    }
211}