Skip to main content

smart_package_tracker/id/
generator.rs

1//! Configurable tracking-ID generation.
2
3use alloc::string::{String, ToString};
4
5use super::checksum;
6use super::TrackingId;
7use crate::error::{Error, Result};
8
9/// Longest prefix we accept. Prefixes exist to make IDs recognisable to
10/// humans, not to carry data.
11const MAX_PREFIX_LEN: usize = 16;
12/// Below this, collisions are guaranteed at trivial volumes.
13const MIN_ENTROPY_BITS: u16 = 16;
14/// Above this the barcode gets impractically wide for no benefit.
15const MAX_ENTROPY_BITS: u16 = 512;
16
17/// Which check character, if any, to append to generated IDs.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20#[non_exhaustive]
21pub enum Checksum {
22    /// No check character. The ID body is entropy only.
23    #[default]
24    None,
25    /// ISO/IEC 7064 MOD 37,36 check character appended to the body.
26    ///
27    /// Catches all single-character substitutions and all adjacent
28    /// transpositions — the two dominant scanner-misread and typo modes.
29    Iso7064Mod37_36,
30}
31
32/// Generates [`TrackingId`]s according to a fixed policy.
33///
34/// # Choosing an entropy width
35///
36/// IDs are random, so duplicates follow the birthday bound: with `n` IDs drawn
37/// from a space of `N = 2^bits`, the chance that at least two collide is
38/// approximately `1 - e^(-n²/2N)`.
39///
40/// | Entropy | Body | 1% collision risk at | 50% collision risk at |
41/// |---------|------|----------------------|-----------------------|
42/// | 32 bits | 8 hex chars  | ~9,000 IDs       | ~77,000 IDs       |
43/// | 48 bits | 12 hex chars | ~2.4 million     | ~20 million       |
44/// | 64 bits | 16 hex chars | ~610 million     | ~5.1 billion      |
45///
46/// The default is 32 bits, which reproduces the familiar `PKG-9ED9285C` shape.
47/// **For production systems that will ever issue more than a few thousand IDs,
48/// configure 64 bits.** Widening later is a data migration; choosing it now is
49/// a one-line change.
50///
51/// Randomness alone cannot guarantee uniqueness at any width. A durable system
52/// should still enforce a unique constraint at the storage layer and retry on
53/// conflict.
54///
55/// # Examples
56///
57/// ```
58/// # #[cfg(feature = "os-rng")]
59/// # fn main() -> Result<(), smart_package_tracker::Error> {
60/// use smart_package_tracker::{Checksum, IdGenerator};
61///
62/// let generator = IdGenerator::builder()
63///     .prefix("PKG")
64///     .entropy_bits(64)
65///     .checksum(Checksum::Iso7064Mod37_36)
66///     .build()?;
67///
68/// let id = generator.generate()?;
69/// assert!(id.as_str().starts_with("PKG-"));
70/// assert_eq!(id.body().len(), 17); // 16 hex characters + 1 check character
71/// generator.validate(&id)?;
72/// # Ok(())
73/// # }
74/// # #[cfg(not(feature = "os-rng"))]
75/// # fn main() {}
76/// ```
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct IdGenerator {
79    prefix: String,
80    entropy_bits: u16,
81    checksum: Checksum,
82}
83
84impl Default for IdGenerator {
85    /// `PKG-` + 32 bits of entropy, no check character — the `PKG-9ED9285C`
86    /// format. See the type-level docs before using this in production.
87    fn default() -> Self {
88        Self {
89            prefix: "PKG".to_string(),
90            entropy_bits: 32,
91            checksum: Checksum::None,
92        }
93    }
94}
95
96impl IdGenerator {
97    /// Start building a generator with a custom policy.
98    pub fn builder() -> IdGeneratorBuilder {
99        IdGeneratorBuilder::default()
100    }
101
102    /// The prefix placed before the separator.
103    pub fn prefix(&self) -> &str {
104        &self.prefix
105    }
106
107    /// Bits of randomness in each generated ID.
108    pub fn entropy_bits(&self) -> u16 {
109        self.entropy_bits
110    }
111
112    /// The configured check-character scheme.
113    pub fn checksum(&self) -> Checksum {
114        self.checksum
115    }
116
117    /// Number of hex characters of entropy in the body.
118    fn entropy_chars(&self) -> usize {
119        self.entropy_bits as usize / 4
120    }
121
122    /// Number of random bytes needed per ID.
123    pub fn entropy_bytes(&self) -> usize {
124        (self.entropy_bits as usize).div_ceil(8)
125    }
126
127    /// Total body length, including any check character.
128    fn body_len(&self) -> usize {
129        self.entropy_chars() + usize::from(self.checksum != Checksum::None)
130    }
131
132    /// Generate an ID using the operating system's cryptographic RNG.
133    ///
134    /// Requires the `os-rng` feature (enabled by default). Without it, use
135    /// [`generate_from_entropy`](Self::generate_from_entropy).
136    ///
137    /// # Errors
138    ///
139    /// Returns [`Error::Entropy`] if the OS entropy source is unavailable.
140    /// This crate never silently falls back to a weaker source.
141    #[cfg(feature = "os-rng")]
142    pub fn generate(&self) -> Result<TrackingId> {
143        let mut bytes = alloc::vec![0u8; self.entropy_bytes()];
144        getrandom::fill(&mut bytes).map_err(|e| Error::Entropy(e.to_string()))?;
145        self.generate_from_entropy(&bytes)
146    }
147
148    /// Generate an ID from caller-supplied entropy.
149    ///
150    /// Useful for deterministic tests, for reproducing an ID from stored
151    /// bytes, or when the entropy comes from an HSM or a database sequence
152    /// rather than the OS.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`Error::InsufficientEntropy`] if fewer than
157    /// [`entropy_bytes`](Self::entropy_bytes) bytes are supplied. Extra bytes
158    /// are ignored.
159    pub fn generate_from_entropy(&self, bytes: &[u8]) -> Result<TrackingId> {
160        let needed = self.entropy_bytes();
161        if bytes.len() < needed {
162            return Err(Error::InsufficientEntropy {
163                needed,
164                got: bytes.len(),
165            });
166        }
167
168        let mut body = String::with_capacity(self.body_len());
169        for byte in &bytes[..needed] {
170            body.push(hex_upper(byte >> 4));
171            body.push(hex_upper(byte & 0x0f));
172        }
173        // An entropy width that is not a whole number of bytes leaves one
174        // extra nibble; drop it.
175        body.truncate(self.entropy_chars());
176
177        if self.checksum == Checksum::Iso7064Mod37_36 {
178            let check = checksum::compute(&body)
179                .expect("body is uppercase hexadecimal, a subset of the alphabet");
180            body.push(check);
181        }
182
183        let mut raw = String::with_capacity(self.prefix.len() + 1 + body.len());
184        raw.push_str(&self.prefix);
185        raw.push(super::SEPARATOR);
186        raw.push_str(&body);
187
188        Ok(TrackingId(raw))
189    }
190
191    /// Check that `id` was produced by this generator's policy.
192    ///
193    /// Verifies the prefix, the body length, and the check character. Note
194    /// that this cannot prove provenance — it only rules out IDs that this
195    /// policy could never have produced.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`Error::IdPolicyMismatch`] describing the first failure.
200    pub fn validate(&self, id: &TrackingId) -> Result<()> {
201        if id.prefix() != self.prefix {
202            return Err(Error::IdPolicyMismatch {
203                reason: alloc::format!(
204                    "expected prefix `{}`, found `{}`",
205                    self.prefix,
206                    id.prefix()
207                ),
208            });
209        }
210
211        let body = id.body();
212        if body.len() != self.body_len() {
213            return Err(Error::IdPolicyMismatch {
214                reason: alloc::format!(
215                    "expected a {}-character body, found {}",
216                    self.body_len(),
217                    body.len()
218                ),
219            });
220        }
221
222        if self.checksum == Checksum::Iso7064Mod37_36 && !checksum::verify(body) {
223            return Err(Error::IdPolicyMismatch {
224                reason: "check character does not match the body".to_string(),
225            });
226        }
227
228        Ok(())
229    }
230}
231
232fn hex_upper(nibble: u8) -> char {
233    debug_assert!(nibble < 16);
234    b"0123456789ABCDEF"[nibble as usize] as char
235}
236
237/// Builder for [`IdGenerator`].
238#[derive(Debug, Clone)]
239pub struct IdGeneratorBuilder {
240    prefix: String,
241    entropy_bits: u16,
242    checksum: Checksum,
243}
244
245impl Default for IdGeneratorBuilder {
246    fn default() -> Self {
247        let d = IdGenerator::default();
248        Self {
249            prefix: d.prefix,
250            entropy_bits: d.entropy_bits,
251            checksum: d.checksum,
252        }
253    }
254}
255
256impl IdGeneratorBuilder {
257    /// Set the prefix. Must be 1–16 characters of `A-Z` or `0-9`.
258    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
259        self.prefix = prefix.into();
260        self
261    }
262
263    /// Set the entropy width in bits. Must be a multiple of 4 (one hex
264    /// character) between 16 and 512.
265    pub fn entropy_bits(mut self, bits: u16) -> Self {
266        self.entropy_bits = bits;
267        self
268    }
269
270    /// Set the check-character scheme.
271    pub fn checksum(mut self, checksum: Checksum) -> Self {
272        self.checksum = checksum;
273        self
274    }
275
276    /// Validate the settings and build the generator.
277    ///
278    /// # Errors
279    ///
280    /// Returns [`Error::InvalidIdConfig`] if the prefix or entropy width is
281    /// out of range.
282    pub fn build(self) -> Result<IdGenerator> {
283        if self.prefix.is_empty() {
284            return Err(Error::InvalidIdConfig(
285                "prefix must not be empty".to_string(),
286            ));
287        }
288        if self.prefix.len() > MAX_PREFIX_LEN {
289            return Err(Error::InvalidIdConfig(alloc::format!(
290                "prefix must be at most {MAX_PREFIX_LEN} characters, got {}",
291                self.prefix.len()
292            )));
293        }
294        if let Some(bad) = self.prefix.chars().find(|c| !super::is_body_char(*c)) {
295            return Err(Error::InvalidIdConfig(alloc::format!(
296                "prefix must consist of `A-Z` and `0-9`, found `{bad}`"
297            )));
298        }
299        if self.entropy_bits % 4 != 0 {
300            return Err(Error::InvalidIdConfig(alloc::format!(
301                "entropy_bits must be a multiple of 4, got {}",
302                self.entropy_bits
303            )));
304        }
305        if !(MIN_ENTROPY_BITS..=MAX_ENTROPY_BITS).contains(&self.entropy_bits) {
306            return Err(Error::InvalidIdConfig(alloc::format!(
307                "entropy_bits must be between {MIN_ENTROPY_BITS} and {MAX_ENTROPY_BITS}, got {}",
308                self.entropy_bits
309            )));
310        }
311
312        Ok(IdGenerator {
313            prefix: self.prefix,
314            entropy_bits: self.entropy_bits,
315            checksum: self.checksum,
316        })
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn default_reproduces_the_documented_format() {
326        let g = IdGenerator::default();
327        let id = g
328            .generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c])
329            .expect("four bytes is enough for 32 bits");
330        assert_eq!(id.as_str(), "PKG-9ED9285C");
331        assert_eq!(id.prefix(), "PKG");
332        assert_eq!(id.body(), "9ED9285C");
333        g.validate(&id).expect("self-consistent");
334    }
335
336    #[test]
337    fn generation_is_deterministic_for_fixed_entropy() {
338        let g = IdGenerator::default();
339        let a = g.generate_from_entropy(&[1, 2, 3, 4]).unwrap();
340        let b = g.generate_from_entropy(&[1, 2, 3, 4]).unwrap();
341        assert_eq!(a, b);
342        assert_eq!(a.as_str(), "PKG-01020304");
343    }
344
345    #[test]
346    fn checksum_round_trips_and_is_validated() {
347        let g = IdGenerator::builder()
348            .checksum(Checksum::Iso7064Mod37_36)
349            .build()
350            .unwrap();
351        let id = g.generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c]).unwrap();
352        assert_eq!(id.body().len(), 9);
353        assert!(id.body().starts_with("9ED9285C"));
354        g.validate(&id).unwrap();
355    }
356
357    #[test]
358    fn validate_rejects_a_corrupted_check_character() {
359        let g = IdGenerator::builder()
360            .checksum(Checksum::Iso7064Mod37_36)
361            .build()
362            .unwrap();
363        let id = g.generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c]).unwrap();
364
365        // Flip one character of the entropy; the check character no longer fits.
366        let corrupted = TrackingId::parse(&id.as_str().replace("9ED", "9EE")).unwrap();
367        assert!(g.validate(&corrupted).is_err());
368    }
369
370    #[test]
371    fn validate_rejects_a_foreign_prefix() {
372        let g = IdGenerator::default();
373        let other = TrackingId::parse("BOX-9ED9285C").unwrap();
374        assert!(g.validate(&other).is_err());
375    }
376
377    #[test]
378    fn wider_entropy_produces_a_longer_body() {
379        let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
380        assert_eq!(g.entropy_bytes(), 8);
381        let id = g.generate_from_entropy(&[0xff; 8]).unwrap();
382        assert_eq!(id.body(), "FFFFFFFFFFFFFFFF");
383    }
384
385    #[test]
386    fn non_byte_aligned_entropy_truncates_cleanly() {
387        let g = IdGenerator::builder().entropy_bits(20).build().unwrap();
388        assert_eq!(g.entropy_bytes(), 3);
389        let id = g.generate_from_entropy(&[0xab, 0xcd, 0xef]).unwrap();
390        assert_eq!(id.body(), "ABCDE");
391    }
392
393    #[test]
394    fn insufficient_entropy_is_an_error_not_a_panic() {
395        let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
396        assert!(matches!(
397            g.generate_from_entropy(&[0; 4]),
398            Err(Error::InsufficientEntropy { needed: 8, got: 4 })
399        ));
400    }
401
402    #[test]
403    fn builder_rejects_bad_configuration() {
404        assert!(IdGenerator::builder().prefix("").build().is_err());
405        assert!(IdGenerator::builder().prefix("pkg").build().is_err());
406        assert!(IdGenerator::builder().prefix("PKG-X").build().is_err());
407        assert!(IdGenerator::builder().entropy_bits(18).build().is_err());
408        assert!(IdGenerator::builder().entropy_bits(8).build().is_err());
409        assert!(IdGenerator::builder().entropy_bits(1024).build().is_err());
410    }
411
412    #[test]
413    #[cfg(feature = "os-rng")]
414    fn os_entropy_produces_distinct_well_formed_ids() {
415        let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
416        let a = g.generate().unwrap();
417        let b = g.generate().unwrap();
418        assert_ne!(a, b, "64-bit ids should not repeat in two draws");
419        g.validate(&a).unwrap();
420        g.validate(&b).unwrap();
421    }
422}