smart_package_tracker/id/
generator.rs1use alloc::string::{String, ToString};
4
5use super::checksum;
6use super::TrackingId;
7use crate::error::{Error, Result};
8
9const MAX_PREFIX_LEN: usize = 16;
12const MIN_ENTROPY_BITS: u16 = 16;
14const MAX_ENTROPY_BITS: u16 = 512;
16
17const _: () = {
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#[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 #[default]
38 None,
39 Iso7064Mod37_36,
44}
45
46#[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 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 pub fn builder() -> IdGeneratorBuilder {
113 IdGeneratorBuilder::default()
114 }
115
116 pub fn prefix(&self) -> &str {
118 &self.prefix
119 }
120
121 pub fn entropy_bits(&self) -> u16 {
123 self.entropy_bits
124 }
125
126 pub fn checksum(&self) -> Checksum {
128 self.checksum
129 }
130
131 fn entropy_chars(&self) -> usize {
133 self.entropy_bits as usize / 4
134 }
135
136 pub fn entropy_bytes(&self) -> usize {
138 (self.entropy_bits as usize).div_ceil(8)
139 }
140
141 fn body_len(&self) -> usize {
143 self.entropy_chars() + usize::from(self.checksum != Checksum::None)
144 }
145
146 #[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 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 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 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 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#[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 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
286 self.prefix = prefix.into();
287 self
288 }
289
290 pub fn entropy_bits(mut self, bits: u16) -> Self {
293 self.entropy_bits = bits;
294 self
295 }
296
297 pub fn checksum(mut self, checksum: Checksum) -> Self {
299 self.checksum = checksum;
300 self
301 }
302
303 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 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 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 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 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 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 let g = IdGenerator::builder()
495 .prefix("ABCDEFGHIJKLMNOP") .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); 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}