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
37pub struct HmacGenerator {
50 key: [u8; 32],
51}
52
53impl Drop for HmacGenerator {
54 fn drop(&mut self) {
55 self.key.zeroize();
56 }
57}
58
59impl HmacGenerator {
60 #[must_use]
62 pub fn new(key: [u8; 32]) -> Self {
63 Self { key }
64 }
65
66 pub fn from_slice(bytes: &[u8]) -> crate::error::Result<Self> {
72 if bytes.len() != 32 {
73 return Err(crate::error::SanitizeError::InvalidSeedLength(bytes.len()));
74 }
75 let mut key = [0u8; 32];
76 key.copy_from_slice(bytes);
77 Ok(Self { key })
78 }
79
80 fn derive(&self, category: &Category, original: &str) -> [u8; 32] {
82 type HmacSha256 = Hmac<Sha256>;
83 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
84 let tag = category.domain_tag_hmac();
85 mac.update(tag.as_bytes());
86 mac.update(b"\x00"); mac.update(original.as_bytes());
88 let result = mac.finalize();
89 let mut out = [0u8; 32];
90 out.copy_from_slice(&result.into_bytes());
91 out
92 }
93}
94
95impl ReplacementGenerator for HmacGenerator {
96 fn generate(&self, category: &Category, original: &str) -> String {
97 let hash = self.derive(category, original);
98 format_replacement(category, &hash, original)
99 }
100}
101
102pub struct RandomGenerator;
112
113impl RandomGenerator {
114 #[must_use]
115 pub fn new() -> Self {
116 Self
117 }
118}
119
120impl Default for RandomGenerator {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126impl ReplacementGenerator for RandomGenerator {
127 fn generate(&self, category: &Category, original: &str) -> String {
128 let mut rng = rand::rng();
129 let mut hash = [0u8; 32];
130 rng.fill(&mut hash);
131 format_replacement(category, &hash, original)
132 }
133}
134
135fn format_replacement(category: &Category, hash: &[u8; 32], original: &str) -> String {
143 let target = original.len();
144 if target == 0 {
145 return String::new();
146 }
147 let hex = hex_bytes(hash);
148 match category {
149 Category::Email => format_email_lp(&hex, original, target),
150 Category::Name => format_name_lp(hash, &hex, target),
151 Category::Phone | Category::CreditCard | Category::IpV4 => {
152 format_digits_lp(hash, original, target)
153 }
154 Category::IpV6 | Category::MacAddress | Category::Uuid | Category::ContainerId => {
155 format_hex_digits_lp(hash, original, target)
156 }
157 Category::Ssn => format_ssn_lp(hash, original, target),
158 Category::Hostname => format_hostname_lp(&hex, original, target),
159 Category::Jwt => format_jwt_lp(hash, original, target),
160 Category::FilePath => format_filepath_lp(&hex, original, target),
161 Category::WindowsSid => format_windows_sid_lp(hash, original, target),
162 Category::Url => format_url_lp(&hex, original, target),
163 Category::AwsArn => format_arn_lp(&hex, original, target),
164 Category::AzureResourceId => format_azure_resource_id_lp(&hex, original, target),
165 Category::AuthToken | Category::Custom(_) => format_custom_lp(&hex, target),
166 }
167}
168
169fn pad_or_truncate(s: &str, target: usize, hex: &[u8; 64]) -> String {
177 let slen = s.len();
178 if slen == target {
179 return s.to_string();
180 }
181 if slen > target {
182 return s[..target].to_string();
183 }
184 let mut buf = String::with_capacity(target);
185 buf.push_str(s);
186 for i in 0..target.saturating_sub(slen) {
187 buf.push(hex[i % 64] as char);
188 }
189 buf
190}
191
192fn format_email_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
196 let domain = original
197 .rfind('@')
198 .map_or("x.co", |pos| &original[pos + 1..]);
199 let at_domain = 1 + domain.len(); if target <= at_domain {
201 return pad_or_truncate("", target, hex);
203 }
204 let user_len = target - at_domain;
205 let mut buf = String::with_capacity(target);
206 for i in 0..user_len {
207 buf.push(hex[i % 64] as char);
208 }
209 buf.push('@');
210 buf.push_str(domain);
211 buf
212}
213
214fn format_name_lp(hash: &[u8; 32], hex: &[u8; 64], target: usize) -> String {
218 let raw = format_name(hash);
219 pad_or_truncate(&raw, target, hex)
220}
221
222fn format_char_class_lp(
227 hash: &[u8; 32],
228 original: &str,
229 is_replaceable: impl Fn(char) -> bool,
230 replacement: impl Fn(char, u8) -> char,
231) -> Option<String> {
232 let mut buf = String::with_capacity(original.len());
233 let mut hi = 0usize;
234 let mut had_replaceable = false;
235 for ch in original.chars() {
236 if is_replaceable(ch) {
237 buf.push(replacement(ch, hash[hi % 32]));
238 hi += 1;
239 had_replaceable = true;
240 } else {
241 buf.push(ch);
242 }
243 }
244 had_replaceable.then_some(buf)
245}
246
247fn format_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
252 format_char_class_lp(
253 hash,
254 original,
255 |c| c.is_ascii_digit(),
256 |_, b| (b'0' + b % 10) as char,
257 )
258 .unwrap_or_else(|| pad_or_truncate("", target, &hex_bytes(hash)))
259}
260
261fn format_hex_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
265 let hex = hex_bytes(hash);
266 format_char_class_lp(
267 hash,
268 original,
269 |c| c.is_ascii_hexdigit(),
270 |ch, b| {
271 let nibble = b % 16;
272 if ch.is_ascii_uppercase() {
273 b"0123456789ABCDEF"[nibble as usize] as char
274 } else {
275 b"0123456789abcdef"[nibble as usize] as char
276 }
277 },
278 )
279 .unwrap_or_else(|| pad_or_truncate("", target, &hex))
280}
281
282fn format_ssn_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
287 let has_digit = original.chars().any(|c| c.is_ascii_digit());
288 if !has_digit {
289 let hex = hex_bytes(hash);
290 return pad_or_truncate("", target, &hex);
291 }
292 let mut buf = String::with_capacity(target);
293 let mut digit_idx = 0usize;
294 for ch in original.chars() {
295 if ch.is_ascii_digit() {
296 if digit_idx < 3 {
297 buf.push('0');
298 } else {
299 buf.push((b'0' + hash[(digit_idx - 3) % 32] % 10) as char);
300 }
301 digit_idx += 1;
302 } else {
303 buf.push(ch);
304 }
305 }
306 buf
307}
308
309fn format_hostname_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
313 let suffix = original.find('.').map_or("", |p| &original[p..]);
314 let prefix_len = target.saturating_sub(suffix.len());
315 if prefix_len == 0 {
316 return pad_or_truncate("", target, hex);
317 }
318 let mut buf = String::with_capacity(target);
319 for i in 0..prefix_len {
320 buf.push(hex[i % 64] as char);
321 }
322 buf.push_str(suffix);
323 buf
324}
325
326fn format_custom_lp(hex: &[u8; 64], target: usize) -> String {
330 let prefix = "__SANITIZED_";
331 let suffix = "__";
332 let overhead = prefix.len() + suffix.len(); if target <= overhead {
334 return pad_or_truncate("", target, hex);
335 }
336 let hex_len = target - overhead;
337 let mut buf = String::with_capacity(target);
338 buf.push_str(prefix);
339 for i in 0..hex_len {
340 buf.push(hex[i % 64] as char);
341 }
342 buf.push_str(suffix);
343 buf
344}
345
346fn format_jwt_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
350 const B64URL: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
351 let mut buf = String::with_capacity(target);
352 let mut hi = 0usize;
353 let mut had_b64 = false;
354 for ch in original.chars() {
355 if ch == '.' || ch == '=' {
356 buf.push(ch);
357 } else if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
358 buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
359 hi += 1;
360 had_b64 = true;
361 } else {
362 for _ in 0..ch.len_utf8() {
364 buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
365 hi += 1;
366 }
367 had_b64 = true;
368 }
369 }
370 if !had_b64 {
371 let hex = hex_bytes(hash);
372 return pad_or_truncate("", target, &hex);
373 }
374 buf
375}
376
377fn format_filepath_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
381 let last_sep = original.rfind(['/', '\\']).map_or(0, |p| p + 1);
383 let filename = &original[last_sep..];
384 let ext_start = filename.rfind('.').filter(|&p| p > 0).map(|p| last_sep + p);
386
387 let mut buf = String::with_capacity(target);
388 let mut hi = 0usize;
389
390 for (i, ch) in original.char_indices() {
391 if matches!(ch, '/' | '\\') || ext_start.is_some_and(|es| i >= es) {
392 buf.push(ch);
394 } else {
395 for _ in 0..ch.len_utf8() {
397 buf.push(hex[hi % 64] as char);
398 hi += 1;
399 }
400 }
401 }
402 if buf.len() != target {
404 return pad_or_truncate(&buf, target, hex);
405 }
406 buf
407}
408
409fn format_windows_sid_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
413 let has_digit = original.chars().any(|c| c.is_ascii_digit());
414 if !has_digit {
415 let hex = hex_bytes(hash);
416 return pad_or_truncate("", target, &hex);
417 }
418 let mut buf = String::with_capacity(target);
419 let mut hi = 0usize;
420 for ch in original.chars() {
421 if ch == 'S' || ch == '-' {
422 buf.push(ch);
423 } else if ch.is_ascii_digit() {
424 buf.push((b'0' + hash[hi % 32] % 10) as char);
425 hi += 1;
426 } else {
427 for _ in 0..ch.len_utf8() {
429 buf.push((b'0' + hash[hi % 32] % 10) as char);
430 hi += 1;
431 }
432 }
433 }
434 buf
435}
436
437fn format_preserving_hex_lp(
445 hex: &[u8; 64],
446 original: &str,
447 target: usize,
448 is_structural: impl Fn(char) -> bool,
449) -> Option<String> {
450 let mut buf = String::with_capacity(target);
451 let mut hi = 0usize;
452 let mut had_content = false;
453
454 for ch in original.chars() {
455 if is_structural(ch) {
456 buf.push(ch);
457 } else {
458 for _ in 0..ch.len_utf8() {
459 buf.push(hex[hi % 64] as char);
460 hi += 1;
461 }
462 had_content = true;
463 }
464 }
465
466 had_content.then_some(buf)
467}
468
469fn format_url_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
474 format_preserving_hex_lp(hex, original, target, |ch| "/:?=&#@.".contains(ch))
475 .unwrap_or_else(|| pad_or_truncate("", target, hex))
476}
477
478fn format_arn_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
482 format_preserving_hex_lp(hex, original, target, |ch| ch == ':' || ch == '/')
483 .unwrap_or_else(|| pad_or_truncate("", target, hex))
484}
485
486fn format_azure_resource_id_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
491 const KNOWN_SEGMENTS: &[&str] = &[
492 "subscriptions",
493 "resourceGroups",
494 "resourcegroups",
495 "providers",
496 ];
497
498 let mut buf = String::with_capacity(target);
499 let mut hi = 0usize;
500
501 let mut prev_was_providers = false;
503 for (pi, part) in original.split('/').enumerate() {
504 if pi > 0 {
505 buf.push('/');
506 }
507 let is_provider_namespace = prev_was_providers && part.contains('.');
512 if part.is_empty() || KNOWN_SEGMENTS.contains(&part) || is_provider_namespace {
513 buf.push_str(part);
514 } else {
515 for ch in part.chars() {
517 for _ in 0..ch.len_utf8() {
518 buf.push(hex[hi % 64] as char);
519 hi += 1;
520 }
521 }
522 }
523 prev_was_providers = part == "providers" || part == "Providers";
524 }
525 if buf.len() != target {
526 return pad_or_truncate(&buf, target, hex);
527 }
528 buf
529}
530
531fn format_name(hash: &[u8; 32]) -> String {
533 const FIRST: &[&str] = &[
537 "Alex", "Blake", "Casey", "Dana", "Ellis", "Finley", "Gray", "Harper", "Ira", "Jordan",
538 "Kai", "Lane", "Morgan", "Noel", "Oakley", "Parker", "Quinn", "Reese", "Sage", "Taylor",
539 "Uri", "Val", "Wren", "Xen", "Yael", "Zion", "Arden", "Blair", "Corin", "Drew", "Emery",
540 "Frost",
541 ];
542 const LAST: &[&str] = &[
543 "Ashford",
544 "Blackwell",
545 "Crawford",
546 "Dalton",
547 "Eastwood",
548 "Fairbanks",
549 "Garrison",
550 "Hartley",
551 "Irvine",
552 "Jensen",
553 "Kendrick",
554 "Langley",
555 "Mercer",
556 "Newland",
557 "Oakwood",
558 "Preston",
559 "Quinlan",
560 "Redmond",
561 "Shepard",
562 "Thornton",
563 "Underwood",
564 "Vance",
565 "Whitmore",
566 "Xavier",
567 "Yardley",
568 "Zimmer",
569 "Ashton",
570 "Beckett",
571 "Calloway",
572 "Dempsey",
573 "Eldridge",
574 "Fletcher",
575 ];
576 let fi = hash[0] as usize % FIRST.len();
577 let li = hash[1] as usize % LAST.len();
578 format!("{} {}", FIRST[fi], LAST[li])
579}
580
581fn hex_bytes(bytes: &[u8; 32]) -> [u8; 64] {
583 const HEX: &[u8; 16] = b"0123456789abcdef";
584 let mut out = [0u8; 64];
585 for (i, &b) in bytes.iter().enumerate() {
586 out[i * 2] = HEX[(b >> 4) as usize];
587 out[i * 2 + 1] = HEX[(b & 0xf) as usize];
588 }
589 out
590}
591
592#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn hmac_deterministic_same_input() {
602 let gen = HmacGenerator::new([42u8; 32]);
603 let a = gen.generate(&Category::Email, "alice@corp.com");
604 let b = gen.generate(&Category::Email, "alice@corp.com");
605 assert_eq!(a, b, "same seed + same input must produce same output");
606 }
607
608 #[test]
609 fn hmac_different_inputs_differ() {
610 let gen = HmacGenerator::new([42u8; 32]);
611 let a = gen.generate(&Category::Email, "alice@corp.com");
612 let b = gen.generate(&Category::Email, "bob@corp.com");
613 assert_ne!(a, b);
614 }
615
616 #[test]
617 fn hmac_different_seeds_differ() {
618 let g1 = HmacGenerator::new([1u8; 32]);
619 let g2 = HmacGenerator::new([2u8; 32]);
620 let a = g1.generate(&Category::Email, "alice@corp.com");
621 let b = g2.generate(&Category::Email, "alice@corp.com");
622 assert_ne!(a, b);
623 }
624
625 #[test]
626 fn hmac_different_categories_differ() {
627 let gen = HmacGenerator::new([42u8; 32]);
628 let a = gen.generate(&Category::Email, "test");
629 let b = gen.generate(&Category::Name, "test");
630 assert_ne!(a, b, "different categories must produce different outputs");
631 }
632
633 #[test]
634 fn email_format() {
635 let gen = HmacGenerator::new([0u8; 32]);
636 let orig = "alice@corp.com";
637 let out = gen.generate(&Category::Email, orig);
638 assert!(out.contains('@'), "email must contain @");
639 assert!(out.ends_with("@corp.com"), "email must preserve domain");
640 assert_eq!(out.len(), orig.len(), "email must preserve length");
641 }
642
643 #[test]
644 fn ipv4_format() {
645 let gen = HmacGenerator::new([0u8; 32]);
646 let orig = "192.168.1.1";
647 let out = gen.generate(&Category::IpV4, orig);
648 let parts: Vec<&str> = out.split('.').collect();
650 assert_eq!(parts.len(), 4);
651 assert_eq!(out.len(), orig.len(), "ipv4 must preserve length");
652 }
653
654 #[test]
655 fn ssn_format() {
656 let gen = HmacGenerator::new([7u8; 32]);
657 let orig = "123-45-6789";
658 let out = gen.generate(&Category::Ssn, orig);
659 assert!(out.starts_with("000-"), "SSN must start with 000");
660 assert_eq!(out.len(), orig.len(), "SSN must preserve length");
661 }
662
663 #[test]
664 fn phone_format() {
665 let gen = HmacGenerator::new([3u8; 32]);
666 let orig = "+1-212-555-0100";
667 let out = gen.generate(&Category::Phone, orig);
668 assert!(out.starts_with('+'));
670 assert_eq!(
671 out.chars().filter(|c| *c == '-').count(),
672 orig.chars().filter(|c| *c == '-').count(),
673 "dashes must be preserved"
674 );
675 assert_eq!(out.len(), orig.len(), "phone must preserve length");
676 }
677
678 #[test]
679 fn hostname_format() {
680 let gen = HmacGenerator::new([5u8; 32]);
681 let orig = "db-prod-01.internal";
682 let out = gen.generate(&Category::Hostname, orig);
683 assert!(out.ends_with(".internal"), "hostname must preserve suffix");
684 assert_eq!(out.len(), orig.len(), "hostname must preserve length");
685 }
686
687 #[test]
688 fn custom_format() {
689 let gen = HmacGenerator::new([9u8; 32]);
690 let cat = Category::Custom("api_key".into());
691 let orig = "sk-abc123-very-long-key";
693 let out = gen.generate(&cat, orig);
694 assert!(out.starts_with("__SANITIZED_"));
695 assert!(out.ends_with("__"));
696 assert_eq!(out.len(), orig.len(), "custom must preserve length");
697 }
698
699 #[test]
700 fn custom_format_short() {
701 let gen = HmacGenerator::new([9u8; 32]);
702 let cat = Category::Custom("api_key".into());
703 let orig = "sk-abc123";
705 let out = gen.generate(&cat, orig);
706 assert_eq!(
707 out.len(),
708 orig.len(),
709 "custom must preserve length even for short inputs"
710 );
711 }
712
713 #[test]
714 fn random_generator_produces_valid_format() {
715 let gen = RandomGenerator::new();
716 let orig = "test@example.com";
717 let out = gen.generate(&Category::Email, orig);
718 assert!(out.contains('@'));
719 assert_eq!(
720 out.len(),
721 orig.len(),
722 "random generator must preserve length"
723 );
724 }
725
726 #[test]
727 fn from_slice_rejects_bad_length() {
728 let result = HmacGenerator::from_slice(&[0u8; 16]);
729 assert!(result.is_err());
730 }
731
732 #[test]
733 fn credit_card_format() {
734 let gen = HmacGenerator::new([11u8; 32]);
735 let orig = "4111-1111-1111-1111";
736 let out = gen.generate(&Category::CreditCard, orig);
737 let parts: Vec<&str> = out.split('-').collect();
739 assert_eq!(parts.len(), 4);
740 for part in &parts {
741 assert_eq!(part.len(), 4);
742 assert!(part.chars().all(|c| c.is_ascii_digit()));
743 }
744 assert_eq!(out.len(), orig.len(), "credit card must preserve length");
745 }
746
747 #[test]
748 fn name_format() {
749 let gen = HmacGenerator::new([0u8; 32]);
750 let orig = "John Doe";
751 let out = gen.generate(&Category::Name, orig);
752 assert_eq!(out.len(), orig.len(), "name must preserve length");
753 }
754
755 #[test]
756 fn ipv6_format() {
757 let gen = HmacGenerator::new([0u8; 32]);
758 let orig = "fd00:abcd:1234:5678::1";
759 let out = gen.generate(&Category::IpV6, orig);
760 assert_eq!(
762 out.chars().filter(|c| *c == ':').count(),
763 orig.chars().filter(|c| *c == ':').count(),
764 "colons must be preserved"
765 );
766 assert_eq!(out.len(), orig.len(), "ipv6 must preserve length");
767 }
768
769 #[test]
770 fn length_preserved_all_categories() {
771 let gen = HmacGenerator::new([42u8; 32]);
772 let cases: Vec<(Category, &str)> = vec![
773 (Category::Email, "alice@corp.com"),
774 (Category::Name, "John Doe"),
775 (Category::Phone, "+1-212-555-0100"),
776 (Category::IpV4, "192.168.1.1"),
777 (Category::IpV6, "fd00::1"),
778 (Category::CreditCard, "4111-1111-1111-1111"),
779 (Category::Ssn, "123-45-6789"),
780 (Category::Hostname, "db-prod-01.internal"),
781 (Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
782 (Category::ContainerId, "a1b2c3d4e5f6"),
783 (Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
784 (Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
785 (Category::AuthToken, "ghp_abc123secrettoken"),
786 (Category::FilePath, "/home/jsmith/config.yaml"),
787 (Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
788 (Category::Url, "https://internal.corp.com/api"),
789 (Category::AwsArn, "arn:aws:iam::123456789012:user/admin"),
790 (
791 Category::AzureResourceId,
792 "/subscriptions/550e8400/resourceGroups/rg-prod",
793 ),
794 (Category::Custom("key".into()), "some-secret-value-here"),
795 ];
796 for (cat, orig) in &cases {
797 let out = gen.generate(cat, orig);
798 assert_eq!(
799 out.len(),
800 orig.len(),
801 "length mismatch for {:?}: '{}' ({}) -> '{}' ({})",
802 cat,
803 orig,
804 orig.len(),
805 out,
806 out.len()
807 );
808 }
809 }
810
811 #[test]
812 fn mac_address_format() {
813 let gen = HmacGenerator::new([7u8; 32]);
814 let orig = "AA:BB:CC:DD:EE:FF";
815 let out = gen.generate(&Category::MacAddress, orig);
816 assert_eq!(out.len(), orig.len(), "mac must preserve length");
817 assert_eq!(
818 out.chars().filter(|c| *c == ':').count(),
819 5,
820 "mac must preserve colons"
821 );
822 }
823
824 #[test]
825 fn mac_address_dash_format() {
826 let gen = HmacGenerator::new([7u8; 32]);
827 let orig = "AA-BB-CC-DD-EE-FF";
828 let out = gen.generate(&Category::MacAddress, orig);
829 assert_eq!(out.len(), orig.len());
830 assert_eq!(out.chars().filter(|c| *c == '-').count(), 5);
831 }
832
833 #[test]
834 fn uuid_format() {
835 let gen = HmacGenerator::new([3u8; 32]);
836 let orig = "550e8400-e29b-41d4-a716-446655440000";
837 let out = gen.generate(&Category::Uuid, orig);
838 assert_eq!(out.len(), orig.len(), "uuid must preserve length");
839 assert_eq!(
840 out.chars().filter(|c| *c == '-').count(),
841 4,
842 "uuid must preserve dashes"
843 );
844 }
845
846 #[test]
847 fn container_id_format() {
848 let gen = HmacGenerator::new([5u8; 32]);
849 let orig = "a1b2c3d4e5f6";
850 let out = gen.generate(&Category::ContainerId, orig);
851 assert_eq!(out.len(), orig.len(), "container id must preserve length");
852 assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
853 }
854
855 #[test]
856 fn jwt_format() {
857 let gen = HmacGenerator::new([11u8; 32]);
858 let orig = "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK";
859 let out = gen.generate(&Category::Jwt, orig);
860 assert_eq!(out.len(), orig.len(), "jwt must preserve length");
861 let orig_dots = orig.chars().filter(|c| *c == '.').count();
862 let out_dots = out.chars().filter(|c| *c == '.').count();
863 assert_eq!(out_dots, orig_dots, "jwt must preserve dots");
864 }
865
866 #[test]
867 fn auth_token_format() {
868 let gen = HmacGenerator::new([9u8; 32]);
869 let orig = "ghp_abc123secrettoken";
870 let out = gen.generate(&Category::AuthToken, orig);
871 assert!(out.starts_with("__SANITIZED_"));
872 assert!(out.ends_with("__"));
873 assert_eq!(out.len(), orig.len(), "auth_token must preserve length");
874 }
875
876 #[test]
877 fn filepath_unix_format() {
878 let gen = HmacGenerator::new([13u8; 32]);
879 let orig = "/home/jsmith/config.yaml";
880 let out = gen.generate(&Category::FilePath, orig);
881 assert_eq!(out.len(), orig.len(), "filepath must preserve length");
882 assert_eq!(
883 std::path::Path::new(&out)
884 .extension()
885 .and_then(|e| e.to_str()),
886 Some("yaml"),
887 "filepath must preserve extension"
888 );
889 assert_eq!(
890 out.chars().filter(|c| *c == '/').count(),
891 orig.chars().filter(|c| *c == '/').count(),
892 "filepath must preserve separators"
893 );
894 }
895
896 #[test]
897 fn filepath_windows_format() {
898 let gen = HmacGenerator::new([13u8; 32]);
899 let orig = "C:\\Users\\admin\\secrets.txt";
900 let out = gen.generate(&Category::FilePath, orig);
901 assert_eq!(out.len(), orig.len(), "filepath must preserve length");
902 assert_eq!(
903 std::path::Path::new(&out)
904 .extension()
905 .and_then(|e| e.to_str()),
906 Some("txt"),
907 "filepath must preserve extension"
908 );
909 assert_eq!(
910 out.chars().filter(|c| *c == '\\').count(),
911 orig.chars().filter(|c| *c == '\\').count(),
912 "filepath must preserve backslashes"
913 );
914 }
915
916 #[test]
917 fn windows_sid_format() {
918 let gen = HmacGenerator::new([7u8; 32]);
919 let orig = "S-1-5-21-3623811015-3361044348-30300820-1013";
920 let out = gen.generate(&Category::WindowsSid, orig);
921 assert_eq!(out.len(), orig.len(), "SID must preserve length");
922 assert!(out.starts_with("S-"), "SID must start with S-");
923 assert_eq!(
924 out.chars().filter(|c| *c == '-').count(),
925 orig.chars().filter(|c| *c == '-').count(),
926 "SID must preserve dashes"
927 );
928 }
929
930 #[test]
931 fn url_format() {
932 let gen = HmacGenerator::new([5u8; 32]);
933 let orig = "https://internal.corp.com/api/users?token=abc123";
934 let out = gen.generate(&Category::Url, orig);
935 assert_eq!(out.len(), orig.len(), "url must preserve length");
936 assert!(out.contains("://"));
938 assert!(out.contains('?'));
939 assert!(out.contains('='));
940 }
941
942 #[test]
943 fn aws_arn_format() {
944 let gen = HmacGenerator::new([3u8; 32]);
945 let orig = "arn:aws:iam::123456789012:user/admin";
946 let out = gen.generate(&Category::AwsArn, orig);
947 assert_eq!(out.len(), orig.len(), "ARN must preserve length");
948 assert_eq!(
949 out.chars().filter(|c| *c == ':').count(),
950 orig.chars().filter(|c| *c == ':').count(),
951 "ARN must preserve colons"
952 );
953 assert!(out.contains('/'), "ARN must preserve slash");
954 }
955
956 #[test]
957 fn azure_resource_id_format() {
958 let gen = HmacGenerator::new([11u8; 32]);
959 let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
960 let out = gen.generate(&Category::AzureResourceId, orig);
961 assert_eq!(
962 out.len(),
963 orig.len(),
964 "Azure resource ID must preserve length"
965 );
966 assert!(
967 out.contains("/subscriptions/"),
968 "must preserve 'subscriptions'"
969 );
970 assert!(
971 out.contains("/resourceGroups/"),
972 "must preserve 'resourceGroups'"
973 );
974 assert!(out.contains("/providers/"), "must preserve 'providers'");
975 assert!(
976 out.contains("Microsoft.Compute"),
977 "must preserve dotted provider name"
978 );
979 }
980
981 #[test]
982 fn azure_dotted_segment_outside_providers_is_replaced() {
983 let gen = HmacGenerator::new([11u8; 32]);
984 let orig = "/subscriptions/10.0.0.1/resourceGroups/rg-prod";
988 let out = gen.generate(&Category::AzureResourceId, orig);
989 assert_eq!(out.len(), orig.len(), "length must be preserved");
990 assert!(out.contains("/subscriptions/"), "subscriptions preserved");
991 assert!(out.contains("/resourceGroups/"), "resourceGroups preserved");
992 assert!(
993 !out.contains("10.0.0.1"),
994 "dotted non-provider segment must be replaced, got: {out}"
995 );
996 assert!(
997 !out.contains("rg-prod"),
998 "variable resource group name must be replaced, got: {out}"
999 );
1000 }
1001}