Skip to main content

omgbase_store/
ids.rs

1//! Identifiers (`spec/store/README.md` §2.1–§2.2): `<prefix>_<7 chars>` of
2//! lowercase Crockford base32 from a CSPRNG, and the minter seam that lets a
3//! fixture runner replace the CSPRNG with per-prefix counters. A minter only
4//! *draws* candidates; the store checks each against what is in use
5//! ([`crate::mint`], §2.1) before handing it out.
6
7use std::collections::BTreeMap;
8
9/// Crockford base32, lowercased: no `i`, `l`, `o`, `u`.
10pub const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
11
12/// Suffix length of a minted id.
13pub const ID_LEN: usize = 7;
14
15/// The id prefixes the store mints (§2.1). `v` is reserved.
16pub const PREFIXES: [&str; 11] = ["d", "b", "c", "r", "x", "col", "cp", "e", "rp", "v", "src"];
17
18/// Where ids come from. The store owns one; production uses [`RandomMinter`],
19/// fixture runners install a [`SequentialMinter`] (§2.2).
20pub trait IdMinter {
21    /// A fresh id with `prefix` (`b`, `d`, `c`, `r`, `rp`, `src`, …).
22    fn mint(&mut self, prefix: &str) -> String;
23}
24
25impl<F: FnMut(&str) -> String> IdMinter for F {
26    fn mint(&mut self, prefix: &str) -> String {
27        self(prefix)
28    }
29}
30
31/// The production minter: 7 CSPRNG-drawn Crockford characters.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub struct RandomMinter;
34
35impl IdMinter for RandomMinter {
36    fn mint(&mut self, prefix: &str) -> String {
37        format!("{prefix}_{}", random_suffix())
38    }
39}
40
41/// `<prefix>_0, <prefix>_1, …` per prefix, each counting from 0 (§2.2).
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
43pub struct SequentialMinter {
44    counters: BTreeMap<String, u64>,
45}
46
47impl SequentialMinter {
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// The next counter value for `prefix` (how many ids it has minted).
54    #[must_use]
55    pub fn next(&self, prefix: &str) -> u64 {
56        self.counters.get(prefix).copied().unwrap_or(0)
57    }
58}
59
60impl IdMinter for SequentialMinter {
61    fn mint(&mut self, prefix: &str) -> String {
62        let n = self.counters.entry(prefix.to_owned()).or_insert(0);
63        let id = format!("{prefix}_{n}");
64        *n += 1;
65        id
66    }
67}
68
69/// The repeating fixture minter (§9.4, `"minter": "repeat"`): the sequential
70/// counter whose every id is offered twice — `rp_0, rp_0, b_0, b_0, b_1, b_1,
71/// …`. The store rejects the second offering as in use (§2.1) and asks again,
72/// so a case run under it projects exactly as under [`SequentialMinter`].
73#[derive(Clone, Debug, Default, PartialEq, Eq)]
74pub struct RepeatingMinter {
75    next: SequentialMinter,
76    pending: BTreeMap<String, String>,
77}
78
79impl RepeatingMinter {
80    #[must_use]
81    pub fn new() -> Self {
82        Self::default()
83    }
84}
85
86impl IdMinter for RepeatingMinter {
87    fn mint(&mut self, prefix: &str) -> String {
88        if let Some(again) = self.pending.remove(prefix) {
89            return again;
90        }
91        let id = self.next.mint(prefix);
92        self.pending.insert(prefix.to_owned(), id.clone());
93        id
94    }
95}
96
97/// `ID_LEN` characters of [`ALPHABET`] from the OS CSPRNG. 32 divides 256, so
98/// masking a byte to five bits is unbiased.
99///
100/// # Panics
101///
102/// If the operating system's random source is unavailable.
103#[must_use]
104pub fn random_suffix() -> String {
105    let mut bytes = [0u8; ID_LEN];
106    getrandom::fill(&mut bytes).expect("the OS random source is available");
107    bytes
108        .iter()
109        .map(|b| ALPHABET[usize::from(b & 0x1f)] as char)
110        .collect()
111}
112
113/// `^[a-z]+_[alphabet]{7}$`, optionally with a given prefix (§2.1).
114#[must_use]
115pub fn is_valid_id(id: &str, prefix: Option<&str>) -> bool {
116    match prefix_of(id) {
117        Some(p) => prefix.is_none_or(|want| want == p),
118        None => false,
119    }
120}
121
122/// The prefix of a well-formed id, or `None` when `id` is not one.
123#[must_use]
124pub fn prefix_of(id: &str) -> Option<&str> {
125    let (prefix, suffix) = id.split_once('_')?;
126    if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_lowercase()) {
127        return None;
128    }
129    if suffix.len() != ID_LEN || !suffix.bytes().all(|b| ALPHABET.contains(&b)) {
130        return None;
131    }
132    Some(prefix)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::collections::HashSet;
139
140    #[test]
141    fn mints_prefixed_seven_char_crockford_ids() {
142        let id = RandomMinter.mint("b");
143        assert!(is_valid_id(&id, Some("b")), "{id}");
144        assert_eq!(prefix_of(&id), Some("b"));
145        assert_eq!(id.len(), 2 + ID_LEN);
146        for _ in 0..100 {
147            let s = random_suffix();
148            assert_eq!(s.len(), ID_LEN);
149            assert!(!s.contains(['i', 'l', 'o', 'u']), "{s}");
150        }
151    }
152
153    #[test]
154    fn validates_prefix_mismatches() {
155        assert!(!is_valid_id("b_k7z2p9q", Some("d")));
156        assert!(is_valid_id("b_k7z2p9q", Some("b")));
157        assert!(is_valid_id("b_k7z2p9q", None));
158        assert!(!is_valid_id("nope", None));
159        assert!(!is_valid_id("b_TOOLONGX", None));
160        assert!(!is_valid_id("b_k7z2p9", None));
161        assert!(!is_valid_id("b_k7z2p9i", None), "i is not Crockford");
162        assert!(!is_valid_id("B_k7z2p9q", None), "prefix is lowercase");
163        assert!(!is_valid_id("_k7z2p9q", None));
164        assert_eq!(prefix_of("col_k7z2p9q"), Some("col"));
165        assert_eq!(prefix_of("b_0"), None, "fixture ids are not production ids");
166    }
167
168    #[test]
169    fn supports_multi_char_prefixes() {
170        assert!(is_valid_id(&RandomMinter.mint("col"), Some("col")));
171        assert!(is_valid_id(&RandomMinter.mint("cp"), Some("cp")));
172        assert!(is_valid_id(&RandomMinter.mint("src"), Some("src")));
173    }
174
175    #[test]
176    fn mints_with_high_uniqueness() {
177        let seen: HashSet<String> = (0..5000).map(|_| RandomMinter.mint("b")).collect();
178        assert_eq!(seen.len(), 5000);
179    }
180
181    #[test]
182    fn sequential_minter_counts_per_prefix_from_zero() {
183        let mut m = SequentialMinter::new();
184        assert_eq!(m.mint("rp"), "rp_0");
185        assert_eq!(m.mint("b"), "b_0");
186        assert_eq!(m.mint("b"), "b_1");
187        assert_eq!(m.mint("d"), "d_0");
188        assert_eq!(m.mint("b"), "b_2");
189        assert_eq!(m.next("b"), 3);
190        assert_eq!(m.next("c"), 0);
191        let mut closure = |p: &str| format!("{p}_x");
192        let dynamic: &mut dyn IdMinter = &mut closure;
193        assert_eq!(dynamic.mint("q"), "q_x");
194    }
195
196    #[test]
197    fn repeating_minter_offers_every_id_twice_per_prefix() {
198        let mut m = RepeatingMinter::new();
199        assert_eq!(m.mint("rp"), "rp_0");
200        assert_eq!(m.mint("rp"), "rp_0");
201        assert_eq!(m.mint("b"), "b_0");
202        assert_eq!(m.mint("d"), "d_0", "pending is per prefix");
203        assert_eq!(m.mint("b"), "b_0");
204        assert_eq!(m.mint("b"), "b_1");
205        assert_eq!(m.mint("d"), "d_0");
206        assert_eq!(m.mint("b"), "b_1");
207        assert_eq!(m.mint("b"), "b_2");
208    }
209}