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 `"`/`<`). 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('&', "&").replace('<', "<");
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('"', """), // XML double-quoted attribute
299 xml_min.replace('\'', "'"), // 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("&"),
443 '<' => out.push_str("<"),
444 '>' => out.push_str(">"),
445 '"' => out.push_str("""),
446 '\'' => out.push_str("'"),
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 (`"`, `\` literal) is an alias.
760 assert_eq!(
761 store.get_or_insert(&cat, "a"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}