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 {
96 fn name(&self) -> &'static str;
98
99 fn replace(&self, category: &Category, original: &str, entropy: &[u8; 32]) -> String;
107}
108
109#[derive(Debug)]
115#[non_exhaustive]
116pub enum EntropyMode {
117 #[non_exhaustive]
119 Deterministic {
120 key: [u8; 32],
122 },
123 Random,
125}
126
127impl EntropyMode {
128 #[must_use]
131 pub fn deterministic(key: [u8; 32]) -> Self {
132 Self::Deterministic { key }
133 }
134}
135
136impl Drop for EntropyMode {
137 fn drop(&mut self) {
138 if let EntropyMode::Deterministic { ref mut key } = self {
139 key.zeroize();
140 }
141 }
142}
143
144pub struct StrategyGenerator {
154 strategy: Box<dyn Strategy>,
155 mode: EntropyMode,
156}
157
158impl StrategyGenerator {
159 #[must_use]
166 pub fn new(strategy: Box<dyn Strategy>, mode: EntropyMode) -> Self {
167 Self { strategy, mode }
168 }
169
170 fn entropy(&self, category: &Category, original: &str) -> [u8; 32] {
172 match &self.mode {
173 EntropyMode::Deterministic { key } => {
174 type HmacSha256 = Hmac<Sha256>;
175 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
176 let tag = category.domain_tag_hmac();
177 mac.update(tag.as_bytes());
178 mac.update(b"\x00");
179 mac.update(original.as_bytes());
180 let result = mac.finalize();
181 let mut out = [0u8; 32];
182 out.copy_from_slice(&result.into_bytes());
183 out
184 }
185 EntropyMode::Random => {
186 let mut buf = [0u8; 32];
187 rand::rng().fill(&mut buf);
188 buf
189 }
190 }
191 }
192
193 #[must_use]
195 pub fn strategy(&self) -> &dyn Strategy {
196 self.strategy.as_ref()
197 }
198}
199
200impl ReplacementGenerator for StrategyGenerator {
201 fn generate(&self, category: &Category, original: &str) -> String {
202 let entropy = self.entropy(category, original);
203 self.strategy.replace(category, original, &entropy)
204 }
205}
206
207#[inline]
217fn xorshift64_seed(entropy: &[u8; 32]) -> u64 {
218 let mut state = 0u64;
219 for chunk in entropy.chunks_exact(8) {
220 let arr: [u8; 8] = chunk
221 .try_into()
222 .expect("chunks_exact(8) yields 8-byte slices");
223 state = state.wrapping_add(u64::from_le_bytes(arr));
224 }
225 if state == 0 {
226 state = 0xDEAD_BEEF_CAFE_BABE;
227 }
228 state
229}
230
231#[inline]
233fn xorshift64_step(state: &mut u64) {
234 *state ^= *state << 13;
235 *state ^= *state >> 7;
236 *state ^= *state << 17;
237}
238
239pub struct RandomString {
248 len: usize,
250}
251
252impl RandomString {
253 #[must_use]
255 pub fn new() -> Self {
256 Self { len: 16 }
257 }
258
259 #[must_use]
261 pub fn with_length(len: usize) -> Self {
262 Self {
263 len: len.clamp(1, 64),
264 }
265 }
266}
267
268impl Default for RandomString {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274impl Strategy for RandomString {
275 fn name(&self) -> &'static str {
276 "random_string"
277 }
278
279 fn replace(&self, _category: &Category, _original: &str, entropy: &[u8; 32]) -> String {
280 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
281 ABCDEFGHIJKLMNOPQRSTUVWXYZ\
282 0123456789";
283 let mut chars = String::with_capacity(self.len);
284 let mut state = xorshift64_seed(entropy);
285
286 for _ in 0..self.len {
287 xorshift64_step(&mut state);
288 #[allow(clippy::cast_possible_truncation)]
289 let idx = (state as usize) % CHARSET.len();
291 chars.push(CHARSET[idx] as char);
292 }
293 chars
294 }
295}
296
297pub struct RandomUuid;
307
308impl RandomUuid {
309 #[must_use]
310 pub fn new() -> Self {
311 Self
312 }
313}
314
315impl Default for RandomUuid {
316 fn default() -> Self {
317 Self::new()
318 }
319}
320
321impl Strategy for RandomUuid {
322 fn name(&self) -> &'static str {
323 "random_uuid"
324 }
325
326 fn replace(&self, _category: &Category, _original: &str, entropy: &[u8; 32]) -> String {
327 let mut bytes = [0u8; 16];
329 bytes.copy_from_slice(&entropy[..16]);
330
331 bytes[6] = (bytes[6] & 0x0F) | 0x40;
333 bytes[8] = (bytes[8] & 0x3F) | 0x80;
335
336 format!(
337 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
338 bytes[0], bytes[1], bytes[2], bytes[3],
339 bytes[4], bytes[5],
340 bytes[6], bytes[7],
341 bytes[8], bytes[9],
342 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
343 )
344 }
345}
346
347pub struct FakeIp;
358
359impl FakeIp {
360 #[must_use]
361 pub fn new() -> Self {
362 Self
363 }
364}
365
366impl Default for FakeIp {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372impl Strategy for FakeIp {
373 fn name(&self) -> &'static str {
374 "fake_ip"
375 }
376
377 fn replace(&self, _category: &Category, original: &str, entropy: &[u8; 32]) -> String {
378 let mut buf = String::with_capacity(original.len());
381 let mut hi = 0usize;
382 for ch in original.chars() {
383 if ch == '.' {
384 buf.push('.');
385 } else {
386 buf.push((b'0' + entropy[hi % 32] % 10) as char);
387 hi += 1;
388 }
389 }
390 buf
391 }
392}
393
394pub struct PreserveLength;
403
404impl PreserveLength {
405 #[must_use]
406 pub fn new() -> Self {
407 Self
408 }
409}
410
411impl Default for PreserveLength {
412 fn default() -> Self {
413 Self::new()
414 }
415}
416
417impl Strategy for PreserveLength {
418 fn name(&self) -> &'static str {
419 "preserve_length"
420 }
421
422 fn replace(&self, _category: &Category, original: &str, entropy: &[u8; 32]) -> String {
423 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
424
425 let target_len = original.len();
426 if target_len == 0 {
427 return String::new();
428 }
429
430 let mut state = xorshift64_seed(entropy);
431 let mut result = String::with_capacity(target_len);
432 for _ in 0..target_len {
433 xorshift64_step(&mut state);
434 #[allow(clippy::cast_possible_truncation)]
435 let idx = (state as usize) % CHARSET.len();
437 result.push(CHARSET[idx] as char);
438 }
439 result
440 }
441}
442
443pub struct HmacHash {
457 key: [u8; 32],
458 output_len: usize,
460}
461
462impl HmacHash {
463 #[must_use]
465 pub fn new(key: [u8; 32]) -> Self {
466 Self {
467 key,
468 output_len: 32,
469 }
470 }
471
472 #[must_use]
474 pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
475 Self {
476 key,
477 output_len: output_len.clamp(1, 64),
478 }
479 }
480}
481
482impl Strategy for HmacHash {
483 fn name(&self) -> &'static str {
484 "hmac_hash"
485 }
486
487 fn replace(&self, _category: &Category, original: &str, _entropy: &[u8; 32]) -> String {
488 use std::fmt::Write;
489
490 type HmacSha256 = Hmac<Sha256>;
491 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
492 mac.update(original.as_bytes());
493 let result = mac.finalize();
494 let hash_bytes: [u8; 32] = {
495 let mut buf = [0u8; 32];
496 buf.copy_from_slice(&result.into_bytes());
497 buf
498 };
499 let mut hex = String::with_capacity(64);
500 for b in &hash_bytes {
501 let _ = write!(hex, "{:02x}", b);
502 }
503 hex[..self.output_len].to_string()
504 }
505}
506
507pub struct CategoryAwareStrategy;
520
521impl CategoryAwareStrategy {
522 #[must_use]
523 pub fn new() -> Self {
524 Self
525 }
526}
527
528impl Default for CategoryAwareStrategy {
529 fn default() -> Self {
530 Self::new()
531 }
532}
533
534impl Strategy for CategoryAwareStrategy {
535 fn name(&self) -> &'static str {
536 "category_aware"
537 }
538
539 fn replace(&self, category: &Category, original: &str, entropy: &[u8; 32]) -> String {
540 crate::generator::format_replacement(
541 category,
542 entropy,
543 original,
544 crate::generator::LengthPolicy::Preserve,
545 )
546 }
547}
548
549#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::category::Category;
557 use std::sync::Arc;
558
559 fn test_entropy() -> [u8; 32] {
561 let mut e = [0u8; 32];
562 for (i, b) in e.iter_mut().enumerate() {
563 #[allow(clippy::cast_possible_truncation)] {
565 *b = (i as u8).wrapping_mul(37).wrapping_add(7);
566 }
567 }
568 e
569 }
570
571 #[test]
574 fn strategies_are_deterministic() {
575 let entropy = test_entropy();
576 let strategies: Vec<Box<dyn Strategy>> = vec![
577 Box::new(RandomString::new()),
578 Box::new(RandomUuid::new()),
579 Box::new(FakeIp::new()),
580 Box::new(PreserveLength::new()),
581 Box::new(HmacHash::new([42u8; 32])),
582 Box::new(CategoryAwareStrategy::new()),
583 ];
584 for s in &strategies {
585 let a = s.replace(&Category::AuthToken, "hello world", &entropy);
586 let b = s.replace(&Category::AuthToken, "hello world", &entropy);
587 assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
588 }
589 }
590
591 #[test]
592 fn different_entropy_different_output() {
593 let e1 = [1u8; 32];
594 let e2 = [2u8; 32];
595 let strategies: Vec<Box<dyn Strategy>> = vec![
596 Box::new(RandomString::new()),
597 Box::new(RandomUuid::new()),
598 Box::new(FakeIp::new()),
599 Box::new(PreserveLength::new()),
600 Box::new(CategoryAwareStrategy::new()),
601 ];
602 for s in &strategies {
603 let a = s.replace(&Category::AuthToken, "test", &e1);
604 let b = s.replace(&Category::AuthToken, "test", &e2);
605 assert_ne!(
606 a,
607 b,
608 "strategy '{}' should differ with different entropy",
609 s.name()
610 );
611 }
612 }
613
614 #[test]
617 fn random_string_default_length() {
618 let s = RandomString::new();
619 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
620 assert_eq!(out.len(), 16);
621 assert!(
622 out.chars().all(|c| c.is_ascii_alphanumeric()),
623 "output must be alphanumeric: {}",
624 out,
625 );
626 }
627
628 #[test]
629 fn random_string_custom_length() {
630 let s = RandomString::with_length(8);
631 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
632 assert_eq!(out.len(), 8);
633 }
634
635 #[test]
636 fn random_string_clamped_length() {
637 let s = RandomString::with_length(999);
638 assert_eq!(s.len, 64);
639 let s = RandomString::with_length(0);
640 assert_eq!(s.len, 1);
641 }
642
643 #[test]
646 fn random_uuid_format() {
647 let s = RandomUuid::new();
648 let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
649 assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
651 let parts: Vec<&str> = out.split('-').collect();
652 assert_eq!(parts.len(), 5);
653 assert_eq!(parts[0].len(), 8);
654 assert_eq!(parts[1].len(), 4);
655 assert_eq!(parts[2].len(), 4);
656 assert_eq!(parts[3].len(), 4);
657 assert_eq!(parts[4].len(), 12);
658 assert_eq!(&parts[2][0..1], "4", "version must be 4");
660 let variant = &parts[3][0..1];
662 assert!(
663 ["8", "9", "a", "b"].contains(&variant),
664 "variant nibble must be 8/9/a/b, got {}",
665 variant,
666 );
667 }
668
669 #[test]
672 fn fake_ip_format() {
673 let s = FakeIp::new();
674 let input = "192.168.1.1";
675 let out = s.replace(&Category::IpV4, input, &test_entropy());
676 assert_eq!(
678 out.len(),
679 input.len(),
680 "FakeIp must preserve length: {}",
681 out
682 );
683 let in_dots: Vec<usize> = input
685 .char_indices()
686 .filter(|&(_, c)| c == '.')
687 .map(|(i, _)| i)
688 .collect();
689 let out_dots: Vec<usize> = out
690 .char_indices()
691 .filter(|&(_, c)| c == '.')
692 .map(|(i, _)| i)
693 .collect();
694 assert_eq!(out_dots, in_dots, "FakeIp must preserve dot positions");
695 assert!(
697 out.chars().all(|c| c == '.' || c.is_ascii_digit()),
698 "FakeIp output must contain only digits and dots: {}",
699 out
700 );
701 assert_ne!(out, input, "FakeIp must change the IP");
703 }
704
705 #[test]
708 fn preserve_length_matches() {
709 let s = PreserveLength::new();
710 for input in &["a", "hello", "this is a fairly long string indeed", ""] {
711 let out = s.replace(&Category::AuthToken, input, &test_entropy());
712 assert_eq!(
713 out.len(),
714 input.len(),
715 "length mismatch for input '{}'",
716 input,
717 );
718 }
719 }
720
721 #[test]
722 fn preserve_length_characters() {
723 let s = PreserveLength::new();
724 let out = s.replace(&Category::AuthToken, "hello!", &test_entropy());
725 assert!(
726 out.chars().all(|c| c.is_ascii_alphanumeric()),
727 "output must be alphanumeric: {}",
728 out,
729 );
730 }
731
732 #[test]
735 fn hmac_hash_deterministic_with_key() {
736 let s = HmacHash::new([42u8; 32]);
737 let a = s.replace(&Category::AuthToken, "secret", &[0u8; 32]);
738 let b = s.replace(&Category::AuthToken, "secret", &[0xFF; 32]);
739 assert_eq!(a, b, "HmacHash must ignore entropy");
741 }
742
743 #[test]
744 fn hmac_hash_default_length() {
745 let s = HmacHash::new([0u8; 32]);
746 let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
747 assert_eq!(out.len(), 32, "default output is 32 hex chars");
748 assert!(
749 out.chars().all(|c| c.is_ascii_hexdigit()),
750 "output must be hex: {}",
751 out,
752 );
753 }
754
755 #[test]
756 fn hmac_hash_custom_length() {
757 let s = HmacHash::with_output_len([0u8; 32], 12);
758 let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
759 assert_eq!(out.len(), 12);
760 }
761
762 #[test]
763 fn hmac_hash_different_keys() {
764 let s1 = HmacHash::new([1u8; 32]);
765 let s2 = HmacHash::new([2u8; 32]);
766 let a = s1.replace(&Category::AuthToken, "test", &[0u8; 32]);
767 let b = s2.replace(&Category::AuthToken, "test", &[0u8; 32]);
768 assert_ne!(a, b, "different keys must produce different output");
769 }
770
771 #[test]
772 fn hmac_hash_different_inputs() {
773 let s = HmacHash::new([42u8; 32]);
774 let a = s.replace(&Category::AuthToken, "alice", &[0u8; 32]);
775 let b = s.replace(&Category::AuthToken, "bob", &[0u8; 32]);
776 assert_ne!(a, b);
777 }
778
779 #[test]
782 fn strategy_generator_deterministic() {
783 let strat = Box::new(RandomString::new());
784 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
785 let a = gen.generate(&Category::Email, "alice@corp.com");
786 let b = gen.generate(&Category::Email, "alice@corp.com");
787 assert_eq!(a, b, "deterministic mode must be repeatable");
788 }
789
790 #[test]
791 fn strategy_generator_different_categories() {
792 let strat = Box::new(RandomString::new());
793 let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
794 let a = gen.generate(&Category::Email, "test");
795 let b = gen.generate(&Category::Name, "test");
796 assert_ne!(a, b, "different categories must produce different entropy");
797 }
798
799 #[test]
800 fn strategy_generator_with_store() {
801 let strat = Box::new(RandomUuid::new());
802 let gen = Arc::new(StrategyGenerator::new(
803 strat,
804 EntropyMode::Deterministic { key: [99u8; 32] },
805 ));
806 let store = crate::store::MappingStore::new(gen, None);
807
808 let s1 = store
809 .get_or_insert(&Category::Email, "alice@corp.com")
810 .unwrap();
811 let s2 = store
812 .get_or_insert(&Category::Email, "alice@corp.com")
813 .unwrap();
814 assert_eq!(s1, s2, "store must cache strategy output");
815 assert_eq!(s1.len(), 36, "output must be UUID-formatted");
816 }
817
818 #[test]
819 fn strategy_generator_random_cached_in_store() {
820 let strat = Box::new(FakeIp::new());
821 let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
822 let store = crate::store::MappingStore::new(gen, None);
823
824 let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
825 let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
826 assert_eq!(s1, s2);
828 assert_eq!(
829 s1.len(),
830 "192.168.1.1".len(),
831 "FakeIp must preserve input length"
832 );
833 }
834
835 #[test]
836 fn all_strategies_implement_send_sync() {
837 fn assert_send_sync<T: Send + Sync>() {}
838 assert_send_sync::<RandomString>();
839 assert_send_sync::<RandomUuid>();
840 assert_send_sync::<FakeIp>();
841 assert_send_sync::<PreserveLength>();
842 assert_send_sync::<HmacHash>();
843 assert_send_sync::<CategoryAwareStrategy>();
844 assert_send_sync::<StrategyGenerator>();
845 }
846
847 #[test]
848 fn strategy_names_unique() {
849 let strategies: Vec<Box<dyn Strategy>> = vec![
850 Box::new(RandomString::new()),
851 Box::new(RandomUuid::new()),
852 Box::new(FakeIp::new()),
853 Box::new(PreserveLength::new()),
854 Box::new(HmacHash::new([0u8; 32])),
855 Box::new(CategoryAwareStrategy::new()),
856 ];
857 let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
858 let len_before = names.len();
859 names.sort_unstable();
860 names.dedup();
861 assert_eq!(names.len(), len_before, "strategy names must be unique");
862 }
863
864 #[test]
867 fn concurrent_strategy_generator() {
868 use std::thread;
869
870 let strat = Box::new(PreserveLength::new());
871 let gen = Arc::new(StrategyGenerator::new(
872 strat,
873 EntropyMode::Deterministic { key: [7u8; 32] },
874 ));
875 let store = Arc::new(crate::store::MappingStore::new(gen, None));
876
877 let mut handles = vec![];
878 for t in 0..4 {
879 let store = Arc::clone(&store);
880 handles.push(thread::spawn(move || {
881 for i in 0..500 {
882 let val = format!("thread{}-val{}", t, i);
883 let result = store.get_or_insert(&Category::Name, &val).unwrap();
884 assert_eq!(
885 result.len(),
886 val.len(),
887 "PreserveLength must match input length",
888 );
889 }
890 }));
891 }
892 for h in handles {
893 h.join().unwrap();
894 }
895 assert_eq!(store.len(), 2000);
896 }
897
898 #[test]
901 fn category_aware_email_shaped() {
902 let s = CategoryAwareStrategy::new();
903 let input = "alice@corp.com";
904 let out = s.replace(&Category::Email, input, &test_entropy());
905 assert_eq!(out.len(), input.len(), "length must be preserved");
906 assert!(out.contains('@'), "output must be email-shaped");
907 assert!(out.ends_with("@corp.com"), "domain must be preserved");
908 }
909
910 #[test]
911 fn category_aware_length_preserved_across_categories() {
912 let s = CategoryAwareStrategy::new();
913 let cases = [
914 (Category::Email, "alice@corp.com"),
915 (Category::IpV4, "192.168.1.1"),
916 (Category::AuthToken, "ghp_abc123secrettoken"),
917 (Category::Hostname, "db-prod.internal"),
918 ];
919 for (cat, input) in &cases {
920 let out = s.replace(cat, input, &test_entropy());
921 assert_eq!(out.len(), input.len(), "length mismatch for {:?}", cat);
922 assert_ne!(out, *input, "output must differ from input for {:?}", cat);
923 }
924 }
925
926 #[test]
927 fn category_aware_random_mode_consistent_within_run() {
928 let gen = Arc::new(StrategyGenerator::new(
931 Box::new(CategoryAwareStrategy::new()),
932 EntropyMode::Random,
933 ));
934 let store = crate::store::MappingStore::new(gen, None);
935 let r1 = store
936 .get_or_insert(&Category::Email, "alice@corp.com")
937 .unwrap();
938 let r2 = store
939 .get_or_insert(&Category::Email, "alice@corp.com")
940 .unwrap();
941 assert_eq!(
942 r1, r2,
943 "store cache must return same replacement within run"
944 );
945 assert!(r1.contains('@'), "replacement must be email-shaped");
946 assert_eq!(r1.len(), "alice@corp.com".len(), "length must be preserved");
947 }
948
949 #[test]
950 fn category_aware_deterministic() {
951 let s = CategoryAwareStrategy::new();
952 let entropy = test_entropy();
953 let a = s.replace(&Category::Email, "alice@corp.com", &entropy);
954 let b = s.replace(&Category::Email, "alice@corp.com", &entropy);
955 assert_eq!(a, b, "category_aware must be deterministic");
956 }
957
958 mod property {
961 use super::*;
962 use crate::store::MappingStore;
963 use proptest::prelude::*;
964
965 fn hmac_store() -> MappingStore {
966 let gen = Arc::new(crate::generator::HmacGenerator::new([77u8; 32]));
967 MappingStore::new(gen, None)
968 }
969
970 fn category_aware_store() -> MappingStore {
971 let gen = Arc::new(StrategyGenerator::new(
972 Box::new(CategoryAwareStrategy::new()),
973 EntropyMode::Deterministic { key: [77u8; 32] },
974 ));
975 MappingStore::new(gen, None)
976 }
977
978 proptest! {
979 #[test]
980 fn category_aware_length_preserved(s in "[a-z0-9]{1,64}") {
981 let store = category_aware_store();
982 let out = store.get_or_insert(&Category::AuthToken, &s).unwrap();
983 prop_assert_eq!(out.len(), s.len());
984 }
985
986 #[test]
987 fn category_aware_email_shaped(
988 local in "[a-z]{3,8}",
989 domain in "[a-z]{3,8}",
990 tld in "[a-z]{2,4}",
991 ) {
992 let input = format!("{local}@{domain}.{tld}");
993 let store = category_aware_store();
994 let out = store.get_or_insert(&Category::Email, &input).unwrap();
995 prop_assert_eq!(out.len(), input.len());
996 prop_assert!(out.contains('@'));
997 let after_at = out.split('@').nth(1).unwrap_or("");
998 prop_assert!(after_at.contains('.'));
999 }
1000
1001 #[test]
1002 fn email_output_is_email_shaped(
1003 local in "[a-z]{3,8}",
1004 domain in "[a-z]{3,8}",
1005 tld in "[a-z]{2,4}",
1006 ) {
1007 let input = format!("{local}@{domain}.{tld}");
1008 let store = hmac_store();
1009 let out = store.get_or_insert(&Category::Email, &input).unwrap();
1010 prop_assert_eq!(out.chars().filter(|&c| c == '@').count(), 1);
1011 let after = out.split('@').nth(1).unwrap_or("");
1012 prop_assert!(after.contains('.'), "no dot in domain part: {out}");
1013 prop_assert_eq!(out.len(), input.len());
1014 }
1015
1016 #[test]
1017 fn ipv4_output_preserves_dot_structure(
1018 a in 0u8..=255u8,
1019 b in 0u8..=255u8,
1020 c in 0u8..=255u8,
1021 d in 0u8..=255u8,
1022 ) {
1023 let input = format!("{a}.{b}.{c}.{d}");
1024 let store = hmac_store();
1025 let out = store.get_or_insert(&Category::IpV4, &input).unwrap();
1026 let in_parts: Vec<&str> = input.split('.').collect();
1030 let out_parts: Vec<&str> = out.split('.').collect();
1031 prop_assert_eq!(out_parts.len(), 4);
1032 for (inp, outp) in in_parts.iter().zip(out_parts.iter()) {
1033 prop_assert_eq!(inp.len(), outp.len());
1034 let octet: Result<u8, _> = outp.parse();
1035 prop_assert!(octet.is_ok(), "invalid octet {outp} in {out}");
1036 }
1037 }
1038
1039 #[test]
1040 fn same_input_always_same_output(s in "[a-z0-9]{4,12}@[a-z]{4,8}\\.com") {
1041 let store = hmac_store();
1042 let out1 = store.get_or_insert(&Category::Email, &s).unwrap();
1043 let out2 = store.get_or_insert(&Category::Email, &s).unwrap();
1044 prop_assert_eq!(out1, out2);
1045 }
1046
1047 #[test]
1048 fn different_categories_produce_different_outputs(s in "[a-z]{6,10}") {
1049 let store = hmac_store();
1050 let as_email = store.get_or_insert(&Category::Email, &format!("{s}@corp.com")).unwrap();
1051 let as_name = store.get_or_insert(&Category::Name, &format!("{s}@corp.com")).unwrap();
1052 prop_assert_ne!(as_email, as_name);
1053 }
1054 }
1055 }
1056}