Skip to main content

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