Skip to main content

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