Skip to main content

rust_sanitize/
generator.rs

1//! Replacement generation strategies.
2//!
3//! Two concrete implementations:
4//! - `HmacGenerator`: Deterministic, seeded with a 32-byte key. Same seed + same
5//!   input = same output across runs. Uses HMAC-SHA256 for domain separation.
6//! - `RandomGenerator`: Cryptographically random replacements. Non-deterministic.
7//!
8//! Both produce category-aware, format-preserving replacements.
9//!
10//! # Design Note
11//!
12//! This module contains the category-aware formatters used by the CLI binary.
13//! For an extensible strategy API that allows custom replacement logic, see
14//! the [`crate::strategy`] module.
15
16use crate::category::Category;
17use hmac::{Hmac, Mac};
18use rand::Rng;
19use sha2::Sha256;
20use zeroize::Zeroize;
21
22// ---------------------------------------------------------------------------
23// Trait
24// ---------------------------------------------------------------------------
25
26/// Strategy for generating a sanitized replacement value.
27///
28/// Implementations MUST be deterministic to their inputs: given the same
29/// `(category, original)` pair (and same internal state / seed), the output
30/// must be identical. This is what enables per-run consistency when backed
31/// by a `MappingStore` that calls `generate` only once per unique value.
32pub trait ReplacementGenerator: Send + Sync {
33    /// Produce a sanitized replacement for `original` classified as `category`.
34    fn generate(&self, category: &Category, original: &str) -> String;
35}
36
37// ---------------------------------------------------------------------------
38// HMAC-SHA256 deterministic generator
39// ---------------------------------------------------------------------------
40
41/// Deterministic replacement generator seeded with a 32-byte key.
42///
43/// ```text
44/// replacement = format(category, HMAC-SHA256(key, category_tag || "\x00" || original))
45/// ```
46///
47/// The same key + same `(category, original)` always yields the same output.
48/// Different keys yield completely different outputs with overwhelming probability.
49pub 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    /// Create a new generator from a 32-byte seed.
61    #[must_use]
62    pub fn new(key: [u8; 32]) -> Self {
63        Self { key }
64    }
65
66    /// Create a generator from a byte slice (must be exactly 32 bytes).
67    ///
68    /// # Errors
69    ///
70    /// Returns [`SanitizeError::InvalidSeedLength`](crate::error::SanitizeError::InvalidSeedLength) if `bytes.len() != 32`.
71    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    /// Derive the raw 32-byte HMAC digest for `(category, original)`.
81    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"); // domain separator
87        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
102// ---------------------------------------------------------------------------
103// Cryptographically-random generator (non-deterministic)
104// ---------------------------------------------------------------------------
105
106/// Random replacement generator using OS CSPRNG.
107///
108/// Each call to `generate` produces a fresh random value. Determinism is
109/// achieved externally by the `MappingStore`, which calls `generate` only
110/// once per unique `(category, original)` pair and caches the result.
111pub 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
135// ---------------------------------------------------------------------------
136// Category-aware formatting helpers
137// ---------------------------------------------------------------------------
138
139/// Format a 32-byte hash into a length-preserving replacement whose
140/// byte length exactly matches `original.len()`. The shape is
141/// category-aware and deterministic for the same `(hash, original)` pair.
142pub(crate) fn 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
169// ---------------------------------------------------------------------------
170// Length-preserving helpers
171// ---------------------------------------------------------------------------
172
173/// Pad `s` with deterministic hex characters from `hex`, or truncate,
174/// to reach exactly `target` bytes.  All generated content is ASCII so
175/// byte length equals character count for the produced output.
176fn 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
192/// Length-preserving email replacement.
193/// Preserves the domain from the original; generates a hex username
194/// sized so the total byte length matches the original.
195fn 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(); // "@" + domain
200    if target <= at_domain {
201        // Too short to fit @domain — use hex fallback.
202        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
214/// Length-preserving name replacement.
215/// Generates a synthetic name via the hash-indexed table, then
216/// truncates or pads to match `target` bytes.
217fn 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
222/// Replace each character matching `is_replaceable` with a deterministic
223/// character produced by `replacement(original_char, hash[hi % 32])`.
224/// All other characters are preserved as-is.
225/// Returns `None` if no replaceable characters were found (caller falls back).
226fn 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
247/// Length-preserving digit replacement.
248/// Preserves every non-digit character in `original`; replaces each
249/// ASCII digit with a deterministic digit derived from `hash`.
250/// Falls back to hex if the original contains no digits.
251fn 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
261/// Length-preserving hex-digit replacement (for IPv6, UUID, MAC, container ID).
262/// Preserves non-hex characters (colons, dashes, etc.); replaces each
263/// ASCII hex digit with a deterministic hex digit from `hash`, preserving case.
264fn 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
282/// Length-preserving SSN replacement.
283/// Preserves all non-digit characters.  The first three digit positions
284/// are forced to '0' (never-issued area code, clearly synthetic).
285/// Remaining digit positions are filled with deterministic digits.
286fn 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
309/// Length-preserving hostname replacement.
310/// Preserves the suffix (everything from the first `.` onward) and
311/// fills the prefix with deterministic hex characters to match `target`.
312fn 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
326/// Length-preserving custom replacement.
327/// Uses `__SANITIZED_<hex>__` format when the target is long enough;
328/// falls back to bare hex for short targets.
329fn format_custom_lp(hex: &[u8; 64], target: usize) -> String {
330    let prefix = "__SANITIZED_";
331    let suffix = "__";
332    let overhead = prefix.len() + suffix.len(); // 14
333    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
346/// Length-preserving JWT replacement.
347/// Preserves `.` separators; replaces base64url characters
348/// (`[A-Za-z0-9_-]`) with deterministic base64url characters.
349fn 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            // Non-base64url, non-structural: emit byte-preserving replacement.
363            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
377/// Length-preserving file path replacement.
378/// Preserves separators (`/`, `\`) and the final extension (from last `.`
379/// in the last segment). Replaces other characters with deterministic hex.
380fn format_filepath_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
381    // Find the last path separator position to identify the filename segment.
382    let last_sep = original.rfind(['/', '\\']).map_or(0, |p| p + 1);
383    let filename = &original[last_sep..];
384    // Find extension in the filename (last `.` that isn't at position 0).
385    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            // Preserve separators and the file extension.
393            buf.push(ch);
394        } else {
395            // Emit as many ASCII hex bytes as the original char's UTF-8 length.
396            for _ in 0..ch.len_utf8() {
397                buf.push(hex[hi % 64] as char);
398                hi += 1;
399            }
400        }
401    }
402    // Ensure exact length (should be equal for ASCII, but guard anyway).
403    if buf.len() != target {
404        return pad_or_truncate(&buf, target, hex);
405    }
406    buf
407}
408
409/// Length-preserving Windows SID replacement.
410/// Preserves the `S-` prefix and `-` separators; replaces digit groups
411/// with deterministic digits.
412fn 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            // Non-digit, non-structural: emit byte-count-preserving hex.
428            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
437/// Shared core for length-preserving hex replacement where a caller-supplied
438/// predicate identifies "structural" characters to preserve as-is.
439///
440/// All non-structural characters are replaced byte-by-byte with deterministic
441/// hex characters derived from `hex`.  Returns `None` if the original
442/// contained no replaceable content (caller should fall back to
443/// [`pad_or_truncate`]).
444fn 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
469/// Length-preserving URL replacement.
470/// Preserves scheme prefix and structural characters
471/// (`://`, `/`, `?`, `=`, `&`, `#`, `:`); replaces content characters
472/// with deterministic hex.
473fn 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
478/// Length-preserving AWS ARN replacement.
479/// Preserves `:` and `/` separators; replaces alphanumeric content
480/// in account/resource segments with deterministic hex.
481fn 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
486/// Length-preserving Azure Resource ID replacement.
487/// Preserves `/` path separators and well-known Azure segment names
488/// (`subscriptions`, `resourceGroups`, `providers`, `resourcegroups`).
489/// Replaces variable segments (IDs, names) with deterministic hex.
490fn 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    // Split on `/`, rebuild with deterministic replacement for non-known segments.
502    let mut prev_was_providers = false;
503    for (pi, part) in original.split('/').enumerate() {
504        if pi > 0 {
505            buf.push('/');
506        }
507        // Dotted segments (e.g. `Microsoft.Compute`) are only preserved when
508        // they immediately follow a `providers` segment. Preserving all dotted
509        // segments would accidentally pass through IPs or hostnames that appear
510        // elsewhere in the path.
511        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            // Replace this segment character-by-character to preserve byte length.
516            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
531/// Deterministic synthetic name from hash bytes.
532fn format_name(hash: &[u8; 32]) -> String {
533    // We use a small, fixed table of first/last name fragments.
534    // The hash selects indices. This is NOT meant to be realistic — it's
535    // meant to be obviously synthetic while remaining structurally plausible.
536    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
581/// Encode 32 bytes as 64 lowercase hex ASCII bytes on the stack.
582fn 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// ---------------------------------------------------------------------------
593// Tests
594// ---------------------------------------------------------------------------
595
596#[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        // Dots preserved, length preserved.
649        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        // Formatting characters preserved.
669        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        // Use an input long enough for the __SANITIZED_..__ wrapper (>14 chars).
692        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        // Short input falls back to hex.
704        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        // Should be ####-####-####-####
738        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        // Colons and :: preserved, length preserved.
761        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        // Structural characters preserved.
937        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        // A dotted segment that is NOT immediately after `providers/` must be
985        // treated as a variable component and replaced, not passed through.
986        // Before the fix, part.contains('.') caused this to be preserved.
987        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}