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(category, entropy, original)
524 }
525}
526
527#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::category::Category;
535 use std::sync::Arc;
536
537 fn test_entropy() -> [u8; 32] {
539 let mut e = [0u8; 32];
540 for (i, b) in e.iter_mut().enumerate() {
541 #[allow(clippy::cast_possible_truncation)] {
543 *b = (i as u8).wrapping_mul(37).wrapping_add(7);
544 }
545 }
546 e
547 }
548
549 #[test]
552 fn strategies_are_deterministic() {
553 let entropy = test_entropy();
554 let strategies: Vec<Box<dyn Strategy>> = vec![
555 Box::new(RandomString::new()),
556 Box::new(RandomUuid::new()),
557 Box::new(FakeIp::new()),
558 Box::new(PreserveLength::new()),
559 Box::new(HmacHash::new([42u8; 32])),
560 Box::new(CategoryAwareStrategy::new()),
561 ];
562 for s in &strategies {
563 let a = s.replace(&Category::AuthToken, "hello world", &entropy);
564 let b = s.replace(&Category::AuthToken, "hello world", &entropy);
565 assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
566 }
567 }
568
569 #[test]
570 fn different_entropy_different_output() {
571 let e1 = [1u8; 32];
572 let e2 = [2u8; 32];
573 let strategies: Vec<Box<dyn Strategy>> = vec![
574 Box::new(RandomString::new()),
575 Box::new(RandomUuid::new()),
576 Box::new(FakeIp::new()),
577 Box::new(PreserveLength::new()),
578 Box::new(CategoryAwareStrategy::new()),
579 ];
580 for s in &strategies {
581 let a = s.replace(&Category::AuthToken, "test", &e1);
582 let b = s.replace(&Category::AuthToken, "test", &e2);
583 assert_ne!(
584 a,
585 b,
586 "strategy '{}' should differ with different entropy",
587 s.name()
588 );
589 }
590 }
591
592 #[test]
595 fn random_string_default_length() {
596 let s = RandomString::new();
597 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
598 assert_eq!(out.len(), 16);
599 assert!(
600 out.chars().all(|c| c.is_ascii_alphanumeric()),
601 "output must be alphanumeric: {}",
602 out,
603 );
604 }
605
606 #[test]
607 fn random_string_custom_length() {
608 let s = RandomString::with_length(8);
609 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
610 assert_eq!(out.len(), 8);
611 }
612
613 #[test]
614 fn random_string_clamped_length() {
615 let s = RandomString::with_length(999);
616 assert_eq!(s.len, 64);
617 let s = RandomString::with_length(0);
618 assert_eq!(s.len, 1);
619 }
620
621 #[test]
624 fn random_uuid_format() {
625 let s = RandomUuid::new();
626 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
627 assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
629 let parts: Vec<&str> = out.split('-').collect();
630 assert_eq!(parts.len(), 5);
631 assert_eq!(parts[0].len(), 8);
632 assert_eq!(parts[1].len(), 4);
633 assert_eq!(parts[2].len(), 4);
634 assert_eq!(parts[3].len(), 4);
635 assert_eq!(parts[4].len(), 12);
636 assert_eq!(&parts[2][0..1], "4", "version must be 4");
638 let variant = &parts[3][0..1];
640 assert!(
641 ["8", "9", "a", "b"].contains(&variant),
642 "variant nibble must be 8/9/a/b, got {}",
643 variant,
644 );
645 }
646
647 #[test]
650 fn fake_ip_format() {
651 let s = FakeIp::new();
652 let input = "192.168.1.1";
653 let out = s.replace(&Category::IpV4, input, &test_entropy());
654 assert_eq!(
656 out.len(),
657 input.len(),
658 "FakeIp must preserve length: {}",
659 out
660 );
661 let in_dots: Vec<usize> = input
663 .char_indices()
664 .filter(|&(_, c)| c == '.')
665 .map(|(i, _)| i)
666 .collect();
667 let out_dots: Vec<usize> = out
668 .char_indices()
669 .filter(|&(_, c)| c == '.')
670 .map(|(i, _)| i)
671 .collect();
672 assert_eq!(out_dots, in_dots, "FakeIp must preserve dot positions");
673 assert!(
675 out.chars().all(|c| c == '.' || c.is_ascii_digit()),
676 "FakeIp output must contain only digits and dots: {}",
677 out
678 );
679 assert_ne!(out, input, "FakeIp must change the IP");
681 }
682
683 #[test]
686 fn preserve_length_matches() {
687 let s = PreserveLength::new();
688 for input in &["a", "hello", "this is a fairly long string indeed", ""] {
689 let out = s.replace(&Category::AuthToken, input, &test_entropy());
690 assert_eq!(
691 out.len(),
692 input.len(),
693 "length mismatch for input '{}'",
694 input,
695 );
696 }
697 }
698
699 #[test]
700 fn preserve_length_characters() {
701 let s = PreserveLength::new();
702 let out = s.replace(&Category::AuthToken, "hello!", &test_entropy());
703 assert!(
704 out.chars().all(|c| c.is_ascii_alphanumeric()),
705 "output must be alphanumeric: {}",
706 out,
707 );
708 }
709
710 #[test]
713 fn hmac_hash_deterministic_with_key() {
714 let s = HmacHash::new([42u8; 32]);
715 let a = s.replace(&Category::AuthToken, "secret", &[0u8; 32]);
716 let b = s.replace(&Category::AuthToken, "secret", &[0xFF; 32]);
717 assert_eq!(a, b, "HmacHash must ignore entropy");
719 }
720
721 #[test]
722 fn hmac_hash_default_length() {
723 let s = HmacHash::new([0u8; 32]);
724 let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
725 assert_eq!(out.len(), 32, "default output is 32 hex chars");
726 assert!(
727 out.chars().all(|c| c.is_ascii_hexdigit()),
728 "output must be hex: {}",
729 out,
730 );
731 }
732
733 #[test]
734 fn hmac_hash_custom_length() {
735 let s = HmacHash::with_output_len([0u8; 32], 12);
736 let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
737 assert_eq!(out.len(), 12);
738 }
739
740 #[test]
741 fn hmac_hash_different_keys() {
742 let s1 = HmacHash::new([1u8; 32]);
743 let s2 = HmacHash::new([2u8; 32]);
744 let a = s1.replace(&Category::AuthToken, "test", &[0u8; 32]);
745 let b = s2.replace(&Category::AuthToken, "test", &[0u8; 32]);
746 assert_ne!(a, b, "different keys must produce different output");
747 }
748
749 #[test]
750 fn hmac_hash_different_inputs() {
751 let s = HmacHash::new([42u8; 32]);
752 let a = s.replace(&Category::AuthToken, "alice", &[0u8; 32]);
753 let b = s.replace(&Category::AuthToken, "bob", &[0u8; 32]);
754 assert_ne!(a, b);
755 }
756
757 #[test]
760 fn strategy_generator_deterministic() {
761 let strat = Box::new(RandomString::new());
762 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
763 let a = gen.generate(&Category::Email, "alice@corp.com");
764 let b = gen.generate(&Category::Email, "alice@corp.com");
765 assert_eq!(a, b, "deterministic mode must be repeatable");
766 }
767
768 #[test]
769 fn strategy_generator_different_categories() {
770 let strat = Box::new(RandomString::new());
771 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
772 let a = gen.generate(&Category::Email, "test");
773 let b = gen.generate(&Category::Name, "test");
774 assert_ne!(a, b, "different categories must produce different entropy");
775 }
776
777 #[test]
778 fn strategy_generator_with_store() {
779 let strat = Box::new(RandomUuid::new());
780 let gen = Arc::new(StrategyGenerator::new(
781 strat,
782 EntropyMode::Deterministic { key: [99u8; 32] },
783 ));
784 let store = crate::store::MappingStore::new(gen, None);
785
786 let s1 = store
787 .get_or_insert(&Category::Email, "alice@corp.com")
788 .unwrap();
789 let s2 = store
790 .get_or_insert(&Category::Email, "alice@corp.com")
791 .unwrap();
792 assert_eq!(s1, s2, "store must cache strategy output");
793 assert_eq!(s1.len(), 36, "output must be UUID-formatted");
794 }
795
796 #[test]
797 fn strategy_generator_random_cached_in_store() {
798 let strat = Box::new(FakeIp::new());
799 let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
800 let store = crate::store::MappingStore::new(gen, None);
801
802 let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
803 let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
804 assert_eq!(s1, s2);
806 assert_eq!(
807 s1.len(),
808 "192.168.1.1".len(),
809 "FakeIp must preserve input length"
810 );
811 }
812
813 #[test]
814 fn all_strategies_implement_send_sync() {
815 fn assert_send_sync<T: Send + Sync>() {}
816 assert_send_sync::<RandomString>();
817 assert_send_sync::<RandomUuid>();
818 assert_send_sync::<FakeIp>();
819 assert_send_sync::<PreserveLength>();
820 assert_send_sync::<HmacHash>();
821 assert_send_sync::<CategoryAwareStrategy>();
822 assert_send_sync::<StrategyGenerator>();
823 }
824
825 #[test]
826 fn strategy_names_unique() {
827 let strategies: Vec<Box<dyn Strategy>> = vec![
828 Box::new(RandomString::new()),
829 Box::new(RandomUuid::new()),
830 Box::new(FakeIp::new()),
831 Box::new(PreserveLength::new()),
832 Box::new(HmacHash::new([0u8; 32])),
833 Box::new(CategoryAwareStrategy::new()),
834 ];
835 let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
836 let len_before = names.len();
837 names.sort_unstable();
838 names.dedup();
839 assert_eq!(names.len(), len_before, "strategy names must be unique");
840 }
841
842 #[test]
845 fn concurrent_strategy_generator() {
846 use std::thread;
847
848 let strat = Box::new(PreserveLength::new());
849 let gen = Arc::new(StrategyGenerator::new(
850 strat,
851 EntropyMode::Deterministic { key: [7u8; 32] },
852 ));
853 let store = Arc::new(crate::store::MappingStore::new(gen, None));
854
855 let mut handles = vec![];
856 for t in 0..4 {
857 let store = Arc::clone(&store);
858 handles.push(thread::spawn(move || {
859 for i in 0..500 {
860 let val = format!("thread{}-val{}", t, i);
861 let result = store.get_or_insert(&Category::Name, &val).unwrap();
862 assert_eq!(
863 result.len(),
864 val.len(),
865 "PreserveLength must match input length",
866 );
867 }
868 }));
869 }
870 for h in handles {
871 h.join().unwrap();
872 }
873 assert_eq!(store.len(), 2000);
874 }
875
876 #[test]
879 fn category_aware_email_shaped() {
880 let s = CategoryAwareStrategy::new();
881 let input = "alice@corp.com";
882 let out = s.replace(&Category::Email, input, &test_entropy());
883 assert_eq!(out.len(), input.len(), "length must be preserved");
884 assert!(out.contains('@'), "output must be email-shaped");
885 assert!(out.ends_with("@corp.com"), "domain must be preserved");
886 }
887
888 #[test]
889 fn category_aware_length_preserved_across_categories() {
890 let s = CategoryAwareStrategy::new();
891 let cases = [
892 (Category::Email, "alice@corp.com"),
893 (Category::IpV4, "192.168.1.1"),
894 (Category::AuthToken, "ghp_abc123secrettoken"),
895 (Category::Hostname, "db-prod.internal"),
896 ];
897 for (cat, input) in &cases {
898 let out = s.replace(cat, input, &test_entropy());
899 assert_eq!(out.len(), input.len(), "length mismatch for {:?}", cat);
900 assert_ne!(out, *input, "output must differ from input for {:?}", cat);
901 }
902 }
903
904 #[test]
905 fn category_aware_random_mode_consistent_within_run() {
906 let gen = Arc::new(StrategyGenerator::new(
909 Box::new(CategoryAwareStrategy::new()),
910 EntropyMode::Random,
911 ));
912 let store = crate::store::MappingStore::new(gen, None);
913 let r1 = store
914 .get_or_insert(&Category::Email, "alice@corp.com")
915 .unwrap();
916 let r2 = store
917 .get_or_insert(&Category::Email, "alice@corp.com")
918 .unwrap();
919 assert_eq!(
920 r1, r2,
921 "store cache must return same replacement within run"
922 );
923 assert!(r1.contains('@'), "replacement must be email-shaped");
924 assert_eq!(r1.len(), "alice@corp.com".len(), "length must be preserved");
925 }
926
927 #[test]
928 fn category_aware_deterministic() {
929 let s = CategoryAwareStrategy::new();
930 let entropy = test_entropy();
931 let a = s.replace(&Category::Email, "alice@corp.com", &entropy);
932 let b = s.replace(&Category::Email, "alice@corp.com", &entropy);
933 assert_eq!(a, b, "category_aware must be deterministic");
934 }
935
936 mod property {
939 use super::*;
940 use crate::store::MappingStore;
941 use proptest::prelude::*;
942
943 fn hmac_store() -> MappingStore {
944 let gen = Arc::new(crate::generator::HmacGenerator::new([77u8; 32]));
945 MappingStore::new(gen, None)
946 }
947
948 fn category_aware_store() -> MappingStore {
949 let gen = Arc::new(StrategyGenerator::new(
950 Box::new(CategoryAwareStrategy::new()),
951 EntropyMode::Deterministic { key: [77u8; 32] },
952 ));
953 MappingStore::new(gen, None)
954 }
955
956 proptest! {
957 #[test]
958 fn category_aware_length_preserved(s in "[a-z0-9]{1,64}") {
959 let store = category_aware_store();
960 let out = store.get_or_insert(&Category::AuthToken, &s).unwrap();
961 prop_assert_eq!(out.len(), s.len());
962 }
963
964 #[test]
965 fn category_aware_email_shaped(
966 local in "[a-z]{3,8}",
967 domain in "[a-z]{3,8}",
968 tld in "[a-z]{2,4}",
969 ) {
970 let input = format!("{local}@{domain}.{tld}");
971 let store = category_aware_store();
972 let out = store.get_or_insert(&Category::Email, &input).unwrap();
973 prop_assert_eq!(out.len(), input.len());
974 prop_assert!(out.contains('@'));
975 let after_at = out.split('@').nth(1).unwrap_or("");
976 prop_assert!(after_at.contains('.'));
977 }
978
979 #[test]
980 fn email_output_is_email_shaped(
981 local in "[a-z]{3,8}",
982 domain in "[a-z]{3,8}",
983 tld in "[a-z]{2,4}",
984 ) {
985 let input = format!("{local}@{domain}.{tld}");
986 let store = hmac_store();
987 let out = store.get_or_insert(&Category::Email, &input).unwrap();
988 prop_assert_eq!(out.chars().filter(|&c| c == '@').count(), 1);
989 let after = out.split('@').nth(1).unwrap_or("");
990 prop_assert!(after.contains('.'), "no dot in domain part: {out}");
991 prop_assert_eq!(out.len(), input.len());
992 }
993
994 #[test]
995 fn ipv4_output_preserves_dot_structure(
996 a in 0u8..=255u8,
997 b in 0u8..=255u8,
998 c in 0u8..=255u8,
999 d in 0u8..=255u8,
1000 ) {
1001 let input = format!("{a}.{b}.{c}.{d}");
1002 let store = hmac_store();
1003 let out = store.get_or_insert(&Category::IpV4, &input).unwrap();
1004 let in_parts: Vec<&str> = input.split('.').collect();
1009 let out_parts: Vec<&str> = out.split('.').collect();
1010 prop_assert_eq!(out_parts.len(), 4);
1011 for (inp, outp) in in_parts.iter().zip(out_parts.iter()) {
1012 prop_assert_eq!(inp.len(), outp.len());
1013 prop_assert!(outp.chars().all(|c| c.is_ascii_digit()));
1014 }
1015 }
1016
1017 #[test]
1018 fn same_input_always_same_output(s in "[a-z0-9]{4,12}@[a-z]{4,8}\\.com") {
1019 let store = hmac_store();
1020 let out1 = store.get_or_insert(&Category::Email, &s).unwrap();
1021 let out2 = store.get_or_insert(&Category::Email, &s).unwrap();
1022 prop_assert_eq!(out1, out2);
1023 }
1024
1025 #[test]
1026 fn different_categories_produce_different_outputs(s in "[a-z]{6,10}") {
1027 let store = hmac_store();
1028 let as_email = store.get_or_insert(&Category::Email, &format!("{s}@corp.com")).unwrap();
1029 let as_name = store.get_or_insert(&Category::Name, &format!("{s}@corp.com")).unwrap();
1030 prop_assert_ne!(as_email, as_name);
1031 }
1032 }
1033 }
1034}