Skip to main content

rust_sanitize/processor/
mod.rs

1//! Structured processors for format-aware sanitization.
2//!
3//! # Architecture
4//!
5//! ```text
6//! ┌──────────────────┐     ┌───────────────────┐     ┌──────────────────┐
7//! │  Input bytes     │ ──▶ │ ProcessorRegistry  │ ──▶ │  Output bytes    │
8//! │  (file content)  │     │ (profile matching) │     │  (sanitized)     │
9//! └──────────────────┘     └────────┬───────────┘     └──────────────────┘
10//!                                   │
11//!                          ┌────────▼────────┐
12//!                          │ dyn Processor    │
13//!                          │                  │
14//!                          │  KeyValue        │ ← gitlab.rb-style
15//!                          │  JsonProcessor   │ ← JSON files
16//!                          │  YamlProcessor   │ ← YAML files
17//!                          │  XmlProcessor    │ ← XML files
18//!                          │  CsvProcessor    │ ← CSV/TSV files
19//!                          └────────┬────────┘
20//!                                   │
21//!                          ┌────────▼────────┐
22//!                          │  MappingStore    │
23//!                          │  (one-way dedup) │
24//!                          └─────────────────┘
25//! ```
26//!
27//! # File-Type Profiles
28//!
29//! A [`FileTypeProfile`] specifies which processor to use and what
30//! fields/keys to sanitize. Users provide profiles to control which
31//! parts of a structured file are replaced. If no profile matches,
32//! the caller falls back to the streaming scanner.
33//!
34//! # Extensibility
35//!
36//! Implement the [`Processor`] trait and register it with the
37//! [`ProcessorRegistry`]. The registry matches profiles to processors
38//! by name and dispatches processing.
39
40#[cfg(feature = "archive")]
41pub mod archive;
42#[cfg(feature = "structured")]
43pub mod csv_proc;
44pub mod env_proc;
45pub mod ini_proc;
46pub mod json_proc;
47pub mod jsonl_proc;
48pub mod key_value;
49pub(crate) mod limits;
50pub mod log_line;
51pub mod profile;
52pub mod registry;
53pub mod toml_proc;
54#[cfg(feature = "structured")]
55pub mod xml_proc;
56pub mod yaml_proc;
57
58// Re-export core types.
59pub use profile::{FieldNameSignal, FieldRule, FileTypeProfile, DEFAULT_FIELD_SIGNAL_THRESHOLD};
60pub use registry::ProcessorRegistry;
61
62use crate::category::Category;
63use crate::error::{Result, SanitizeError};
64use crate::store::MappingStore;
65use std::io;
66
67// ---------------------------------------------------------------------------
68// Processor trait
69// ---------------------------------------------------------------------------
70
71/// A structured processor that can sanitize a specific file format while
72/// preserving its structure and formatting as much as possible.
73///
74/// Processors are **stateless** — all mutable state lives in the
75/// [`MappingStore`] they receive. This makes processors `Send + Sync`
76/// and reusable across files.
77///
78/// # Contract
79///
80/// - `name()` must return a unique, lowercase identifier (e.g. `"json"`).
81/// - `can_handle()` is a fast heuristic check; it may inspect a few
82///   bytes or the file extension but should not fully parse.
83/// - `process()` performs the full structured sanitization. It should
84///   preserve formatting/whitespace where possible and only replace
85///   values in fields matched by the profile's [`FieldRule`]s.
86/// - Replacements are **one-way** via the `MappingStore` — no reverse
87///   mapping is produced.
88pub trait Processor: Send + Sync {
89    /// Unique name for this processor (e.g. `"json"`, `"yaml"`, `"key_value"`).
90    fn name(&self) -> &'static str;
91
92    /// Quick heuristic: can this processor handle the given content?
93    ///
94    /// Implementations may check magic bytes, file extension hints in
95    /// the profile, or the first few bytes of content. This is called
96    /// before `process()` and should be fast.
97    fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool;
98
99    /// Process the content, replacing matched field values one-way.
100    ///
101    /// # Arguments
102    ///
103    /// - `content` — raw file bytes.
104    /// - `profile` — the user-supplied profile with field rules.
105    /// - `store` — the mapping store for dedup-consistent one-way replacements.
106    ///
107    /// # Returns
108    ///
109    /// The sanitized content as bytes, preserving structure/formatting
110    /// where possible.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`SanitizeError`] if parsing or replacement generation fails.
115    fn process(
116        &self,
117        content: &[u8],
118        profile: &FileTypeProfile,
119        store: &MappingStore,
120    ) -> Result<Vec<u8>>;
121
122    /// Span-based structured sanitization: return the byte-range edits to apply
123    /// to the original `content` so each matched field value is replaced **in
124    /// place** with its sanitized token, or `None` if this processor does not
125    /// support span editing.
126    ///
127    /// Unlike [`process`](Self::process), which re-serializes the parsed tree
128    /// (losing comments/formatting and, when matched against raw bytes, missing
129    /// values that were escaped in the source), edit-based processing splices
130    /// tokens directly into the source. This is both leak-free and fully
131    /// format-preserving. Edits must be non-overlapping.
132    ///
133    /// The store is populated with `original -> token` mappings as a side
134    /// effect, so the streaming scanner can also redact the same values where
135    /// they appear in comments or other files.
136    ///
137    /// The default returns `None`; callers then fall back to `process` plus the
138    /// format-preserving scanner.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`SanitizeError`] if parsing or replacement generation fails.
143    fn process_to_edits(
144        &self,
145        _content: &[u8],
146        _profile: &FileTypeProfile,
147        _store: &MappingStore,
148    ) -> Result<Option<Vec<Replacement>>> {
149        Ok(None)
150    }
151
152    /// Whether this processor supports bounded-memory streaming via
153    /// [`process_stream`](Self::process_stream).
154    ///
155    /// Processors that return `true` here are eligible for the streaming
156    /// structured path in the CLI, which opens the file as a reader instead
157    /// of reading it fully into memory. The default is `false`.
158    fn supports_streaming(&self) -> bool {
159        false
160    }
161
162    /// Process content from a reader, writing sanitized output to a writer.
163    ///
164    /// The default implementation reads the entire reader into memory and
165    /// delegates to [`process`](Self::process). Processors that return
166    /// `true` from [`supports_streaming`](Self::supports_streaming) should
167    /// override this to handle data incrementally, keeping memory usage
168    /// bounded regardless of input size.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`SanitizeError`] on read, parse,
173    /// or write failure.
174    fn process_stream(
175        &self,
176        reader: &mut dyn io::Read,
177        writer: &mut dyn io::Write,
178        profile: &FileTypeProfile,
179        store: &MappingStore,
180    ) -> Result<()> {
181        let mut buf = Vec::new();
182        io::Read::read_to_end(reader, &mut buf)?;
183        let out = self.process(&buf, profile, store)?;
184        io::Write::write_all(writer, &out)?;
185        Ok(())
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Helpers shared across processors
191// ---------------------------------------------------------------------------
192
193/// Validate the content size and decode it as UTF-8, returning a `&str`.
194///
195/// Used by the tree-based processors (JSON, YAML, TOML, JSONL) to share the
196/// identical "reject if too large, then decode" preamble without copy-pasting it.
197pub(crate) fn check_size_and_decode<'a>(
198    content: &'a [u8],
199    format: &str,
200    size_limit: usize,
201) -> Result<&'a str> {
202    if content.len() > size_limit {
203        return Err(SanitizeError::InputTooLarge {
204            size: content.len(),
205            limit: size_limit,
206        });
207    }
208    std::str::from_utf8(content).map_err(|e| SanitizeError::ParseError {
209        format: format.into(),
210        message: format!("invalid UTF-8: {e}"),
211    })
212}
213
214/// Compute the sanitized token for a matched value during span-based editing,
215/// applying the same rule / field-signal / `min_length` / entropy logic as the
216/// tree walk's [`replace_value`] / [`replace_by_signal`]. Returns `None` when
217/// the value is not matched or is filtered out (and so left unedited).
218///
219/// Like the tree-walk path, this also registers the source-escaped store
220/// aliases via [`register_escaped_aliases`]. The span edit replaces this
221/// field's own bytes directly, but the aliases are the phase-2 cross-location
222/// safety net for the same value reappearing — escaped differently — in an
223/// *unmatched* field of another file (see [`register_escaped_aliases`]).
224///
225/// # Errors
226///
227/// Propagates capacity errors from the mapping store.
228pub(crate) fn edit_token(
229    key: &str,
230    path: &str,
231    value: &str,
232    profile: &FileTypeProfile,
233    store: &MappingStore,
234) -> Result<Option<String>> {
235    if let Some(rule) = find_matching_rule(path, profile) {
236        if let Some(min) = rule.min_length {
237            if value.len() < min {
238                return Ok(None);
239            }
240        }
241        let category = rule
242            .category
243            .clone()
244            .unwrap_or(Category::Custom("field".into()));
245        let sanitized = store.get_or_insert(&category, value)?.to_string();
246        register_escaped_aliases(store, &category, value, &sanitized);
247        return Ok(Some(sanitized));
248    }
249    if let Some(sig) = find_field_signal(key, &profile.field_name_signals) {
250        if value.is_empty() || shannon_entropy(value.as_bytes()) < sig.threshold {
251            return Ok(None);
252        }
253        let sanitized = store.get_or_insert(&sig.category, value)?.to_string();
254        register_escaped_aliases(store, &sig.category, value, &sanitized);
255        return Ok(Some(sanitized));
256    }
257    Ok(None)
258}
259
260/// Register the format-specific *escaped* representations of a discovered
261/// `value` as store aliases pointing to `sanitized`, for every structured
262/// format whose escaping differs from the raw value.
263///
264/// The span-edit path ([`Processor::process_to_edits`]) hits the exact source
265/// bytes of a matched field and needs no alias. These aliases are purely the
266/// **phase-2 cross-location safety net**: a value discovered in one file can
267/// reappear — escaped differently — in an *unmatched* field of another file
268/// (a JSON/YAML `\"`, a CSV `""`-doubled quote, an XML `&quot;`/`&lt;`). The
269/// literal scanner only matches raw bytes, so without these aliases the
270/// escaped occurrence would leak. No-op for values without escapable
271/// characters (every variant equals the raw value).
272fn register_escaped_aliases(
273    store: &MappingStore,
274    category: &Category,
275    value: &str,
276    sanitized: &str,
277) {
278    // XML escaping is context-dependent: only `&` and `<` must always be
279    // escaped; `"` is escaped only inside double-quoted attributes, `'` only
280    // inside single-quoted attributes, and `>` is usually left literal. A
281    // single maximally-escaped form would miss the realistic minimal encodings,
282    // so register each context's form (the dedup below drops no-op variants).
283    let xml_min = value.replace('&', "&amp;").replace('<', "&lt;");
284    let variants = [
285        json_string_escape(value),        // JSON / JSONL / YAML double-quoted
286        yaml_double_quoted_escape(value), // (YAML differs from JSON for some chars)
287        toml_basic_escape(value),
288        xml_min.clone(),                 // XML element text (`&`,`<` only)
289        xml_min.replace('"', "&quot;"),  // XML double-quoted attribute
290        xml_min.replace('\'', "&apos;"), // XML single-quoted attribute
291        xml_escape(value),               // XML maximal (over-escaping writers)
292        value.replace('"', "\"\""),      // CSV quote-doubling
293    ];
294    let mut seen: Vec<&str> = Vec::new();
295    for v in &variants {
296        if v != value && !seen.contains(&v.as_str()) {
297            seen.push(v.as_str());
298            store.register_alias(category, v, sanitized);
299        }
300    }
301}
302
303/// A byte-range edit on the original source: replace `content[start..end]` with
304/// `value`.
305///
306/// Produced by span-based processors ([`Processor::process_to_edits`]) so a
307/// matched field value is replaced exactly where it appears in the source,
308/// leaving all surrounding bytes — quotes, comments, whitespace, key order, and
309/// the precise escaping of unrelated content — byte-for-byte intact. This is
310/// what makes structured sanitization both leak-free (the real bytes are hit,
311/// regardless of how the value was escaped) and fully format-preserving.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct Replacement {
314    /// Start byte offset (inclusive) of the range to replace.
315    pub start: usize,
316    /// End byte offset (exclusive) of the range to replace.
317    pub end: usize,
318    /// Replacement text (the sanitized token, already in the form it should
319    /// take in the source — e.g. including surrounding quotes for a string).
320    pub value: String,
321}
322
323/// Apply non-overlapping byte-range `edits` to `content`, returning the edited
324/// bytes. Edits are applied in ascending start order; any edit that overlaps a
325/// previous one or falls outside `content` is skipped defensively (a correct
326/// processor never emits such edits).
327#[must_use]
328pub(crate) fn apply_edits(content: &[u8], mut edits: Vec<Replacement>) -> Vec<u8> {
329    edits.sort_by_key(|e| e.start);
330    let mut out = Vec::with_capacity(content.len());
331    let mut pos = 0usize;
332    for e in edits {
333        if e.start < pos || e.end > content.len() || e.start > e.end {
334            continue;
335        }
336        out.extend_from_slice(&content[pos..e.start]);
337        out.extend_from_slice(e.value.as_bytes());
338        pos = e.end;
339    }
340    out.extend_from_slice(&content[pos..]);
341    out
342}
343
344/// Replace a value through the mapping store using a field rule's category.
345///
346/// Returns the original `value` unchanged when it is shorter than
347/// `rule.min_length` (if set). This prevents broad glob patterns like
348/// `*token*` from redacting obviously non-secret values such as `"false"`,
349/// `"0"`, or `"nil"`.
350pub(crate) fn replace_value(value: &str, rule: &FieldRule, store: &MappingStore) -> Result<String> {
351    if let Some(min) = rule.min_length {
352        if value.len() < min {
353            return Ok(value.to_string());
354        }
355    }
356    let category = rule
357        .category
358        .clone()
359        .unwrap_or(Category::Custom("field".into()));
360    let sanitized = store.get_or_insert(&category, value)?;
361    register_escaped_aliases(store, &category, value, sanitized.as_str());
362    Ok(sanitized.to_string())
363}
364
365/// JSON string-body escaping (the bytes between the surrounding quotes).
366fn json_string_escape(s: &str) -> String {
367    match serde_json::to_string(s) {
368        // `to_string` of a `&str` yields a quoted JSON string literal; the body
369        // is everything between the first and last byte.
370        Ok(quoted) if quoted.len() >= 2 => quoted[1..quoted.len() - 1].to_string(),
371        _ => s.to_string(),
372    }
373}
374
375/// TOML basic-string escaping (the body of a `"..."` string).
376fn toml_basic_escape(s: &str) -> String {
377    use std::fmt::Write as _;
378    let mut out = String::with_capacity(s.len());
379    for c in s.chars() {
380        match c {
381            '\\' => out.push_str("\\\\"),
382            '"' => out.push_str("\\\""),
383            '\n' => out.push_str("\\n"),
384            '\r' => out.push_str("\\r"),
385            '\t' => out.push_str("\\t"),
386            '\u{08}' => out.push_str("\\b"),
387            '\u{0c}' => out.push_str("\\f"),
388            c if (c as u32) < 0x20 => {
389                let _ = write!(out, "\\u{:04X}", c as u32);
390            }
391            c => out.push(c),
392        }
393    }
394    out
395}
396
397/// YAML double-quoted-scalar escaping (the body of a `"..."` scalar).
398fn yaml_double_quoted_escape(s: &str) -> String {
399    let mut out = String::with_capacity(s.len());
400    for c in s.chars() {
401        match c {
402            '\\' => out.push_str("\\\\"),
403            '"' => out.push_str("\\\""),
404            '\n' => out.push_str("\\n"),
405            '\r' => out.push_str("\\r"),
406            '\t' => out.push_str("\\t"),
407            c => out.push(c),
408        }
409    }
410    out
411}
412
413/// XML text/attribute entity escaping.
414fn xml_escape(s: &str) -> String {
415    let mut out = String::with_capacity(s.len());
416    for c in s.chars() {
417        match c {
418            '&' => out.push_str("&amp;"),
419            '<' => out.push_str("&lt;"),
420            '>' => out.push_str("&gt;"),
421            '"' => out.push_str("&quot;"),
422            '\'' => out.push_str("&apos;"),
423            c => out.push(c),
424        }
425    }
426    out
427}
428
429/// Build a dot-separated key path by appending `key` to `prefix`.
430///
431/// Returns `key` unchanged when `prefix` is empty.
432#[must_use]
433pub(crate) fn build_path(prefix: &str, key: &str) -> String {
434    if prefix.is_empty() {
435        key.to_string()
436    } else {
437        format!("{}.{}", prefix, key)
438    }
439}
440
441/// Check whether a single glob `pattern` matches `key_path`.
442///
443/// `*` is the only wildcard character. It matches any sequence of characters,
444/// including empty strings and path separators (`.`, `[`, `]`).
445///
446/// | Pattern | Matches |
447/// |---------|---------|
448/// | `"*"` | anything |
449/// | `"password"` | `"password"` exactly |
450/// | `"*.password"` | `"password"`, `"db.password"`, `"a.b.password"` |
451/// | `"db.*"` | `"db.host"`, `"db.port"`, `"db.nested.key"` |
452/// | `"*password*"` | any key containing `"password"` as a substring |
453/// | `"*['smtp_password']"` | `"gitlab_rails['smtp_password']"` (bracket notation) |
454#[must_use]
455pub(crate) fn pattern_matches(pattern: &str, key_path: &str) -> bool {
456    // Fast path: `*` matches everything.
457    if pattern == "*" {
458        return true;
459    }
460    // Fast path: exact match.
461    if pattern == key_path {
462        return true;
463    }
464    // Fast path: no wildcards — only the exact match above can succeed.
465    if !pattern.contains('*') {
466        return false;
467    }
468    // Dot-path glob: `*.suffix` — requires a dot boundary before the suffix
469    // so that `*.password` matches `db.password` but not `dbpassword`.
470    if let Some(suffix) = pattern.strip_prefix("*.") {
471        if !suffix.contains('*')
472            && (key_path == suffix
473                || key_path
474                    .strip_suffix(suffix)
475                    .is_some_and(|rest| rest.ends_with('.')))
476        {
477            return true;
478        }
479    }
480    // Dot-path glob: `prefix.*` — `db.*` matches `db.host`, `db.nested.key`.
481    if let Some(prefix) = pattern.strip_suffix(".*") {
482        if !prefix.contains('*')
483            && key_path
484                .strip_prefix(prefix)
485                .is_some_and(|rest| rest.starts_with('.'))
486        {
487            return true;
488        }
489    }
490    // General multi-wildcard glob: split on `*` and verify segments appear in
491    // order. This handles patterns like `*password*`, `*['key']`, `a*b*c`.
492    glob_matches(pattern, key_path)
493}
494
495use crate::allowlist::glob_matches;
496
497/// Compute Shannon entropy of `data` in bits per character.
498///
499/// Returns `0.0` for empty input. Uses a fixed 256-element frequency table
500/// so the cost is O(n) time and O(1) space regardless of alphabet size.
501#[inline]
502#[allow(clippy::cast_precision_loss)]
503pub(crate) fn shannon_entropy(data: &[u8]) -> f64 {
504    if data.is_empty() {
505        return 0.0;
506    }
507    let mut counts = [0u32; 256];
508    for &b in data {
509        counts[b as usize] += 1;
510    }
511    let len = data.len() as f64;
512    counts
513        .iter()
514        .filter(|&&c| c > 0)
515        .map(|&c| {
516            let p = f64::from(c) / len;
517            -p * p.log2()
518        })
519        .sum()
520}
521
522/// Return the first [`FieldNameSignal`] whose key pattern matches `key`.
523///
524/// `key` is the **bare** field name (leaf key only, not the full dot-path).
525#[must_use]
526pub(crate) fn find_field_signal<'a>(
527    key: &str,
528    signals: &'a [FieldNameSignal],
529) -> Option<&'a FieldNameSignal> {
530    signals.iter().find(|sig| sig.matches_key(key))
531}
532
533/// Replace `value` via the mapping store when its entropy meets the signal's gate.
534///
535/// Returns `Some(replacement)` when the value's Shannon entropy is at or above
536/// `sig.threshold`, or `None` when the entropy is too low to be a real secret
537/// (e.g. `"Bearer"`, `"basic"`, `"true"`).
538pub(crate) fn replace_by_signal(
539    value: &str,
540    sig: &FieldNameSignal,
541    store: &MappingStore,
542) -> Result<Option<String>> {
543    if value.is_empty() {
544        return Ok(None);
545    }
546    if shannon_entropy(value.as_bytes()) < sig.threshold {
547        return Ok(None);
548    }
549    let replaced = store.get_or_insert(&sig.category, value)?;
550    register_escaped_aliases(store, &sig.category, value, replaced.as_str());
551    Ok(Some(replaced.to_string()))
552}
553
554/// Return the first rule in `profile` whose pattern matches `key_path`.
555///
556/// Supports exact matches and glob patterns — see [`pattern_matches`] for the
557/// full pattern syntax including dot-path globs and bracket notation.
558#[must_use]
559pub(crate) fn find_matching_rule<'a>(
560    key_path: &str,
561    profile: &'a FileTypeProfile,
562) -> Option<&'a FieldRule> {
563    profile
564        .fields
565        .iter()
566        .find(|rule| pattern_matches(&rule.pattern, key_path))
567}
568
569// ---------------------------------------------------------------------------
570// Shared tree walker
571// ---------------------------------------------------------------------------
572
573/// Visitor interface over a structured value tree.
574///
575/// Implemented by [`serde_json::Value`], [`serde_yaml_ng::Value`], and
576/// [`toml::Value`] so that [`walk_tree`] can drive sanitization without
577/// knowing the format it is operating on.
578pub(crate) trait TreeNode {
579    /// Call `f(key, child)` for every entry in this map node.
580    /// Is a no-op (returns `Ok(())`) if this node is not a map.
581    fn for_each_map_entry<F>(&mut self, f: F) -> Result<()>
582    where
583        F: FnMut(&str, &mut Self) -> Result<()>;
584
585    /// Call `f(item)` for every item in this sequence node.
586    /// Is a no-op (returns `Ok(())`) if this node is not a sequence.
587    fn for_each_seq_item<F>(&mut self, f: F) -> Result<()>
588    where
589        F: FnMut(&mut Self) -> Result<()>;
590
591    /// Mutable access to the inner `String` if this is a string node.
592    fn as_str_mut(&mut self) -> Option<&mut String>;
593
594    /// `true` if this is a non-string primitive scalar (number, bool, datetime, …).
595    fn is_scalar(&self) -> bool;
596
597    /// String representation used as the replacement input for scalar values.
598    fn scalar_to_string(&self) -> String;
599
600    /// Replace this node's content with a string value in-place.
601    fn set_string(&mut self, s: String);
602}
603
604/// Recursively walk a structured value tree, replacing matched leaf values.
605///
606/// This is the shared implementation for the JSON, YAML, and TOML processors.
607/// Each processor implements [`TreeNode`] for its own value type and wraps
608/// this call in a thin format-named function.
609pub(crate) fn walk_tree<V: TreeNode>(
610    value: &mut V,
611    prefix: &str,
612    profile: &FileTypeProfile,
613    store: &MappingStore,
614    depth: usize,
615    format_name: &str,
616) -> Result<()> {
617    if depth > limits::DEFAULT_DEPTH {
618        return Err(SanitizeError::RecursionDepthExceeded(format!(
619            "{format_name} recursion depth exceeds limit of {}",
620            limits::DEFAULT_DEPTH
621        )));
622    }
623    value.for_each_map_entry(|key, v| {
624        let path = build_path(prefix, key);
625        if let Some(s) = v.as_str_mut() {
626            if let Some(rule) = find_matching_rule(&path, profile) {
627                *s = replace_value(s, rule, store)?;
628            } else if let Some(sig) = find_field_signal(key, &profile.field_name_signals) {
629                if let Some(replaced) = replace_by_signal(s, sig, store)? {
630                    *s = replaced;
631                }
632            }
633        } else if v.is_scalar() {
634            if let Some(rule) = find_matching_rule(&path, profile) {
635                let repr = v.scalar_to_string();
636                let replaced = replace_value(&repr, rule, store)?;
637                v.set_string(replaced);
638            } else if let Some(sig) = find_field_signal(key, &profile.field_name_signals) {
639                let repr = v.scalar_to_string();
640                if let Some(replaced) = replace_by_signal(&repr, sig, store)? {
641                    v.set_string(replaced);
642                }
643            }
644        } else {
645            walk_tree(v, &path, profile, store, depth + 1, format_name)?;
646        }
647        Ok(())
648    })?;
649    value.for_each_seq_item(|item| walk_tree(item, prefix, profile, store, depth + 1, format_name))
650}
651
652// ---------------------------------------------------------------------------
653// Unit tests
654// ---------------------------------------------------------------------------
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::category::Category;
660
661    // ── register_escaped_aliases ─────────────────────────────────────────────
662
663    #[test]
664    fn escaped_aliases_cover_all_formats() {
665        let gen = std::sync::Arc::new(crate::generator::HmacGenerator::new([7u8; 32]));
666        let store = MappingStore::new(gen, None);
667        let cat = Category::AuthToken;
668        // A value with a quote and a backslash → distinct escaped forms register.
669        let sanitized = store.get_or_insert(&cat, r#"a"b\c"#).unwrap().to_string();
670        register_escaped_aliases(&store, &cat, r#"a"b\c"#, &sanitized);
671        // JSON/YAML/TOML body escaping (`\"` + `\\`) is now an alias.
672        assert_eq!(
673            store.get_or_insert(&cat, r#"a\"b\\c"#).unwrap().as_str(),
674            sanitized
675        );
676        // XML double-quoted-attribute form (`&quot;`, `\` literal) is an alias.
677        assert_eq!(
678            store.get_or_insert(&cat, "a&quot;b\\c").unwrap().as_str(),
679            sanitized
680        );
681        // CSV quote-doubling form is an alias.
682        assert_eq!(
683            store.get_or_insert(&cat, r#"a""b\c"#).unwrap().as_str(),
684            sanitized
685        );
686    }
687
688    // ── shannon_entropy ──────────────────────────────────────────────────────
689
690    #[test]
691    #[allow(clippy::float_cmp)]
692    fn entropy_empty_is_zero() {
693        assert_eq!(shannon_entropy(b""), 0.0);
694    }
695
696    #[test]
697    #[allow(clippy::float_cmp)]
698    fn entropy_single_byte_is_zero() {
699        // All characters the same → zero entropy.
700        assert_eq!(shannon_entropy(b"aaaa"), 0.0);
701    }
702
703    #[test]
704    fn entropy_two_equal_symbols_is_one_bit() {
705        // "ab" repeated — 2 equally likely symbols → exactly 1.0 bit.
706        assert!((shannon_entropy(b"abababab") - 1.0).abs() < 1e-10);
707    }
708
709    #[test]
710    fn entropy_high_for_random_hex() {
711        // 32-char hex string should be well above 3.5 bits/char.
712        let h = shannon_entropy(b"a3f8c2d1e9b7f4a2c8d3e1b9f7a4c2d1");
713        assert!(h > 3.5, "expected entropy > 3.5, got {h}");
714    }
715
716    #[test]
717    fn entropy_low_for_word() {
718        // "Bearer" uses only 5 distinct chars, should be below 3.0.
719        let h = shannon_entropy(b"Bearer");
720        assert!(h < 3.0, "expected entropy < 3.0, got {h}");
721    }
722
723    // ── FieldNameSignal::matches_key ─────────────────────────────────────────
724
725    #[test]
726    fn signal_matches_exact_key() {
727        let sig = FieldNameSignal::new("^password$", Category::AuthToken, None, 3.5).unwrap();
728        assert!(sig.matches_key("password"));
729        assert!(!sig.matches_key("db_password"));
730        assert!(!sig.matches_key("PASSWORD_HASH"));
731    }
732
733    #[test]
734    fn signal_match_is_case_insensitive() {
735        let sig = FieldNameSignal::new("^password$", Category::AuthToken, None, 3.5).unwrap();
736        assert!(sig.matches_key("PASSWORD"));
737        assert!(sig.matches_key("Password"));
738    }
739
740    #[test]
741    fn signal_alternation_pattern() {
742        let sig =
743            FieldNameSignal::new(r"^(password|secret|token)$", Category::AuthToken, None, 3.5)
744                .unwrap();
745        assert!(sig.matches_key("password"));
746        assert!(sig.matches_key("secret"));
747        assert!(sig.matches_key("token"));
748        assert!(!sig.matches_key("token_type"));
749    }
750
751    #[test]
752    fn signal_invalid_regex_returns_error() {
753        let result = FieldNameSignal::new("[invalid(", Category::AuthToken, None, 3.5);
754        assert!(result.is_err());
755    }
756
757    #[test]
758    fn signal_default_label_derived_from_pattern() {
759        let sig = FieldNameSignal::new("^secret$", Category::AuthToken, None, 3.5).unwrap();
760        assert_eq!(sig.label, "field-signal:^secret$");
761    }
762
763    #[test]
764    fn signal_custom_label_preserved() {
765        let sig = FieldNameSignal::new(
766            "^secret$",
767            Category::AuthToken,
768            Some("my-label".into()),
769            3.5,
770        )
771        .unwrap();
772        assert_eq!(sig.label, "my-label");
773    }
774
775    // ── find_field_signal ────────────────────────────────────────────────────
776
777    #[test]
778    fn find_returns_none_for_empty_signals() {
779        assert!(find_field_signal("password", &[]).is_none());
780    }
781
782    #[test]
783    fn find_returns_first_matching_signal() {
784        let s1 = FieldNameSignal::new("^password$", Category::AuthToken, Some("s1".into()), 3.0)
785            .unwrap();
786        let s2 =
787            FieldNameSignal::new("^token$", Category::AuthToken, Some("s2".into()), 3.5).unwrap();
788        let signals = vec![s1, s2];
789
790        let found = find_field_signal("token", &signals).unwrap();
791        assert_eq!(found.label, "s2");
792    }
793
794    #[test]
795    fn find_returns_none_when_no_match() {
796        let sig = FieldNameSignal::new("^password$", Category::AuthToken, None, 3.5).unwrap();
797        assert!(find_field_signal("hostname", &[sig]).is_none());
798    }
799}