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/// A generator must never mint an ID that [`TrackingId::parse`] would reject.
18/// The widest policy is the longest prefix, the separator, every entropy
19/// character and a check character; if that ever outgrows the format's length
20/// limit, this fails the build rather than the round trip.
21const _: () = {
22    let separator = 1;
23    let check_character = 1;
24    let widest = MAX_PREFIX_LEN + separator + (MAX_ENTROPY_BITS as usize) / 4 + check_character;
25    assert!(
26        widest <= super::MAX_LEN,
27        "the widest IdGenerator policy must still parse as a TrackingId"
28    );
29};
30
31/// Which check character, if any, to append to generated IDs.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[non_exhaustive]
35pub enum Checksum {
36    /// No check character. The ID body is entropy only.
37    #[default]
38    None,
39    /// ISO/IEC 7064 MOD 37,36 check character appended to the body.
40    ///
41    /// Catches all single-character substitutions and all adjacent
42    /// transpositions — the two dominant scanner-misread and typo modes.
43    Iso7064Mod37_36,
44}
45
46/// Generates [`TrackingId`]s according to a fixed policy.
47///
48/// # Choosing an entropy width
49///
50/// IDs are random, so duplicates follow the birthday bound: with `n` IDs drawn
51/// from a space of `N = 2^bits`, the chance that at least two collide is
52/// approximately `1 - e^(-n²/2N)`.
53///
54/// | Entropy | Body | 1% collision risk at | 50% collision risk at |
55/// |---------|------|----------------------|-----------------------|
56/// | 32 bits | 8 hex chars  | ~9,000 IDs       | ~77,000 IDs       |
57/// | 48 bits | 12 hex chars | ~2.4 million     | ~20 million       |
58/// | 64 bits | 16 hex chars | ~610 million     | ~5.1 billion      |
59///
60/// The default is 32 bits, which reproduces the familiar `PKG-9ED9285C` shape.
61/// **For production systems that will ever issue more than a few thousand IDs,
62/// configure 64 bits.** Widening later is a data migration; choosing it now is
63/// a one-line change.
64///
65/// Randomness alone cannot guarantee uniqueness at any width. A durable system
66/// should still enforce a unique constraint at the storage layer and retry on
67/// conflict.
68///
69/// # Examples
70///
71/// ```
72/// # #[cfg(feature = "os-rng")]
73/// # fn main() -> Result<(), smart_package_tracker::Error> {
74/// use smart_package_tracker::{Checksum, IdGenerator};
75///
76/// let generator = IdGenerator::builder()
77///     .prefix("PKG")
78///     .entropy_bits(64)
79///     .checksum(Checksum::Iso7064Mod37_36)
80///     .build()?;
81///
82/// let id = generator.generate()?;
83/// assert!(id.as_str().starts_with("PKG-"));
84/// assert_eq!(id.body().len(), 17); // 16 hex characters + 1 check character
85/// generator.validate(&id)?;
86/// # Ok(())
87/// # }
88/// # #[cfg(not(feature = "os-rng"))]
89/// # fn main() {}
90/// ```
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct IdGenerator {
93    prefix: String,
94    entropy_bits: u16,
95    checksum: Checksum,
96}
97
98impl Default for IdGenerator {
99    /// `PKG-` + 32 bits of entropy, no check character — the `PKG-9ED9285C`
100    /// format. See the type-level docs before using this in production.
101    fn default() -> Self {
102        Self {
103            prefix: "PKG".to_string(),
104            entropy_bits: 32,
105            checksum: Checksum::None,
106        }
107    }
108}
109
110impl IdGenerator {
111    /// Start building a generator with a custom policy.
112    pub fn builder() -> IdGeneratorBuilder {
113        IdGeneratorBuilder::default()
114    }
115
116    /// The prefix placed before the separator.
117    pub fn prefix(&self) -> &str {
118        &self.prefix
119    }
120
121    /// Bits of randomness in each generated ID.
122    pub fn entropy_bits(&self) -> u16 {
123        self.entropy_bits
124    }
125
126    /// The configured check-character scheme.
127    pub fn checksum(&self) -> Checksum {
128        self.checksum
129    }
130
131    /// Number of hex characters of entropy in the body.
132    fn entropy_chars(&self) -> usize {
133        self.entropy_bits as usize / 4
134    }
135
136    /// Number of random bytes needed per ID.
137    pub fn entropy_bytes(&self) -> usize {
138        (self.entropy_bits as usize).div_ceil(8)
139    }
140
141    /// Total body length, including any check character.
142    fn body_len(&self) -> usize {
143        self.entropy_chars() + usize::from(self.checksum != Checksum::None)
144    }
145
146    /// Generate an ID using the operating system's cryptographic RNG.
147    ///
148    /// Requires the `os-rng` feature (enabled by default). Without it, use
149    /// [`generate_from_entropy`](Self::generate_from_entropy).
150    ///
151    /// # Errors
152    ///
153    /// Returns [`Error::Entropy`] if the OS entropy source is unavailable.
154    /// This crate never silently falls back to a weaker source.
155    #[cfg(feature = "os-rng")]
156    pub fn generate(&self) -> Result<TrackingId> {
157        let mut bytes = alloc::vec![0u8; self.entropy_bytes()];
158        getrandom::fill(&mut bytes).map_err(|e| Error::Entropy(e.to_string()))?;
159        self.generate_from_entropy(&bytes)
160    }
161
162    /// Generate an ID from caller-supplied entropy.
163    ///
164    /// Useful for deterministic tests, for reproducing an ID from stored
165    /// bytes, or when the entropy comes from an HSM or a database sequence
166    /// rather than the OS.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`Error::InsufficientEntropy`] if fewer than
171    /// [`entropy_bytes`](Self::entropy_bytes) bytes are supplied. Extra bytes
172    /// are ignored.
173    pub fn generate_from_entropy(&self, bytes: &[u8]) -> Result<TrackingId> {
174        let needed = self.entropy_bytes();
175        if bytes.len() < needed {
176            return Err(Error::InsufficientEntropy {
177                needed,
178                got: bytes.len(),
179            });
180        }
181
182        let mut body = String::with_capacity(self.body_len());
183        for byte in &bytes[..needed] {
184            body.push(hex_upper(byte >> 4));
185            body.push(hex_upper(byte & 0x0f));
186        }
187        // An entropy width that is not a whole number of bytes leaves one
188        // extra nibble; drop it.
189        body.truncate(self.entropy_chars());
190
191        if self.checksum == Checksum::Iso7064Mod37_36 {
192            let check = checksum::compute(&body)
193                .expect("body is uppercase hexadecimal, a subset of the alphabet");
194            body.push(check);
195        }
196
197        let mut raw = String::with_capacity(self.prefix.len() + 1 + body.len());
198        raw.push_str(&self.prefix);
199        raw.push(super::SEPARATOR);
200        raw.push_str(&body);
201
202        Ok(TrackingId(raw))
203    }
204
205    /// Check that `id` was produced by this generator's policy.
206    ///
207    /// Verifies the prefix, the body length, that the entropy characters are
208    /// uppercase hexadecimal, and the check character. Note that this cannot
209    /// prove provenance — it only rules out IDs that this policy could never
210    /// have produced.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`Error::IdPolicyMismatch`] describing the first failure.
215    pub fn validate(&self, id: &TrackingId) -> Result<()> {
216        if id.prefix() != self.prefix {
217            return Err(Error::IdPolicyMismatch {
218                reason: alloc::format!(
219                    "expected prefix `{}`, found `{}`",
220                    self.prefix,
221                    id.prefix()
222                ),
223            });
224        }
225
226        let body = id.body();
227        if body.len() != self.body_len() {
228            return Err(Error::IdPolicyMismatch {
229                reason: alloc::format!(
230                    "expected a {}-character body, found {}",
231                    self.body_len(),
232                    body.len()
233                ),
234            });
235        }
236
237        // The generator only ever emits `0-9A-F` here, so anything else could
238        // not have come from this policy. The check character is drawn from the
239        // wider 36-character alphabet and is verified separately below.
240        if let Some(bad) = body[..self.entropy_chars()]
241            .chars()
242            .find(|c| !c.is_ascii_digit() && !matches!(c, 'A'..='F'))
243        {
244            return Err(Error::IdPolicyMismatch {
245                reason: alloc::format!("body must be uppercase hexadecimal, found `{bad}`"),
246            });
247        }
248
249        if self.checksum == Checksum::Iso7064Mod37_36 && !checksum::verify(body) {
250            return Err(Error::IdPolicyMismatch {
251                reason: "check character does not match the body".to_string(),
252            });
253        }
254
255        Ok(())
256    }
257}
258
259fn hex_upper(nibble: u8) -> char {
260    debug_assert!(nibble < 16);
261    b"0123456789ABCDEF"[nibble as usize] as char
262}
263
264/// Builder for [`IdGenerator`].
265#[derive(Debug, Clone)]
266pub struct IdGeneratorBuilder {
267    prefix: String,
268    entropy_bits: u16,
269    checksum: Checksum,
270}
271
272impl Default for IdGeneratorBuilder {
273    fn default() -> Self {
274        let d = IdGenerator::default();
275        Self {
276            prefix: d.prefix,
277            entropy_bits: d.entropy_bits,
278            checksum: d.checksum,
279        }
280    }
281}
282
283impl IdGeneratorBuilder {
284    /// Set the prefix. Must be 1–16 characters of `A-Z` or `0-9`.
285    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
286        self.prefix = prefix.into();
287        self
288    }
289
290    /// Set the entropy width in bits. Must be a multiple of 4 (one hex
291    /// character) between 16 and 512.
292    pub fn entropy_bits(mut self, bits: u16) -> Self {
293        self.entropy_bits = bits;
294        self
295    }
296
297    /// Set the check-character scheme.
298    pub fn checksum(mut self, checksum: Checksum) -> Self {
299        self.checksum = checksum;
300        self
301    }
302
303    /// Validate the settings and build the generator.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`Error::InvalidIdConfig`] if the prefix or entropy width is
308    /// out of range.
309    pub fn build(self) -> Result<IdGenerator> {
310        if self.prefix.is_empty() {
311            return Err(Error::InvalidIdConfig(
312                "prefix must not be empty".to_string(),
313            ));
314        }
315        // Charset first: `len()` counts bytes, so a multi-byte prefix would
316        // otherwise be reported as too long rather than as containing a
317        // character that is not allowed at all.
318        if let Some(bad) = self.prefix.chars().find(|c| !super::is_body_char(*c)) {
319            return Err(Error::InvalidIdConfig(alloc::format!(
320                "prefix must consist of `A-Z` and `0-9`, found `{bad}`"
321            )));
322        }
323        if self.prefix.len() > MAX_PREFIX_LEN {
324            return Err(Error::InvalidIdConfig(alloc::format!(
325                "prefix must be at most {MAX_PREFIX_LEN} characters, got {}",
326                self.prefix.len()
327            )));
328        }
329        if self.entropy_bits % 4 != 0 {
330            return Err(Error::InvalidIdConfig(alloc::format!(
331                "entropy_bits must be a multiple of 4, got {}",
332                self.entropy_bits
333            )));
334        }
335        if !(MIN_ENTROPY_BITS..=MAX_ENTROPY_BITS).contains(&self.entropy_bits) {
336            return Err(Error::InvalidIdConfig(alloc::format!(
337                "entropy_bits must be between {MIN_ENTROPY_BITS} and {MAX_ENTROPY_BITS}, got {}",
338                self.entropy_bits
339            )));
340        }
341
342        Ok(IdGenerator {
343            prefix: self.prefix,
344            entropy_bits: self.entropy_bits,
345            checksum: self.checksum,
346        })
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn default_reproduces_the_documented_format() {
356        let g = IdGenerator::default();
357        let id = g
358            .generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c])
359            .expect("four bytes is enough for 32 bits");
360        assert_eq!(id.as_str(), "PKG-9ED9285C");
361        assert_eq!(id.prefix(), "PKG");
362        assert_eq!(id.body(), "9ED9285C");
363        g.validate(&id).expect("self-consistent");
364    }
365
366    #[test]
367    fn generation_is_deterministic_for_fixed_entropy() {
368        let g = IdGenerator::default();
369        let a = g.generate_from_entropy(&[1, 2, 3, 4]).unwrap();
370        let b = g.generate_from_entropy(&[1, 2, 3, 4]).unwrap();
371        assert_eq!(a, b);
372        assert_eq!(a.as_str(), "PKG-01020304");
373    }
374
375    #[test]
376    fn checksum_round_trips_and_is_validated() {
377        let g = IdGenerator::builder()
378            .checksum(Checksum::Iso7064Mod37_36)
379            .build()
380            .unwrap();
381        let id = g.generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c]).unwrap();
382        assert_eq!(id.body().len(), 9);
383        assert!(id.body().starts_with("9ED9285C"));
384        g.validate(&id).unwrap();
385    }
386
387    #[test]
388    fn validate_rejects_a_corrupted_check_character() {
389        let g = IdGenerator::builder()
390            .checksum(Checksum::Iso7064Mod37_36)
391            .build()
392            .unwrap();
393        let id = g.generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c]).unwrap();
394
395        // Flip one character of the entropy; the check character no longer fits.
396        let corrupted = TrackingId::parse(&id.as_str().replace("9ED", "9EE")).unwrap();
397        assert!(g.validate(&corrupted).is_err());
398    }
399
400    #[test]
401    fn validate_rejects_a_body_the_policy_could_not_have_produced() {
402        // Right prefix, right length, but the generator only ever emits
403        // `0-9A-F` — a body of `Z`s can only be corruption or a forgery.
404        let g = IdGenerator::default();
405        let err = g
406            .validate(&TrackingId::parse("PKG-ZZZZZZZZ").unwrap())
407            .unwrap_err();
408        assert!(matches!(err, Error::IdPolicyMismatch { .. }), "got {err:?}");
409
410        g.validate(&TrackingId::parse("PKG-9ED9285C").unwrap())
411            .expect("hexadecimal bodies are still accepted");
412    }
413
414    #[test]
415    fn validate_still_accepts_a_non_hex_check_character() {
416        // The check character comes from the 36-character alphabet, so it may
417        // fall outside `0-9A-F` while the entropy before it does not.
418        let g = IdGenerator::builder()
419            .checksum(Checksum::Iso7064Mod37_36)
420            .build()
421            .unwrap();
422        for seed in 0u8..32 {
423            let id = g.generate_from_entropy(&[seed; 4]).unwrap();
424            g.validate(&id)
425                .unwrap_or_else(|e| panic!("{id} should validate: {e}"));
426        }
427    }
428
429    #[test]
430    fn a_multibyte_prefix_is_reported_as_a_charset_error() {
431        // Six characters, but 18 bytes: the length check counts bytes, so
432        // running it first would report "at most 16 characters, got 18" for a
433        // prefix whose real problem is that none of it is allowed.
434        let prefix = "\u{4e2d}\u{6587}\u{4e2d}\u{6587}\u{4e2d}\u{6587}";
435        assert_eq!(prefix.chars().count(), 6);
436        assert_eq!(prefix.len(), 18);
437
438        let err = IdGenerator::builder().prefix(prefix).build().unwrap_err();
439        let message = alloc::format!("{err}");
440        assert!(
441            message.contains("`A-Z` and `0-9`"),
442            "expected a charset error, got: {message}"
443        );
444    }
445
446    #[test]
447    fn validate_rejects_a_foreign_prefix() {
448        let g = IdGenerator::default();
449        let other = TrackingId::parse("BOX-9ED9285C").unwrap();
450        assert!(g.validate(&other).is_err());
451    }
452
453    #[test]
454    fn wider_entropy_produces_a_longer_body() {
455        let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
456        assert_eq!(g.entropy_bytes(), 8);
457        let id = g.generate_from_entropy(&[0xff; 8]).unwrap();
458        assert_eq!(id.body(), "FFFFFFFFFFFFFFFF");
459    }
460
461    #[test]
462    fn non_byte_aligned_entropy_truncates_cleanly() {
463        let g = IdGenerator::builder().entropy_bits(20).build().unwrap();
464        assert_eq!(g.entropy_bytes(), 3);
465        let id = g.generate_from_entropy(&[0xab, 0xcd, 0xef]).unwrap();
466        assert_eq!(id.body(), "ABCDE");
467    }
468
469    #[test]
470    fn insufficient_entropy_is_an_error_not_a_panic() {
471        let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
472        assert!(matches!(
473            g.generate_from_entropy(&[0; 4]),
474            Err(Error::InsufficientEntropy { needed: 8, got: 4 })
475        ));
476    }
477
478    #[test]
479    fn builder_rejects_bad_configuration() {
480        assert!(IdGenerator::builder().prefix("").build().is_err());
481        assert!(IdGenerator::builder().prefix("pkg").build().is_err());
482        assert!(IdGenerator::builder().prefix("PKG-X").build().is_err());
483        assert!(IdGenerator::builder().entropy_bits(18).build().is_err());
484        assert!(IdGenerator::builder().entropy_bits(8).build().is_err());
485        assert!(IdGenerator::builder().entropy_bits(1024).build().is_err());
486    }
487
488    #[test]
489    fn the_widest_policy_still_parses_as_a_tracking_id() {
490        // The generator's limits and the format's length limit were chosen
491        // independently; at 512 bits the ID used to come out longer than
492        // `TrackingId::parse` would accept, so an ID could be minted and then
493        // rejected on the way back out of storage.
494        let g = IdGenerator::builder()
495            .prefix("ABCDEFGHIJKLMNOP") // MAX_PREFIX_LEN
496            .entropy_bits(MAX_ENTROPY_BITS)
497            .checksum(Checksum::Iso7064Mod37_36)
498            .build()
499            .expect("the widest policy must be buildable");
500
501        let id = g.generate_from_entropy(&[0xab; 64]).unwrap();
502        assert_eq!(id.body().len(), 129); // 128 hex characters + check character
503
504        let round_tripped = TrackingId::parse(id.as_str())
505            .unwrap_or_else(|e| panic!("the widest policy must round trip: {e}"));
506        assert_eq!(id, round_tripped);
507        g.validate(&round_tripped).unwrap();
508    }
509
510    #[test]
511    #[cfg(feature = "os-rng")]
512    fn every_supported_entropy_width_round_trips() {
513        for bits in [MIN_ENTROPY_BITS, 32, 64, 128, 256, 480, MAX_ENTROPY_BITS] {
514            let g = IdGenerator::builder().entropy_bits(bits).build().unwrap();
515            let id = g.generate().unwrap();
516            let parsed = TrackingId::parse(id.as_str())
517                .unwrap_or_else(|e| panic!("{bits} bits produced an unparseable id: {e}"));
518            assert_eq!(id, parsed);
519        }
520    }
521
522    #[test]
523    #[cfg(feature = "os-rng")]
524    fn os_entropy_produces_distinct_well_formed_ids() {
525        let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
526        let a = g.generate().unwrap();
527        let b = g.generate().unwrap();
528        assert_ne!(a, b, "64-bit ids should not repeat in two draws");
529        g.validate(&a).unwrap();
530        g.validate(&b).unwrap();
531    }
532}