1use crate::category::Category;
70use crate::generator::ReplacementGenerator;
71use hmac::{Hmac, Mac};
72use rand::Rng;
73use sha2::Sha256;
74use zeroize::Zeroize;
75
76pub trait Strategy: Send + Sync {
90 fn name(&self) -> &'static str;
92
93 fn replace(&self, original: &str, entropy: &[u8; 32]) -> String;
101}
102
103#[derive(Debug)]
109pub enum EntropyMode {
110 Deterministic {
112 key: [u8; 32],
114 },
115 Random,
117}
118
119impl Drop for EntropyMode {
120 fn drop(&mut self) {
121 if let EntropyMode::Deterministic { ref mut key } = self {
122 key.zeroize();
123 }
124 }
125}
126
127pub struct StrategyGenerator {
137 strategy: Box<dyn Strategy>,
138 mode: EntropyMode,
139}
140
141impl StrategyGenerator {
142 #[must_use]
149 pub fn new(strategy: Box<dyn Strategy>, mode: EntropyMode) -> Self {
150 Self { strategy, mode }
151 }
152
153 fn entropy(&self, category: &Category, original: &str) -> [u8; 32] {
155 match &self.mode {
156 EntropyMode::Deterministic { key } => {
157 type HmacSha256 = Hmac<Sha256>;
158 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
159 let tag = category.domain_tag_hmac();
160 mac.update(tag.as_bytes());
161 mac.update(b"\x00");
162 mac.update(original.as_bytes());
163 let result = mac.finalize();
164 let mut out = [0u8; 32];
165 out.copy_from_slice(&result.into_bytes());
166 out
167 }
168 EntropyMode::Random => {
169 let mut buf = [0u8; 32];
170 rand::thread_rng().fill(&mut buf);
171 buf
172 }
173 }
174 }
175
176 #[must_use]
178 pub fn strategy(&self) -> &dyn Strategy {
179 &*self.strategy
180 }
181}
182
183impl ReplacementGenerator for StrategyGenerator {
184 fn generate(&self, category: &Category, original: &str) -> String {
185 let entropy = self.entropy(category, original);
186 self.strategy.replace(original, &entropy)
187 }
188}
189
190pub struct RandomString {
203 len: usize,
205}
206
207impl RandomString {
208 #[must_use]
210 pub fn new() -> Self {
211 Self { len: 16 }
212 }
213
214 #[must_use]
216 pub fn with_length(len: usize) -> Self {
217 Self {
218 len: len.clamp(1, 64),
219 }
220 }
221}
222
223impl Default for RandomString {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229impl Strategy for RandomString {
230 fn name(&self) -> &'static str {
231 "random_string"
232 }
233
234 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
235 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
236 ABCDEFGHIJKLMNOPQRSTUVWXYZ\
237 0123456789";
238 let mut chars = String::with_capacity(self.len);
241 let mut state = 0u64;
243 for chunk in entropy.chunks_exact(8) {
244 let arr: [u8; 8] = chunk.try_into().expect("chunks_exact(8) yields 8-byte slices");
246 state = state.wrapping_add(u64::from_le_bytes(arr));
247 }
248 if state == 0 {
249 state = 0xDEAD_BEEF_CAFE_BABE; }
251
252 for _ in 0..self.len {
253 state ^= state << 13;
255 state ^= state >> 7;
256 state ^= state << 17;
257 #[allow(clippy::cast_possible_truncation)]
258 let idx = (state as usize) % CHARSET.len();
260 chars.push(CHARSET[idx] as char);
261 }
262 chars
263 }
264}
265
266pub struct RandomUuid;
276
277impl RandomUuid {
278 #[must_use]
279 pub fn new() -> Self {
280 Self
281 }
282}
283
284impl Default for RandomUuid {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290impl Strategy for RandomUuid {
291 fn name(&self) -> &'static str {
292 "random_uuid"
293 }
294
295 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
296 let mut bytes = [0u8; 16];
298 bytes.copy_from_slice(&entropy[..16]);
299
300 bytes[6] = (bytes[6] & 0x0F) | 0x40;
302 bytes[8] = (bytes[8] & 0x3F) | 0x80;
304
305 format!(
306 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
307 bytes[0], bytes[1], bytes[2], bytes[3],
308 bytes[4], bytes[5],
309 bytes[6], bytes[7],
310 bytes[8], bytes[9],
311 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
312 )
313 }
314}
315
316pub struct FakeIp;
324
325impl FakeIp {
326 #[must_use]
327 pub fn new() -> Self {
328 Self
329 }
330}
331
332impl Default for FakeIp {
333 fn default() -> Self {
334 Self::new()
335 }
336}
337
338impl Strategy for FakeIp {
339 fn name(&self) -> &'static str {
340 "fake_ip"
341 }
342
343 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
344 let a = entropy[0];
345 let b = entropy[1];
346 let c = entropy[2].max(1);
348 format!("10.{}.{}.{}", a, b, c)
349 }
350}
351
352pub struct PreserveLength;
361
362impl PreserveLength {
363 #[must_use]
364 pub fn new() -> Self {
365 Self
366 }
367}
368
369impl Default for PreserveLength {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375impl Strategy for PreserveLength {
376 fn name(&self) -> &'static str {
377 "preserve_length"
378 }
379
380 fn replace(&self, original: &str, entropy: &[u8; 32]) -> String {
381 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
382
383 let target_len = original.len();
384 if target_len == 0 {
385 return String::new();
386 }
387
388 let mut state = 0u64;
390 for chunk in entropy.chunks_exact(8) {
391 let arr: [u8; 8] = chunk.try_into().expect("chunks_exact(8) yields 8-byte slices");
393 state = state.wrapping_add(u64::from_le_bytes(arr));
394 }
395 if state == 0 {
396 state = 0xCAFE_BABE_DEAD_BEEFu64;
397 }
398
399 let mut result = String::with_capacity(target_len);
400 for _ in 0..target_len {
401 state ^= state << 13;
402 state ^= state >> 7;
403 state ^= state << 17;
404 #[allow(clippy::cast_possible_truncation)]
405 let idx = (state as usize) % CHARSET.len();
407 result.push(CHARSET[idx] as char);
408 }
409 result
410 }
411}
412
413pub struct HmacHash {
427 key: [u8; 32],
428 output_len: usize,
430}
431
432impl HmacHash {
433 #[must_use]
435 pub fn new(key: [u8; 32]) -> Self {
436 Self {
437 key,
438 output_len: 32,
439 }
440 }
441
442 #[must_use]
444 pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
445 Self {
446 key,
447 output_len: output_len.clamp(1, 64),
448 }
449 }
450}
451
452impl Strategy for HmacHash {
453 fn name(&self) -> &'static str {
454 "hmac_hash"
455 }
456
457 fn replace(&self, original: &str, _entropy: &[u8; 32]) -> String {
458 use std::fmt::Write;
459
460 type HmacSha256 = Hmac<Sha256>;
461 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
462 mac.update(original.as_bytes());
463 let result = mac.finalize();
464 let hash_bytes: [u8; 32] = {
465 let mut buf = [0u8; 32];
466 buf.copy_from_slice(&result.into_bytes());
467 buf
468 };
469 let mut hex = String::with_capacity(64);
470 for b in &hash_bytes {
471 let _ = write!(hex, "{:02x}", b);
472 }
473 hex[..self.output_len].to_string()
474 }
475}
476
477#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::category::Category;
485 use std::sync::Arc;
486
487 fn test_entropy() -> [u8; 32] {
489 let mut e = [0u8; 32];
490 for (i, b) in e.iter_mut().enumerate() {
491 #[allow(clippy::cast_possible_truncation)] {
493 *b = (i as u8).wrapping_mul(37).wrapping_add(7);
494 }
495 }
496 e
497 }
498
499 #[test]
502 fn strategies_are_deterministic() {
503 let entropy = test_entropy();
504 let strategies: Vec<Box<dyn Strategy>> = vec![
505 Box::new(RandomString::new()),
506 Box::new(RandomUuid::new()),
507 Box::new(FakeIp::new()),
508 Box::new(PreserveLength::new()),
509 Box::new(HmacHash::new([42u8; 32])),
510 ];
511 for s in &strategies {
512 let a = s.replace("hello world", &entropy);
513 let b = s.replace("hello world", &entropy);
514 assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
515 }
516 }
517
518 #[test]
519 fn different_entropy_different_output() {
520 let e1 = [1u8; 32];
521 let e2 = [2u8; 32];
522 let strategies: Vec<Box<dyn Strategy>> = vec![
523 Box::new(RandomString::new()),
524 Box::new(RandomUuid::new()),
525 Box::new(FakeIp::new()),
526 Box::new(PreserveLength::new()),
527 ];
528 for s in &strategies {
529 let a = s.replace("test", &e1);
530 let b = s.replace("test", &e2);
531 assert_ne!(
532 a,
533 b,
534 "strategy '{}' should differ with different entropy",
535 s.name()
536 );
537 }
538 }
539
540 #[test]
543 fn random_string_default_length() {
544 let s = RandomString::new();
545 let out = s.replace("anything", &test_entropy());
546 assert_eq!(out.len(), 16);
547 assert!(
548 out.chars().all(|c| c.is_ascii_alphanumeric()),
549 "output must be alphanumeric: {}",
550 out,
551 );
552 }
553
554 #[test]
555 fn random_string_custom_length() {
556 let s = RandomString::with_length(8);
557 let out = s.replace("anything", &test_entropy());
558 assert_eq!(out.len(), 8);
559 }
560
561 #[test]
562 fn random_string_clamped_length() {
563 let s = RandomString::with_length(999);
564 assert_eq!(s.len, 64);
565 let s = RandomString::with_length(0);
566 assert_eq!(s.len, 1);
567 }
568
569 #[test]
572 fn random_uuid_format() {
573 let s = RandomUuid::new();
574 let out = s.replace("anything", &test_entropy());
575 assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
577 let parts: Vec<&str> = out.split('-').collect();
578 assert_eq!(parts.len(), 5);
579 assert_eq!(parts[0].len(), 8);
580 assert_eq!(parts[1].len(), 4);
581 assert_eq!(parts[2].len(), 4);
582 assert_eq!(parts[3].len(), 4);
583 assert_eq!(parts[4].len(), 12);
584 assert_eq!(&parts[2][0..1], "4", "version must be 4");
586 let variant = &parts[3][0..1];
588 assert!(
589 ["8", "9", "a", "b"].contains(&variant),
590 "variant nibble must be 8/9/a/b, got {}",
591 variant,
592 );
593 }
594
595 #[test]
598 fn fake_ip_format() {
599 let s = FakeIp::new();
600 let out = s.replace("192.168.1.1", &test_entropy());
601 assert!(out.starts_with("10."), "must be in 10.0.0.0/8: {}", out);
602 let octets: Vec<&str> = out.split('.').collect();
603 assert_eq!(octets.len(), 4);
604 for octet in &octets {
605 let _n: u8 = octet.parse().expect("octet must be a valid u8");
606 }
607 let last: u8 = octets[3].parse().unwrap();
609 assert!(last >= 1, "last octet must be ≥ 1");
610 }
611
612 #[test]
615 fn preserve_length_matches() {
616 let s = PreserveLength::new();
617 for input in &["a", "hello", "this is a fairly long string indeed", ""] {
618 let out = s.replace(input, &test_entropy());
619 assert_eq!(
620 out.len(),
621 input.len(),
622 "length mismatch for input '{}'",
623 input,
624 );
625 }
626 }
627
628 #[test]
629 fn preserve_length_characters() {
630 let s = PreserveLength::new();
631 let out = s.replace("hello!", &test_entropy());
632 assert!(
633 out.chars().all(|c| c.is_ascii_alphanumeric()),
634 "output must be alphanumeric: {}",
635 out,
636 );
637 }
638
639 #[test]
642 fn hmac_hash_deterministic_with_key() {
643 let s = HmacHash::new([42u8; 32]);
644 let a = s.replace("secret", &[0u8; 32]);
645 let b = s.replace("secret", &[0xFF; 32]);
646 assert_eq!(a, b, "HmacHash must ignore entropy");
648 }
649
650 #[test]
651 fn hmac_hash_default_length() {
652 let s = HmacHash::new([0u8; 32]);
653 let out = s.replace("test", &[0u8; 32]);
654 assert_eq!(out.len(), 32, "default output is 32 hex chars");
655 assert!(
656 out.chars().all(|c| c.is_ascii_hexdigit()),
657 "output must be hex: {}",
658 out,
659 );
660 }
661
662 #[test]
663 fn hmac_hash_custom_length() {
664 let s = HmacHash::with_output_len([0u8; 32], 12);
665 let out = s.replace("test", &[0u8; 32]);
666 assert_eq!(out.len(), 12);
667 }
668
669 #[test]
670 fn hmac_hash_different_keys() {
671 let s1 = HmacHash::new([1u8; 32]);
672 let s2 = HmacHash::new([2u8; 32]);
673 let a = s1.replace("test", &[0u8; 32]);
674 let b = s2.replace("test", &[0u8; 32]);
675 assert_ne!(a, b, "different keys must produce different output");
676 }
677
678 #[test]
679 fn hmac_hash_different_inputs() {
680 let s = HmacHash::new([42u8; 32]);
681 let a = s.replace("alice", &[0u8; 32]);
682 let b = s.replace("bob", &[0u8; 32]);
683 assert_ne!(a, b);
684 }
685
686 #[test]
689 fn strategy_generator_deterministic() {
690 let strat = Box::new(RandomString::new());
691 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
692 let a = gen.generate(&Category::Email, "alice@corp.com");
693 let b = gen.generate(&Category::Email, "alice@corp.com");
694 assert_eq!(a, b, "deterministic mode must be repeatable");
695 }
696
697 #[test]
698 fn strategy_generator_different_categories() {
699 let strat = Box::new(RandomString::new());
700 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
701 let a = gen.generate(&Category::Email, "test");
702 let b = gen.generate(&Category::Name, "test");
703 assert_ne!(a, b, "different categories must produce different entropy");
704 }
705
706 #[test]
707 fn strategy_generator_with_store() {
708 let strat = Box::new(RandomUuid::new());
709 let gen = Arc::new(StrategyGenerator::new(
710 strat,
711 EntropyMode::Deterministic { key: [99u8; 32] },
712 ));
713 let store = crate::store::MappingStore::new(gen, None);
714
715 let s1 = store
716 .get_or_insert(&Category::Email, "alice@corp.com")
717 .unwrap();
718 let s2 = store
719 .get_or_insert(&Category::Email, "alice@corp.com")
720 .unwrap();
721 assert_eq!(s1, s2, "store must cache strategy output");
722 assert_eq!(s1.len(), 36, "output must be UUID-formatted");
723 }
724
725 #[test]
726 fn strategy_generator_random_cached_in_store() {
727 let strat = Box::new(FakeIp::new());
728 let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
729 let store = crate::store::MappingStore::new(gen, None);
730
731 let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
732 let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
733 assert_eq!(s1, s2);
735 assert!(s1.starts_with("10."));
736 }
737
738 #[test]
739 fn all_strategies_implement_send_sync() {
740 fn assert_send_sync<T: Send + Sync>() {}
741 assert_send_sync::<RandomString>();
742 assert_send_sync::<RandomUuid>();
743 assert_send_sync::<FakeIp>();
744 assert_send_sync::<PreserveLength>();
745 assert_send_sync::<HmacHash>();
746 assert_send_sync::<StrategyGenerator>();
747 }
748
749 #[test]
750 fn strategy_names_unique() {
751 let strategies: Vec<Box<dyn Strategy>> = vec![
752 Box::new(RandomString::new()),
753 Box::new(RandomUuid::new()),
754 Box::new(FakeIp::new()),
755 Box::new(PreserveLength::new()),
756 Box::new(HmacHash::new([0u8; 32])),
757 ];
758 let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
759 let len_before = names.len();
760 names.sort_unstable();
761 names.dedup();
762 assert_eq!(names.len(), len_before, "strategy names must be unique");
763 }
764
765 #[test]
768 fn concurrent_strategy_generator() {
769 use std::thread;
770
771 let strat = Box::new(PreserveLength::new());
772 let gen = Arc::new(StrategyGenerator::new(
773 strat,
774 EntropyMode::Deterministic { key: [7u8; 32] },
775 ));
776 let store = Arc::new(crate::store::MappingStore::new(gen, None));
777
778 let mut handles = vec![];
779 for t in 0..4 {
780 let store = Arc::clone(&store);
781 handles.push(thread::spawn(move || {
782 for i in 0..500 {
783 let val = format!("thread{}-val{}", t, i);
784 let result = store.get_or_insert(&Category::Name, &val).unwrap();
785 assert_eq!(
786 result.len(),
787 val.len(),
788 "PreserveLength must match input length",
789 );
790 }
791 }));
792 }
793 for h in handles {
794 h.join().unwrap();
795 }
796 assert_eq!(store.len(), 2000);
797 }
798}