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, category: &Category, 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.as_ref()
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(category, 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
214#[inline]
216fn xorshift64_step(state: &mut u64) {
217 *state ^= *state << 13;
218 *state ^= *state >> 7;
219 *state ^= *state << 17;
220}
221
222pub struct RandomString {
231 len: usize,
233}
234
235impl RandomString {
236 #[must_use]
238 pub fn new() -> Self {
239 Self { len: 16 }
240 }
241
242 #[must_use]
244 pub fn with_length(len: usize) -> Self {
245 Self {
246 len: len.clamp(1, 64),
247 }
248 }
249}
250
251impl Default for RandomString {
252 fn default() -> Self {
253 Self::new()
254 }
255}
256
257impl Strategy for RandomString {
258 fn name(&self) -> &'static str {
259 "random_string"
260 }
261
262 fn replace(&self, _category: &Category, _original: &str, entropy: &[u8; 32]) -> String {
263 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
264 ABCDEFGHIJKLMNOPQRSTUVWXYZ\
265 0123456789";
266 let mut chars = String::with_capacity(self.len);
267 let mut state = xorshift64_seed(entropy);
268
269 for _ in 0..self.len {
270 xorshift64_step(&mut state);
271 #[allow(clippy::cast_possible_truncation)]
272 let idx = (state as usize) % CHARSET.len();
274 chars.push(CHARSET[idx] as char);
275 }
276 chars
277 }
278}
279
280pub struct RandomUuid;
290
291impl RandomUuid {
292 #[must_use]
293 pub fn new() -> Self {
294 Self
295 }
296}
297
298impl Default for RandomUuid {
299 fn default() -> Self {
300 Self::new()
301 }
302}
303
304impl Strategy for RandomUuid {
305 fn name(&self) -> &'static str {
306 "random_uuid"
307 }
308
309 fn replace(&self, _category: &Category, _original: &str, entropy: &[u8; 32]) -> String {
310 let mut bytes = [0u8; 16];
312 bytes.copy_from_slice(&entropy[..16]);
313
314 bytes[6] = (bytes[6] & 0x0F) | 0x40;
316 bytes[8] = (bytes[8] & 0x3F) | 0x80;
318
319 format!(
320 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
321 bytes[0], bytes[1], bytes[2], bytes[3],
322 bytes[4], bytes[5],
323 bytes[6], bytes[7],
324 bytes[8], bytes[9],
325 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
326 )
327 }
328}
329
330pub struct FakeIp;
341
342impl FakeIp {
343 #[must_use]
344 pub fn new() -> Self {
345 Self
346 }
347}
348
349impl Default for FakeIp {
350 fn default() -> Self {
351 Self::new()
352 }
353}
354
355impl Strategy for FakeIp {
356 fn name(&self) -> &'static str {
357 "fake_ip"
358 }
359
360 fn replace(&self, _category: &Category, original: &str, entropy: &[u8; 32]) -> String {
361 let mut buf = String::with_capacity(original.len());
364 let mut hi = 0usize;
365 for ch in original.chars() {
366 if ch == '.' {
367 buf.push('.');
368 } else {
369 buf.push((b'0' + entropy[hi % 32] % 10) as char);
370 hi += 1;
371 }
372 }
373 buf
374 }
375}
376
377pub struct PreserveLength;
386
387impl PreserveLength {
388 #[must_use]
389 pub fn new() -> Self {
390 Self
391 }
392}
393
394impl Default for PreserveLength {
395 fn default() -> Self {
396 Self::new()
397 }
398}
399
400impl Strategy for PreserveLength {
401 fn name(&self) -> &'static str {
402 "preserve_length"
403 }
404
405 fn replace(&self, _category: &Category, original: &str, entropy: &[u8; 32]) -> String {
406 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
407
408 let target_len = original.len();
409 if target_len == 0 {
410 return String::new();
411 }
412
413 let mut state = xorshift64_seed(entropy);
414 let mut result = String::with_capacity(target_len);
415 for _ in 0..target_len {
416 xorshift64_step(&mut state);
417 #[allow(clippy::cast_possible_truncation)]
418 let idx = (state as usize) % CHARSET.len();
420 result.push(CHARSET[idx] as char);
421 }
422 result
423 }
424}
425
426pub struct HmacHash {
440 key: [u8; 32],
441 output_len: usize,
443}
444
445impl HmacHash {
446 #[must_use]
448 pub fn new(key: [u8; 32]) -> Self {
449 Self {
450 key,
451 output_len: 32,
452 }
453 }
454
455 #[must_use]
457 pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
458 Self {
459 key,
460 output_len: output_len.clamp(1, 64),
461 }
462 }
463}
464
465impl Strategy for HmacHash {
466 fn name(&self) -> &'static str {
467 "hmac_hash"
468 }
469
470 fn replace(&self, _category: &Category, original: &str, _entropy: &[u8; 32]) -> String {
471 use std::fmt::Write;
472
473 type HmacSha256 = Hmac<Sha256>;
474 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
475 mac.update(original.as_bytes());
476 let result = mac.finalize();
477 let hash_bytes: [u8; 32] = {
478 let mut buf = [0u8; 32];
479 buf.copy_from_slice(&result.into_bytes());
480 buf
481 };
482 let mut hex = String::with_capacity(64);
483 for b in &hash_bytes {
484 let _ = write!(hex, "{:02x}", b);
485 }
486 hex[..self.output_len].to_string()
487 }
488}
489
490pub struct CategoryAwareStrategy;
503
504impl CategoryAwareStrategy {
505 #[must_use]
506 pub fn new() -> Self {
507 Self
508 }
509}
510
511impl Default for CategoryAwareStrategy {
512 fn default() -> Self {
513 Self::new()
514 }
515}
516
517impl Strategy for CategoryAwareStrategy {
518 fn name(&self) -> &'static str {
519 "category_aware"
520 }
521
522 fn replace(&self, category: &Category, original: &str, entropy: &[u8; 32]) -> String {
523 crate::generator::format_replacement(
524 category,
525 entropy,
526 original,
527 crate::generator::LengthPolicy::Preserve,
528 )
529 }
530}
531
532#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::category::Category;
540 use std::sync::Arc;
541
542 fn test_entropy() -> [u8; 32] {
544 let mut e = [0u8; 32];
545 for (i, b) in e.iter_mut().enumerate() {
546 #[allow(clippy::cast_possible_truncation)] {
548 *b = (i as u8).wrapping_mul(37).wrapping_add(7);
549 }
550 }
551 e
552 }
553
554 #[test]
557 fn strategies_are_deterministic() {
558 let entropy = test_entropy();
559 let strategies: Vec<Box<dyn Strategy>> = vec![
560 Box::new(RandomString::new()),
561 Box::new(RandomUuid::new()),
562 Box::new(FakeIp::new()),
563 Box::new(PreserveLength::new()),
564 Box::new(HmacHash::new([42u8; 32])),
565 Box::new(CategoryAwareStrategy::new()),
566 ];
567 for s in &strategies {
568 let a = s.replace(&Category::AuthToken, "hello world", &entropy);
569 let b = s.replace(&Category::AuthToken, "hello world", &entropy);
570 assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
571 }
572 }
573
574 #[test]
575 fn different_entropy_different_output() {
576 let e1 = [1u8; 32];
577 let e2 = [2u8; 32];
578 let strategies: Vec<Box<dyn Strategy>> = vec![
579 Box::new(RandomString::new()),
580 Box::new(RandomUuid::new()),
581 Box::new(FakeIp::new()),
582 Box::new(PreserveLength::new()),
583 Box::new(CategoryAwareStrategy::new()),
584 ];
585 for s in &strategies {
586 let a = s.replace(&Category::AuthToken, "test", &e1);
587 let b = s.replace(&Category::AuthToken, "test", &e2);
588 assert_ne!(
589 a,
590 b,
591 "strategy '{}' should differ with different entropy",
592 s.name()
593 );
594 }
595 }
596
597 #[test]
600 fn random_string_default_length() {
601 let s = RandomString::new();
602 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
603 assert_eq!(out.len(), 16);
604 assert!(
605 out.chars().all(|c| c.is_ascii_alphanumeric()),
606 "output must be alphanumeric: {}",
607 out,
608 );
609 }
610
611 #[test]
612 fn random_string_custom_length() {
613 let s = RandomString::with_length(8);
614 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
615 assert_eq!(out.len(), 8);
616 }
617
618 #[test]
619 fn random_string_clamped_length() {
620 let s = RandomString::with_length(999);
621 assert_eq!(s.len, 64);
622 let s = RandomString::with_length(0);
623 assert_eq!(s.len, 1);
624 }
625
626 #[test]
629 fn random_uuid_format() {
630 let s = RandomUuid::new();
631 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
632 assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
634 let parts: Vec<&str> = out.split('-').collect();
635 assert_eq!(parts.len(), 5);
636 assert_eq!(parts[0].len(), 8);
637 assert_eq!(parts[1].len(), 4);
638 assert_eq!(parts[2].len(), 4);
639 assert_eq!(parts[3].len(), 4);
640 assert_eq!(parts[4].len(), 12);
641 assert_eq!(&parts[2][0..1], "4", "version must be 4");
643 let variant = &parts[3][0..1];
645 assert!(
646 ["8", "9", "a", "b"].contains(&variant),
647 "variant nibble must be 8/9/a/b, got {}",
648 variant,
649 );
650 }
651
652 #[test]
655 fn fake_ip_format() {
656 let s = FakeIp::new();
657 let input = "192.168.1.1";
658 let out = s.replace(&Category::IpV4, input, &test_entropy());
659 assert_eq!(
661 out.len(),
662 input.len(),
663 "FakeIp must preserve length: {}",
664 out
665 );
666 let in_dots: Vec<usize> = input
668 .char_indices()
669 .filter(|&(_, c)| c == '.')
670 .map(|(i, _)| i)
671 .collect();
672 let out_dots: Vec<usize> = out
673 .char_indices()
674 .filter(|&(_, c)| c == '.')
675 .map(|(i, _)| i)
676 .collect();
677 assert_eq!(out_dots, in_dots, "FakeIp must preserve dot positions");
678 assert!(
680 out.chars().all(|c| c == '.' || c.is_ascii_digit()),
681 "FakeIp output must contain only digits and dots: {}",
682 out
683 );
684 assert_ne!(out, input, "FakeIp must change the IP");
686 }
687
688 #[test]
691 fn preserve_length_matches() {
692 let s = PreserveLength::new();
693 for input in &["a", "hello", "this is a fairly long string indeed", ""] {
694 let out = s.replace(&Category::AuthToken, input, &test_entropy());
695 assert_eq!(
696 out.len(),
697 input.len(),
698 "length mismatch for input '{}'",
699 input,
700 );
701 }
702 }
703
704 #[test]
705 fn preserve_length_characters() {
706 let s = PreserveLength::new();
707 let out = s.replace(&Category::AuthToken, "hello!", &test_entropy());
708 assert!(
709 out.chars().all(|c| c.is_ascii_alphanumeric()),
710 "output must be alphanumeric: {}",
711 out,
712 );
713 }
714
715 #[test]
718 fn hmac_hash_deterministic_with_key() {
719 let s = HmacHash::new([42u8; 32]);
720 let a = s.replace(&Category::AuthToken, "secret", &[0u8; 32]);
721 let b = s.replace(&Category::AuthToken, "secret", &[0xFF; 32]);
722 assert_eq!(a, b, "HmacHash must ignore entropy");
724 }
725
726 #[test]
727 fn hmac_hash_default_length() {
728 let s = HmacHash::new([0u8; 32]);
729 let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
730 assert_eq!(out.len(), 32, "default output is 32 hex chars");
731 assert!(
732 out.chars().all(|c| c.is_ascii_hexdigit()),
733 "output must be hex: {}",
734 out,
735 );
736 }
737
738 #[test]
739 fn hmac_hash_custom_length() {
740 let s = HmacHash::with_output_len([0u8; 32], 12);
741 let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
742 assert_eq!(out.len(), 12);
743 }
744
745 #[test]
746 fn hmac_hash_different_keys() {
747 let s1 = HmacHash::new([1u8; 32]);
748 let s2 = HmacHash::new([2u8; 32]);
749 let a = s1.replace(&Category::AuthToken, "test", &[0u8; 32]);
750 let b = s2.replace(&Category::AuthToken, "test", &[0u8; 32]);
751 assert_ne!(a, b, "different keys must produce different output");
752 }
753
754 #[test]
755 fn hmac_hash_different_inputs() {
756 let s = HmacHash::new([42u8; 32]);
757 let a = s.replace(&Category::AuthToken, "alice", &[0u8; 32]);
758 let b = s.replace(&Category::AuthToken, "bob", &[0u8; 32]);
759 assert_ne!(a, b);
760 }
761
762 #[test]
765 fn strategy_generator_deterministic() {
766 let strat = Box::new(RandomString::new());
767 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
768 let a = gen.generate(&Category::Email, "alice@corp.com");
769 let b = gen.generate(&Category::Email, "alice@corp.com");
770 assert_eq!(a, b, "deterministic mode must be repeatable");
771 }
772
773 #[test]
774 fn strategy_generator_different_categories() {
775 let strat = Box::new(RandomString::new());
776 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
777 let a = gen.generate(&Category::Email, "test");
778 let b = gen.generate(&Category::Name, "test");
779 assert_ne!(a, b, "different categories must produce different entropy");
780 }
781
782 #[test]
783 fn strategy_generator_with_store() {
784 let strat = Box::new(RandomUuid::new());
785 let gen = Arc::new(StrategyGenerator::new(
786 strat,
787 EntropyMode::Deterministic { key: [99u8; 32] },
788 ));
789 let store = crate::store::MappingStore::new(gen, None);
790
791 let s1 = store
792 .get_or_insert(&Category::Email, "alice@corp.com")
793 .unwrap();
794 let s2 = store
795 .get_or_insert(&Category::Email, "alice@corp.com")
796 .unwrap();
797 assert_eq!(s1, s2, "store must cache strategy output");
798 assert_eq!(s1.len(), 36, "output must be UUID-formatted");
799 }
800
801 #[test]
802 fn strategy_generator_random_cached_in_store() {
803 let strat = Box::new(FakeIp::new());
804 let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
805 let store = crate::store::MappingStore::new(gen, None);
806
807 let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
808 let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
809 assert_eq!(s1, s2);
811 assert_eq!(
812 s1.len(),
813 "192.168.1.1".len(),
814 "FakeIp must preserve input length"
815 );
816 }
817
818 #[test]
819 fn all_strategies_implement_send_sync() {
820 fn assert_send_sync<T: Send + Sync>() {}
821 assert_send_sync::<RandomString>();
822 assert_send_sync::<RandomUuid>();
823 assert_send_sync::<FakeIp>();
824 assert_send_sync::<PreserveLength>();
825 assert_send_sync::<HmacHash>();
826 assert_send_sync::<CategoryAwareStrategy>();
827 assert_send_sync::<StrategyGenerator>();
828 }
829
830 #[test]
831 fn strategy_names_unique() {
832 let strategies: Vec<Box<dyn Strategy>> = vec![
833 Box::new(RandomString::new()),
834 Box::new(RandomUuid::new()),
835 Box::new(FakeIp::new()),
836 Box::new(PreserveLength::new()),
837 Box::new(HmacHash::new([0u8; 32])),
838 Box::new(CategoryAwareStrategy::new()),
839 ];
840 let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
841 let len_before = names.len();
842 names.sort_unstable();
843 names.dedup();
844 assert_eq!(names.len(), len_before, "strategy names must be unique");
845 }
846
847 #[test]
850 fn concurrent_strategy_generator() {
851 use std::thread;
852
853 let strat = Box::new(PreserveLength::new());
854 let gen = Arc::new(StrategyGenerator::new(
855 strat,
856 EntropyMode::Deterministic { key: [7u8; 32] },
857 ));
858 let store = Arc::new(crate::store::MappingStore::new(gen, None));
859
860 let mut handles = vec![];
861 for t in 0..4 {
862 let store = Arc::clone(&store);
863 handles.push(thread::spawn(move || {
864 for i in 0..500 {
865 let val = format!("thread{}-val{}", t, i);
866 let result = store.get_or_insert(&Category::Name, &val).unwrap();
867 assert_eq!(
868 result.len(),
869 val.len(),
870 "PreserveLength must match input length",
871 );
872 }
873 }));
874 }
875 for h in handles {
876 h.join().unwrap();
877 }
878 assert_eq!(store.len(), 2000);
879 }
880
881 #[test]
884 fn category_aware_email_shaped() {
885 let s = CategoryAwareStrategy::new();
886 let input = "alice@corp.com";
887 let out = s.replace(&Category::Email, input, &test_entropy());
888 assert_eq!(out.len(), input.len(), "length must be preserved");
889 assert!(out.contains('@'), "output must be email-shaped");
890 assert!(out.ends_with("@corp.com"), "domain must be preserved");
891 }
892
893 #[test]
894 fn category_aware_length_preserved_across_categories() {
895 let s = CategoryAwareStrategy::new();
896 let cases = [
897 (Category::Email, "alice@corp.com"),
898 (Category::IpV4, "192.168.1.1"),
899 (Category::AuthToken, "ghp_abc123secrettoken"),
900 (Category::Hostname, "db-prod.internal"),
901 ];
902 for (cat, input) in &cases {
903 let out = s.replace(cat, input, &test_entropy());
904 assert_eq!(out.len(), input.len(), "length mismatch for {:?}", cat);
905 assert_ne!(out, *input, "output must differ from input for {:?}", cat);
906 }
907 }
908
909 #[test]
910 fn category_aware_random_mode_consistent_within_run() {
911 let gen = Arc::new(StrategyGenerator::new(
914 Box::new(CategoryAwareStrategy::new()),
915 EntropyMode::Random,
916 ));
917 let store = crate::store::MappingStore::new(gen, None);
918 let r1 = store
919 .get_or_insert(&Category::Email, "alice@corp.com")
920 .unwrap();
921 let r2 = store
922 .get_or_insert(&Category::Email, "alice@corp.com")
923 .unwrap();
924 assert_eq!(
925 r1, r2,
926 "store cache must return same replacement within run"
927 );
928 assert!(r1.contains('@'), "replacement must be email-shaped");
929 assert_eq!(r1.len(), "alice@corp.com".len(), "length must be preserved");
930 }
931
932 #[test]
933 fn category_aware_deterministic() {
934 let s = CategoryAwareStrategy::new();
935 let entropy = test_entropy();
936 let a = s.replace(&Category::Email, "alice@corp.com", &entropy);
937 let b = s.replace(&Category::Email, "alice@corp.com", &entropy);
938 assert_eq!(a, b, "category_aware must be deterministic");
939 }
940
941 mod property {
944 use super::*;
945 use crate::store::MappingStore;
946 use proptest::prelude::*;
947
948 fn hmac_store() -> MappingStore {
949 let gen = Arc::new(crate::generator::HmacGenerator::new([77u8; 32]));
950 MappingStore::new(gen, None)
951 }
952
953 fn category_aware_store() -> MappingStore {
954 let gen = Arc::new(StrategyGenerator::new(
955 Box::new(CategoryAwareStrategy::new()),
956 EntropyMode::Deterministic { key: [77u8; 32] },
957 ));
958 MappingStore::new(gen, None)
959 }
960
961 proptest! {
962 #[test]
963 fn category_aware_length_preserved(s in "[a-z0-9]{1,64}") {
964 let store = category_aware_store();
965 let out = store.get_or_insert(&Category::AuthToken, &s).unwrap();
966 prop_assert_eq!(out.len(), s.len());
967 }
968
969 #[test]
970 fn category_aware_email_shaped(
971 local in "[a-z]{3,8}",
972 domain in "[a-z]{3,8}",
973 tld in "[a-z]{2,4}",
974 ) {
975 let input = format!("{local}@{domain}.{tld}");
976 let store = category_aware_store();
977 let out = store.get_or_insert(&Category::Email, &input).unwrap();
978 prop_assert_eq!(out.len(), input.len());
979 prop_assert!(out.contains('@'));
980 let after_at = out.split('@').nth(1).unwrap_or("");
981 prop_assert!(after_at.contains('.'));
982 }
983
984 #[test]
985 fn email_output_is_email_shaped(
986 local in "[a-z]{3,8}",
987 domain in "[a-z]{3,8}",
988 tld in "[a-z]{2,4}",
989 ) {
990 let input = format!("{local}@{domain}.{tld}");
991 let store = hmac_store();
992 let out = store.get_or_insert(&Category::Email, &input).unwrap();
993 prop_assert_eq!(out.chars().filter(|&c| c == '@').count(), 1);
994 let after = out.split('@').nth(1).unwrap_or("");
995 prop_assert!(after.contains('.'), "no dot in domain part: {out}");
996 prop_assert_eq!(out.len(), input.len());
997 }
998
999 #[test]
1000 fn ipv4_output_preserves_dot_structure(
1001 a in 0u8..=255u8,
1002 b in 0u8..=255u8,
1003 c in 0u8..=255u8,
1004 d in 0u8..=255u8,
1005 ) {
1006 let input = format!("{a}.{b}.{c}.{d}");
1007 let store = hmac_store();
1008 let out = store.get_or_insert(&Category::IpV4, &input).unwrap();
1009 let in_parts: Vec<&str> = input.split('.').collect();
1014 let out_parts: Vec<&str> = out.split('.').collect();
1015 prop_assert_eq!(out_parts.len(), 4);
1016 for (inp, outp) in in_parts.iter().zip(out_parts.iter()) {
1017 prop_assert_eq!(inp.len(), outp.len());
1018 prop_assert!(outp.chars().all(|c| c.is_ascii_digit()));
1019 }
1020 }
1021
1022 #[test]
1023 fn same_input_always_same_output(s in "[a-z0-9]{4,12}@[a-z]{4,8}\\.com") {
1024 let store = hmac_store();
1025 let out1 = store.get_or_insert(&Category::Email, &s).unwrap();
1026 let out2 = store.get_or_insert(&Category::Email, &s).unwrap();
1027 prop_assert_eq!(out1, out2);
1028 }
1029
1030 #[test]
1031 fn different_categories_produce_different_outputs(s in "[a-z]{6,10}") {
1032 let store = hmac_store();
1033 let as_email = store.get_or_insert(&Category::Email, &format!("{s}@corp.com")).unwrap();
1034 let as_name = store.get_or_insert(&Category::Name, &format!("{s}@corp.com")).unwrap();
1035 prop_assert_ne!(as_email, as_name);
1036 }
1037 }
1038 }
1039}