1use crate::category::Category;
17use hmac::{Hmac, Mac};
18use rand::Rng;
19use sha2::Sha256;
20use zeroize::Zeroize;
21
22pub trait ReplacementGenerator: Send + Sync {
39 fn generate(&self, category: &Category, original: &str) -> String;
41}
42
43#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
55#[non_exhaustive]
56pub enum LengthPolicy {
57 #[default]
59 Preserve,
60 Randomized,
63}
64
65pub struct HmacGenerator {
78 key: [u8; 32],
79 policy: LengthPolicy,
80}
81
82impl Drop for HmacGenerator {
83 fn drop(&mut self) {
84 self.key.zeroize();
85 }
86}
87
88impl HmacGenerator {
89 #[must_use]
91 pub fn new(key: [u8; 32]) -> Self {
92 Self {
93 key,
94 policy: LengthPolicy::Preserve,
95 }
96 }
97
98 pub fn from_slice(bytes: &[u8]) -> crate::error::Result<Self> {
104 if bytes.len() != 32 {
105 return Err(crate::error::SanitizeError::InvalidSeedLength(bytes.len()));
106 }
107 let mut key = [0u8; 32];
108 key.copy_from_slice(bytes);
109 Ok(Self {
110 key,
111 policy: LengthPolicy::Preserve,
112 })
113 }
114
115 #[must_use]
117 pub fn with_length_policy(mut self, policy: LengthPolicy) -> Self {
118 self.policy = policy;
119 self
120 }
121
122 fn derive(&self, category: &Category, original: &str) -> [u8; 32] {
124 type HmacSha256 = Hmac<Sha256>;
125 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
126 let tag = category.domain_tag_hmac();
127 mac.update(tag.as_bytes());
128 mac.update(b"\x00"); mac.update(original.as_bytes());
130 let result = mac.finalize();
131 let mut out = [0u8; 32];
132 out.copy_from_slice(&result.into_bytes());
133 out
134 }
135}
136
137impl ReplacementGenerator for HmacGenerator {
138 fn generate(&self, category: &Category, original: &str) -> String {
139 let hash = self.derive(category, original);
140 format_replacement(category, &hash, original, self.policy)
141 }
142}
143
144pub struct RandomGenerator {
154 policy: LengthPolicy,
155}
156
157impl RandomGenerator {
158 #[must_use]
159 pub fn new() -> Self {
160 Self {
161 policy: LengthPolicy::Preserve,
162 }
163 }
164
165 #[must_use]
167 pub fn with_length_policy(mut self, policy: LengthPolicy) -> Self {
168 self.policy = policy;
169 self
170 }
171}
172
173impl Default for RandomGenerator {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179impl ReplacementGenerator for RandomGenerator {
180 fn generate(&self, category: &Category, original: &str) -> String {
181 let mut rng = rand::rng();
182 let mut hash = [0u8; 32];
183 rng.fill(&mut hash);
184 format_replacement(category, &hash, original, self.policy)
185 }
186}
187
188pub(crate) fn format_replacement(
200 category: &Category,
201 hash: &[u8; 32],
202 original: &str,
203 policy: LengthPolicy,
204) -> String {
205 let randomized = matches!(policy, LengthPolicy::Randomized);
206 let target = match policy {
207 LengthPolicy::Preserve => original.len(),
208 LengthPolicy::Randomized => {
209 randomized_target(category, hash, original).unwrap_or(original.len())
210 }
211 };
212 if target == 0 {
213 return String::new();
214 }
215 let hex = hex_bytes(hash);
216 match category {
217 Category::Email => format_email_lp(&hex, original, target),
218 Category::Name => format_name_lp(hash, &hex, target),
219 Category::Phone | Category::CreditCard if randomized => {
222 format_digits_synth(hash, target, false)
223 }
224 Category::Ssn if randomized => format_digits_synth(hash, target, true),
225 Category::Phone | Category::CreditCard => format_digits_lp(hash, original, target),
226 Category::IpV4 => format_ipv4_lp(hash, original, target),
227 Category::IpV6 | Category::MacAddress | Category::Uuid | Category::ContainerId => {
228 format_hex_digits_lp(hash, original, target)
229 }
230 Category::Ssn => format_ssn_lp(hash, original, target),
231 Category::Hostname => {
237 let (host, port) = split_host_port(original);
238 let host_target = target.saturating_sub(port.len());
239 let replaced = if host.parse::<std::net::Ipv4Addr>().is_ok() {
240 format_ipv4_lp(hash, host, host_target)
241 } else if host.parse::<std::net::Ipv6Addr>().is_ok() {
242 format_hex_digits_lp(hash, host, host_target)
243 } else {
244 format_hostname_lp(&hex, host, host_target)
245 };
246 if port.is_empty() {
247 replaced
248 } else {
249 format!("{replaced}{port}")
250 }
251 }
252 Category::Jwt => format_jwt_lp(hash, original, target),
253 Category::FilePath => format_filepath_lp(&hex, original, target, randomized),
254 Category::WindowsSid => format_windows_sid_lp(hash, original, target),
255 Category::Url => format_url_lp(&hex, original, target, randomized),
256 Category::AwsArn => format_arn_lp(&hex, original, target, randomized),
257 Category::AzureResourceId => {
258 format_azure_resource_id_lp(&hex, original, target, randomized)
259 }
260 Category::AuthToken | Category::Custom(_) => format_custom_lp(&hex, target),
261 }
262}
263
264const DIGITS_BAND: (usize, usize) = (8, 18);
271const EMAIL_USER_BAND: (usize, usize) = (6, 16);
272const HOSTNAME_PREFIX_BAND: (usize, usize) = (6, 16);
273const NAME_BAND: (usize, usize) = (12, 28);
274const TOKEN_BAND: (usize, usize) = (24, 48);
275const SEGMENT_BAND: (usize, usize) = (6, 20);
276
277fn band_pick(bytes: &[u8], idx: usize, lo: usize, hi: usize) -> usize {
281 debug_assert!(lo <= hi);
282 debug_assert!(!bytes.is_empty());
283 let span = (hi - lo + 1) as u64;
284 let mut w = 0u64;
285 for k in 0..8 {
286 let b = bytes[(idx.wrapping_mul(8).wrapping_add(k)) % bytes.len()];
287 w = (w << 8) | u64::from(b);
288 }
289 #[allow(clippy::cast_possible_truncation)] let offset = (w % span) as usize;
291 lo + offset
292}
293
294fn randomized_target(category: &Category, hash: &[u8; 32], original: &str) -> Option<usize> {
305 match category {
306 Category::Phone | Category::CreditCard | Category::Ssn => {
307 Some(band_pick(hash, 0, DIGITS_BAND.0, DIGITS_BAND.1))
308 }
309 Category::Name => Some(band_pick(hash, 0, NAME_BAND.0, NAME_BAND.1)),
310 Category::AuthToken | Category::Custom(_) => {
311 let overhead = "__SANITIZED_".len() + "__".len();
312 Some(band_pick(hash, 0, TOKEN_BAND.0, TOKEN_BAND.1) + overhead)
313 }
314 Category::Email => {
315 let domain = original.rfind('@').map_or("x.co", |p| &original[p + 1..]);
316 Some(band_pick(hash, 0, EMAIL_USER_BAND.0, EMAIL_USER_BAND.1) + 1 + domain.len())
317 }
318 Category::Hostname
320 if split_host_port(original)
321 .0
322 .parse::<std::net::IpAddr>()
323 .is_ok() =>
324 {
325 None
326 }
327 Category::Hostname => {
328 let suffix = hostname_kept_suffix(original);
329 Some(band_pick(hash, 0, HOSTNAME_PREFIX_BAND.0, HOSTNAME_PREFIX_BAND.1) + suffix.len())
330 }
331 Category::FilePath
334 | Category::Url
335 | Category::AwsArn
336 | Category::AzureResourceId
337 | Category::Uuid
338 | Category::MacAddress
339 | Category::IpV4
340 | Category::IpV6
341 | Category::ContainerId
342 | Category::WindowsSid
343 | Category::Jwt => None,
344 }
345}
346
347fn format_digits_synth(hash: &[u8; 32], target: usize, ssn: bool) -> String {
351 let mut buf = String::with_capacity(target);
352 for i in 0..target {
353 if ssn && i == 0 {
354 buf.push('0');
355 } else {
356 buf.push((b'0' + hash[i % 32] % 10) as char);
357 }
358 }
359 buf
360}
361
362fn format_preserving_hex_rand(
367 hex: &[u8; 64],
368 original: &str,
369 is_structural: impl Fn(char) -> bool,
370) -> String {
371 let mut buf = String::with_capacity(original.len());
372 let mut seg_idx = 0usize;
373 let mut hi = 0usize;
374 let mut in_var = false;
375 for ch in original.chars() {
376 if is_structural(ch) {
377 buf.push(ch);
378 in_var = false;
379 } else if !in_var {
380 let seg_len = band_pick(hex, seg_idx + 1, SEGMENT_BAND.0, SEGMENT_BAND.1);
383 for _ in 0..seg_len {
384 buf.push(hex[hi % 64] as char);
385 hi += 1;
386 }
387 seg_idx += 1;
388 in_var = true;
389 }
390 }
391 buf
392}
393
394fn pad_or_truncate(s: &str, target: usize, hex: &[u8; 64]) -> String {
402 let slen = s.len();
403 if slen == target {
404 return s.to_string();
405 }
406 if slen > target {
407 return s[..target].to_string();
408 }
409 let mut buf = String::with_capacity(target);
410 buf.push_str(s);
411 for i in 0..target.saturating_sub(slen) {
412 buf.push(hex[i % 64] as char);
413 }
414 buf
415}
416
417fn format_email_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
421 let domain = original
422 .rfind('@')
423 .map_or("x.co", |pos| &original[pos + 1..]);
424 let at_domain = 1 + domain.len(); if target <= at_domain {
426 return pad_or_truncate("", target, hex);
428 }
429 let user_len = target - at_domain;
430 let mut buf = String::with_capacity(target);
431 for i in 0..user_len {
432 buf.push(hex[i % 64] as char);
433 }
434 buf.push('@');
435 buf.push_str(domain);
436 buf
437}
438
439fn format_name_lp(hash: &[u8; 32], hex: &[u8; 64], target: usize) -> String {
443 let raw = format_name(hash);
444 pad_or_truncate(&raw, target, hex)
445}
446
447fn format_char_class_lp(
452 hash: &[u8; 32],
453 original: &str,
454 is_replaceable: impl Fn(char) -> bool,
455 replacement: impl Fn(char, u8) -> char,
456) -> Option<String> {
457 let mut buf = String::with_capacity(original.len());
458 let mut hi = 0usize;
459 let mut had_replaceable = false;
460 for ch in original.chars() {
461 if is_replaceable(ch) {
462 buf.push(replacement(ch, hash[hi % 32]));
463 hi += 1;
464 had_replaceable = true;
465 } else {
466 buf.push(ch);
467 }
468 }
469 had_replaceable.then_some(buf)
470}
471
472fn format_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
477 format_char_class_lp(
478 hash,
479 original,
480 |c| c.is_ascii_digit(),
481 |_, b| (b'0' + b % 10) as char,
482 )
483 .unwrap_or_else(|| pad_or_truncate("", target, &hex_bytes(hash)))
484}
485
486fn format_ipv4_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
492 let mut buf = String::with_capacity(target);
493 for (i, part) in original.split('.').enumerate() {
494 if i > 0 {
495 buf.push('.');
496 }
497 let all_digits = !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit());
498 let range = match (all_digits, part.len()) {
499 (true, 1) => Some((0u16, 9u16)),
500 (true, 2) => Some((10, 99)),
501 (true, 3) => Some((100, 255)),
502 _ => None,
503 };
504 if let Some((lo, hi)) = range {
505 let w = (u16::from(hash[(i * 2) % 32]) << 8) | u16::from(hash[(i * 2 + 1) % 32]);
506 buf.push_str(&(lo + w % (hi - lo + 1)).to_string());
507 } else {
508 for (j, ch) in part.chars().enumerate() {
509 if ch.is_ascii_digit() {
510 buf.push((b'0' + hash[(i * 4 + j) % 32] % 10) as char);
511 } else {
512 buf.push(ch);
513 }
514 }
515 }
516 }
517 buf
518}
519
520fn format_hex_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
524 let hex = hex_bytes(hash);
525 format_char_class_lp(
526 hash,
527 original,
528 |c| c.is_ascii_hexdigit(),
529 |ch, b| {
530 let nibble = b % 16;
531 if ch.is_ascii_uppercase() {
532 b"0123456789ABCDEF"[nibble as usize] as char
533 } else {
534 b"0123456789abcdef"[nibble as usize] as char
535 }
536 },
537 )
538 .unwrap_or_else(|| pad_or_truncate("", target, &hex))
539}
540
541fn format_ssn_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
546 let has_digit = original.chars().any(|c| c.is_ascii_digit());
547 if !has_digit {
548 let hex = hex_bytes(hash);
549 return pad_or_truncate("", target, &hex);
550 }
551 let mut buf = String::with_capacity(target);
552 let mut digit_idx = 0usize;
553 for ch in original.chars() {
554 if ch.is_ascii_digit() {
555 if digit_idx < 3 {
556 buf.push('0');
557 } else {
558 buf.push((b'0' + hash[(digit_idx - 3) % 32] % 10) as char);
559 }
560 digit_idx += 1;
561 } else {
562 buf.push(ch);
563 }
564 }
565 buf
566}
567
568fn split_host_port(original: &str) -> (&str, &str) {
572 if let Some(colon) = original.rfind(':') {
573 let port = &original[colon + 1..];
574 if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) {
575 return (&original[..colon], &original[colon..]);
576 }
577 }
578 (original, "")
579}
580
581fn hostname_kept_suffix(original: &str) -> &str {
591 let Some(last_dot) = original.rfind('.') else {
592 return "";
593 };
594 let last_label = &original[last_dot + 1..];
595 if !last_label.is_empty() && last_label.bytes().all(|b| b.is_ascii_digit()) {
596 return "";
597 }
598 match original[..last_dot].rfind('.') {
599 Some(prev_dot) => &original[prev_dot..],
600 None => &original[last_dot..],
601 }
602}
603
604fn format_hostname_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
608 let suffix = hostname_kept_suffix(original);
609 let prefix_len = target.saturating_sub(suffix.len());
610 if prefix_len == 0 {
611 return pad_or_truncate("", target, hex);
612 }
613 let mut buf = String::with_capacity(target);
614 for i in 0..prefix_len {
615 buf.push(hex[i % 64] as char);
616 }
617 buf.push_str(suffix);
618 buf
619}
620
621fn format_custom_lp(hex: &[u8; 64], target: usize) -> String {
625 let prefix = "__SANITIZED_";
626 let suffix = "__";
627 let overhead = prefix.len() + suffix.len(); if target <= overhead {
629 return pad_or_truncate("", target, hex);
630 }
631 let hex_len = target - overhead;
632 let mut buf = String::with_capacity(target);
633 buf.push_str(prefix);
634 for i in 0..hex_len {
635 buf.push(hex[i % 64] as char);
636 }
637 buf.push_str(suffix);
638 buf
639}
640
641fn format_jwt_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
645 const B64URL: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
646 let mut buf = String::with_capacity(target);
647 let mut hi = 0usize;
648 let mut had_b64 = false;
649 for ch in original.chars() {
650 if ch == '.' || ch == '=' {
651 buf.push(ch);
652 } else if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
653 buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
654 hi += 1;
655 had_b64 = true;
656 } else {
657 for _ in 0..ch.len_utf8() {
659 buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
660 hi += 1;
661 }
662 had_b64 = true;
663 }
664 }
665 if !had_b64 {
666 let hex = hex_bytes(hash);
667 return pad_or_truncate("", target, &hex);
668 }
669 buf
670}
671
672fn format_filepath_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
683 let last_sep = original.rfind(['/', '\\']).map_or(0, |p| p + 1);
685 let filename = &original[last_sep..];
686 let ext_start = filename.rfind('.').filter(|&p| p > 0).map(|p| last_sep + p);
688
689 if randomized {
690 let mut buf = String::new();
691 let mut hi = 0usize;
692 for ch in original[..last_sep].chars() {
695 if matches!(ch, '/' | '\\') {
696 buf.push(ch);
697 } else {
698 for _ in 0..ch.len_utf8() {
699 buf.push(hex[hi % 64] as char);
700 hi += 1;
701 }
702 }
703 }
704 let stem_len = band_pick(hex, 0, SEGMENT_BAND.0, SEGMENT_BAND.1);
706 for _ in 0..stem_len {
707 buf.push(hex[hi % 64] as char);
708 hi += 1;
709 }
710 if let Some(es) = ext_start {
712 buf.push_str(&original[es..]);
713 }
714 return buf;
715 }
716
717 let mut buf = String::with_capacity(target);
718 let mut hi = 0usize;
719
720 for (i, ch) in original.char_indices() {
721 if matches!(ch, '/' | '\\') || ext_start.is_some_and(|es| i >= es) {
722 buf.push(ch);
724 } else {
725 for _ in 0..ch.len_utf8() {
727 buf.push(hex[hi % 64] as char);
728 hi += 1;
729 }
730 }
731 }
732 if buf.len() != target {
734 return pad_or_truncate(&buf, target, hex);
735 }
736 buf
737}
738
739fn format_windows_sid_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
743 let has_digit = original.chars().any(|c| c.is_ascii_digit());
744 if !has_digit {
745 let hex = hex_bytes(hash);
746 return pad_or_truncate("", target, &hex);
747 }
748 let mut buf = String::with_capacity(target);
749 let mut hi = 0usize;
750 for ch in original.chars() {
751 if ch == 'S' || ch == '-' {
752 buf.push(ch);
753 } else if ch.is_ascii_digit() {
754 buf.push((b'0' + hash[hi % 32] % 10) as char);
755 hi += 1;
756 } else {
757 for _ in 0..ch.len_utf8() {
759 buf.push((b'0' + hash[hi % 32] % 10) as char);
760 hi += 1;
761 }
762 }
763 }
764 buf
765}
766
767fn format_preserving_hex_lp(
775 hex: &[u8; 64],
776 original: &str,
777 target: usize,
778 is_structural: impl Fn(char) -> bool,
779) -> Option<String> {
780 let mut buf = String::with_capacity(target);
781 let mut hi = 0usize;
782 let mut had_content = false;
783
784 for ch in original.chars() {
785 if is_structural(ch) {
786 buf.push(ch);
787 } else {
788 for _ in 0..ch.len_utf8() {
789 buf.push(hex[hi % 64] as char);
790 hi += 1;
791 }
792 had_content = true;
793 }
794 }
795
796 had_content.then_some(buf)
797}
798
799fn format_url_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
805 let (scheme, rest) = match original.find("://") {
808 Some(p)
809 if original[..p]
810 .chars()
811 .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) =>
812 {
813 original.split_at(p + 3)
814 }
815 _ => ("", original),
816 };
817 let is_structural = |ch| "/:?=&#@.".contains(ch);
818 if randomized {
819 return format!(
820 "{scheme}{}",
821 format_preserving_hex_rand(hex, rest, is_structural)
822 );
823 }
824 let body_target = target.saturating_sub(scheme.len());
825 let body = format_preserving_hex_lp(hex, rest, body_target, is_structural)
826 .unwrap_or_else(|| pad_or_truncate("", body_target, hex));
827 format!("{scheme}{body}")
828}
829
830fn format_arn_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
835 let is_structural = |ch| ch == ':' || ch == '/';
836 if randomized {
837 return format_preserving_hex_rand(hex, original, is_structural);
838 }
839 format_preserving_hex_lp(hex, original, target, is_structural)
840 .unwrap_or_else(|| pad_or_truncate("", target, hex))
841}
842
843fn format_azure_resource_id_lp(
848 hex: &[u8; 64],
849 original: &str,
850 target: usize,
851 randomized: bool,
852) -> String {
853 const KNOWN_SEGMENTS: &[&str] = &[
854 "subscriptions",
855 "resourceGroups",
856 "resourcegroups",
857 "providers",
858 ];
859
860 let mut buf = String::with_capacity(target);
861 let mut hi = 0usize;
862 let mut seg_idx = 0usize;
863
864 let mut prev_was_providers = false;
866 for (pi, part) in original.split('/').enumerate() {
867 if pi > 0 {
868 buf.push('/');
869 }
870 let is_provider_namespace = prev_was_providers && part.contains('.');
875 if part.is_empty() || KNOWN_SEGMENTS.contains(&part) || is_provider_namespace {
876 buf.push_str(part);
877 } else if randomized {
878 let seg_len = band_pick(hex, seg_idx + 1, SEGMENT_BAND.0, SEGMENT_BAND.1);
881 for _ in 0..seg_len {
882 buf.push(hex[hi % 64] as char);
883 hi += 1;
884 }
885 seg_idx += 1;
886 } else {
887 for ch in part.chars() {
889 for _ in 0..ch.len_utf8() {
890 buf.push(hex[hi % 64] as char);
891 hi += 1;
892 }
893 }
894 }
895 prev_was_providers = part == "providers" || part == "Providers";
896 }
897 if !randomized && buf.len() != target {
898 return pad_or_truncate(&buf, target, hex);
899 }
900 buf
901}
902
903fn format_name(hash: &[u8; 32]) -> String {
905 const FIRST: &[&str] = &[
909 "Alex", "Blake", "Casey", "Dana", "Ellis", "Finley", "Gray", "Harper", "Ira", "Jordan",
910 "Kai", "Lane", "Morgan", "Noel", "Oakley", "Parker", "Quinn", "Reese", "Sage", "Taylor",
911 "Uri", "Val", "Wren", "Xen", "Yael", "Zion", "Arden", "Blair", "Corin", "Drew", "Emery",
912 "Frost",
913 ];
914 const LAST: &[&str] = &[
915 "Ashford",
916 "Blackwell",
917 "Crawford",
918 "Dalton",
919 "Eastwood",
920 "Fairbanks",
921 "Garrison",
922 "Hartley",
923 "Irvine",
924 "Jensen",
925 "Kendrick",
926 "Langley",
927 "Mercer",
928 "Newland",
929 "Oakwood",
930 "Preston",
931 "Quinlan",
932 "Redmond",
933 "Shepard",
934 "Thornton",
935 "Underwood",
936 "Vance",
937 "Whitmore",
938 "Xavier",
939 "Yardley",
940 "Zimmer",
941 "Ashton",
942 "Beckett",
943 "Calloway",
944 "Dempsey",
945 "Eldridge",
946 "Fletcher",
947 ];
948 let fi = hash[0] as usize % FIRST.len();
949 let li = hash[1] as usize % LAST.len();
950 format!("{} {}", FIRST[fi], LAST[li])
951}
952
953fn hex_bytes(bytes: &[u8; 32]) -> [u8; 64] {
955 const HEX: &[u8; 16] = b"0123456789abcdef";
956 let mut out = [0u8; 64];
957 for (i, &b) in bytes.iter().enumerate() {
958 out[i * 2] = HEX[(b >> 4) as usize];
959 out[i * 2 + 1] = HEX[(b & 0xf) as usize];
960 }
961 out
962}
963
964#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn hmac_deterministic_same_input() {
974 let gen = HmacGenerator::new([42u8; 32]);
975 let a = gen.generate(&Category::Email, "alice@corp.com");
976 let b = gen.generate(&Category::Email, "alice@corp.com");
977 assert_eq!(a, b, "same seed + same input must produce same output");
978 }
979
980 #[test]
981 fn hmac_different_inputs_differ() {
982 let gen = HmacGenerator::new([42u8; 32]);
983 let a = gen.generate(&Category::Email, "alice@corp.com");
984 let b = gen.generate(&Category::Email, "bob@corp.com");
985 assert_ne!(a, b);
986 }
987
988 #[test]
989 fn hmac_different_seeds_differ() {
990 let g1 = HmacGenerator::new([1u8; 32]);
991 let g2 = HmacGenerator::new([2u8; 32]);
992 let a = g1.generate(&Category::Email, "alice@corp.com");
993 let b = g2.generate(&Category::Email, "alice@corp.com");
994 assert_ne!(a, b);
995 }
996
997 #[test]
998 fn hmac_different_categories_differ() {
999 let gen = HmacGenerator::new([42u8; 32]);
1000 let a = gen.generate(&Category::Email, "test");
1001 let b = gen.generate(&Category::Name, "test");
1002 assert_ne!(a, b, "different categories must produce different outputs");
1003 }
1004
1005 #[test]
1006 fn email_format() {
1007 let gen = HmacGenerator::new([0u8; 32]);
1008 let orig = "alice@corp.com";
1009 let out = gen.generate(&Category::Email, orig);
1010 assert!(out.contains('@'), "email must contain @");
1011 assert!(out.ends_with("@corp.com"), "email must preserve domain");
1012 assert_eq!(out.len(), orig.len(), "email must preserve length");
1013 }
1014
1015 #[test]
1016 fn ipv4_format() {
1017 let gen = HmacGenerator::new([0u8; 32]);
1018 let orig = "192.168.1.1";
1019 let out = gen.generate(&Category::IpV4, orig);
1020 let parts: Vec<&str> = out.split('.').collect();
1022 assert_eq!(parts.len(), 4);
1023 assert_eq!(out.len(), orig.len(), "ipv4 must preserve length");
1024 for p in parts {
1025 assert!(p.parse::<u8>().is_ok(), "octet {p} out of range in {out}");
1026 }
1027 }
1028
1029 #[test]
1030 fn ssn_format() {
1031 let gen = HmacGenerator::new([7u8; 32]);
1032 let orig = "123-45-6789";
1033 let out = gen.generate(&Category::Ssn, orig);
1034 assert!(out.starts_with("000-"), "SSN must start with 000");
1035 assert_eq!(out.len(), orig.len(), "SSN must preserve length");
1036 }
1037
1038 #[test]
1039 fn phone_format() {
1040 let gen = HmacGenerator::new([3u8; 32]);
1041 let orig = "+1-212-555-0100";
1042 let out = gen.generate(&Category::Phone, orig);
1043 assert!(out.starts_with('+'));
1045 assert_eq!(
1046 out.chars().filter(|c| *c == '-').count(),
1047 orig.chars().filter(|c| *c == '-').count(),
1048 "dashes must be preserved"
1049 );
1050 assert_eq!(out.len(), orig.len(), "phone must preserve length");
1051 }
1052
1053 #[test]
1054 fn hostname_format() {
1055 let gen = HmacGenerator::new([5u8; 32]);
1056 let orig = "db-prod-01.internal";
1057 let out = gen.generate(&Category::Hostname, orig);
1058 assert!(out.ends_with(".internal"), "hostname must preserve suffix");
1059 assert_eq!(out.len(), orig.len(), "hostname must preserve length");
1060 }
1061
1062 #[test]
1063 fn hostname_keeps_at_most_the_final_two_labels() {
1064 let gen = HmacGenerator::new([5u8; 32]);
1067 let orig = "env-0a1b2c3d.gcp.vendorsandbox.net";
1068 let out = gen.generate(&Category::Hostname, orig);
1069 assert!(
1070 out.ends_with(".vendorsandbox.net"),
1071 "org+TLD tail preserved: {out}"
1072 );
1073 assert!(
1074 !out.contains("env-0a1b2c3d") && !out.contains(".gcp."),
1075 "leading and intermediate labels replaced: {out}"
1076 );
1077 assert_eq!(out.len(), orig.len(), "hostname must preserve length");
1078 }
1079
1080 #[test]
1081 fn hostname_categorized_ip_replaces_every_octet() {
1082 let gen = HmacGenerator::new([5u8; 32]);
1086 let orig = "203.0.113.121";
1087 let out = gen.generate(&Category::Hostname, orig);
1088 assert!(
1089 !out.ends_with(".0.113.121"),
1090 "trailing octets must not pass through verbatim: {out}"
1091 );
1092 for (i, p) in out.split('.').enumerate() {
1093 assert!(p.parse::<u8>().is_ok(), "octet {p} out of range in {out}");
1094 assert_ne!(
1095 p,
1096 orig.split('.').nth(i).unwrap(),
1097 "octet {i} survived: {out}"
1098 );
1099 }
1100 }
1101
1102 #[test]
1103 fn hostname_categorized_ip_with_port_keeps_only_the_port() {
1104 let gen = HmacGenerator::new([5u8; 32]);
1107 let out = gen.generate(&Category::Hostname, "203.0.113.121:80");
1108 assert!(out.ends_with(":80"), "port kept verbatim: {out}");
1109 assert!(
1110 !out.contains(".74.121"),
1111 "no original octets may survive: {out}"
1112 );
1113 let host = out.strip_suffix(":80").unwrap();
1114 for p in host.split('.') {
1115 assert!(p.parse::<u8>().is_ok(), "octet {p} out of range in {out}");
1116 }
1117 }
1118
1119 #[test]
1120 fn hostname_with_numeric_tail_keeps_nothing() {
1121 let gen = HmacGenerator::new([5u8; 32]);
1125 let out = gen.generate(&Category::Hostname, "c3.0.113.121:80");
1126 assert!(
1127 !out.contains("196") && !out.contains(".74.") && !out.contains("121"),
1128 "no octet fragment may survive: {out}"
1129 );
1130 assert!(out.ends_with(":80"), "port kept verbatim: {out}");
1131 }
1132
1133 #[test]
1134 fn hostname_with_port_keeps_domain_tail_and_port() {
1135 let gen = HmacGenerator::new([5u8; 32]);
1136 let out = gen.generate(
1137 &Category::Hostname,
1138 "env-0a1b2c3d.gcp.vendorsandbox.net:8080",
1139 );
1140 assert!(
1141 out.ends_with(".vendorsandbox.net:8080"),
1142 "org+TLD tail and port preserved: {out}"
1143 );
1144 assert!(
1145 !out.contains("env-0a1b2c3d") && !out.contains(".gcp."),
1146 "identifying labels replaced: {out}"
1147 );
1148 }
1149
1150 #[test]
1151 fn custom_format() {
1152 let gen = HmacGenerator::new([9u8; 32]);
1153 let cat = Category::Custom("api_key".into());
1154 let orig = "sk-abc123-very-long-key";
1156 let out = gen.generate(&cat, orig);
1157 assert!(out.starts_with("__SANITIZED_"));
1158 assert!(out.ends_with("__"));
1159 assert_eq!(out.len(), orig.len(), "custom must preserve length");
1160 }
1161
1162 #[test]
1163 fn custom_format_short() {
1164 let gen = HmacGenerator::new([9u8; 32]);
1165 let cat = Category::Custom("api_key".into());
1166 let orig = "sk-abc123";
1168 let out = gen.generate(&cat, orig);
1169 assert_eq!(
1170 out.len(),
1171 orig.len(),
1172 "custom must preserve length even for short inputs"
1173 );
1174 }
1175
1176 #[test]
1177 fn random_generator_produces_valid_format() {
1178 let gen = RandomGenerator::new();
1179 let orig = "test@example.com";
1180 let out = gen.generate(&Category::Email, orig);
1181 assert!(out.contains('@'));
1182 assert_eq!(
1183 out.len(),
1184 orig.len(),
1185 "random generator must preserve length"
1186 );
1187 }
1188
1189 #[test]
1190 fn from_slice_rejects_bad_length() {
1191 let result = HmacGenerator::from_slice(&[0u8; 16]);
1192 assert!(result.is_err());
1193 }
1194
1195 #[test]
1196 fn credit_card_format() {
1197 let gen = HmacGenerator::new([11u8; 32]);
1198 let orig = "4111-1111-1111-1111";
1199 let out = gen.generate(&Category::CreditCard, orig);
1200 let parts: Vec<&str> = out.split('-').collect();
1202 assert_eq!(parts.len(), 4);
1203 for part in &parts {
1204 assert_eq!(part.len(), 4);
1205 assert!(part.chars().all(|c| c.is_ascii_digit()));
1206 }
1207 assert_eq!(out.len(), orig.len(), "credit card must preserve length");
1208 }
1209
1210 #[test]
1211 fn name_format() {
1212 let gen = HmacGenerator::new([0u8; 32]);
1213 let orig = "John Doe";
1214 let out = gen.generate(&Category::Name, orig);
1215 assert_eq!(out.len(), orig.len(), "name must preserve length");
1216 }
1217
1218 #[test]
1219 fn ipv6_format() {
1220 let gen = HmacGenerator::new([0u8; 32]);
1221 let orig = "fd00:abcd:1234:5678::1";
1222 let out = gen.generate(&Category::IpV6, orig);
1223 assert_eq!(
1225 out.chars().filter(|c| *c == ':').count(),
1226 orig.chars().filter(|c| *c == ':').count(),
1227 "colons must be preserved"
1228 );
1229 assert_eq!(out.len(), orig.len(), "ipv6 must preserve length");
1230 }
1231
1232 #[test]
1233 fn length_preserved_all_categories() {
1234 let gen = HmacGenerator::new([42u8; 32]);
1235 let cases: Vec<(Category, &str)> = vec![
1236 (Category::Email, "alice@corp.com"),
1237 (Category::Name, "John Doe"),
1238 (Category::Phone, "+1-212-555-0100"),
1239 (Category::IpV4, "192.168.1.1"),
1240 (Category::IpV6, "fd00::1"),
1241 (Category::CreditCard, "4111-1111-1111-1111"),
1242 (Category::Ssn, "123-45-6789"),
1243 (Category::Hostname, "db-prod-01.internal"),
1244 (Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
1245 (Category::ContainerId, "a1b2c3d4e5f6"),
1246 (Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
1247 (Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
1248 (Category::AuthToken, "ghp_abc123secrettoken"),
1249 (Category::FilePath, "/home/jsmith/config.yaml"),
1250 (Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
1251 (Category::Url, "https://internal.corp.com/api"),
1252 (Category::AwsArn, "arn:aws:iam::123456789012:user/admin"),
1253 (
1254 Category::AzureResourceId,
1255 "/subscriptions/550e8400/resourceGroups/rg-prod",
1256 ),
1257 (Category::Custom("key".into()), "some-secret-value-here"),
1258 ];
1259 for (cat, orig) in &cases {
1260 let out = gen.generate(cat, orig);
1261 assert_eq!(
1262 out.len(),
1263 orig.len(),
1264 "length mismatch for {:?}: '{}' ({}) -> '{}' ({})",
1265 cat,
1266 orig,
1267 orig.len(),
1268 out,
1269 out.len()
1270 );
1271 }
1272 }
1273
1274 #[test]
1275 fn mac_address_format() {
1276 let gen = HmacGenerator::new([7u8; 32]);
1277 let orig = "AA:BB:CC:DD:EE:FF";
1278 let out = gen.generate(&Category::MacAddress, orig);
1279 assert_eq!(out.len(), orig.len(), "mac must preserve length");
1280 assert_eq!(
1281 out.chars().filter(|c| *c == ':').count(),
1282 5,
1283 "mac must preserve colons"
1284 );
1285 }
1286
1287 #[test]
1288 fn mac_address_dash_format() {
1289 let gen = HmacGenerator::new([7u8; 32]);
1290 let orig = "AA-BB-CC-DD-EE-FF";
1291 let out = gen.generate(&Category::MacAddress, orig);
1292 assert_eq!(out.len(), orig.len());
1293 assert_eq!(out.chars().filter(|c| *c == '-').count(), 5);
1294 }
1295
1296 #[test]
1297 fn uuid_format() {
1298 let gen = HmacGenerator::new([3u8; 32]);
1299 let orig = "550e8400-e29b-41d4-a716-446655440000";
1300 let out = gen.generate(&Category::Uuid, orig);
1301 assert_eq!(out.len(), orig.len(), "uuid must preserve length");
1302 assert_eq!(
1303 out.chars().filter(|c| *c == '-').count(),
1304 4,
1305 "uuid must preserve dashes"
1306 );
1307 }
1308
1309 #[test]
1310 fn container_id_format() {
1311 let gen = HmacGenerator::new([5u8; 32]);
1312 let orig = "a1b2c3d4e5f6";
1313 let out = gen.generate(&Category::ContainerId, orig);
1314 assert_eq!(out.len(), orig.len(), "container id must preserve length");
1315 assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
1316 }
1317
1318 #[test]
1319 fn jwt_format() {
1320 let gen = HmacGenerator::new([11u8; 32]);
1321 let orig = "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK";
1322 let out = gen.generate(&Category::Jwt, orig);
1323 assert_eq!(out.len(), orig.len(), "jwt must preserve length");
1324 let orig_dots = orig.chars().filter(|c| *c == '.').count();
1325 let out_dots = out.chars().filter(|c| *c == '.').count();
1326 assert_eq!(out_dots, orig_dots, "jwt must preserve dots");
1327 }
1328
1329 #[test]
1330 fn auth_token_format() {
1331 let gen = HmacGenerator::new([9u8; 32]);
1332 let orig = "ghp_abc123secrettoken";
1333 let out = gen.generate(&Category::AuthToken, orig);
1334 assert!(out.starts_with("__SANITIZED_"));
1335 assert!(out.ends_with("__"));
1336 assert_eq!(out.len(), orig.len(), "auth_token must preserve length");
1337 }
1338
1339 #[test]
1340 fn filepath_unix_format() {
1341 let gen = HmacGenerator::new([13u8; 32]);
1342 let orig = "/home/jsmith/config.yaml";
1343 let out = gen.generate(&Category::FilePath, orig);
1344 assert_eq!(out.len(), orig.len(), "filepath must preserve length");
1345 assert_eq!(
1346 std::path::Path::new(&out)
1347 .extension()
1348 .and_then(|e| e.to_str()),
1349 Some("yaml"),
1350 "filepath must preserve extension"
1351 );
1352 assert_eq!(
1353 out.chars().filter(|c| *c == '/').count(),
1354 orig.chars().filter(|c| *c == '/').count(),
1355 "filepath must preserve separators"
1356 );
1357 }
1358
1359 #[test]
1360 fn filepath_windows_format() {
1361 let gen = HmacGenerator::new([13u8; 32]);
1362 let orig = "C:\\Users\\admin\\secrets.txt";
1363 let out = gen.generate(&Category::FilePath, orig);
1364 assert_eq!(out.len(), orig.len(), "filepath must preserve length");
1365 assert_eq!(
1366 std::path::Path::new(&out)
1367 .extension()
1368 .and_then(|e| e.to_str()),
1369 Some("txt"),
1370 "filepath must preserve extension"
1371 );
1372 assert_eq!(
1373 out.chars().filter(|c| *c == '\\').count(),
1374 orig.chars().filter(|c| *c == '\\').count(),
1375 "filepath must preserve backslashes"
1376 );
1377 }
1378
1379 #[test]
1380 fn windows_sid_format() {
1381 let gen = HmacGenerator::new([7u8; 32]);
1382 let orig = "S-1-5-21-3623811015-3361044348-30300820-1013";
1383 let out = gen.generate(&Category::WindowsSid, orig);
1384 assert_eq!(out.len(), orig.len(), "SID must preserve length");
1385 assert!(out.starts_with("S-"), "SID must start with S-");
1386 assert_eq!(
1387 out.chars().filter(|c| *c == '-').count(),
1388 orig.chars().filter(|c| *c == '-').count(),
1389 "SID must preserve dashes"
1390 );
1391 }
1392
1393 #[test]
1394 fn url_format() {
1395 let gen = HmacGenerator::new([5u8; 32]);
1396 let orig = "https://internal.corp.com/api/users?token=abc123";
1397 let out = gen.generate(&Category::Url, orig);
1398 assert_eq!(out.len(), orig.len(), "url must preserve length");
1399 assert!(out.starts_with("https://"), "scheme must survive: {out}");
1401 assert!(out.contains('?'));
1402 assert!(out.contains('='));
1403 assert!(
1404 !out.contains("internal") && !out.contains("corp"),
1405 "host content must be replaced: {out}"
1406 );
1407 }
1408
1409 #[test]
1410 fn url_without_scheme_still_replaced() {
1411 let gen = HmacGenerator::new([5u8; 32]);
1412 let orig = "internal.corp.com/api/users";
1413 let out = gen.generate(&Category::Url, orig);
1414 assert_eq!(out.len(), orig.len());
1415 assert!(!out.contains("internal"), "host must be replaced: {out}");
1416 }
1417
1418 #[test]
1419 fn aws_arn_format() {
1420 let gen = HmacGenerator::new([3u8; 32]);
1421 let orig = "arn:aws:iam::123456789012:user/admin";
1422 let out = gen.generate(&Category::AwsArn, orig);
1423 assert_eq!(out.len(), orig.len(), "ARN must preserve length");
1424 assert_eq!(
1425 out.chars().filter(|c| *c == ':').count(),
1426 orig.chars().filter(|c| *c == ':').count(),
1427 "ARN must preserve colons"
1428 );
1429 assert!(out.contains('/'), "ARN must preserve slash");
1430 }
1431
1432 #[test]
1433 fn azure_resource_id_format() {
1434 let gen = HmacGenerator::new([11u8; 32]);
1435 let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
1436 let out = gen.generate(&Category::AzureResourceId, orig);
1437 assert_eq!(
1438 out.len(),
1439 orig.len(),
1440 "Azure resource ID must preserve length"
1441 );
1442 assert!(
1443 out.contains("/subscriptions/"),
1444 "must preserve 'subscriptions'"
1445 );
1446 assert!(
1447 out.contains("/resourceGroups/"),
1448 "must preserve 'resourceGroups'"
1449 );
1450 assert!(out.contains("/providers/"), "must preserve 'providers'");
1451 assert!(
1452 out.contains("Microsoft.Compute"),
1453 "must preserve dotted provider name"
1454 );
1455 }
1456
1457 #[test]
1458 fn azure_dotted_segment_outside_providers_is_replaced() {
1459 let gen = HmacGenerator::new([11u8; 32]);
1460 let orig = "/subscriptions/10.0.0.1/resourceGroups/rg-prod";
1464 let out = gen.generate(&Category::AzureResourceId, orig);
1465 assert_eq!(out.len(), orig.len(), "length must be preserved");
1466 assert!(out.contains("/subscriptions/"), "subscriptions preserved");
1467 assert!(out.contains("/resourceGroups/"), "resourceGroups preserved");
1468 assert!(
1469 !out.contains("10.0.0.1"),
1470 "dotted non-provider segment must be replaced, got: {out}"
1471 );
1472 assert!(
1473 !out.contains("rg-prod"),
1474 "variable resource group name must be replaced, got: {out}"
1475 );
1476 }
1477
1478 fn rand_gen(seed: u8) -> HmacGenerator {
1483 HmacGenerator::new([seed; 32]).with_length_policy(LengthPolicy::Randomized)
1484 }
1485
1486 #[test]
1487 fn randomized_digits_decoupled_from_input_length() {
1488 let gen = rand_gen(42);
1489 for cat in [Category::Phone, Category::CreditCard, Category::Ssn] {
1490 for len in 1..40usize {
1491 let orig: String = "1234567890".chars().cycle().take(len).collect();
1493 let out = gen.generate(&cat, &orig);
1494 assert!(
1495 out.chars().all(|c| c.is_ascii_digit()),
1496 "{cat:?} output must be all digits: {out}"
1497 );
1498 assert!(
1499 out.len() >= DIGITS_BAND.0 && out.len() <= DIGITS_BAND.1,
1500 "{cat:?} length {} out of band for input len {len}",
1501 out.len()
1502 );
1503 if cat == Category::Ssn {
1504 assert!(out.starts_with('0'), "ssn must start with 0: {out}");
1505 }
1506 }
1507 }
1508 }
1509
1510 #[test]
1511 fn randomized_is_stable_per_value() {
1512 let gen = rand_gen(7);
1513 for (cat, orig) in [
1514 (Category::Phone, "+1-212-555-0100"),
1515 (Category::Email, "alice@corp.com"),
1516 (Category::Hostname, "db-prod-01.internal"),
1517 (Category::FilePath, "/home/jsmith/config.yaml"),
1518 (Category::AuthToken, "ghp_abc123secrettoken"),
1519 ] {
1520 let a = gen.generate(&cat, orig);
1521 let b = gen.generate(&cat, orig);
1522 assert_eq!(a, b, "{cat:?} must be stable for the same value");
1523 }
1524 }
1525
1526 #[test]
1527 fn randomized_email_keeps_domain_varies_user() {
1528 let gen = rand_gen(3);
1529 for ulen in 1..30usize {
1532 let user = "a".repeat(ulen);
1533 let orig = format!("{user}@corp.com");
1534 let out = gen.generate(&Category::Email, &orig);
1535 assert!(
1536 out.ends_with("@corp.com"),
1537 "domain must be preserved: {out}"
1538 );
1539 assert!(out.contains('@'));
1540 let out_user = out.split('@').next().unwrap().len();
1541 assert!(
1542 out_user >= EMAIL_USER_BAND.0 && out_user <= EMAIL_USER_BAND.1,
1543 "email user length {out_user} out of band for input ulen {ulen}"
1544 );
1545 }
1546 }
1547
1548 #[test]
1549 fn randomized_hostname_keeps_suffix() {
1550 let gen = rand_gen(5);
1551 let out = gen.generate(&Category::Hostname, "db-prod-01.internal");
1552 assert!(
1553 out.ends_with(".internal"),
1554 "suffix must be preserved: {out}"
1555 );
1556 let prefix = out.strip_suffix(".internal").unwrap().len();
1557 assert!(prefix >= HOSTNAME_PREFIX_BAND.0 && prefix <= HOSTNAME_PREFIX_BAND.1);
1558 }
1559
1560 #[test]
1561 fn randomized_filepath_keeps_extension_and_separators() {
1562 let gen = rand_gen(13);
1563 let orig = "/home/jsmith/config.yaml";
1564 let out = gen.generate(&Category::FilePath, orig);
1565 assert_eq!(
1566 std::path::Path::new(&out)
1567 .extension()
1568 .and_then(|e| e.to_str()),
1569 Some("yaml"),
1570 "extension must stay at the end: {out}"
1571 );
1572 assert_eq!(
1573 out.chars().filter(|c| *c == '/').count(),
1574 orig.chars().filter(|c| *c == '/').count(),
1575 "separators must be preserved: {out}"
1576 );
1577 assert!(
1578 !out.ends_with("yaml") || out.contains(".yaml"),
1579 "must contain the .yaml extension verbatim: {out}"
1580 );
1581 }
1582
1583 #[test]
1584 fn randomized_url_arn_keep_structure() {
1585 let gen = rand_gen(9);
1586 let url = gen.generate(
1587 &Category::Url,
1588 "https://internal.corp.com/api/users?token=abc123",
1589 );
1590 assert!(url.contains("://"), "url scheme separator preserved: {url}");
1591 assert!(
1592 url.contains('?') && url.contains('='),
1593 "url query preserved: {url}"
1594 );
1595
1596 let arn = gen.generate(&Category::AwsArn, "arn:aws:iam::123456789012:user/admin");
1597 assert_eq!(
1598 arn.chars().filter(|c| *c == ':').count(),
1599 "arn:aws:iam::123456789012:user/admin"
1600 .chars()
1601 .filter(|c| *c == ':')
1602 .count(),
1603 "arn colons preserved: {arn}"
1604 );
1605 assert!(arn.contains('/'), "arn slash preserved: {arn}");
1606 }
1607
1608 #[test]
1609 fn randomized_azure_keeps_known_segments() {
1610 let gen = rand_gen(11);
1611 let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
1612 let out = gen.generate(&Category::AzureResourceId, orig);
1613 assert!(out.contains("/subscriptions/"));
1614 assert!(out.contains("/resourceGroups/"));
1615 assert!(out.contains("/providers/"));
1616 assert!(out.contains("Microsoft.Compute"));
1617 assert!(
1618 !out.contains("rg-prod"),
1619 "variable segment must be replaced: {out}"
1620 );
1621 }
1622
1623 #[test]
1624 fn randomized_canonical_categories_are_noop() {
1625 let gen = rand_gen(1);
1628 let cases: Vec<(Category, &str)> = vec![
1629 (Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
1630 (Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
1631 (Category::IpV4, "192.168.1.1"),
1632 (Category::IpV6, "fd00:abcd:1234:5678::1"),
1633 (Category::ContainerId, "a1b2c3d4e5f6"),
1634 (Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
1635 (Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
1636 ];
1637 for (cat, orig) in &cases {
1638 let out = gen.generate(cat, orig);
1639 assert_eq!(
1640 out.len(),
1641 orig.len(),
1642 "{cat:?} must stay length-preserving under Randomized: {orig} -> {out}"
1643 );
1644 }
1645 }
1646
1647 #[test]
1648 fn randomized_token_is_valid_and_in_band() {
1649 let gen = rand_gen(9);
1650 for cat in [Category::AuthToken, Category::Custom("api_key".into())] {
1651 let out = gen.generate(&cat, "sk-abc123-some-secret-value");
1652 assert!(out.starts_with("__SANITIZED_"), "{cat:?}: {out}");
1653 assert!(out.ends_with("__"), "{cat:?}: {out}");
1654 let overhead = "__SANITIZED_".len() + "__".len();
1655 let hex_len = out.len() - overhead;
1656 assert!(
1657 hex_len >= TOKEN_BAND.0 && hex_len <= TOKEN_BAND.1,
1658 "{cat:?} token hex length {hex_len} out of band"
1659 );
1660 }
1661 }
1662}