1use crate::category::Category;
61use crate::generator::ReplacementGenerator;
62use hmac::{Hmac, Mac};
63use rand::Rng;
64use sha2::Sha256;
65use zeroize::Zeroize;
66
67pub trait Strategy: Send + Sync {
81 fn name(&self) -> &'static str;
83
84 fn replace(&self, original: &str, entropy: &[u8; 32]) -> String;
92}
93
94#[derive(Debug)]
100pub enum EntropyMode {
101 Deterministic {
103 key: [u8; 32],
105 },
106 Random,
108}
109
110impl Drop for EntropyMode {
111 fn drop(&mut self) {
112 if let EntropyMode::Deterministic { ref mut key } = self {
113 key.zeroize();
114 }
115 }
116}
117
118pub struct StrategyGenerator {
128 strategy: Box<dyn Strategy>,
129 mode: EntropyMode,
130}
131
132impl StrategyGenerator {
133 #[must_use]
140 pub fn new(strategy: Box<dyn Strategy>, mode: EntropyMode) -> Self {
141 Self { strategy, mode }
142 }
143
144 fn entropy(&self, category: &Category, original: &str) -> [u8; 32] {
146 match &self.mode {
147 EntropyMode::Deterministic { key } => {
148 type HmacSha256 = Hmac<Sha256>;
149 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
150 let tag = category.domain_tag_hmac();
151 mac.update(tag.as_bytes());
152 mac.update(b"\x00");
153 mac.update(original.as_bytes());
154 let result = mac.finalize();
155 let mut out = [0u8; 32];
156 out.copy_from_slice(&result.into_bytes());
157 out
158 }
159 EntropyMode::Random => {
160 let mut buf = [0u8; 32];
161 rand::thread_rng().fill(&mut buf);
162 buf
163 }
164 }
165 }
166
167 #[must_use]
169 pub fn strategy(&self) -> &dyn Strategy {
170 &*self.strategy
171 }
172}
173
174impl ReplacementGenerator for StrategyGenerator {
175 fn generate(&self, category: &Category, original: &str) -> String {
176 let entropy = self.entropy(category, original);
177 self.strategy.replace(original, &entropy)
178 }
179}
180
181pub struct RandomString {
194 len: usize,
196}
197
198impl RandomString {
199 #[must_use]
201 pub fn new() -> Self {
202 Self { len: 16 }
203 }
204
205 #[must_use]
207 pub fn with_length(len: usize) -> Self {
208 Self {
209 len: len.clamp(1, 64),
210 }
211 }
212}
213
214impl Default for RandomString {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220impl Strategy for RandomString {
221 fn name(&self) -> &'static str {
222 "random_string"
223 }
224
225 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
226 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
227 ABCDEFGHIJKLMNOPQRSTUVWXYZ\
228 0123456789";
229 let mut chars = String::with_capacity(self.len);
232 let mut state = 0u64;
234 for chunk in entropy.chunks_exact(8) {
235 state = state.wrapping_add(u64::from_le_bytes(chunk.try_into().unwrap()));
236 }
237 if state == 0 {
238 state = 0xDEAD_BEEF_CAFE_BABE; }
240
241 for _ in 0..self.len {
242 state ^= state << 13;
244 state ^= state >> 7;
245 state ^= state << 17;
246 #[allow(clippy::cast_possible_truncation)]
247 let idx = (state as usize) % CHARSET.len();
249 chars.push(CHARSET[idx] as char);
250 }
251 chars
252 }
253}
254
255pub struct RandomUuid;
265
266impl RandomUuid {
267 #[must_use]
268 pub fn new() -> Self {
269 Self
270 }
271}
272
273impl Default for RandomUuid {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279impl Strategy for RandomUuid {
280 fn name(&self) -> &'static str {
281 "random_uuid"
282 }
283
284 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
285 let mut bytes = [0u8; 16];
287 bytes.copy_from_slice(&entropy[..16]);
288
289 bytes[6] = (bytes[6] & 0x0F) | 0x40;
291 bytes[8] = (bytes[8] & 0x3F) | 0x80;
293
294 format!(
295 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
296 bytes[0], bytes[1], bytes[2], bytes[3],
297 bytes[4], bytes[5],
298 bytes[6], bytes[7],
299 bytes[8], bytes[9],
300 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
301 )
302 }
303}
304
305pub struct FakeIp;
313
314impl FakeIp {
315 #[must_use]
316 pub fn new() -> Self {
317 Self
318 }
319}
320
321impl Default for FakeIp {
322 fn default() -> Self {
323 Self::new()
324 }
325}
326
327impl Strategy for FakeIp {
328 fn name(&self) -> &'static str {
329 "fake_ip"
330 }
331
332 fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
333 let a = entropy[0];
334 let b = entropy[1];
335 let c = entropy[2].max(1);
337 format!("10.{}.{}.{}", a, b, c)
338 }
339}
340
341pub struct PreserveLength;
350
351impl PreserveLength {
352 #[must_use]
353 pub fn new() -> Self {
354 Self
355 }
356}
357
358impl Default for PreserveLength {
359 fn default() -> Self {
360 Self::new()
361 }
362}
363
364impl Strategy for PreserveLength {
365 fn name(&self) -> &'static str {
366 "preserve_length"
367 }
368
369 fn replace(&self, original: &str, entropy: &[u8; 32]) -> String {
370 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
371
372 let target_len = original.len();
373 if target_len == 0 {
374 return String::new();
375 }
376
377 let mut state = 0u64;
379 for chunk in entropy.chunks_exact(8) {
380 state = state.wrapping_add(u64::from_le_bytes(chunk.try_into().unwrap()));
381 }
382 if state == 0 {
383 state = 0xCAFE_BABE_DEAD_BEEFu64;
384 }
385
386 let mut result = String::with_capacity(target_len);
387 for _ in 0..target_len {
388 state ^= state << 13;
389 state ^= state >> 7;
390 state ^= state << 17;
391 #[allow(clippy::cast_possible_truncation)]
392 let idx = (state as usize) % CHARSET.len();
394 result.push(CHARSET[idx] as char);
395 }
396 result
397 }
398}
399
400pub struct HmacHash {
414 key: [u8; 32],
415 output_len: usize,
417}
418
419impl HmacHash {
420 #[must_use]
422 pub fn new(key: [u8; 32]) -> Self {
423 Self {
424 key,
425 output_len: 32,
426 }
427 }
428
429 #[must_use]
431 pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
432 Self {
433 key,
434 output_len: output_len.clamp(1, 64),
435 }
436 }
437}
438
439impl Strategy for HmacHash {
440 fn name(&self) -> &'static str {
441 "hmac_hash"
442 }
443
444 fn replace(&self, original: &str, _entropy: &[u8; 32]) -> String {
445 use std::fmt::Write;
446
447 type HmacSha256 = Hmac<Sha256>;
448 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
449 mac.update(original.as_bytes());
450 let result = mac.finalize();
451 let hash_bytes: [u8; 32] = {
452 let mut buf = [0u8; 32];
453 buf.copy_from_slice(&result.into_bytes());
454 buf
455 };
456 let mut hex = String::with_capacity(64);
457 for b in &hash_bytes {
458 let _ = write!(hex, "{:02x}", b);
459 }
460 hex[..self.output_len].to_string()
461 }
462}
463
464#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::category::Category;
472 use std::sync::Arc;
473
474 fn test_entropy() -> [u8; 32] {
476 let mut e = [0u8; 32];
477 for (i, b) in e.iter_mut().enumerate() {
478 #[allow(clippy::cast_possible_truncation)] {
480 *b = (i as u8).wrapping_mul(37).wrapping_add(7);
481 }
482 }
483 e
484 }
485
486 #[test]
489 fn strategies_are_deterministic() {
490 let entropy = test_entropy();
491 let strategies: Vec<Box<dyn Strategy>> = vec![
492 Box::new(RandomString::new()),
493 Box::new(RandomUuid::new()),
494 Box::new(FakeIp::new()),
495 Box::new(PreserveLength::new()),
496 Box::new(HmacHash::new([42u8; 32])),
497 ];
498 for s in &strategies {
499 let a = s.replace("hello world", &entropy);
500 let b = s.replace("hello world", &entropy);
501 assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
502 }
503 }
504
505 #[test]
506 fn different_entropy_different_output() {
507 let e1 = [1u8; 32];
508 let e2 = [2u8; 32];
509 let strategies: Vec<Box<dyn Strategy>> = vec![
510 Box::new(RandomString::new()),
511 Box::new(RandomUuid::new()),
512 Box::new(FakeIp::new()),
513 Box::new(PreserveLength::new()),
514 ];
515 for s in &strategies {
516 let a = s.replace("test", &e1);
517 let b = s.replace("test", &e2);
518 assert_ne!(
519 a,
520 b,
521 "strategy '{}' should differ with different entropy",
522 s.name()
523 );
524 }
525 }
526
527 #[test]
530 fn random_string_default_length() {
531 let s = RandomString::new();
532 let out = s.replace("anything", &test_entropy());
533 assert_eq!(out.len(), 16);
534 assert!(
535 out.chars().all(|c| c.is_ascii_alphanumeric()),
536 "output must be alphanumeric: {}",
537 out,
538 );
539 }
540
541 #[test]
542 fn random_string_custom_length() {
543 let s = RandomString::with_length(8);
544 let out = s.replace("anything", &test_entropy());
545 assert_eq!(out.len(), 8);
546 }
547
548 #[test]
549 fn random_string_clamped_length() {
550 let s = RandomString::with_length(999);
551 assert_eq!(s.len, 64);
552 let s = RandomString::with_length(0);
553 assert_eq!(s.len, 1);
554 }
555
556 #[test]
559 fn random_uuid_format() {
560 let s = RandomUuid::new();
561 let out = s.replace("anything", &test_entropy());
562 assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
564 let parts: Vec<&str> = out.split('-').collect();
565 assert_eq!(parts.len(), 5);
566 assert_eq!(parts[0].len(), 8);
567 assert_eq!(parts[1].len(), 4);
568 assert_eq!(parts[2].len(), 4);
569 assert_eq!(parts[3].len(), 4);
570 assert_eq!(parts[4].len(), 12);
571 assert_eq!(&parts[2][0..1], "4", "version must be 4");
573 let variant = &parts[3][0..1];
575 assert!(
576 ["8", "9", "a", "b"].contains(&variant),
577 "variant nibble must be 8/9/a/b, got {}",
578 variant,
579 );
580 }
581
582 #[test]
585 fn fake_ip_format() {
586 let s = FakeIp::new();
587 let out = s.replace("192.168.1.1", &test_entropy());
588 assert!(out.starts_with("10."), "must be in 10.0.0.0/8: {}", out);
589 let octets: Vec<&str> = out.split('.').collect();
590 assert_eq!(octets.len(), 4);
591 for octet in &octets {
592 let _n: u8 = octet.parse().expect("octet must be a valid u8");
593 }
594 let last: u8 = octets[3].parse().unwrap();
596 assert!(last >= 1, "last octet must be ≥ 1");
597 }
598
599 #[test]
602 fn preserve_length_matches() {
603 let s = PreserveLength::new();
604 for input in &["a", "hello", "this is a fairly long string indeed", ""] {
605 let out = s.replace(input, &test_entropy());
606 assert_eq!(
607 out.len(),
608 input.len(),
609 "length mismatch for input '{}'",
610 input,
611 );
612 }
613 }
614
615 #[test]
616 fn preserve_length_characters() {
617 let s = PreserveLength::new();
618 let out = s.replace("hello!", &test_entropy());
619 assert!(
620 out.chars().all(|c| c.is_ascii_alphanumeric()),
621 "output must be alphanumeric: {}",
622 out,
623 );
624 }
625
626 #[test]
629 fn hmac_hash_deterministic_with_key() {
630 let s = HmacHash::new([42u8; 32]);
631 let a = s.replace("secret", &[0u8; 32]);
632 let b = s.replace("secret", &[0xFF; 32]);
633 assert_eq!(a, b, "HmacHash must ignore entropy");
635 }
636
637 #[test]
638 fn hmac_hash_default_length() {
639 let s = HmacHash::new([0u8; 32]);
640 let out = s.replace("test", &[0u8; 32]);
641 assert_eq!(out.len(), 32, "default output is 32 hex chars");
642 assert!(
643 out.chars().all(|c| c.is_ascii_hexdigit()),
644 "output must be hex: {}",
645 out,
646 );
647 }
648
649 #[test]
650 fn hmac_hash_custom_length() {
651 let s = HmacHash::with_output_len([0u8; 32], 12);
652 let out = s.replace("test", &[0u8; 32]);
653 assert_eq!(out.len(), 12);
654 }
655
656 #[test]
657 fn hmac_hash_different_keys() {
658 let s1 = HmacHash::new([1u8; 32]);
659 let s2 = HmacHash::new([2u8; 32]);
660 let a = s1.replace("test", &[0u8; 32]);
661 let b = s2.replace("test", &[0u8; 32]);
662 assert_ne!(a, b, "different keys must produce different output");
663 }
664
665 #[test]
666 fn hmac_hash_different_inputs() {
667 let s = HmacHash::new([42u8; 32]);
668 let a = s.replace("alice", &[0u8; 32]);
669 let b = s.replace("bob", &[0u8; 32]);
670 assert_ne!(a, b);
671 }
672
673 #[test]
676 fn strategy_generator_deterministic() {
677 let strat = Box::new(RandomString::new());
678 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
679 let a = gen.generate(&Category::Email, "alice@corp.com");
680 let b = gen.generate(&Category::Email, "alice@corp.com");
681 assert_eq!(a, b, "deterministic mode must be repeatable");
682 }
683
684 #[test]
685 fn strategy_generator_different_categories() {
686 let strat = Box::new(RandomString::new());
687 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
688 let a = gen.generate(&Category::Email, "test");
689 let b = gen.generate(&Category::Name, "test");
690 assert_ne!(a, b, "different categories must produce different entropy");
691 }
692
693 #[test]
694 fn strategy_generator_with_store() {
695 let strat = Box::new(RandomUuid::new());
696 let gen = Arc::new(StrategyGenerator::new(
697 strat,
698 EntropyMode::Deterministic { key: [99u8; 32] },
699 ));
700 let store = crate::store::MappingStore::new(gen, None);
701
702 let s1 = store
703 .get_or_insert(&Category::Email, "alice@corp.com")
704 .unwrap();
705 let s2 = store
706 .get_or_insert(&Category::Email, "alice@corp.com")
707 .unwrap();
708 assert_eq!(s1, s2, "store must cache strategy output");
709 assert_eq!(s1.len(), 36, "output must be UUID-formatted");
710 }
711
712 #[test]
713 fn strategy_generator_random_cached_in_store() {
714 let strat = Box::new(FakeIp::new());
715 let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
716 let store = crate::store::MappingStore::new(gen, None);
717
718 let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
719 let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
720 assert_eq!(s1, s2);
722 assert!(s1.starts_with("10."));
723 }
724
725 #[test]
726 fn all_strategies_implement_send_sync() {
727 fn assert_send_sync<T: Send + Sync>() {}
728 assert_send_sync::<RandomString>();
729 assert_send_sync::<RandomUuid>();
730 assert_send_sync::<FakeIp>();
731 assert_send_sync::<PreserveLength>();
732 assert_send_sync::<HmacHash>();
733 assert_send_sync::<StrategyGenerator>();
734 }
735
736 #[test]
737 fn strategy_names_unique() {
738 let strategies: Vec<Box<dyn Strategy>> = vec![
739 Box::new(RandomString::new()),
740 Box::new(RandomUuid::new()),
741 Box::new(FakeIp::new()),
742 Box::new(PreserveLength::new()),
743 Box::new(HmacHash::new([0u8; 32])),
744 ];
745 let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
746 let len_before = names.len();
747 names.sort_unstable();
748 names.dedup();
749 assert_eq!(names.len(), len_before, "strategy names must be unique");
750 }
751
752 #[test]
755 fn concurrent_strategy_generator() {
756 use std::thread;
757
758 let strat = Box::new(PreserveLength::new());
759 let gen = Arc::new(StrategyGenerator::new(
760 strat,
761 EntropyMode::Deterministic { key: [7u8; 32] },
762 ));
763 let store = Arc::new(crate::store::MappingStore::new(gen, None));
764
765 let mut handles = vec![];
766 for t in 0..4 {
767 let store = Arc::clone(&store);
768 handles.push(thread::spawn(move || {
769 for i in 0..500 {
770 let val = format!("thread{}-val{}", t, i);
771 let result = store.get_or_insert(&Category::Name, &val).unwrap();
772 assert_eq!(
773 result.len(),
774 val.len(),
775 "PreserveLength must match input length",
776 );
777 }
778 }));
779 }
780 for h in handles {
781 h.join().unwrap();
782 }
783 assert_eq!(store.len(), 2000);
784 }
785}