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/// Controls whether a replacement preserves the original's byte length or draws
38/// a fresh, category-appropriate length independent of it.
39///
40/// `Preserve` (the default) keeps today's behavior: the replacement's byte
41/// length exactly matches the original, so length and rough structure are
42/// retained. `Randomized` instead picks each replacement's length from a
43/// per-category band derived from the hash, uncorrelated to the original — the
44/// output stays type-valid (digits stay digits, an email stays an email) but no
45/// longer leaks the original's length. Non-secret structure that is copied
46/// verbatim (email domain, hostname suffix, file extension, ARN/Azure known
47/// segments) is unaffected by this policy.
48#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
49pub enum LengthPolicy {
50    /// Output byte length exactly matches the original (default).
51    #[default]
52    Preserve,
53    /// Output length is drawn from a per-category band, hiding the original's
54    /// length.
55    Randomized,
56}
57
58// ---------------------------------------------------------------------------
59// HMAC-SHA256 deterministic generator
60// ---------------------------------------------------------------------------
61
62/// Deterministic replacement generator seeded with a 32-byte key.
63///
64/// ```text
65/// replacement = format(category, HMAC-SHA256(key, category_tag || "\x00" || original))
66/// ```
67///
68/// The same key + same `(category, original)` always yields the same output.
69/// Different keys yield completely different outputs with overwhelming probability.
70pub struct HmacGenerator {
71    key: [u8; 32],
72    policy: LengthPolicy,
73}
74
75impl Drop for HmacGenerator {
76    fn drop(&mut self) {
77        self.key.zeroize();
78    }
79}
80
81impl HmacGenerator {
82    /// Create a new generator from a 32-byte seed.
83    #[must_use]
84    pub fn new(key: [u8; 32]) -> Self {
85        Self {
86            key,
87            policy: LengthPolicy::Preserve,
88        }
89    }
90
91    /// Create a generator from a byte slice (must be exactly 32 bytes).
92    ///
93    /// # Errors
94    ///
95    /// Returns [`SanitizeError::InvalidSeedLength`](crate::error::SanitizeError::InvalidSeedLength) if `bytes.len() != 32`.
96    pub fn from_slice(bytes: &[u8]) -> crate::error::Result<Self> {
97        if bytes.len() != 32 {
98            return Err(crate::error::SanitizeError::InvalidSeedLength(bytes.len()));
99        }
100        let mut key = [0u8; 32];
101        key.copy_from_slice(bytes);
102        Ok(Self {
103            key,
104            policy: LengthPolicy::Preserve,
105        })
106    }
107
108    /// Set the [`LengthPolicy`] for this generator (builder style).
109    #[must_use]
110    pub fn with_length_policy(mut self, policy: LengthPolicy) -> Self {
111        self.policy = policy;
112        self
113    }
114
115    /// Derive the raw 32-byte HMAC digest for `(category, original)`.
116    fn derive(&self, category: &Category, original: &str) -> [u8; 32] {
117        type HmacSha256 = Hmac<Sha256>;
118        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
119        let tag = category.domain_tag_hmac();
120        mac.update(tag.as_bytes());
121        mac.update(b"\x00"); // domain separator
122        mac.update(original.as_bytes());
123        let result = mac.finalize();
124        let mut out = [0u8; 32];
125        out.copy_from_slice(&result.into_bytes());
126        out
127    }
128}
129
130impl ReplacementGenerator for HmacGenerator {
131    fn generate(&self, category: &Category, original: &str) -> String {
132        let hash = self.derive(category, original);
133        format_replacement(category, &hash, original, self.policy)
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Cryptographically-random generator (non-deterministic)
139// ---------------------------------------------------------------------------
140
141/// Random replacement generator using OS CSPRNG.
142///
143/// Each call to `generate` produces a fresh random value. Determinism is
144/// achieved externally by the `MappingStore`, which calls `generate` only
145/// once per unique `(category, original)` pair and caches the result.
146pub struct RandomGenerator {
147    policy: LengthPolicy,
148}
149
150impl RandomGenerator {
151    #[must_use]
152    pub fn new() -> Self {
153        Self {
154            policy: LengthPolicy::Preserve,
155        }
156    }
157
158    /// Set the [`LengthPolicy`] for this generator (builder style).
159    #[must_use]
160    pub fn with_length_policy(mut self, policy: LengthPolicy) -> Self {
161        self.policy = policy;
162        self
163    }
164}
165
166impl Default for RandomGenerator {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172impl ReplacementGenerator for RandomGenerator {
173    fn generate(&self, category: &Category, original: &str) -> String {
174        let mut rng = rand::rng();
175        let mut hash = [0u8; 32];
176        rng.fill(&mut hash);
177        format_replacement(category, &hash, original, self.policy)
178    }
179}
180
181// ---------------------------------------------------------------------------
182// Category-aware formatting helpers
183// ---------------------------------------------------------------------------
184
185/// Format a 32-byte hash into a category-aware replacement.
186///
187/// Under [`LengthPolicy::Preserve`] (default) the output's byte length exactly
188/// matches `original.len()`. Under [`LengthPolicy::Randomized`] the length is
189/// drawn from a per-category band (see [`randomized_target`]) so it no longer
190/// leaks the original's length, while the output stays type-valid. The shape is
191/// deterministic for the same `(hash, original, policy)` triple.
192pub(crate) fn format_replacement(
193    category: &Category,
194    hash: &[u8; 32],
195    original: &str,
196    policy: LengthPolicy,
197) -> String {
198    let randomized = matches!(policy, LengthPolicy::Randomized);
199    let target = match policy {
200        LengthPolicy::Preserve => original.len(),
201        LengthPolicy::Randomized => {
202            randomized_target(category, hash, original).unwrap_or(original.len())
203        }
204    };
205    if target == 0 {
206        return String::new();
207    }
208    let hex = hex_bytes(hash);
209    match category {
210        Category::Email => format_email_lp(&hex, original, target),
211        Category::Name => format_name_lp(hash, &hex, target),
212        // Digit categories with no canonical length: under `Randomized` we emit a
213        // fresh run of `target` digits (separators dropped); IPv4 stays canonical.
214        Category::Phone | Category::CreditCard if randomized => {
215            format_digits_synth(hash, target, false)
216        }
217        Category::Ssn if randomized => format_digits_synth(hash, target, true),
218        Category::Phone | Category::CreditCard | Category::IpV4 => {
219            format_digits_lp(hash, original, target)
220        }
221        Category::IpV6 | Category::MacAddress | Category::Uuid | Category::ContainerId => {
222            format_hex_digits_lp(hash, original, target)
223        }
224        Category::Ssn => format_ssn_lp(hash, original, target),
225        Category::Hostname => format_hostname_lp(&hex, original, target),
226        Category::Jwt => format_jwt_lp(hash, original, target),
227        Category::FilePath => format_filepath_lp(&hex, original, target, randomized),
228        Category::WindowsSid => format_windows_sid_lp(hash, original, target),
229        Category::Url => format_url_lp(&hex, original, target, randomized),
230        Category::AwsArn => format_arn_lp(&hex, original, target, randomized),
231        Category::AzureResourceId => {
232            format_azure_resource_id_lp(&hex, original, target, randomized)
233        }
234        Category::AuthToken | Category::Custom(_) => format_custom_lp(&hex, target),
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Length-randomizing helpers (LengthPolicy::Randomized)
240// ---------------------------------------------------------------------------
241
242// Per-category length bands for `LengthPolicy::Randomized`. Tunable in one
243// place. Digits cap at 18 so the value stays parseable as an `i64`.
244const DIGITS_BAND: (usize, usize) = (8, 18);
245const EMAIL_USER_BAND: (usize, usize) = (6, 16);
246const HOSTNAME_PREFIX_BAND: (usize, usize) = (6, 16);
247const NAME_BAND: (usize, usize) = (12, 28);
248const TOKEN_BAND: (usize, usize) = (24, 48);
249const SEGMENT_BAND: (usize, usize) = (6, 20);
250
251/// Pick a length in `[lo, hi]` deterministically from `bytes`, mixing in `idx`
252/// so callers can draw independent lengths for successive segments. Stable for
253/// the same `(bytes, idx)`, so per-value consistency is preserved.
254fn band_pick(bytes: &[u8], idx: usize, lo: usize, hi: usize) -> usize {
255    debug_assert!(lo <= hi);
256    debug_assert!(!bytes.is_empty());
257    let span = (hi - lo + 1) as u64;
258    let mut w = 0u64;
259    for k in 0..8 {
260        let b = bytes[(idx.wrapping_mul(8).wrapping_add(k)) % bytes.len()];
261        w = (w << 8) | u64::from(b);
262    }
263    #[allow(clippy::cast_possible_truncation)] // (w % span) < span <= band max, fits usize
264    let offset = (w % span) as usize;
265    lo + offset
266}
267
268/// Total target byte length for a category under [`LengthPolicy::Randomized`].
269///
270/// Returns `None` for categories whose length must not change — canonical /
271/// fixed-shape values (UUID, MAC, IPv4/6, container ID, Windows SID), JWT
272/// (deliberate exception), and the variable-segment categories
273/// (file path / URL / ARN / Azure) whose lengths are synthesized per-segment
274/// inside their own formatters. For the remaining free-length categories it
275/// returns a band-derived total that includes any verbatim structural overhead
276/// (the `@domain`, hostname suffix, or `__SANITIZED_…__` wrapper) so the
277/// existing generate-to-target formatters can be reused unchanged.
278fn randomized_target(category: &Category, hash: &[u8; 32], original: &str) -> Option<usize> {
279    match category {
280        Category::Phone | Category::CreditCard | Category::Ssn => {
281            Some(band_pick(hash, 0, DIGITS_BAND.0, DIGITS_BAND.1))
282        }
283        Category::Name => Some(band_pick(hash, 0, NAME_BAND.0, NAME_BAND.1)),
284        Category::AuthToken | Category::Custom(_) => {
285            let overhead = "__SANITIZED_".len() + "__".len();
286            Some(band_pick(hash, 0, TOKEN_BAND.0, TOKEN_BAND.1) + overhead)
287        }
288        Category::Email => {
289            let domain = original.rfind('@').map_or("x.co", |p| &original[p + 1..]);
290            Some(band_pick(hash, 0, EMAIL_USER_BAND.0, EMAIL_USER_BAND.1) + 1 + domain.len())
291        }
292        Category::Hostname => {
293            let suffix = original.find('.').map_or("", |p| &original[p..]);
294            Some(band_pick(hash, 0, HOSTNAME_PREFIX_BAND.0, HOSTNAME_PREFIX_BAND.1) + suffix.len())
295        }
296        // Variable-segment categories synthesize per-segment lengths in their own
297        // formatters; canonical/fixed-shape categories and JWT never randomize.
298        Category::FilePath
299        | Category::Url
300        | Category::AwsArn
301        | Category::AzureResourceId
302        | Category::Uuid
303        | Category::MacAddress
304        | Category::IpV4
305        | Category::IpV6
306        | Category::ContainerId
307        | Category::WindowsSid
308        | Category::Jwt => None,
309    }
310}
311
312/// Emit a fresh run of `target` deterministic digits (length-randomized digit
313/// categories). When `ssn` is set the leading digit is forced to `0`, keeping
314/// the value clearly synthetic.
315fn format_digits_synth(hash: &[u8; 32], target: usize, ssn: bool) -> String {
316    let mut buf = String::with_capacity(target);
317    for i in 0..target {
318        if ssn && i == 0 {
319            buf.push('0');
320        } else {
321            buf.push((b'0' + hash[i % 32] % 10) as char);
322        }
323    }
324    buf
325}
326
327/// Randomized variant of [`format_preserving_hex_lp`]: copy structural
328/// characters verbatim, but replace each maximal run of non-structural
329/// ("variable") characters with a band-derived-length hex run. Segment lengths
330/// are independent of the original, so per-segment length no longer leaks.
331fn format_preserving_hex_rand(
332    hex: &[u8; 64],
333    original: &str,
334    is_structural: impl Fn(char) -> bool,
335) -> String {
336    let mut buf = String::with_capacity(original.len());
337    let mut seg_idx = 0usize;
338    let mut hi = 0usize;
339    let mut in_var = false;
340    for ch in original.chars() {
341        if is_structural(ch) {
342            buf.push(ch);
343            in_var = false;
344        } else if !in_var {
345            // Start of a new variable segment: emit one synth-length hex run and
346            // suppress the original characters of this segment.
347            let seg_len = band_pick(hex, seg_idx + 1, SEGMENT_BAND.0, SEGMENT_BAND.1);
348            for _ in 0..seg_len {
349                buf.push(hex[hi % 64] as char);
350                hi += 1;
351            }
352            seg_idx += 1;
353            in_var = true;
354        }
355    }
356    buf
357}
358
359// ---------------------------------------------------------------------------
360// Length-preserving helpers
361// ---------------------------------------------------------------------------
362
363/// Pad `s` with deterministic hex characters from `hex`, or truncate,
364/// to reach exactly `target` bytes.  All generated content is ASCII so
365/// byte length equals character count for the produced output.
366fn pad_or_truncate(s: &str, target: usize, hex: &[u8; 64]) -> String {
367    let slen = s.len();
368    if slen == target {
369        return s.to_string();
370    }
371    if slen > target {
372        return s[..target].to_string();
373    }
374    let mut buf = String::with_capacity(target);
375    buf.push_str(s);
376    for i in 0..target.saturating_sub(slen) {
377        buf.push(hex[i % 64] as char);
378    }
379    buf
380}
381
382/// Length-preserving email replacement.
383/// Preserves the domain from the original; generates a hex username
384/// sized so the total byte length matches the original.
385fn format_email_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
386    let domain = original
387        .rfind('@')
388        .map_or("x.co", |pos| &original[pos + 1..]);
389    let at_domain = 1 + domain.len(); // "@" + domain
390    if target <= at_domain {
391        // Too short to fit @domain — use hex fallback.
392        return pad_or_truncate("", target, hex);
393    }
394    let user_len = target - at_domain;
395    let mut buf = String::with_capacity(target);
396    for i in 0..user_len {
397        buf.push(hex[i % 64] as char);
398    }
399    buf.push('@');
400    buf.push_str(domain);
401    buf
402}
403
404/// Length-preserving name replacement.
405/// Generates a synthetic name via the hash-indexed table, then
406/// truncates or pads to match `target` bytes.
407fn format_name_lp(hash: &[u8; 32], hex: &[u8; 64], target: usize) -> String {
408    let raw = format_name(hash);
409    pad_or_truncate(&raw, target, hex)
410}
411
412/// Replace each character matching `is_replaceable` with a deterministic
413/// character produced by `replacement(original_char, hash[hi % 32])`.
414/// All other characters are preserved as-is.
415/// Returns `None` if no replaceable characters were found (caller falls back).
416fn format_char_class_lp(
417    hash: &[u8; 32],
418    original: &str,
419    is_replaceable: impl Fn(char) -> bool,
420    replacement: impl Fn(char, u8) -> char,
421) -> Option<String> {
422    let mut buf = String::with_capacity(original.len());
423    let mut hi = 0usize;
424    let mut had_replaceable = false;
425    for ch in original.chars() {
426        if is_replaceable(ch) {
427            buf.push(replacement(ch, hash[hi % 32]));
428            hi += 1;
429            had_replaceable = true;
430        } else {
431            buf.push(ch);
432        }
433    }
434    had_replaceable.then_some(buf)
435}
436
437/// Length-preserving digit replacement.
438/// Preserves every non-digit character in `original`; replaces each
439/// ASCII digit with a deterministic digit derived from `hash`.
440/// Falls back to hex if the original contains no digits.
441fn format_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
442    format_char_class_lp(
443        hash,
444        original,
445        |c| c.is_ascii_digit(),
446        |_, b| (b'0' + b % 10) as char,
447    )
448    .unwrap_or_else(|| pad_or_truncate("", target, &hex_bytes(hash)))
449}
450
451/// Length-preserving hex-digit replacement (for IPv6, UUID, MAC, container ID).
452/// Preserves non-hex characters (colons, dashes, etc.); replaces each
453/// ASCII hex digit with a deterministic hex digit from `hash`, preserving case.
454fn format_hex_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
455    let hex = hex_bytes(hash);
456    format_char_class_lp(
457        hash,
458        original,
459        |c| c.is_ascii_hexdigit(),
460        |ch, b| {
461            let nibble = b % 16;
462            if ch.is_ascii_uppercase() {
463                b"0123456789ABCDEF"[nibble as usize] as char
464            } else {
465                b"0123456789abcdef"[nibble as usize] as char
466            }
467        },
468    )
469    .unwrap_or_else(|| pad_or_truncate("", target, &hex))
470}
471
472/// Length-preserving SSN replacement.
473/// Preserves all non-digit characters.  The first three digit positions
474/// are forced to '0' (never-issued area code, clearly synthetic).
475/// Remaining digit positions are filled with deterministic digits.
476fn format_ssn_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
477    let has_digit = original.chars().any(|c| c.is_ascii_digit());
478    if !has_digit {
479        let hex = hex_bytes(hash);
480        return pad_or_truncate("", target, &hex);
481    }
482    let mut buf = String::with_capacity(target);
483    let mut digit_idx = 0usize;
484    for ch in original.chars() {
485        if ch.is_ascii_digit() {
486            if digit_idx < 3 {
487                buf.push('0');
488            } else {
489                buf.push((b'0' + hash[(digit_idx - 3) % 32] % 10) as char);
490            }
491            digit_idx += 1;
492        } else {
493            buf.push(ch);
494        }
495    }
496    buf
497}
498
499/// Length-preserving hostname replacement.
500/// Preserves the suffix (everything from the first `.` onward) and
501/// fills the prefix with deterministic hex characters to match `target`.
502fn format_hostname_lp(hex: &[u8; 64], original: &str, target: usize) -> String {
503    let suffix = original.find('.').map_or("", |p| &original[p..]);
504    let prefix_len = target.saturating_sub(suffix.len());
505    if prefix_len == 0 {
506        return pad_or_truncate("", target, hex);
507    }
508    let mut buf = String::with_capacity(target);
509    for i in 0..prefix_len {
510        buf.push(hex[i % 64] as char);
511    }
512    buf.push_str(suffix);
513    buf
514}
515
516/// Length-preserving custom replacement.
517/// Uses `__SANITIZED_<hex>__` format when the target is long enough;
518/// falls back to bare hex for short targets.
519fn format_custom_lp(hex: &[u8; 64], target: usize) -> String {
520    let prefix = "__SANITIZED_";
521    let suffix = "__";
522    let overhead = prefix.len() + suffix.len(); // 14
523    if target <= overhead {
524        return pad_or_truncate("", target, hex);
525    }
526    let hex_len = target - overhead;
527    let mut buf = String::with_capacity(target);
528    buf.push_str(prefix);
529    for i in 0..hex_len {
530        buf.push(hex[i % 64] as char);
531    }
532    buf.push_str(suffix);
533    buf
534}
535
536/// Length-preserving JWT replacement.
537/// Preserves `.` separators; replaces base64url characters
538/// (`[A-Za-z0-9_-]`) with deterministic base64url characters.
539fn format_jwt_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
540    const B64URL: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
541    let mut buf = String::with_capacity(target);
542    let mut hi = 0usize;
543    let mut had_b64 = false;
544    for ch in original.chars() {
545        if ch == '.' || ch == '=' {
546            buf.push(ch);
547        } else if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
548            buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
549            hi += 1;
550            had_b64 = true;
551        } else {
552            // Non-base64url, non-structural: emit byte-preserving replacement.
553            for _ in 0..ch.len_utf8() {
554                buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
555                hi += 1;
556            }
557            had_b64 = true;
558        }
559    }
560    if !had_b64 {
561        let hex = hex_bytes(hash);
562        return pad_or_truncate("", target, &hex);
563    }
564    buf
565}
566
567/// File path replacement.
568/// Preserves separators (`/`, `\`) and the final extension (from last `.`
569/// in the last segment). Replaces other characters with deterministic hex.
570///
571/// Under `randomized`, the filename **stem** (between the last separator and the
572/// extension) is emitted as a band-derived-length hex run instead of being
573/// length-preserving, so the stem length no longer leaks. Other path segments
574/// stay length-preserving; separators and the trailing extension stay verbatim
575/// (and in place — this is the fix for the trailing-pad bug that would otherwise
576/// append filler *after* the extension for a longer target).
577fn format_filepath_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
578    // Find the last path separator position to identify the filename segment.
579    let last_sep = original.rfind(['/', '\\']).map_or(0, |p| p + 1);
580    let filename = &original[last_sep..];
581    // Find extension in the filename (last `.` that isn't at position 0).
582    let ext_start = filename.rfind('.').filter(|&p| p > 0).map(|p| last_sep + p);
583
584    if randomized {
585        let mut buf = String::new();
586        let mut hi = 0usize;
587        // Directory prefix (up to and including the last separator): preserve
588        // separators, replace other characters byte-for-byte.
589        for ch in original[..last_sep].chars() {
590            if matches!(ch, '/' | '\\') {
591                buf.push(ch);
592            } else {
593                for _ in 0..ch.len_utf8() {
594                    buf.push(hex[hi % 64] as char);
595                    hi += 1;
596                }
597            }
598        }
599        // Filename stem: band-derived length.
600        let stem_len = band_pick(hex, 0, SEGMENT_BAND.0, SEGMENT_BAND.1);
601        for _ in 0..stem_len {
602            buf.push(hex[hi % 64] as char);
603            hi += 1;
604        }
605        // Extension (from the `.` to end of the original), verbatim.
606        if let Some(es) = ext_start {
607            buf.push_str(&original[es..]);
608        }
609        return buf;
610    }
611
612    let mut buf = String::with_capacity(target);
613    let mut hi = 0usize;
614
615    for (i, ch) in original.char_indices() {
616        if matches!(ch, '/' | '\\') || ext_start.is_some_and(|es| i >= es) {
617            // Preserve separators and the file extension.
618            buf.push(ch);
619        } else {
620            // Emit as many ASCII hex bytes as the original char's UTF-8 length.
621            for _ in 0..ch.len_utf8() {
622                buf.push(hex[hi % 64] as char);
623                hi += 1;
624            }
625        }
626    }
627    // Ensure exact length (should be equal for ASCII, but guard anyway).
628    if buf.len() != target {
629        return pad_or_truncate(&buf, target, hex);
630    }
631    buf
632}
633
634/// Length-preserving Windows SID replacement.
635/// Preserves the `S-` prefix and `-` separators; replaces digit groups
636/// with deterministic digits.
637fn format_windows_sid_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
638    let has_digit = original.chars().any(|c| c.is_ascii_digit());
639    if !has_digit {
640        let hex = hex_bytes(hash);
641        return pad_or_truncate("", target, &hex);
642    }
643    let mut buf = String::with_capacity(target);
644    let mut hi = 0usize;
645    for ch in original.chars() {
646        if ch == 'S' || ch == '-' {
647            buf.push(ch);
648        } else if ch.is_ascii_digit() {
649            buf.push((b'0' + hash[hi % 32] % 10) as char);
650            hi += 1;
651        } else {
652            // Non-digit, non-structural: emit byte-count-preserving hex.
653            for _ in 0..ch.len_utf8() {
654                buf.push((b'0' + hash[hi % 32] % 10) as char);
655                hi += 1;
656            }
657        }
658    }
659    buf
660}
661
662/// Shared core for length-preserving hex replacement where a caller-supplied
663/// predicate identifies "structural" characters to preserve as-is.
664///
665/// All non-structural characters are replaced byte-by-byte with deterministic
666/// hex characters derived from `hex`.  Returns `None` if the original
667/// contained no replaceable content (caller should fall back to
668/// [`pad_or_truncate`]).
669fn format_preserving_hex_lp(
670    hex: &[u8; 64],
671    original: &str,
672    target: usize,
673    is_structural: impl Fn(char) -> bool,
674) -> Option<String> {
675    let mut buf = String::with_capacity(target);
676    let mut hi = 0usize;
677    let mut had_content = false;
678
679    for ch in original.chars() {
680        if is_structural(ch) {
681            buf.push(ch);
682        } else {
683            for _ in 0..ch.len_utf8() {
684                buf.push(hex[hi % 64] as char);
685                hi += 1;
686            }
687            had_content = true;
688        }
689    }
690
691    had_content.then_some(buf)
692}
693
694/// URL replacement.
695/// Preserves scheme prefix and structural characters
696/// (`://`, `/`, `?`, `=`, `&`, `#`, `:`); replaces content characters
697/// with deterministic hex. Under `randomized`, each variable segment is
698/// emitted with a band-derived length instead of being length-preserving.
699fn format_url_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
700    let is_structural = |ch| "/:?=&#@.".contains(ch);
701    if randomized {
702        return format_preserving_hex_rand(hex, original, is_structural);
703    }
704    format_preserving_hex_lp(hex, original, target, is_structural)
705        .unwrap_or_else(|| pad_or_truncate("", target, hex))
706}
707
708/// AWS ARN replacement.
709/// Preserves `:` and `/` separators; replaces alphanumeric content
710/// in account/resource segments with deterministic hex. Under `randomized`,
711/// each variable segment is emitted with a band-derived length.
712fn format_arn_lp(hex: &[u8; 64], original: &str, target: usize, randomized: bool) -> String {
713    let is_structural = |ch| ch == ':' || ch == '/';
714    if randomized {
715        return format_preserving_hex_rand(hex, original, is_structural);
716    }
717    format_preserving_hex_lp(hex, original, target, is_structural)
718        .unwrap_or_else(|| pad_or_truncate("", target, hex))
719}
720
721/// Length-preserving Azure Resource ID replacement.
722/// Preserves `/` path separators and well-known Azure segment names
723/// (`subscriptions`, `resourceGroups`, `providers`, `resourcegroups`).
724/// Replaces variable segments (IDs, names) with deterministic hex.
725fn format_azure_resource_id_lp(
726    hex: &[u8; 64],
727    original: &str,
728    target: usize,
729    randomized: bool,
730) -> String {
731    const KNOWN_SEGMENTS: &[&str] = &[
732        "subscriptions",
733        "resourceGroups",
734        "resourcegroups",
735        "providers",
736    ];
737
738    let mut buf = String::with_capacity(target);
739    let mut hi = 0usize;
740    let mut seg_idx = 0usize;
741
742    // Split on `/`, rebuild with deterministic replacement for non-known segments.
743    let mut prev_was_providers = false;
744    for (pi, part) in original.split('/').enumerate() {
745        if pi > 0 {
746            buf.push('/');
747        }
748        // Dotted segments (e.g. `Microsoft.Compute`) are only preserved when
749        // they immediately follow a `providers` segment. Preserving all dotted
750        // segments would accidentally pass through IPs or hostnames that appear
751        // elsewhere in the path.
752        let is_provider_namespace = prev_was_providers && part.contains('.');
753        if part.is_empty() || KNOWN_SEGMENTS.contains(&part) || is_provider_namespace {
754            buf.push_str(part);
755        } else if randomized {
756            // Emit a band-derived-length hex run so the variable segment's length
757            // no longer leaks.
758            let seg_len = band_pick(hex, seg_idx + 1, SEGMENT_BAND.0, SEGMENT_BAND.1);
759            for _ in 0..seg_len {
760                buf.push(hex[hi % 64] as char);
761                hi += 1;
762            }
763            seg_idx += 1;
764        } else {
765            // Replace this segment character-by-character to preserve byte length.
766            for ch in part.chars() {
767                for _ in 0..ch.len_utf8() {
768                    buf.push(hex[hi % 64] as char);
769                    hi += 1;
770                }
771            }
772        }
773        prev_was_providers = part == "providers" || part == "Providers";
774    }
775    if !randomized && buf.len() != target {
776        return pad_or_truncate(&buf, target, hex);
777    }
778    buf
779}
780
781/// Deterministic synthetic name from hash bytes.
782fn format_name(hash: &[u8; 32]) -> String {
783    // We use a small, fixed table of first/last name fragments.
784    // The hash selects indices. This is NOT meant to be realistic — it's
785    // meant to be obviously synthetic while remaining structurally plausible.
786    const FIRST: &[&str] = &[
787        "Alex", "Blake", "Casey", "Dana", "Ellis", "Finley", "Gray", "Harper", "Ira", "Jordan",
788        "Kai", "Lane", "Morgan", "Noel", "Oakley", "Parker", "Quinn", "Reese", "Sage", "Taylor",
789        "Uri", "Val", "Wren", "Xen", "Yael", "Zion", "Arden", "Blair", "Corin", "Drew", "Emery",
790        "Frost",
791    ];
792    const LAST: &[&str] = &[
793        "Ashford",
794        "Blackwell",
795        "Crawford",
796        "Dalton",
797        "Eastwood",
798        "Fairbanks",
799        "Garrison",
800        "Hartley",
801        "Irvine",
802        "Jensen",
803        "Kendrick",
804        "Langley",
805        "Mercer",
806        "Newland",
807        "Oakwood",
808        "Preston",
809        "Quinlan",
810        "Redmond",
811        "Shepard",
812        "Thornton",
813        "Underwood",
814        "Vance",
815        "Whitmore",
816        "Xavier",
817        "Yardley",
818        "Zimmer",
819        "Ashton",
820        "Beckett",
821        "Calloway",
822        "Dempsey",
823        "Eldridge",
824        "Fletcher",
825    ];
826    let fi = hash[0] as usize % FIRST.len();
827    let li = hash[1] as usize % LAST.len();
828    format!("{} {}", FIRST[fi], LAST[li])
829}
830
831/// Encode 32 bytes as 64 lowercase hex ASCII bytes on the stack.
832fn hex_bytes(bytes: &[u8; 32]) -> [u8; 64] {
833    const HEX: &[u8; 16] = b"0123456789abcdef";
834    let mut out = [0u8; 64];
835    for (i, &b) in bytes.iter().enumerate() {
836        out[i * 2] = HEX[(b >> 4) as usize];
837        out[i * 2 + 1] = HEX[(b & 0xf) as usize];
838    }
839    out
840}
841
842// ---------------------------------------------------------------------------
843// Tests
844// ---------------------------------------------------------------------------
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn hmac_deterministic_same_input() {
852        let gen = HmacGenerator::new([42u8; 32]);
853        let a = gen.generate(&Category::Email, "alice@corp.com");
854        let b = gen.generate(&Category::Email, "alice@corp.com");
855        assert_eq!(a, b, "same seed + same input must produce same output");
856    }
857
858    #[test]
859    fn hmac_different_inputs_differ() {
860        let gen = HmacGenerator::new([42u8; 32]);
861        let a = gen.generate(&Category::Email, "alice@corp.com");
862        let b = gen.generate(&Category::Email, "bob@corp.com");
863        assert_ne!(a, b);
864    }
865
866    #[test]
867    fn hmac_different_seeds_differ() {
868        let g1 = HmacGenerator::new([1u8; 32]);
869        let g2 = HmacGenerator::new([2u8; 32]);
870        let a = g1.generate(&Category::Email, "alice@corp.com");
871        let b = g2.generate(&Category::Email, "alice@corp.com");
872        assert_ne!(a, b);
873    }
874
875    #[test]
876    fn hmac_different_categories_differ() {
877        let gen = HmacGenerator::new([42u8; 32]);
878        let a = gen.generate(&Category::Email, "test");
879        let b = gen.generate(&Category::Name, "test");
880        assert_ne!(a, b, "different categories must produce different outputs");
881    }
882
883    #[test]
884    fn email_format() {
885        let gen = HmacGenerator::new([0u8; 32]);
886        let orig = "alice@corp.com";
887        let out = gen.generate(&Category::Email, orig);
888        assert!(out.contains('@'), "email must contain @");
889        assert!(out.ends_with("@corp.com"), "email must preserve domain");
890        assert_eq!(out.len(), orig.len(), "email must preserve length");
891    }
892
893    #[test]
894    fn ipv4_format() {
895        let gen = HmacGenerator::new([0u8; 32]);
896        let orig = "192.168.1.1";
897        let out = gen.generate(&Category::IpV4, orig);
898        // Dots preserved, length preserved.
899        let parts: Vec<&str> = out.split('.').collect();
900        assert_eq!(parts.len(), 4);
901        assert_eq!(out.len(), orig.len(), "ipv4 must preserve length");
902    }
903
904    #[test]
905    fn ssn_format() {
906        let gen = HmacGenerator::new([7u8; 32]);
907        let orig = "123-45-6789";
908        let out = gen.generate(&Category::Ssn, orig);
909        assert!(out.starts_with("000-"), "SSN must start with 000");
910        assert_eq!(out.len(), orig.len(), "SSN must preserve length");
911    }
912
913    #[test]
914    fn phone_format() {
915        let gen = HmacGenerator::new([3u8; 32]);
916        let orig = "+1-212-555-0100";
917        let out = gen.generate(&Category::Phone, orig);
918        // Formatting characters preserved.
919        assert!(out.starts_with('+'));
920        assert_eq!(
921            out.chars().filter(|c| *c == '-').count(),
922            orig.chars().filter(|c| *c == '-').count(),
923            "dashes must be preserved"
924        );
925        assert_eq!(out.len(), orig.len(), "phone must preserve length");
926    }
927
928    #[test]
929    fn hostname_format() {
930        let gen = HmacGenerator::new([5u8; 32]);
931        let orig = "db-prod-01.internal";
932        let out = gen.generate(&Category::Hostname, orig);
933        assert!(out.ends_with(".internal"), "hostname must preserve suffix");
934        assert_eq!(out.len(), orig.len(), "hostname must preserve length");
935    }
936
937    #[test]
938    fn custom_format() {
939        let gen = HmacGenerator::new([9u8; 32]);
940        let cat = Category::Custom("api_key".into());
941        // Use an input long enough for the __SANITIZED_..__ wrapper (>14 chars).
942        let orig = "sk-abc123-very-long-key";
943        let out = gen.generate(&cat, orig);
944        assert!(out.starts_with("__SANITIZED_"));
945        assert!(out.ends_with("__"));
946        assert_eq!(out.len(), orig.len(), "custom must preserve length");
947    }
948
949    #[test]
950    fn custom_format_short() {
951        let gen = HmacGenerator::new([9u8; 32]);
952        let cat = Category::Custom("api_key".into());
953        // Short input falls back to hex.
954        let orig = "sk-abc123";
955        let out = gen.generate(&cat, orig);
956        assert_eq!(
957            out.len(),
958            orig.len(),
959            "custom must preserve length even for short inputs"
960        );
961    }
962
963    #[test]
964    fn random_generator_produces_valid_format() {
965        let gen = RandomGenerator::new();
966        let orig = "test@example.com";
967        let out = gen.generate(&Category::Email, orig);
968        assert!(out.contains('@'));
969        assert_eq!(
970            out.len(),
971            orig.len(),
972            "random generator must preserve length"
973        );
974    }
975
976    #[test]
977    fn from_slice_rejects_bad_length() {
978        let result = HmacGenerator::from_slice(&[0u8; 16]);
979        assert!(result.is_err());
980    }
981
982    #[test]
983    fn credit_card_format() {
984        let gen = HmacGenerator::new([11u8; 32]);
985        let orig = "4111-1111-1111-1111";
986        let out = gen.generate(&Category::CreditCard, orig);
987        // Should be ####-####-####-####
988        let parts: Vec<&str> = out.split('-').collect();
989        assert_eq!(parts.len(), 4);
990        for part in &parts {
991            assert_eq!(part.len(), 4);
992            assert!(part.chars().all(|c| c.is_ascii_digit()));
993        }
994        assert_eq!(out.len(), orig.len(), "credit card must preserve length");
995    }
996
997    #[test]
998    fn name_format() {
999        let gen = HmacGenerator::new([0u8; 32]);
1000        let orig = "John Doe";
1001        let out = gen.generate(&Category::Name, orig);
1002        assert_eq!(out.len(), orig.len(), "name must preserve length");
1003    }
1004
1005    #[test]
1006    fn ipv6_format() {
1007        let gen = HmacGenerator::new([0u8; 32]);
1008        let orig = "fd00:abcd:1234:5678::1";
1009        let out = gen.generate(&Category::IpV6, orig);
1010        // Colons and :: preserved, length preserved.
1011        assert_eq!(
1012            out.chars().filter(|c| *c == ':').count(),
1013            orig.chars().filter(|c| *c == ':').count(),
1014            "colons must be preserved"
1015        );
1016        assert_eq!(out.len(), orig.len(), "ipv6 must preserve length");
1017    }
1018
1019    #[test]
1020    fn length_preserved_all_categories() {
1021        let gen = HmacGenerator::new([42u8; 32]);
1022        let cases: Vec<(Category, &str)> = vec![
1023            (Category::Email, "alice@corp.com"),
1024            (Category::Name, "John Doe"),
1025            (Category::Phone, "+1-212-555-0100"),
1026            (Category::IpV4, "192.168.1.1"),
1027            (Category::IpV6, "fd00::1"),
1028            (Category::CreditCard, "4111-1111-1111-1111"),
1029            (Category::Ssn, "123-45-6789"),
1030            (Category::Hostname, "db-prod-01.internal"),
1031            (Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
1032            (Category::ContainerId, "a1b2c3d4e5f6"),
1033            (Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
1034            (Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
1035            (Category::AuthToken, "ghp_abc123secrettoken"),
1036            (Category::FilePath, "/home/jsmith/config.yaml"),
1037            (Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
1038            (Category::Url, "https://internal.corp.com/api"),
1039            (Category::AwsArn, "arn:aws:iam::123456789012:user/admin"),
1040            (
1041                Category::AzureResourceId,
1042                "/subscriptions/550e8400/resourceGroups/rg-prod",
1043            ),
1044            (Category::Custom("key".into()), "some-secret-value-here"),
1045        ];
1046        for (cat, orig) in &cases {
1047            let out = gen.generate(cat, orig);
1048            assert_eq!(
1049                out.len(),
1050                orig.len(),
1051                "length mismatch for {:?}: '{}' ({}) -> '{}' ({})",
1052                cat,
1053                orig,
1054                orig.len(),
1055                out,
1056                out.len()
1057            );
1058        }
1059    }
1060
1061    #[test]
1062    fn mac_address_format() {
1063        let gen = HmacGenerator::new([7u8; 32]);
1064        let orig = "AA:BB:CC:DD:EE:FF";
1065        let out = gen.generate(&Category::MacAddress, orig);
1066        assert_eq!(out.len(), orig.len(), "mac must preserve length");
1067        assert_eq!(
1068            out.chars().filter(|c| *c == ':').count(),
1069            5,
1070            "mac must preserve colons"
1071        );
1072    }
1073
1074    #[test]
1075    fn mac_address_dash_format() {
1076        let gen = HmacGenerator::new([7u8; 32]);
1077        let orig = "AA-BB-CC-DD-EE-FF";
1078        let out = gen.generate(&Category::MacAddress, orig);
1079        assert_eq!(out.len(), orig.len());
1080        assert_eq!(out.chars().filter(|c| *c == '-').count(), 5);
1081    }
1082
1083    #[test]
1084    fn uuid_format() {
1085        let gen = HmacGenerator::new([3u8; 32]);
1086        let orig = "550e8400-e29b-41d4-a716-446655440000";
1087        let out = gen.generate(&Category::Uuid, orig);
1088        assert_eq!(out.len(), orig.len(), "uuid must preserve length");
1089        assert_eq!(
1090            out.chars().filter(|c| *c == '-').count(),
1091            4,
1092            "uuid must preserve dashes"
1093        );
1094    }
1095
1096    #[test]
1097    fn container_id_format() {
1098        let gen = HmacGenerator::new([5u8; 32]);
1099        let orig = "a1b2c3d4e5f6";
1100        let out = gen.generate(&Category::ContainerId, orig);
1101        assert_eq!(out.len(), orig.len(), "container id must preserve length");
1102        assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
1103    }
1104
1105    #[test]
1106    fn jwt_format() {
1107        let gen = HmacGenerator::new([11u8; 32]);
1108        let orig = "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK";
1109        let out = gen.generate(&Category::Jwt, orig);
1110        assert_eq!(out.len(), orig.len(), "jwt must preserve length");
1111        let orig_dots = orig.chars().filter(|c| *c == '.').count();
1112        let out_dots = out.chars().filter(|c| *c == '.').count();
1113        assert_eq!(out_dots, orig_dots, "jwt must preserve dots");
1114    }
1115
1116    #[test]
1117    fn auth_token_format() {
1118        let gen = HmacGenerator::new([9u8; 32]);
1119        let orig = "ghp_abc123secrettoken";
1120        let out = gen.generate(&Category::AuthToken, orig);
1121        assert!(out.starts_with("__SANITIZED_"));
1122        assert!(out.ends_with("__"));
1123        assert_eq!(out.len(), orig.len(), "auth_token must preserve length");
1124    }
1125
1126    #[test]
1127    fn filepath_unix_format() {
1128        let gen = HmacGenerator::new([13u8; 32]);
1129        let orig = "/home/jsmith/config.yaml";
1130        let out = gen.generate(&Category::FilePath, orig);
1131        assert_eq!(out.len(), orig.len(), "filepath must preserve length");
1132        assert_eq!(
1133            std::path::Path::new(&out)
1134                .extension()
1135                .and_then(|e| e.to_str()),
1136            Some("yaml"),
1137            "filepath must preserve extension"
1138        );
1139        assert_eq!(
1140            out.chars().filter(|c| *c == '/').count(),
1141            orig.chars().filter(|c| *c == '/').count(),
1142            "filepath must preserve separators"
1143        );
1144    }
1145
1146    #[test]
1147    fn filepath_windows_format() {
1148        let gen = HmacGenerator::new([13u8; 32]);
1149        let orig = "C:\\Users\\admin\\secrets.txt";
1150        let out = gen.generate(&Category::FilePath, orig);
1151        assert_eq!(out.len(), orig.len(), "filepath must preserve length");
1152        assert_eq!(
1153            std::path::Path::new(&out)
1154                .extension()
1155                .and_then(|e| e.to_str()),
1156            Some("txt"),
1157            "filepath must preserve extension"
1158        );
1159        assert_eq!(
1160            out.chars().filter(|c| *c == '\\').count(),
1161            orig.chars().filter(|c| *c == '\\').count(),
1162            "filepath must preserve backslashes"
1163        );
1164    }
1165
1166    #[test]
1167    fn windows_sid_format() {
1168        let gen = HmacGenerator::new([7u8; 32]);
1169        let orig = "S-1-5-21-3623811015-3361044348-30300820-1013";
1170        let out = gen.generate(&Category::WindowsSid, orig);
1171        assert_eq!(out.len(), orig.len(), "SID must preserve length");
1172        assert!(out.starts_with("S-"), "SID must start with S-");
1173        assert_eq!(
1174            out.chars().filter(|c| *c == '-').count(),
1175            orig.chars().filter(|c| *c == '-').count(),
1176            "SID must preserve dashes"
1177        );
1178    }
1179
1180    #[test]
1181    fn url_format() {
1182        let gen = HmacGenerator::new([5u8; 32]);
1183        let orig = "https://internal.corp.com/api/users?token=abc123";
1184        let out = gen.generate(&Category::Url, orig);
1185        assert_eq!(out.len(), orig.len(), "url must preserve length");
1186        // Structural characters preserved.
1187        assert!(out.contains("://"));
1188        assert!(out.contains('?'));
1189        assert!(out.contains('='));
1190    }
1191
1192    #[test]
1193    fn aws_arn_format() {
1194        let gen = HmacGenerator::new([3u8; 32]);
1195        let orig = "arn:aws:iam::123456789012:user/admin";
1196        let out = gen.generate(&Category::AwsArn, orig);
1197        assert_eq!(out.len(), orig.len(), "ARN must preserve length");
1198        assert_eq!(
1199            out.chars().filter(|c| *c == ':').count(),
1200            orig.chars().filter(|c| *c == ':').count(),
1201            "ARN must preserve colons"
1202        );
1203        assert!(out.contains('/'), "ARN must preserve slash");
1204    }
1205
1206    #[test]
1207    fn azure_resource_id_format() {
1208        let gen = HmacGenerator::new([11u8; 32]);
1209        let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
1210        let out = gen.generate(&Category::AzureResourceId, orig);
1211        assert_eq!(
1212            out.len(),
1213            orig.len(),
1214            "Azure resource ID must preserve length"
1215        );
1216        assert!(
1217            out.contains("/subscriptions/"),
1218            "must preserve 'subscriptions'"
1219        );
1220        assert!(
1221            out.contains("/resourceGroups/"),
1222            "must preserve 'resourceGroups'"
1223        );
1224        assert!(out.contains("/providers/"), "must preserve 'providers'");
1225        assert!(
1226            out.contains("Microsoft.Compute"),
1227            "must preserve dotted provider name"
1228        );
1229    }
1230
1231    #[test]
1232    fn azure_dotted_segment_outside_providers_is_replaced() {
1233        let gen = HmacGenerator::new([11u8; 32]);
1234        // A dotted segment that is NOT immediately after `providers/` must be
1235        // treated as a variable component and replaced, not passed through.
1236        // Before the fix, part.contains('.') caused this to be preserved.
1237        let orig = "/subscriptions/10.0.0.1/resourceGroups/rg-prod";
1238        let out = gen.generate(&Category::AzureResourceId, orig);
1239        assert_eq!(out.len(), orig.len(), "length must be preserved");
1240        assert!(out.contains("/subscriptions/"), "subscriptions preserved");
1241        assert!(out.contains("/resourceGroups/"), "resourceGroups preserved");
1242        assert!(
1243            !out.contains("10.0.0.1"),
1244            "dotted non-provider segment must be replaced, got: {out}"
1245        );
1246        assert!(
1247            !out.contains("rg-prod"),
1248            "variable resource group name must be replaced, got: {out}"
1249        );
1250    }
1251
1252    // -----------------------------------------------------------------------
1253    // LengthPolicy::Randomized
1254    // -----------------------------------------------------------------------
1255
1256    fn rand_gen(seed: u8) -> HmacGenerator {
1257        HmacGenerator::new([seed; 32]).with_length_policy(LengthPolicy::Randomized)
1258    }
1259
1260    #[test]
1261    fn randomized_digits_decoupled_from_input_length() {
1262        let gen = rand_gen(42);
1263        for cat in [Category::Phone, Category::CreditCard, Category::Ssn] {
1264            for len in 1..40usize {
1265                // Build a digit input of byte length `len`.
1266                let orig: String = "1234567890".chars().cycle().take(len).collect();
1267                let out = gen.generate(&cat, &orig);
1268                assert!(
1269                    out.chars().all(|c| c.is_ascii_digit()),
1270                    "{cat:?} output must be all digits: {out}"
1271                );
1272                assert!(
1273                    out.len() >= DIGITS_BAND.0 && out.len() <= DIGITS_BAND.1,
1274                    "{cat:?} length {} out of band for input len {len}",
1275                    out.len()
1276                );
1277                if cat == Category::Ssn {
1278                    assert!(out.starts_with('0'), "ssn must start with 0: {out}");
1279                }
1280            }
1281        }
1282    }
1283
1284    #[test]
1285    fn randomized_is_stable_per_value() {
1286        let gen = rand_gen(7);
1287        for (cat, orig) in [
1288            (Category::Phone, "+1-212-555-0100"),
1289            (Category::Email, "alice@corp.com"),
1290            (Category::Hostname, "db-prod-01.internal"),
1291            (Category::FilePath, "/home/jsmith/config.yaml"),
1292            (Category::AuthToken, "ghp_abc123secrettoken"),
1293        ] {
1294            let a = gen.generate(&cat, orig);
1295            let b = gen.generate(&cat, orig);
1296            assert_eq!(a, b, "{cat:?} must be stable for the same value");
1297        }
1298    }
1299
1300    #[test]
1301    fn randomized_email_keeps_domain_varies_user() {
1302        let gen = rand_gen(3);
1303        // Same domain, many username lengths → output user length stays in band,
1304        // independent of the input's username length.
1305        for ulen in 1..30usize {
1306            let user = "a".repeat(ulen);
1307            let orig = format!("{user}@corp.com");
1308            let out = gen.generate(&Category::Email, &orig);
1309            assert!(
1310                out.ends_with("@corp.com"),
1311                "domain must be preserved: {out}"
1312            );
1313            assert!(out.contains('@'));
1314            let out_user = out.split('@').next().unwrap().len();
1315            assert!(
1316                out_user >= EMAIL_USER_BAND.0 && out_user <= EMAIL_USER_BAND.1,
1317                "email user length {out_user} out of band for input ulen {ulen}"
1318            );
1319        }
1320    }
1321
1322    #[test]
1323    fn randomized_hostname_keeps_suffix() {
1324        let gen = rand_gen(5);
1325        let out = gen.generate(&Category::Hostname, "db-prod-01.internal");
1326        assert!(
1327            out.ends_with(".internal"),
1328            "suffix must be preserved: {out}"
1329        );
1330        let prefix = out.strip_suffix(".internal").unwrap().len();
1331        assert!(prefix >= HOSTNAME_PREFIX_BAND.0 && prefix <= HOSTNAME_PREFIX_BAND.1);
1332    }
1333
1334    #[test]
1335    fn randomized_filepath_keeps_extension_and_separators() {
1336        let gen = rand_gen(13);
1337        let orig = "/home/jsmith/config.yaml";
1338        let out = gen.generate(&Category::FilePath, orig);
1339        assert_eq!(
1340            std::path::Path::new(&out)
1341                .extension()
1342                .and_then(|e| e.to_str()),
1343            Some("yaml"),
1344            "extension must stay at the end: {out}"
1345        );
1346        assert_eq!(
1347            out.chars().filter(|c| *c == '/').count(),
1348            orig.chars().filter(|c| *c == '/').count(),
1349            "separators must be preserved: {out}"
1350        );
1351        assert!(
1352            !out.ends_with("yaml") || out.contains(".yaml"),
1353            "must contain the .yaml extension verbatim: {out}"
1354        );
1355    }
1356
1357    #[test]
1358    fn randomized_url_arn_keep_structure() {
1359        let gen = rand_gen(9);
1360        let url = gen.generate(
1361            &Category::Url,
1362            "https://internal.corp.com/api/users?token=abc123",
1363        );
1364        assert!(url.contains("://"), "url scheme separator preserved: {url}");
1365        assert!(
1366            url.contains('?') && url.contains('='),
1367            "url query preserved: {url}"
1368        );
1369
1370        let arn = gen.generate(&Category::AwsArn, "arn:aws:iam::123456789012:user/admin");
1371        assert_eq!(
1372            arn.chars().filter(|c| *c == ':').count(),
1373            "arn:aws:iam::123456789012:user/admin"
1374                .chars()
1375                .filter(|c| *c == ':')
1376                .count(),
1377            "arn colons preserved: {arn}"
1378        );
1379        assert!(arn.contains('/'), "arn slash preserved: {arn}");
1380    }
1381
1382    #[test]
1383    fn randomized_azure_keeps_known_segments() {
1384        let gen = rand_gen(11);
1385        let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
1386        let out = gen.generate(&Category::AzureResourceId, orig);
1387        assert!(out.contains("/subscriptions/"));
1388        assert!(out.contains("/resourceGroups/"));
1389        assert!(out.contains("/providers/"));
1390        assert!(out.contains("Microsoft.Compute"));
1391        assert!(
1392            !out.contains("rg-prod"),
1393            "variable segment must be replaced: {out}"
1394        );
1395    }
1396
1397    #[test]
1398    fn randomized_canonical_categories_are_noop() {
1399        // These categories have a canonical/fixed shape (and JWT is a deliberate
1400        // exception); Randomized must leave their length unchanged.
1401        let gen = rand_gen(1);
1402        let cases: Vec<(Category, &str)> = vec![
1403            (Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
1404            (Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
1405            (Category::IpV4, "192.168.1.1"),
1406            (Category::IpV6, "fd00:abcd:1234:5678::1"),
1407            (Category::ContainerId, "a1b2c3d4e5f6"),
1408            (Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
1409            (Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
1410        ];
1411        for (cat, orig) in &cases {
1412            let out = gen.generate(cat, orig);
1413            assert_eq!(
1414                out.len(),
1415                orig.len(),
1416                "{cat:?} must stay length-preserving under Randomized: {orig} -> {out}"
1417            );
1418        }
1419    }
1420
1421    #[test]
1422    fn randomized_token_is_valid_and_in_band() {
1423        let gen = rand_gen(9);
1424        for cat in [Category::AuthToken, Category::Custom("api_key".into())] {
1425            let out = gen.generate(&cat, "sk-abc123-some-secret-value");
1426            assert!(out.starts_with("__SANITIZED_"), "{cat:?}: {out}");
1427            assert!(out.ends_with("__"), "{cat:?}: {out}");
1428            let overhead = "__SANITIZED_".len() + "__".len();
1429            let hex_len = out.len() - overhead;
1430            assert!(
1431                hex_len >= TOKEN_BAND.0 && hex_len <= TOKEN_BAND.1,
1432                "{cat:?} token hex length {hex_len} out of band"
1433            );
1434        }
1435    }
1436}