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
17#[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 #[default]
24 None,
25 Iso7064Mod37_36,
30}
31
32#[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 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 pub fn builder() -> IdGeneratorBuilder {
99 IdGeneratorBuilder::default()
100 }
101
102 pub fn prefix(&self) -> &str {
104 &self.prefix
105 }
106
107 pub fn entropy_bits(&self) -> u16 {
109 self.entropy_bits
110 }
111
112 pub fn checksum(&self) -> Checksum {
114 self.checksum
115 }
116
117 fn entropy_chars(&self) -> usize {
119 self.entropy_bits as usize / 4
120 }
121
122 pub fn entropy_bytes(&self) -> usize {
124 (self.entropy_bits as usize).div_ceil(8)
125 }
126
127 fn body_len(&self) -> usize {
129 self.entropy_chars() + usize::from(self.checksum != Checksum::None)
130 }
131
132 #[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 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 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 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#[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 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
259 self.prefix = prefix.into();
260 self
261 }
262
263 pub fn entropy_bits(mut self, bits: u16) -> Self {
266 self.entropy_bits = bits;
267 self
268 }
269
270 pub fn checksum(mut self, checksum: Checksum) -> Self {
272 self.checksum = checksum;
273 self
274 }
275
276 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 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}