1use crate::category::Category;
17use hmac::{Hmac, Mac};
18use rand::Rng;
19use sha2::Sha256;
20use zeroize::Zeroize;
21
22pub trait ReplacementGenerator: Send + Sync {
33 fn generate(&self, category: &Category, original: &str) -> String;
35}
36
37#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
49pub enum LengthPolicy {
50 #[default]
52 Preserve,
53 Randomized,
56}
57
58pub struct HmacGenerator {
71 key: [u8; 32],
72 policy: LengthPolicy,
73}
74
75impl Drop for HmacGenerator {
76 fn drop(&mut self) {
77 self.key.zeroize();
78 }
79}
80
81impl HmacGenerator {
82 #[must_use]
84 pub fn new(key: [u8; 32]) -> Self {
85 Self {
86 key,
87 policy: LengthPolicy::Preserve,
88 }
89 }
90
91 pub fn from_slice(bytes: &[u8]) -> crate::error::Result<Self> {
97 if bytes.len() != 32 {
98 return Err(crate::error::SanitizeError::InvalidSeedLength(bytes.len()));
99 }
100 let mut key = [0u8; 32];
101 key.copy_from_slice(bytes);
102 Ok(Self {
103 key,
104 policy: LengthPolicy::Preserve,
105 })
106 }
107
108 #[must_use]
110 pub fn with_length_policy(mut self, policy: LengthPolicy) -> Self {
111 self.policy = policy;
112 self
113 }
114
115 fn derive(&self, category: &Category, original: &str) -> [u8; 32] {
117 type HmacSha256 = Hmac<Sha256>;
118 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
119 let tag = category.domain_tag_hmac();
120 mac.update(tag.as_bytes());
121 mac.update(b"\x00"); mac.update(original.as_bytes());
123 let result = mac.finalize();
124 let mut out = [0u8; 32];
125 out.copy_from_slice(&result.into_bytes());
126 out
127 }
128}
129
130impl ReplacementGenerator for HmacGenerator {
131 fn generate(&self, category: &Category, original: &str) -> String {
132 let hash = self.derive(category, original);
133 format_replacement(category, &hash, original, self.policy)
134 }
135}
136
137pub struct RandomGenerator {
147 policy: LengthPolicy,
148}
149
150impl RandomGenerator {
151 #[must_use]
152 pub fn new() -> Self {
153 Self {
154 policy: LengthPolicy::Preserve,
155 }
156 }
157
158 #[must_use]
160 pub fn with_length_policy(mut self, policy: LengthPolicy) -> Self {
161 self.policy = policy;
162 self
163 }
164}
165
166impl Default for RandomGenerator {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172impl ReplacementGenerator for RandomGenerator {
173 fn generate(&self, category: &Category, original: &str) -> String {
174 let mut rng = rand::rng();
175 let mut hash = [0u8; 32];
176 rng.fill(&mut hash);
177 format_replacement(category, &hash, original, self.policy)
178 }
179}
180
181pub(crate) fn format_replacement(
193 category: &Category,
194 hash: &[u8; 32],
195 original: &str,
196 policy: LengthPolicy,
197) -> String {
198 let randomized = matches!(policy, LengthPolicy::Randomized);
199 let target = match policy {
200 LengthPolicy::Preserve => original.len(),
201 LengthPolicy::Randomized => {
202 randomized_target(category, hash, original).unwrap_or(original.len())
203 }
204 };
205 if target == 0 {
206 return String::new();
207 }
208 let hex = hex_bytes(hash);
209 match category {
210 Category::Email => format_email_lp(&hex, original, target),
211 Category::Name => format_name_lp(hash, &hex, target),
212 Category::Phone | Category::CreditCard if randomized => {
215 format_digits_synth(hash, target, false)
216 }
217 Category::Ssn if randomized => format_digits_synth(hash, target, true),
218 Category::Phone | Category::CreditCard | Category::IpV4 => {
219 format_digits_lp(hash, original, target)
220 }
221 Category::IpV6 | Category::MacAddress | Category::Uuid | Category::ContainerId => {
222 format_hex_digits_lp(hash, original, target)
223 }
224 Category::Ssn => format_ssn_lp(hash, original, target),
225 Category::Hostname => format_hostname_lp(&hex, original, target),
226 Category::Jwt => format_jwt_lp(hash, original, target),
227 Category::FilePath => format_filepath_lp(&hex, original, target, randomized),
228 Category::WindowsSid => format_windows_sid_lp(hash, original, target),
229 Category::Url => format_url_lp(&hex, original, target, randomized),
230 Category::AwsArn => format_arn_lp(&hex, original, target, randomized),
231 Category::AzureResourceId => {
232 format_azure_resource_id_lp(&hex, original, target, randomized)
233 }
234 Category::AuthToken | Category::Custom(_) => format_custom_lp(&hex, target),
235 }
236}
237
238const DIGITS_BAND: (usize, usize) = (8, 18);
245const EMAIL_USER_BAND: (usize, usize) = (6, 16);
246const HOSTNAME_PREFIX_BAND: (usize, usize) = (6, 16);
247const NAME_BAND: (usize, usize) = (12, 28);
248const TOKEN_BAND: (usize, usize) = (24, 48);
249const SEGMENT_BAND: (usize, usize) = (6, 20);
250
251fn band_pick(bytes: &[u8], idx: usize, lo: usize, hi: usize) -> usize {
255 debug_assert!(lo <= hi);
256 debug_assert!(!bytes.is_empty());
257 let span = (hi - lo + 1) as u64;
258 let mut w = 0u64;
259 for k in 0..8 {
260 let b = bytes[(idx.wrapping_mul(8).wrapping_add(k)) % bytes.len()];
261 w = (w << 8) | u64::from(b);
262 }
263 #[allow(clippy::cast_possible_truncation)] let offset = (w % span) as usize;
265 lo + offset
266}
267
268fn randomized_target(category: &Category, hash: &[u8; 32], original: &str) -> Option<usize> {
279 match category {
280 Category::Phone | Category::CreditCard | Category::Ssn => {
281 Some(band_pick(hash, 0, DIGITS_BAND.0, DIGITS_BAND.1))
282 }
283 Category::Name => Some(band_pick(hash, 0, NAME_BAND.0, NAME_BAND.1)),
284 Category::AuthToken | Category::Custom(_) => {
285 let overhead = "__SANITIZED_".len() + "__".len();
286 Some(band_pick(hash, 0, TOKEN_BAND.0, TOKEN_BAND.1) + overhead)
287 }
288 Category::Email => {
289 let domain = original.rfind('@').map_or("x.co", |p| &original[p + 1..]);
290 Some(band_pick(hash, 0, EMAIL_USER_BAND.0, EMAIL_USER_BAND.1) + 1 + domain.len())
291 }
292 Category::Hostname => {
293 let suffix = original.find('.').map_or("", |p| &original[p..]);
294 Some(band_pick(hash, 0, HOSTNAME_PREFIX_BAND.0, HOSTNAME_PREFIX_BAND.1) + suffix.len())
295 }
296 Category::FilePath
299 | Category::Url
300 | Category::AwsArn
301 | Category::AzureResourceId
302 | Category::Uuid
303 | Category::MacAddress
304 | Category::IpV4
305 | Category::IpV6
306 | Category::ContainerId
307 | Category::WindowsSid
308 | Category::Jwt => None,
309 }
310}
311
312fn format_digits_synth(hash: &[u8; 32], target: usize, ssn: bool) -> String {
316 let mut buf = String::with_capacity(target);
317 for i in 0..target {
318 if ssn && i == 0 {
319 buf.push('0');
320 } else {
321 buf.push((b'0' + hash[i % 32] % 10) as char);
322 }
323 }
324 buf
325}
326
327fn format_preserving_hex_rand(
332 hex: &[u8; 64],
333 original: &str,
334 is_structural: impl Fn(char) -> bool,
335) -> String {
336 let mut buf = String::with_capacity(original.len());
337 let mut seg_idx = 0usize;
338 let mut hi = 0usize;
339 let mut in_var = false;
340 for ch in original.chars() {
341 if is_structural(ch) {
342 buf.push(ch);
343 in_var = false;
344 } else if !in_var {
345 let seg_len = band_pick(hex, seg_idx + 1, SEGMENT_BAND.0, SEGMENT_BAND.1);
348 for _ in 0..seg_len {
349 buf.push(hex[hi % 64] as char);
350 hi += 1;
351 }
352 seg_idx += 1;
353 in_var = true;
354 }
355 }
356 buf
357}
358
359fn pad_or_truncate(s: &str, target: usize, hex: &[u8; 64]) -> String {
367 let slen = s.len();
368 if slen == target {
369 return s.to_string();
370 }
371 if slen > target {
372 return s[..target].to_string();
373 }
374 let mut buf = String::with_capacity(target);
375 buf.push_str(s);
376 for i in 0..target.saturating_sub(slen) {
377 buf.push(hex[i % 64] as char);
378 }
379 buf
380}
381
382fn format_email_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
386 let domain = original
387 .rfind('@')
388 .map_or("x.co", |pos| &original[pos + 1..]);
389 let at_domain = 1 + domain.len(); if target <= at_domain {
391 return pad_or_truncate("", target, hex);
393 }
394 let user_len = target - at_domain;
395 let mut buf = String::with_capacity(target);
396 for i in 0..user_len {
397 buf.push(hex[i % 64] as char);
398 }
399 buf.push('@');
400 buf.push_str(domain);
401 buf
402}
403
404fn format_name_lp(hash: &[u8; 32], hex: &[u8; 64], target: usize) -> String {
408 let raw = format_name(hash);
409 pad_or_truncate(&raw, target, hex)
410}
411
412fn format_char_class_lp(
417 hash: &[u8; 32],
418 original: &str,
419 is_replaceable: impl Fn(char) -> bool,
420 replacement: impl Fn(char, u8) -> char,
421) -> Option<String> {
422 let mut buf = String::with_capacity(original.len());
423 let mut hi = 0usize;
424 let mut had_replaceable = false;
425 for ch in original.chars() {
426 if is_replaceable(ch) {
427 buf.push(replacement(ch, hash[hi % 32]));
428 hi += 1;
429 had_replaceable = true;
430 } else {
431 buf.push(ch);
432 }
433 }
434 had_replaceable.then_some(buf)
435}
436
437fn format_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
442 format_char_class_lp(
443 hash,
444 original,
445 |c| c.is_ascii_digit(),
446 |_, b| (b'0' + b % 10) as char,
447 )
448 .unwrap_or_else(|| pad_or_truncate("", target, &hex_bytes(hash)))
449}
450
451fn format_hex_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
455 let hex = hex_bytes(hash);
456 format_char_class_lp(
457 hash,
458 original,
459 |c| c.is_ascii_hexdigit(),
460 |ch, b| {
461 let nibble = b % 16;
462 if ch.is_ascii_uppercase() {
463 b"0123456789ABCDEF"[nibble as usize] as char
464 } else {
465 b"0123456789abcdef"[nibble as usize] as char
466 }
467 },
468 )
469 .unwrap_or_else(|| pad_or_truncate("", target, &hex))
470}
471
472fn format_ssn_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
477 let has_digit = original.chars().any(|c| c.is_ascii_digit());
478 if !has_digit {
479 let hex = hex_bytes(hash);
480 return pad_or_truncate("", target, &hex);
481 }
482 let mut buf = String::with_capacity(target);
483 let mut digit_idx = 0usize;
484 for ch in original.chars() {
485 if ch.is_ascii_digit() {
486 if digit_idx < 3 {
487 buf.push('0');
488 } else {
489 buf.push((b'0' + hash[(digit_idx - 3) % 32] % 10) as char);
490 }
491 digit_idx += 1;
492 } else {
493 buf.push(ch);
494 }
495 }
496 buf
497}
498
499fn format_hostname_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
503 let suffix = original.find('.').map_or("", |p| &original[p..]);
504 let prefix_len = target.saturating_sub(suffix.len());
505 if prefix_len == 0 {
506 return pad_or_truncate("", target, hex);
507 }
508 let mut buf = String::with_capacity(target);
509 for i in 0..prefix_len {
510 buf.push(hex[i % 64] as char);
511 }
512 buf.push_str(suffix);
513 buf
514}
515
516fn format_custom_lp(hex: &[u8; 64], target: usize) -> String {
520 let prefix = "__SANITIZED_";
521 let suffix = "__";
522 let overhead = prefix.len() + suffix.len(); if target <= overhead {
524 return pad_or_truncate("", target, hex);
525 }
526 let hex_len = target - overhead;
527 let mut buf = String::with_capacity(target);
528 buf.push_str(prefix);
529 for i in 0..hex_len {
530 buf.push(hex[i % 64] as char);
531 }
532 buf.push_str(suffix);
533 buf
534}
535
536fn format_jwt_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
540 const B64URL: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
541 let mut buf = String::with_capacity(target);
542 let mut hi = 0usize;
543 let mut had_b64 = false;
544 for ch in original.chars() {
545 if ch == '.' || ch == '=' {
546 buf.push(ch);
547 } else if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
548 buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
549 hi += 1;
550 had_b64 = true;
551 } else {
552 for _ in 0..ch.len_utf8() {
554 buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
555 hi += 1;
556 }
557 had_b64 = true;
558 }
559 }
560 if !had_b64 {
561 let hex = hex_bytes(hash);
562 return pad_or_truncate("", target, &hex);
563 }
564 buf
565}
566
567fn format_filepath_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
578 let last_sep = original.rfind(['/', '\\']).map_or(0, |p| p + 1);
580 let filename = &original[last_sep..];
581 let ext_start = filename.rfind('.').filter(|&p| p > 0).map(|p| last_sep + p);
583
584 if randomized {
585 let mut buf = String::new();
586 let mut hi = 0usize;
587 for ch in original[..last_sep].chars() {
590 if matches!(ch, '/' | '\\') {
591 buf.push(ch);
592 } else {
593 for _ in 0..ch.len_utf8() {
594 buf.push(hex[hi % 64] as char);
595 hi += 1;
596 }
597 }
598 }
599 let stem_len = band_pick(hex, 0, SEGMENT_BAND.0, SEGMENT_BAND.1);
601 for _ in 0..stem_len {
602 buf.push(hex[hi % 64] as char);
603 hi += 1;
604 }
605 if let Some(es) = ext_start {
607 buf.push_str(&original[es..]);
608 }
609 return buf;
610 }
611
612 let mut buf = String::with_capacity(target);
613 let mut hi = 0usize;
614
615 for (i, ch) in original.char_indices() {
616 if matches!(ch, '/' | '\\') || ext_start.is_some_and(|es| i >= es) {
617 buf.push(ch);
619 } else {
620 for _ in 0..ch.len_utf8() {
622 buf.push(hex[hi % 64] as char);
623 hi += 1;
624 }
625 }
626 }
627 if buf.len() != target {
629 return pad_or_truncate(&buf, target, hex);
630 }
631 buf
632}
633
634fn format_windows_sid_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
638 let has_digit = original.chars().any(|c| c.is_ascii_digit());
639 if !has_digit {
640 let hex = hex_bytes(hash);
641 return pad_or_truncate("", target, &hex);
642 }
643 let mut buf = String::with_capacity(target);
644 let mut hi = 0usize;
645 for ch in original.chars() {
646 if ch == 'S' || ch == '-' {
647 buf.push(ch);
648 } else if ch.is_ascii_digit() {
649 buf.push((b'0' + hash[hi % 32] % 10) as char);
650 hi += 1;
651 } else {
652 for _ in 0..ch.len_utf8() {
654 buf.push((b'0' + hash[hi % 32] % 10) as char);
655 hi += 1;
656 }
657 }
658 }
659 buf
660}
661
662fn format_preserving_hex_lp(
670 hex: &[u8; 64],
671 original: &str,
672 target: usize,
673 is_structural: impl Fn(char) -> bool,
674) -> Option<String> {
675 let mut buf = String::with_capacity(target);
676 let mut hi = 0usize;
677 let mut had_content = false;
678
679 for ch in original.chars() {
680 if is_structural(ch) {
681 buf.push(ch);
682 } else {
683 for _ in 0..ch.len_utf8() {
684 buf.push(hex[hi % 64] as char);
685 hi += 1;
686 }
687 had_content = true;
688 }
689 }
690
691 had_content.then_some(buf)
692}
693
694fn format_url_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
700 let is_structural = |ch| "/:?=&#@.".contains(ch);
701 if randomized {
702 return format_preserving_hex_rand(hex, original, is_structural);
703 }
704 format_preserving_hex_lp(hex, original, target, is_structural)
705 .unwrap_or_else(|| pad_or_truncate("", target, hex))
706}
707
708fn format_arn_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
713 let is_structural = |ch| ch == ':' || ch == '/';
714 if randomized {
715 return format_preserving_hex_rand(hex, original, is_structural);
716 }
717 format_preserving_hex_lp(hex, original, target, is_structural)
718 .unwrap_or_else(|| pad_or_truncate("", target, hex))
719}
720
721fn format_azure_resource_id_lp(
726 hex: &[u8; 64],
727 original: &str,
728 target: usize,
729 randomized: bool,
730) -> String {
731 const KNOWN_SEGMENTS: &[&str] = &[
732 "subscriptions",
733 "resourceGroups",
734 "resourcegroups",
735 "providers",
736 ];
737
738 let mut buf = String::with_capacity(target);
739 let mut hi = 0usize;
740 let mut seg_idx = 0usize;
741
742 let mut prev_was_providers = false;
744 for (pi, part) in original.split('/').enumerate() {
745 if pi > 0 {
746 buf.push('/');
747 }
748 let is_provider_namespace = prev_was_providers && part.contains('.');
753 if part.is_empty() || KNOWN_SEGMENTS.contains(&part) || is_provider_namespace {
754 buf.push_str(part);
755 } else if randomized {
756 let seg_len = band_pick(hex, seg_idx + 1, SEGMENT_BAND.0, SEGMENT_BAND.1);
759 for _ in 0..seg_len {
760 buf.push(hex[hi % 64] as char);
761 hi += 1;
762 }
763 seg_idx += 1;
764 } else {
765 for ch in part.chars() {
767 for _ in 0..ch.len_utf8() {
768 buf.push(hex[hi % 64] as char);
769 hi += 1;
770 }
771 }
772 }
773 prev_was_providers = part == "providers" || part == "Providers";
774 }
775 if !randomized && buf.len() != target {
776 return pad_or_truncate(&buf, target, hex);
777 }
778 buf
779}
780
781fn format_name(hash: &[u8; 32]) -> String {
783 const FIRST: &[&str] = &[
787 "Alex", "Blake", "Casey", "Dana", "Ellis", "Finley", "Gray", "Harper", "Ira", "Jordan",
788 "Kai", "Lane", "Morgan", "Noel", "Oakley", "Parker", "Quinn", "Reese", "Sage", "Taylor",
789 "Uri", "Val", "Wren", "Xen", "Yael", "Zion", "Arden", "Blair", "Corin", "Drew", "Emery",
790 "Frost",
791 ];
792 const LAST: &[&str] = &[
793 "Ashford",
794 "Blackwell",
795 "Crawford",
796 "Dalton",
797 "Eastwood",
798 "Fairbanks",
799 "Garrison",
800 "Hartley",
801 "Irvine",
802 "Jensen",
803 "Kendrick",
804 "Langley",
805 "Mercer",
806 "Newland",
807 "Oakwood",
808 "Preston",
809 "Quinlan",
810 "Redmond",
811 "Shepard",
812 "Thornton",
813 "Underwood",
814 "Vance",
815 "Whitmore",
816 "Xavier",
817 "Yardley",
818 "Zimmer",
819 "Ashton",
820 "Beckett",
821 "Calloway",
822 "Dempsey",
823 "Eldridge",
824 "Fletcher",
825 ];
826 let fi = hash[0] as usize % FIRST.len();
827 let li = hash[1] as usize % LAST.len();
828 format!("{} {}", FIRST[fi], LAST[li])
829}
830
831fn hex_bytes(bytes: &[u8; 32]) -> [u8; 64] {
833 const HEX: &[u8; 16] = b"0123456789abcdef";
834 let mut out = [0u8; 64];
835 for (i, &b) in bytes.iter().enumerate() {
836 out[i * 2] = HEX[(b >> 4) as usize];
837 out[i * 2 + 1] = HEX[(b & 0xf) as usize];
838 }
839 out
840}
841
842#[cfg(test)]
847mod tests {
848 use super::*;
849
850 #[test]
851 fn hmac_deterministic_same_input() {
852 let gen = HmacGenerator::new([42u8; 32]);
853 let a = gen.generate(&Category::Email, "alice@corp.com");
854 let b = gen.generate(&Category::Email, "alice@corp.com");
855 assert_eq!(a, b, "same seed + same input must produce same output");
856 }
857
858 #[test]
859 fn hmac_different_inputs_differ() {
860 let gen = HmacGenerator::new([42u8; 32]);
861 let a = gen.generate(&Category::Email, "alice@corp.com");
862 let b = gen.generate(&Category::Email, "bob@corp.com");
863 assert_ne!(a, b);
864 }
865
866 #[test]
867 fn hmac_different_seeds_differ() {
868 let g1 = HmacGenerator::new([1u8; 32]);
869 let g2 = HmacGenerator::new([2u8; 32]);
870 let a = g1.generate(&Category::Email, "alice@corp.com");
871 let b = g2.generate(&Category::Email, "alice@corp.com");
872 assert_ne!(a, b);
873 }
874
875 #[test]
876 fn hmac_different_categories_differ() {
877 let gen = HmacGenerator::new([42u8; 32]);
878 let a = gen.generate(&Category::Email, "test");
879 let b = gen.generate(&Category::Name, "test");
880 assert_ne!(a, b, "different categories must produce different outputs");
881 }
882
883 #[test]
884 fn email_format() {
885 let gen = HmacGenerator::new([0u8; 32]);
886 let orig = "alice@corp.com";
887 let out = gen.generate(&Category::Email, orig);
888 assert!(out.contains('@'), "email must contain @");
889 assert!(out.ends_with("@corp.com"), "email must preserve domain");
890 assert_eq!(out.len(), orig.len(), "email must preserve length");
891 }
892
893 #[test]
894 fn ipv4_format() {
895 let gen = HmacGenerator::new([0u8; 32]);
896 let orig = "192.168.1.1";
897 let out = gen.generate(&Category::IpV4, orig);
898 let parts: Vec<&str> = out.split('.').collect();
900 assert_eq!(parts.len(), 4);
901 assert_eq!(out.len(), orig.len(), "ipv4 must preserve length");
902 }
903
904 #[test]
905 fn ssn_format() {
906 let gen = HmacGenerator::new([7u8; 32]);
907 let orig = "123-45-6789";
908 let out = gen.generate(&Category::Ssn, orig);
909 assert!(out.starts_with("000-"), "SSN must start with 000");
910 assert_eq!(out.len(), orig.len(), "SSN must preserve length");
911 }
912
913 #[test]
914 fn phone_format() {
915 let gen = HmacGenerator::new([3u8; 32]);
916 let orig = "+1-212-555-0100";
917 let out = gen.generate(&Category::Phone, orig);
918 assert!(out.starts_with('+'));
920 assert_eq!(
921 out.chars().filter(|c| *c == '-').count(),
922 orig.chars().filter(|c| *c == '-').count(),
923 "dashes must be preserved"
924 );
925 assert_eq!(out.len(), orig.len(), "phone must preserve length");
926 }
927
928 #[test]
929 fn hostname_format() {
930 let gen = HmacGenerator::new([5u8; 32]);
931 let orig = "db-prod-01.internal";
932 let out = gen.generate(&Category::Hostname, orig);
933 assert!(out.ends_with(".internal"), "hostname must preserve suffix");
934 assert_eq!(out.len(), orig.len(), "hostname must preserve length");
935 }
936
937 #[test]
938 fn custom_format() {
939 let gen = HmacGenerator::new([9u8; 32]);
940 let cat = Category::Custom("api_key".into());
941 let orig = "sk-abc123-very-long-key";
943 let out = gen.generate(&cat, orig);
944 assert!(out.starts_with("__SANITIZED_"));
945 assert!(out.ends_with("__"));
946 assert_eq!(out.len(), orig.len(), "custom must preserve length");
947 }
948
949 #[test]
950 fn custom_format_short() {
951 let gen = HmacGenerator::new([9u8; 32]);
952 let cat = Category::Custom("api_key".into());
953 let orig = "sk-abc123";
955 let out = gen.generate(&cat, orig);
956 assert_eq!(
957 out.len(),
958 orig.len(),
959 "custom must preserve length even for short inputs"
960 );
961 }
962
963 #[test]
964 fn random_generator_produces_valid_format() {
965 let gen = RandomGenerator::new();
966 let orig = "test@example.com";
967 let out = gen.generate(&Category::Email, orig);
968 assert!(out.contains('@'));
969 assert_eq!(
970 out.len(),
971 orig.len(),
972 "random generator must preserve length"
973 );
974 }
975
976 #[test]
977 fn from_slice_rejects_bad_length() {
978 let result = HmacGenerator::from_slice(&[0u8; 16]);
979 assert!(result.is_err());
980 }
981
982 #[test]
983 fn credit_card_format() {
984 let gen = HmacGenerator::new([11u8; 32]);
985 let orig = "4111-1111-1111-1111";
986 let out = gen.generate(&Category::CreditCard, orig);
987 let parts: Vec<&str> = out.split('-').collect();
989 assert_eq!(parts.len(), 4);
990 for part in &parts {
991 assert_eq!(part.len(), 4);
992 assert!(part.chars().all(|c| c.is_ascii_digit()));
993 }
994 assert_eq!(out.len(), orig.len(), "credit card must preserve length");
995 }
996
997 #[test]
998 fn name_format() {
999 let gen = HmacGenerator::new([0u8; 32]);
1000 let orig = "John Doe";
1001 let out = gen.generate(&Category::Name, orig);
1002 assert_eq!(out.len(), orig.len(), "name must preserve length");
1003 }
1004
1005 #[test]
1006 fn ipv6_format() {
1007 let gen = HmacGenerator::new([0u8; 32]);
1008 let orig = "fd00:abcd:1234:5678::1";
1009 let out = gen.generate(&Category::IpV6, orig);
1010 assert_eq!(
1012 out.chars().filter(|c| *c == ':').count(),
1013 orig.chars().filter(|c| *c == ':').count(),
1014 "colons must be preserved"
1015 );
1016 assert_eq!(out.len(), orig.len(), "ipv6 must preserve length");
1017 }
1018
1019 #[test]
1020 fn length_preserved_all_categories() {
1021 let gen = HmacGenerator::new([42u8; 32]);
1022 let cases: Vec<(Category, &str)> = vec![
1023 (Category::Email, "alice@corp.com"),
1024 (Category::Name, "John Doe"),
1025 (Category::Phone, "+1-212-555-0100"),
1026 (Category::IpV4, "192.168.1.1"),
1027 (Category::IpV6, "fd00::1"),
1028 (Category::CreditCard, "4111-1111-1111-1111"),
1029 (Category::Ssn, "123-45-6789"),
1030 (Category::Hostname, "db-prod-01.internal"),
1031 (Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
1032 (Category::ContainerId, "a1b2c3d4e5f6"),
1033 (Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
1034 (Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
1035 (Category::AuthToken, "ghp_abc123secrettoken"),
1036 (Category::FilePath, "/home/jsmith/config.yaml"),
1037 (Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
1038 (Category::Url, "https://internal.corp.com/api"),
1039 (Category::AwsArn, "arn:aws:iam::123456789012:user/admin"),
1040 (
1041 Category::AzureResourceId,
1042 "/subscriptions/550e8400/resourceGroups/rg-prod",
1043 ),
1044 (Category::Custom("key".into()), "some-secret-value-here"),
1045 ];
1046 for (cat, orig) in &cases {
1047 let out = gen.generate(cat, orig);
1048 assert_eq!(
1049 out.len(),
1050 orig.len(),
1051 "length mismatch for {:?}: '{}' ({}) -> '{}' ({})",
1052 cat,
1053 orig,
1054 orig.len(),
1055 out,
1056 out.len()
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn mac_address_format() {
1063 let gen = HmacGenerator::new([7u8; 32]);
1064 let orig = "AA:BB:CC:DD:EE:FF";
1065 let out = gen.generate(&Category::MacAddress, orig);
1066 assert_eq!(out.len(), orig.len(), "mac must preserve length");
1067 assert_eq!(
1068 out.chars().filter(|c| *c == ':').count(),
1069 5,
1070 "mac must preserve colons"
1071 );
1072 }
1073
1074 #[test]
1075 fn mac_address_dash_format() {
1076 let gen = HmacGenerator::new([7u8; 32]);
1077 let orig = "AA-BB-CC-DD-EE-FF";
1078 let out = gen.generate(&Category::MacAddress, orig);
1079 assert_eq!(out.len(), orig.len());
1080 assert_eq!(out.chars().filter(|c| *c == '-').count(), 5);
1081 }
1082
1083 #[test]
1084 fn uuid_format() {
1085 let gen = HmacGenerator::new([3u8; 32]);
1086 let orig = "550e8400-e29b-41d4-a716-446655440000";
1087 let out = gen.generate(&Category::Uuid, orig);
1088 assert_eq!(out.len(), orig.len(), "uuid must preserve length");
1089 assert_eq!(
1090 out.chars().filter(|c| *c == '-').count(),
1091 4,
1092 "uuid must preserve dashes"
1093 );
1094 }
1095
1096 #[test]
1097 fn container_id_format() {
1098 let gen = HmacGenerator::new([5u8; 32]);
1099 let orig = "a1b2c3d4e5f6";
1100 let out = gen.generate(&Category::ContainerId, orig);
1101 assert_eq!(out.len(), orig.len(), "container id must preserve length");
1102 assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
1103 }
1104
1105 #[test]
1106 fn jwt_format() {
1107 let gen = HmacGenerator::new([11u8; 32]);
1108 let orig = "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK";
1109 let out = gen.generate(&Category::Jwt, orig);
1110 assert_eq!(out.len(), orig.len(), "jwt must preserve length");
1111 let orig_dots = orig.chars().filter(|c| *c == '.').count();
1112 let out_dots = out.chars().filter(|c| *c == '.').count();
1113 assert_eq!(out_dots, orig_dots, "jwt must preserve dots");
1114 }
1115
1116 #[test]
1117 fn auth_token_format() {
1118 let gen = HmacGenerator::new([9u8; 32]);
1119 let orig = "ghp_abc123secrettoken";
1120 let out = gen.generate(&Category::AuthToken, orig);
1121 assert!(out.starts_with("__SANITIZED_"));
1122 assert!(out.ends_with("__"));
1123 assert_eq!(out.len(), orig.len(), "auth_token must preserve length");
1124 }
1125
1126 #[test]
1127 fn filepath_unix_format() {
1128 let gen = HmacGenerator::new([13u8; 32]);
1129 let orig = "/home/jsmith/config.yaml";
1130 let out = gen.generate(&Category::FilePath, orig);
1131 assert_eq!(out.len(), orig.len(), "filepath must preserve length");
1132 assert_eq!(
1133 std::path::Path::new(&out)
1134 .extension()
1135 .and_then(|e| e.to_str()),
1136 Some("yaml"),
1137 "filepath must preserve extension"
1138 );
1139 assert_eq!(
1140 out.chars().filter(|c| *c == '/').count(),
1141 orig.chars().filter(|c| *c == '/').count(),
1142 "filepath must preserve separators"
1143 );
1144 }
1145
1146 #[test]
1147 fn filepath_windows_format() {
1148 let gen = HmacGenerator::new([13u8; 32]);
1149 let orig = "C:\\Users\\admin\\secrets.txt";
1150 let out = gen.generate(&Category::FilePath, orig);
1151 assert_eq!(out.len(), orig.len(), "filepath must preserve length");
1152 assert_eq!(
1153 std::path::Path::new(&out)
1154 .extension()
1155 .and_then(|e| e.to_str()),
1156 Some("txt"),
1157 "filepath must preserve extension"
1158 );
1159 assert_eq!(
1160 out.chars().filter(|c| *c == '\\').count(),
1161 orig.chars().filter(|c| *c == '\\').count(),
1162 "filepath must preserve backslashes"
1163 );
1164 }
1165
1166 #[test]
1167 fn windows_sid_format() {
1168 let gen = HmacGenerator::new([7u8; 32]);
1169 let orig = "S-1-5-21-3623811015-3361044348-30300820-1013";
1170 let out = gen.generate(&Category::WindowsSid, orig);
1171 assert_eq!(out.len(), orig.len(), "SID must preserve length");
1172 assert!(out.starts_with("S-"), "SID must start with S-");
1173 assert_eq!(
1174 out.chars().filter(|c| *c == '-').count(),
1175 orig.chars().filter(|c| *c == '-').count(),
1176 "SID must preserve dashes"
1177 );
1178 }
1179
1180 #[test]
1181 fn url_format() {
1182 let gen = HmacGenerator::new([5u8; 32]);
1183 let orig = "https://internal.corp.com/api/users?token=abc123";
1184 let out = gen.generate(&Category::Url, orig);
1185 assert_eq!(out.len(), orig.len(), "url must preserve length");
1186 assert!(out.contains("://"));
1188 assert!(out.contains('?'));
1189 assert!(out.contains('='));
1190 }
1191
1192 #[test]
1193 fn aws_arn_format() {
1194 let gen = HmacGenerator::new([3u8; 32]);
1195 let orig = "arn:aws:iam::123456789012:user/admin";
1196 let out = gen.generate(&Category::AwsArn, orig);
1197 assert_eq!(out.len(), orig.len(), "ARN must preserve length");
1198 assert_eq!(
1199 out.chars().filter(|c| *c == ':').count(),
1200 orig.chars().filter(|c| *c == ':').count(),
1201 "ARN must preserve colons"
1202 );
1203 assert!(out.contains('/'), "ARN must preserve slash");
1204 }
1205
1206 #[test]
1207 fn azure_resource_id_format() {
1208 let gen = HmacGenerator::new([11u8; 32]);
1209 let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
1210 let out = gen.generate(&Category::AzureResourceId, orig);
1211 assert_eq!(
1212 out.len(),
1213 orig.len(),
1214 "Azure resource ID must preserve length"
1215 );
1216 assert!(
1217 out.contains("/subscriptions/"),
1218 "must preserve 'subscriptions'"
1219 );
1220 assert!(
1221 out.contains("/resourceGroups/"),
1222 "must preserve 'resourceGroups'"
1223 );
1224 assert!(out.contains("/providers/"), "must preserve 'providers'");
1225 assert!(
1226 out.contains("Microsoft.Compute"),
1227 "must preserve dotted provider name"
1228 );
1229 }
1230
1231 #[test]
1232 fn azure_dotted_segment_outside_providers_is_replaced() {
1233 let gen = HmacGenerator::new([11u8; 32]);
1234 let orig = "/subscriptions/10.0.0.1/resourceGroups/rg-prod";
1238 let out = gen.generate(&Category::AzureResourceId, orig);
1239 assert_eq!(out.len(), orig.len(), "length must be preserved");
1240 assert!(out.contains("/subscriptions/"), "subscriptions preserved");
1241 assert!(out.contains("/resourceGroups/"), "resourceGroups preserved");
1242 assert!(
1243 !out.contains("10.0.0.1"),
1244 "dotted non-provider segment must be replaced, got: {out}"
1245 );
1246 assert!(
1247 !out.contains("rg-prod"),
1248 "variable resource group name must be replaced, got: {out}"
1249 );
1250 }
1251
1252 fn rand_gen(seed: u8) -> HmacGenerator {
1257 HmacGenerator::new([seed; 32]).with_length_policy(LengthPolicy::Randomized)
1258 }
1259
1260 #[test]
1261 fn randomized_digits_decoupled_from_input_length() {
1262 let gen = rand_gen(42);
1263 for cat in [Category::Phone, Category::CreditCard, Category::Ssn] {
1264 for len in 1..40usize {
1265 let orig: String = "1234567890".chars().cycle().take(len).collect();
1267 let out = gen.generate(&cat, &orig);
1268 assert!(
1269 out.chars().all(|c| c.is_ascii_digit()),
1270 "{cat:?} output must be all digits: {out}"
1271 );
1272 assert!(
1273 out.len() >= DIGITS_BAND.0 && out.len() <= DIGITS_BAND.1,
1274 "{cat:?} length {} out of band for input len {len}",
1275 out.len()
1276 );
1277 if cat == Category::Ssn {
1278 assert!(out.starts_with('0'), "ssn must start with 0: {out}");
1279 }
1280 }
1281 }
1282 }
1283
1284 #[test]
1285 fn randomized_is_stable_per_value() {
1286 let gen = rand_gen(7);
1287 for (cat, orig) in [
1288 (Category::Phone, "+1-212-555-0100"),
1289 (Category::Email, "alice@corp.com"),
1290 (Category::Hostname, "db-prod-01.internal"),
1291 (Category::FilePath, "/home/jsmith/config.yaml"),
1292 (Category::AuthToken, "ghp_abc123secrettoken"),
1293 ] {
1294 let a = gen.generate(&cat, orig);
1295 let b = gen.generate(&cat, orig);
1296 assert_eq!(a, b, "{cat:?} must be stable for the same value");
1297 }
1298 }
1299
1300 #[test]
1301 fn randomized_email_keeps_domain_varies_user() {
1302 let gen = rand_gen(3);
1303 for ulen in 1..30usize {
1306 let user = "a".repeat(ulen);
1307 let orig = format!("{user}@corp.com");
1308 let out = gen.generate(&Category::Email, &orig);
1309 assert!(
1310 out.ends_with("@corp.com"),
1311 "domain must be preserved: {out}"
1312 );
1313 assert!(out.contains('@'));
1314 let out_user = out.split('@').next().unwrap().len();
1315 assert!(
1316 out_user >= EMAIL_USER_BAND.0 && out_user <= EMAIL_USER_BAND.1,
1317 "email user length {out_user} out of band for input ulen {ulen}"
1318 );
1319 }
1320 }
1321
1322 #[test]
1323 fn randomized_hostname_keeps_suffix() {
1324 let gen = rand_gen(5);
1325 let out = gen.generate(&Category::Hostname, "db-prod-01.internal");
1326 assert!(
1327 out.ends_with(".internal"),
1328 "suffix must be preserved: {out}"
1329 );
1330 let prefix = out.strip_suffix(".internal").unwrap().len();
1331 assert!(prefix >= HOSTNAME_PREFIX_BAND.0 && prefix <= HOSTNAME_PREFIX_BAND.1);
1332 }
1333
1334 #[test]
1335 fn randomized_filepath_keeps_extension_and_separators() {
1336 let gen = rand_gen(13);
1337 let orig = "/home/jsmith/config.yaml";
1338 let out = gen.generate(&Category::FilePath, orig);
1339 assert_eq!(
1340 std::path::Path::new(&out)
1341 .extension()
1342 .and_then(|e| e.to_str()),
1343 Some("yaml"),
1344 "extension must stay at the end: {out}"
1345 );
1346 assert_eq!(
1347 out.chars().filter(|c| *c == '/').count(),
1348 orig.chars().filter(|c| *c == '/').count(),
1349 "separators must be preserved: {out}"
1350 );
1351 assert!(
1352 !out.ends_with("yaml") || out.contains(".yaml"),
1353 "must contain the .yaml extension verbatim: {out}"
1354 );
1355 }
1356
1357 #[test]
1358 fn randomized_url_arn_keep_structure() {
1359 let gen = rand_gen(9);
1360 let url = gen.generate(
1361 &Category::Url,
1362 "https://internal.corp.com/api/users?token=abc123",
1363 );
1364 assert!(url.contains("://"), "url scheme separator preserved: {url}");
1365 assert!(
1366 url.contains('?') && url.contains('='),
1367 "url query preserved: {url}"
1368 );
1369
1370 let arn = gen.generate(&Category::AwsArn, "arn:aws:iam::123456789012:user/admin");
1371 assert_eq!(
1372 arn.chars().filter(|c| *c == ':').count(),
1373 "arn:aws:iam::123456789012:user/admin"
1374 .chars()
1375 .filter(|c| *c == ':')
1376 .count(),
1377 "arn colons preserved: {arn}"
1378 );
1379 assert!(arn.contains('/'), "arn slash preserved: {arn}");
1380 }
1381
1382 #[test]
1383 fn randomized_azure_keeps_known_segments() {
1384 let gen = rand_gen(11);
1385 let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
1386 let out = gen.generate(&Category::AzureResourceId, orig);
1387 assert!(out.contains("/subscriptions/"));
1388 assert!(out.contains("/resourceGroups/"));
1389 assert!(out.contains("/providers/"));
1390 assert!(out.contains("Microsoft.Compute"));
1391 assert!(
1392 !out.contains("rg-prod"),
1393 "variable segment must be replaced: {out}"
1394 );
1395 }
1396
1397 #[test]
1398 fn randomized_canonical_categories_are_noop() {
1399 let gen = rand_gen(1);
1402 let cases: Vec<(Category, &str)> = vec![
1403 (Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
1404 (Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
1405 (Category::IpV4, "192.168.1.1"),
1406 (Category::IpV6, "fd00:abcd:1234:5678::1"),
1407 (Category::ContainerId, "a1b2c3d4e5f6"),
1408 (Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
1409 (Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
1410 ];
1411 for (cat, orig) in &cases {
1412 let out = gen.generate(cat, orig);
1413 assert_eq!(
1414 out.len(),
1415 orig.len(),
1416 "{cat:?} must stay length-preserving under Randomized: {orig} -> {out}"
1417 );
1418 }
1419 }
1420
1421 #[test]
1422 fn randomized_token_is_valid_and_in_band() {
1423 let gen = rand_gen(9);
1424 for cat in [Category::AuthToken, Category::Custom("api_key".into())] {
1425 let out = gen.generate(&cat, "sk-abc123-some-secret-value");
1426 assert!(out.starts_with("__SANITIZED_"), "{cat:?}: {out}");
1427 assert!(out.ends_with("__"), "{cat:?}: {out}");
1428 let overhead = "__SANITIZED_".len() + "__".len();
1429 let hex_len = out.len() - overhead;
1430 assert!(
1431 hex_len >= TOKEN_BAND.0 && hex_len <= TOKEN_BAND.1,
1432 "{cat:?} token hex length {hex_len} out of band"
1433 );
1434 }
1435 }
1436}