use alloc::string::{String, ToString};
use super::checksum;
use super::TrackingId;
use crate::error::{Error, Result};
const MAX_PREFIX_LEN: usize = 16;
const MIN_ENTROPY_BITS: u16 = 16;
const MAX_ENTROPY_BITS: u16 = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Checksum {
#[default]
None,
Iso7064Mod37_36,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdGenerator {
prefix: String,
entropy_bits: u16,
checksum: Checksum,
}
impl Default for IdGenerator {
fn default() -> Self {
Self {
prefix: "PKG".to_string(),
entropy_bits: 32,
checksum: Checksum::None,
}
}
}
impl IdGenerator {
pub fn builder() -> IdGeneratorBuilder {
IdGeneratorBuilder::default()
}
pub fn prefix(&self) -> &str {
&self.prefix
}
pub fn entropy_bits(&self) -> u16 {
self.entropy_bits
}
pub fn checksum(&self) -> Checksum {
self.checksum
}
fn entropy_chars(&self) -> usize {
self.entropy_bits as usize / 4
}
pub fn entropy_bytes(&self) -> usize {
(self.entropy_bits as usize).div_ceil(8)
}
fn body_len(&self) -> usize {
self.entropy_chars() + usize::from(self.checksum != Checksum::None)
}
#[cfg(feature = "os-rng")]
pub fn generate(&self) -> Result<TrackingId> {
let mut bytes = alloc::vec![0u8; self.entropy_bytes()];
getrandom::fill(&mut bytes).map_err(|e| Error::Entropy(e.to_string()))?;
self.generate_from_entropy(&bytes)
}
pub fn generate_from_entropy(&self, bytes: &[u8]) -> Result<TrackingId> {
let needed = self.entropy_bytes();
if bytes.len() < needed {
return Err(Error::InsufficientEntropy {
needed,
got: bytes.len(),
});
}
let mut body = String::with_capacity(self.body_len());
for byte in &bytes[..needed] {
body.push(hex_upper(byte >> 4));
body.push(hex_upper(byte & 0x0f));
}
body.truncate(self.entropy_chars());
if self.checksum == Checksum::Iso7064Mod37_36 {
let check = checksum::compute(&body)
.expect("body is uppercase hexadecimal, a subset of the alphabet");
body.push(check);
}
let mut raw = String::with_capacity(self.prefix.len() + 1 + body.len());
raw.push_str(&self.prefix);
raw.push(super::SEPARATOR);
raw.push_str(&body);
Ok(TrackingId(raw))
}
pub fn validate(&self, id: &TrackingId) -> Result<()> {
if id.prefix() != self.prefix {
return Err(Error::IdPolicyMismatch {
reason: alloc::format!(
"expected prefix `{}`, found `{}`",
self.prefix,
id.prefix()
),
});
}
let body = id.body();
if body.len() != self.body_len() {
return Err(Error::IdPolicyMismatch {
reason: alloc::format!(
"expected a {}-character body, found {}",
self.body_len(),
body.len()
),
});
}
if self.checksum == Checksum::Iso7064Mod37_36 && !checksum::verify(body) {
return Err(Error::IdPolicyMismatch {
reason: "check character does not match the body".to_string(),
});
}
Ok(())
}
}
fn hex_upper(nibble: u8) -> char {
debug_assert!(nibble < 16);
b"0123456789ABCDEF"[nibble as usize] as char
}
#[derive(Debug, Clone)]
pub struct IdGeneratorBuilder {
prefix: String,
entropy_bits: u16,
checksum: Checksum,
}
impl Default for IdGeneratorBuilder {
fn default() -> Self {
let d = IdGenerator::default();
Self {
prefix: d.prefix,
entropy_bits: d.entropy_bits,
checksum: d.checksum,
}
}
}
impl IdGeneratorBuilder {
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn entropy_bits(mut self, bits: u16) -> Self {
self.entropy_bits = bits;
self
}
pub fn checksum(mut self, checksum: Checksum) -> Self {
self.checksum = checksum;
self
}
pub fn build(self) -> Result<IdGenerator> {
if self.prefix.is_empty() {
return Err(Error::InvalidIdConfig(
"prefix must not be empty".to_string(),
));
}
if self.prefix.len() > MAX_PREFIX_LEN {
return Err(Error::InvalidIdConfig(alloc::format!(
"prefix must be at most {MAX_PREFIX_LEN} characters, got {}",
self.prefix.len()
)));
}
if let Some(bad) = self.prefix.chars().find(|c| !super::is_body_char(*c)) {
return Err(Error::InvalidIdConfig(alloc::format!(
"prefix must consist of `A-Z` and `0-9`, found `{bad}`"
)));
}
if self.entropy_bits % 4 != 0 {
return Err(Error::InvalidIdConfig(alloc::format!(
"entropy_bits must be a multiple of 4, got {}",
self.entropy_bits
)));
}
if !(MIN_ENTROPY_BITS..=MAX_ENTROPY_BITS).contains(&self.entropy_bits) {
return Err(Error::InvalidIdConfig(alloc::format!(
"entropy_bits must be between {MIN_ENTROPY_BITS} and {MAX_ENTROPY_BITS}, got {}",
self.entropy_bits
)));
}
Ok(IdGenerator {
prefix: self.prefix,
entropy_bits: self.entropy_bits,
checksum: self.checksum,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_reproduces_the_documented_format() {
let g = IdGenerator::default();
let id = g
.generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c])
.expect("four bytes is enough for 32 bits");
assert_eq!(id.as_str(), "PKG-9ED9285C");
assert_eq!(id.prefix(), "PKG");
assert_eq!(id.body(), "9ED9285C");
g.validate(&id).expect("self-consistent");
}
#[test]
fn generation_is_deterministic_for_fixed_entropy() {
let g = IdGenerator::default();
let a = g.generate_from_entropy(&[1, 2, 3, 4]).unwrap();
let b = g.generate_from_entropy(&[1, 2, 3, 4]).unwrap();
assert_eq!(a, b);
assert_eq!(a.as_str(), "PKG-01020304");
}
#[test]
fn checksum_round_trips_and_is_validated() {
let g = IdGenerator::builder()
.checksum(Checksum::Iso7064Mod37_36)
.build()
.unwrap();
let id = g.generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c]).unwrap();
assert_eq!(id.body().len(), 9);
assert!(id.body().starts_with("9ED9285C"));
g.validate(&id).unwrap();
}
#[test]
fn validate_rejects_a_corrupted_check_character() {
let g = IdGenerator::builder()
.checksum(Checksum::Iso7064Mod37_36)
.build()
.unwrap();
let id = g.generate_from_entropy(&[0x9e, 0xd9, 0x28, 0x5c]).unwrap();
let corrupted = TrackingId::parse(&id.as_str().replace("9ED", "9EE")).unwrap();
assert!(g.validate(&corrupted).is_err());
}
#[test]
fn validate_rejects_a_foreign_prefix() {
let g = IdGenerator::default();
let other = TrackingId::parse("BOX-9ED9285C").unwrap();
assert!(g.validate(&other).is_err());
}
#[test]
fn wider_entropy_produces_a_longer_body() {
let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
assert_eq!(g.entropy_bytes(), 8);
let id = g.generate_from_entropy(&[0xff; 8]).unwrap();
assert_eq!(id.body(), "FFFFFFFFFFFFFFFF");
}
#[test]
fn non_byte_aligned_entropy_truncates_cleanly() {
let g = IdGenerator::builder().entropy_bits(20).build().unwrap();
assert_eq!(g.entropy_bytes(), 3);
let id = g.generate_from_entropy(&[0xab, 0xcd, 0xef]).unwrap();
assert_eq!(id.body(), "ABCDE");
}
#[test]
fn insufficient_entropy_is_an_error_not_a_panic() {
let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
assert!(matches!(
g.generate_from_entropy(&[0; 4]),
Err(Error::InsufficientEntropy { needed: 8, got: 4 })
));
}
#[test]
fn builder_rejects_bad_configuration() {
assert!(IdGenerator::builder().prefix("").build().is_err());
assert!(IdGenerator::builder().prefix("pkg").build().is_err());
assert!(IdGenerator::builder().prefix("PKG-X").build().is_err());
assert!(IdGenerator::builder().entropy_bits(18).build().is_err());
assert!(IdGenerator::builder().entropy_bits(8).build().is_err());
assert!(IdGenerator::builder().entropy_bits(1024).build().is_err());
}
#[test]
#[cfg(feature = "os-rng")]
fn os_entropy_produces_distinct_well_formed_ids() {
let g = IdGenerator::builder().entropy_bits(64).build().unwrap();
let a = g.generate().unwrap();
let b = g.generate().unwrap();
assert_ne!(a, b, "64-bit ids should not repeat in two draws");
g.validate(&a).unwrap();
g.validate(&b).unwrap();
}
}