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::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
190#[inline]
200fn xorshift64_seed(entropy: &[u8; 32]) -> u64 {
201 let mut state = 0u64;
202 for chunk in entropy.chunks_exact(8) {
203 let arr: [u8; 8] = chunk
204 .try_into()
205 .expect("chunks_exact(8) yields 8-byte slices");
206 state = state.wrapping_add(u64::from_le_bytes(arr));
207 }
208 if state == 0 {
209 state = 0xDEAD_BEEF_CAFE_BABE;
210 }
211 state
212}
213
214pub struct RandomString {
223 len: usize,
225}
226
227impl RandomString {
228 #[must_use]
230 pub fn new() -> Self {
231 Self { len: 16 }
232 }
233
234 #[must_use]
236 pub fn with_length(len: usize) -> Self {
237 Self {
238 len: len.clamp(1, 64),
239 }
240 }
241}
242
243impl Default for RandomString {
244 fn default() -> Self {
245 Self::new()
246 }
247}
248
249impl Strategy for RandomString {
250 fn name(&self) -> &'static str {
251 "random_string"
252 }
253
254 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
255 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
256 ABCDEFGHIJKLMNOPQRSTUVWXYZ\
257 0123456789";
258 let mut chars = String::with_capacity(self.len);
259 let mut state = xorshift64_seed(entropy);
260
261 for _ in 0..self.len {
262 state ^= state << 13;
264 state ^= state >> 7;
265 state ^= state << 17;
266 #[allow(clippy::cast_possible_truncation)]
267 let idx = (state as usize) % CHARSET.len();
269 chars.push(CHARSET[idx] as char);
270 }
271 chars
272 }
273}
274
275pub struct RandomUuid;
285
286impl RandomUuid {
287 #[must_use]
288 pub fn new() -> Self {
289 Self
290 }
291}
292
293impl Default for RandomUuid {
294 fn default() -> Self {
295 Self::new()
296 }
297}
298
299impl Strategy for RandomUuid {
300 fn name(&self) -> &'static str {
301 "random_uuid"
302 }
303
304 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
305 let mut bytes = [0u8; 16];
307 bytes.copy_from_slice(&entropy[..16]);
308
309 bytes[6] = (bytes[6] & 0x0F) | 0x40;
311 bytes[8] = (bytes[8] & 0x3F) | 0x80;
313
314 format!(
315 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
316 bytes[0], bytes[1], bytes[2], bytes[3],
317 bytes[4], bytes[5],
318 bytes[6], bytes[7],
319 bytes[8], bytes[9],
320 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
321 )
322 }
323}
324
325pub struct FakeIp;
333
334impl FakeIp {
335 #[must_use]
336 pub fn new() -> Self {
337 Self
338 }
339}
340
341impl Default for FakeIp {
342 fn default() -> Self {
343 Self::new()
344 }
345}
346
347impl Strategy for FakeIp {
348 fn name(&self) -> &'static str {
349 "fake_ip"
350 }
351
352 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
353 let a = entropy[0];
354 let b = entropy[1];
355 let c = entropy[2].max(1);
357 format!("10.{}.{}.{}", a, b, c)
358 }
359}
360
361pub struct PreserveLength;
370
371impl PreserveLength {
372 #[must_use]
373 pub fn new() -> Self {
374 Self
375 }
376}
377
378impl Default for PreserveLength {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384impl Strategy for PreserveLength {
385 fn name(&self) -> &'static str {
386 "preserve_length"
387 }
388
389 fn replace(&self, original: &str, entropy: &[u8; 32]) -> String {
390 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
391
392 let target_len = original.len();
393 if target_len == 0 {
394 return String::new();
395 }
396
397 let mut state = xorshift64_seed(entropy);
398 let mut result = String::with_capacity(target_len);
399 for _ in 0..target_len {
400 state ^= state << 13;
401 state ^= state >> 7;
402 state ^= state << 17;
403 #[allow(clippy::cast_possible_truncation)]
404 let idx = (state as usize) % CHARSET.len();
406 result.push(CHARSET[idx] as char);
407 }
408 result
409 }
410}
411
412pub struct HmacHash {
426 key: [u8; 32],
427 output_len: usize,
429}
430
431impl HmacHash {
432 #[must_use]
434 pub fn new(key: [u8; 32]) -> Self {
435 Self {
436 key,
437 output_len: 32,
438 }
439 }
440
441 #[must_use]
443 pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
444 Self {
445 key,
446 output_len: output_len.clamp(1, 64),
447 }
448 }
449}
450
451impl Strategy for HmacHash {
452 fn name(&self) -> &'static str {
453 "hmac_hash"
454 }
455
456 fn replace(&self, original: &str, _entropy: &[u8; 32]) -> String {
457 use std::fmt::Write;
458
459 type HmacSha256 = Hmac<Sha256>;
460 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
461 mac.update(original.as_bytes());
462 let result = mac.finalize();
463 let hash_bytes: [u8; 32] = {
464 let mut buf = [0u8; 32];
465 buf.copy_from_slice(&result.into_bytes());
466 buf
467 };
468 let mut hex = String::with_capacity(64);
469 for b in &hash_bytes {
470 let _ = write!(hex, "{:02x}", b);
471 }
472 hex[..self.output_len].to_string()
473 }
474}
475
476#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::category::Category;
484 use std::sync::Arc;
485
486 fn test_entropy() -> [u8; 32] {
488 let mut e = [0u8; 32];
489 for (i, b) in e.iter_mut().enumerate() {
490 #[allow(clippy::cast_possible_truncation)] {
492 *b = (i as u8).wrapping_mul(37).wrapping_add(7);
493 }
494 }
495 e
496 }
497
498 #[test]
501 fn strategies_are_deterministic() {
502 let entropy = test_entropy();
503 let strategies: Vec<Box<dyn Strategy>> = vec![
504 Box::new(RandomString::new()),
505 Box::new(RandomUuid::new()),
506 Box::new(FakeIp::new()),
507 Box::new(PreserveLength::new()),
508 Box::new(HmacHash::new([42u8; 32])),
509 ];
510 for s in &strategies {
511 let a = s.replace("hello world", &entropy);
512 let b = s.replace("hello world", &entropy);
513 assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
514 }
515 }
516
517 #[test]
518 fn different_entropy_different_output() {
519 let e1 = [1u8; 32];
520 let e2 = [2u8; 32];
521 let strategies: Vec<Box<dyn Strategy>> = vec![
522 Box::new(RandomString::new()),
523 Box::new(RandomUuid::new()),
524 Box::new(FakeIp::new()),
525 Box::new(PreserveLength::new()),
526 ];
527 for s in &strategies {
528 let a = s.replace("test", &e1);
529 let b = s.replace("test", &e2);
530 assert_ne!(
531 a,
532 b,
533 "strategy '{}' should differ with different entropy",
534 s.name()
535 );
536 }
537 }
538
539 #[test]
542 fn random_string_default_length() {
543 let s = RandomString::new();
544 let out = s.replace("anything", &test_entropy());
545 assert_eq!(out.len(), 16);
546 assert!(
547 out.chars().all(|c| c.is_ascii_alphanumeric()),
548 "output must be alphanumeric: {}",
549 out,
550 );
551 }
552
553 #[test]
554 fn random_string_custom_length() {
555 let s = RandomString::with_length(8);
556 let out = s.replace("anything", &test_entropy());
557 assert_eq!(out.len(), 8);
558 }
559
560 #[test]
561 fn random_string_clamped_length() {
562 let s = RandomString::with_length(999);
563 assert_eq!(s.len, 64);
564 let s = RandomString::with_length(0);
565 assert_eq!(s.len, 1);
566 }
567
568 #[test]
571 fn random_uuid_format() {
572 let s = RandomUuid::new();
573 let out = s.replace("anything", &test_entropy());
574 assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
576 let parts: Vec<&str> = out.split('-').collect();
577 assert_eq!(parts.len(), 5);
578 assert_eq!(parts[0].len(), 8);
579 assert_eq!(parts[1].len(), 4);
580 assert_eq!(parts[2].len(), 4);
581 assert_eq!(parts[3].len(), 4);
582 assert_eq!(parts[4].len(), 12);
583 assert_eq!(&parts[2][0..1], "4", "version must be 4");
585 let variant = &parts[3][0..1];
587 assert!(
588 ["8", "9", "a", "b"].contains(&variant),
589 "variant nibble must be 8/9/a/b, got {}",
590 variant,
591 );
592 }
593
594 #[test]
597 fn fake_ip_format() {
598 let s = FakeIp::new();
599 let out = s.replace("192.168.1.1", &test_entropy());
600 assert!(out.starts_with("10."), "must be in 10.0.0.0/8: {}", out);
601 let octets: Vec<&str> = out.split('.').collect();
602 assert_eq!(octets.len(), 4);
603 for octet in &octets {
604 let _n: u8 = octet.parse().expect("octet must be a valid u8");
605 }
606 let last: u8 = octets[3].parse().unwrap();
608 assert!(last >= 1, "last octet must be ≥ 1");
609 }
610
611 #[test]
614 fn preserve_length_matches() {
615 let s = PreserveLength::new();
616 for input in &["a", "hello", "this is a fairly long string indeed", ""] {
617 let out = s.replace(input, &test_entropy());
618 assert_eq!(
619 out.len(),
620 input.len(),
621 "length mismatch for input '{}'",
622 input,
623 );
624 }
625 }
626
627 #[test]
628 fn preserve_length_characters() {
629 let s = PreserveLength::new();
630 let out = s.replace("hello!", &test_entropy());
631 assert!(
632 out.chars().all(|c| c.is_ascii_alphanumeric()),
633 "output must be alphanumeric: {}",
634 out,
635 );
636 }
637
638 #[test]
641 fn hmac_hash_deterministic_with_key() {
642 let s = HmacHash::new([42u8; 32]);
643 let a = s.replace("secret", &[0u8; 32]);
644 let b = s.replace("secret", &[0xFF; 32]);
645 assert_eq!(a, b, "HmacHash must ignore entropy");
647 }
648
649 #[test]
650 fn hmac_hash_default_length() {
651 let s = HmacHash::new([0u8; 32]);
652 let out = s.replace("test", &[0u8; 32]);
653 assert_eq!(out.len(), 32, "default output is 32 hex chars");
654 assert!(
655 out.chars().all(|c| c.is_ascii_hexdigit()),
656 "output must be hex: {}",
657 out,
658 );
659 }
660
661 #[test]
662 fn hmac_hash_custom_length() {
663 let s = HmacHash::with_output_len([0u8; 32], 12);
664 let out = s.replace("test", &[0u8; 32]);
665 assert_eq!(out.len(), 12);
666 }
667
668 #[test]
669 fn hmac_hash_different_keys() {
670 let s1 = HmacHash::new([1u8; 32]);
671 let s2 = HmacHash::new([2u8; 32]);
672 let a = s1.replace("test", &[0u8; 32]);
673 let b = s2.replace("test", &[0u8; 32]);
674 assert_ne!(a, b, "different keys must produce different output");
675 }
676
677 #[test]
678 fn hmac_hash_different_inputs() {
679 let s = HmacHash::new([42u8; 32]);
680 let a = s.replace("alice", &[0u8; 32]);
681 let b = s.replace("bob", &[0u8; 32]);
682 assert_ne!(a, b);
683 }
684
685 #[test]
688 fn strategy_generator_deterministic() {
689 let strat = Box::new(RandomString::new());
690 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
691 let a = gen.generate(&Category::Email, "alice@corp.com");
692 let b = gen.generate(&Category::Email, "alice@corp.com");
693 assert_eq!(a, b, "deterministic mode must be repeatable");
694 }
695
696 #[test]
697 fn strategy_generator_different_categories() {
698 let strat = Box::new(RandomString::new());
699 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
700 let a = gen.generate(&Category::Email, "test");
701 let b = gen.generate(&Category::Name, "test");
702 assert_ne!(a, b, "different categories must produce different entropy");
703 }
704
705 #[test]
706 fn strategy_generator_with_store() {
707 let strat = Box::new(RandomUuid::new());
708 let gen = Arc::new(StrategyGenerator::new(
709 strat,
710 EntropyMode::Deterministic { key: [99u8; 32] },
711 ));
712 let store = crate::store::MappingStore::new(gen, None);
713
714 let s1 = store
715 .get_or_insert(&Category::Email, "alice@corp.com")
716 .unwrap();
717 let s2 = store
718 .get_or_insert(&Category::Email, "alice@corp.com")
719 .unwrap();
720 assert_eq!(s1, s2, "store must cache strategy output");
721 assert_eq!(s1.len(), 36, "output must be UUID-formatted");
722 }
723
724 #[test]
725 fn strategy_generator_random_cached_in_store() {
726 let strat = Box::new(FakeIp::new());
727 let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
728 let store = crate::store::MappingStore::new(gen, None);
729
730 let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
731 let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
732 assert_eq!(s1, s2);
734 assert!(s1.starts_with("10."));
735 }
736
737 #[test]
738 fn all_strategies_implement_send_sync() {
739 fn assert_send_sync<T: Send + Sync>() {}
740 assert_send_sync::<RandomString>();
741 assert_send_sync::<RandomUuid>();
742 assert_send_sync::<FakeIp>();
743 assert_send_sync::<PreserveLength>();
744 assert_send_sync::<HmacHash>();
745 assert_send_sync::<StrategyGenerator>();
746 }
747
748 #[test]
749 fn strategy_names_unique() {
750 let strategies: Vec<Box<dyn Strategy>> = vec![
751 Box::new(RandomString::new()),
752 Box::new(RandomUuid::new()),
753 Box::new(FakeIp::new()),
754 Box::new(PreserveLength::new()),
755 Box::new(HmacHash::new([0u8; 32])),
756 ];
757 let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
758 let len_before = names.len();
759 names.sort_unstable();
760 names.dedup();
761 assert_eq!(names.len(), len_before, "strategy names must be unique");
762 }
763
764 #[test]
767 fn concurrent_strategy_generator() {
768 use std::thread;
769
770 let strat = Box::new(PreserveLength::new());
771 let gen = Arc::new(StrategyGenerator::new(
772 strat,
773 EntropyMode::Deterministic { key: [7u8; 32] },
774 ));
775 let store = Arc::new(crate::store::MappingStore::new(gen, None));
776
777 let mut handles = vec![];
778 for t in 0..4 {
779 let store = Arc::clone(&store);
780 handles.push(thread::spawn(move || {
781 for i in 0..500 {
782 let val = format!("thread{}-val{}", t, i);
783 let result = store.get_or_insert(&Category::Name, &val).unwrap();
784 assert_eq!(
785 result.len(),
786 val.len(),
787 "PreserveLength must match input length",
788 );
789 }
790 }));
791 }
792 for h in handles {
793 h.join().unwrap();
794 }
795 assert_eq!(store.len(), 2000);
796 }
797}