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 `"`/`<`). 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('&', "&").replace('<', "<");
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('"', """), // XML double-quoted attribute
290 xml_min.replace('\'', "'"), // 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("&"),
419 '<' => out.push_str("<"),
420 '>' => out.push_str(">"),
421 '"' => out.push_str("""),
422 '\'' => out.push_str("'"),
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 (`"`, `\` literal) is an alias.
677 assert_eq!(
678 store.get_or_insert(&cat, "a"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}