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
40pub mod archive;
41pub mod csv_proc;
42pub mod env_proc;
43pub mod ini_proc;
44pub mod json_proc;
45pub mod jsonl_proc;
46pub mod key_value;
47pub(crate) mod limits;
48pub mod log_line;
49pub mod profile;
50pub mod registry;
51pub mod toml_proc;
52pub mod xml_proc;
53pub mod yaml_proc;
54
55// Re-export core types.
56pub use profile::{FieldNameSignal, FieldRule, FileTypeProfile, DEFAULT_FIELD_SIGNAL_THRESHOLD};
57pub use registry::ProcessorRegistry;
58
59use crate::category::Category;
60use crate::error::{Result, SanitizeError};
61use crate::store::MappingStore;
62use std::io;
63
64// ---------------------------------------------------------------------------
65// Processor trait
66// ---------------------------------------------------------------------------
67
68/// A structured processor that can sanitize a specific file format while
69/// preserving its structure and formatting as much as possible.
70///
71/// Processors are **stateless** — all mutable state lives in the
72/// [`MappingStore`] they receive. This makes processors `Send + Sync`
73/// and reusable across files.
74///
75/// # Contract
76///
77/// - `name()` must return a unique, lowercase identifier (e.g. `"json"`).
78/// - `can_handle()` is a fast heuristic check; it may inspect a few
79/// bytes or the file extension but should not fully parse.
80/// - `process()` performs the full structured sanitization. It should
81/// preserve formatting/whitespace where possible and only replace
82/// values in fields matched by the profile's [`FieldRule`]s.
83/// - Replacements are **one-way** via the `MappingStore` — no reverse
84/// mapping is produced.
85pub trait Processor: Send + Sync {
86 /// Unique name for this processor (e.g. `"json"`, `"yaml"`, `"key_value"`).
87 fn name(&self) -> &'static str;
88
89 /// Quick heuristic: can this processor handle the given content?
90 ///
91 /// Implementations may check magic bytes, file extension hints in
92 /// the profile, or the first few bytes of content. This is called
93 /// before `process()` and should be fast.
94 fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool;
95
96 /// Process the content, replacing matched field values one-way.
97 ///
98 /// # Arguments
99 ///
100 /// - `content` — raw file bytes.
101 /// - `profile` — the user-supplied profile with field rules.
102 /// - `store` — the mapping store for dedup-consistent one-way replacements.
103 ///
104 /// # Returns
105 ///
106 /// The sanitized content as bytes, preserving structure/formatting
107 /// where possible.
108 ///
109 /// # Errors
110 ///
111 /// Returns [`SanitizeError`] if parsing or replacement generation fails.
112 fn process(
113 &self,
114 content: &[u8],
115 profile: &FileTypeProfile,
116 store: &MappingStore,
117 ) -> Result<Vec<u8>>;
118
119 /// Whether this processor supports bounded-memory streaming via
120 /// [`process_stream`](Self::process_stream).
121 ///
122 /// Processors that return `true` here are eligible for the streaming
123 /// structured path in the CLI, which opens the file as a reader instead
124 /// of reading it fully into memory. The default is `false`.
125 fn supports_streaming(&self) -> bool {
126 false
127 }
128
129 /// Process content from a reader, writing sanitized output to a writer.
130 ///
131 /// The default implementation reads the entire reader into memory and
132 /// delegates to [`process`](Self::process). Processors that return
133 /// `true` from [`supports_streaming`](Self::supports_streaming) should
134 /// override this to handle data incrementally, keeping memory usage
135 /// bounded regardless of input size.
136 ///
137 /// # Errors
138 ///
139 /// Returns [`SanitizeError`] on read, parse,
140 /// or write failure.
141 fn process_stream(
142 &self,
143 reader: &mut dyn io::Read,
144 writer: &mut dyn io::Write,
145 profile: &FileTypeProfile,
146 store: &MappingStore,
147 ) -> Result<()> {
148 let mut buf = Vec::new();
149 io::Read::read_to_end(reader, &mut buf)?;
150 let out = self.process(&buf, profile, store)?;
151 io::Write::write_all(writer, &out)?;
152 Ok(())
153 }
154}
155
156// ---------------------------------------------------------------------------
157// Helpers shared across processors
158// ---------------------------------------------------------------------------
159
160/// Validate the content size and decode it as UTF-8, returning a `&str`.
161///
162/// Used by the tree-based processors (JSON, YAML, TOML, JSONL) to share the
163/// identical "reject if too large, then decode" preamble without copy-pasting it.
164pub(crate) fn check_size_and_decode<'a>(
165 content: &'a [u8],
166 format: &str,
167 size_limit: usize,
168) -> Result<&'a str> {
169 if content.len() > size_limit {
170 return Err(SanitizeError::InputTooLarge {
171 size: content.len(),
172 limit: size_limit,
173 });
174 }
175 std::str::from_utf8(content).map_err(|e| SanitizeError::ParseError {
176 format: format.into(),
177 message: format!("invalid UTF-8: {e}"),
178 })
179}
180
181/// Replace a value through the mapping store using a field rule's category.
182///
183/// Returns the original `value` unchanged when it is shorter than
184/// `rule.min_length` (if set). This prevents broad glob patterns like
185/// `*token*` from redacting obviously non-secret values such as `"false"`,
186/// `"0"`, or `"nil"`.
187pub(crate) fn replace_value(value: &str, rule: &FieldRule, store: &MappingStore) -> Result<String> {
188 if let Some(min) = rule.min_length {
189 if value.len() < min {
190 return Ok(value.to_string());
191 }
192 }
193 let category = rule
194 .category
195 .clone()
196 .unwrap_or(Category::Custom("field".into()));
197 let sanitized = store.get_or_insert(&category, value)?;
198 Ok(sanitized.to_string())
199}
200
201/// Build a dot-separated key path by appending `key` to `prefix`.
202///
203/// Returns `key` unchanged when `prefix` is empty.
204#[must_use]
205pub(crate) fn build_path(prefix: &str, key: &str) -> String {
206 if prefix.is_empty() {
207 key.to_string()
208 } else {
209 format!("{}.{}", prefix, key)
210 }
211}
212
213/// Check whether a single glob `pattern` matches `key_path`.
214///
215/// `*` is the only wildcard character. It matches any sequence of characters,
216/// including empty strings and path separators (`.`, `[`, `]`).
217///
218/// | Pattern | Matches |
219/// |---------|---------|
220/// | `"*"` | anything |
221/// | `"password"` | `"password"` exactly |
222/// | `"*.password"` | `"password"`, `"db.password"`, `"a.b.password"` |
223/// | `"db.*"` | `"db.host"`, `"db.port"`, `"db.nested.key"` |
224/// | `"*password*"` | any key containing `"password"` as a substring |
225/// | `"*['smtp_password']"` | `"gitlab_rails['smtp_password']"` (bracket notation) |
226#[must_use]
227pub(crate) fn pattern_matches(pattern: &str, key_path: &str) -> bool {
228 // Fast path: `*` matches everything.
229 if pattern == "*" {
230 return true;
231 }
232 // Fast path: exact match.
233 if pattern == key_path {
234 return true;
235 }
236 // Fast path: no wildcards — only the exact match above can succeed.
237 if !pattern.contains('*') {
238 return false;
239 }
240 // Dot-path glob: `*.suffix` — requires a dot boundary before the suffix
241 // so that `*.password` matches `db.password` but not `dbpassword`.
242 if let Some(suffix) = pattern.strip_prefix("*.") {
243 if !suffix.contains('*')
244 && (key_path == suffix
245 || key_path
246 .strip_suffix(suffix)
247 .is_some_and(|rest| rest.ends_with('.')))
248 {
249 return true;
250 }
251 }
252 // Dot-path glob: `prefix.*` — `db.*` matches `db.host`, `db.nested.key`.
253 if let Some(prefix) = pattern.strip_suffix(".*") {
254 if !prefix.contains('*')
255 && key_path
256 .strip_prefix(prefix)
257 .is_some_and(|rest| rest.starts_with('.'))
258 {
259 return true;
260 }
261 }
262 // General multi-wildcard glob: split on `*` and verify segments appear in
263 // order. This handles patterns like `*password*`, `*['key']`, `a*b*c`.
264 glob_matches(pattern, key_path)
265}
266
267use crate::allowlist::glob_matches;
268
269/// Compute Shannon entropy of `data` in bits per character.
270///
271/// Returns `0.0` for empty input. Uses a fixed 256-element frequency table
272/// so the cost is O(n) time and O(1) space regardless of alphabet size.
273#[inline]
274#[allow(clippy::cast_precision_loss)]
275pub(crate) fn shannon_entropy(data: &[u8]) -> f64 {
276 if data.is_empty() {
277 return 0.0;
278 }
279 let mut counts = [0u32; 256];
280 for &b in data {
281 counts[b as usize] += 1;
282 }
283 let len = data.len() as f64;
284 counts
285 .iter()
286 .filter(|&&c| c > 0)
287 .map(|&c| {
288 let p = f64::from(c) / len;
289 -p * p.log2()
290 })
291 .sum()
292}
293
294/// Return the first [`FieldNameSignal`] whose key pattern matches `key`.
295///
296/// `key` is the **bare** field name (leaf key only, not the full dot-path).
297#[must_use]
298pub(crate) fn find_field_signal<'a>(
299 key: &str,
300 signals: &'a [FieldNameSignal],
301) -> Option<&'a FieldNameSignal> {
302 signals.iter().find(|sig| sig.matches_key(key))
303}
304
305/// Replace `value` via the mapping store when its entropy meets the signal's gate.
306///
307/// Returns `Some(replacement)` when the value's Shannon entropy is at or above
308/// `sig.threshold`, or `None` when the entropy is too low to be a real secret
309/// (e.g. `"Bearer"`, `"basic"`, `"true"`).
310pub(crate) fn replace_by_signal(
311 value: &str,
312 sig: &FieldNameSignal,
313 store: &MappingStore,
314) -> Result<Option<String>> {
315 if value.is_empty() {
316 return Ok(None);
317 }
318 if shannon_entropy(value.as_bytes()) < sig.threshold {
319 return Ok(None);
320 }
321 let replaced = store.get_or_insert(&sig.category, value)?;
322 Ok(Some(replaced.to_string()))
323}
324
325/// Return the first rule in `profile` whose pattern matches `key_path`.
326///
327/// Supports exact matches and glob patterns — see [`pattern_matches`] for the
328/// full pattern syntax including dot-path globs and bracket notation.
329#[must_use]
330pub(crate) fn find_matching_rule<'a>(
331 key_path: &str,
332 profile: &'a FileTypeProfile,
333) -> Option<&'a FieldRule> {
334 profile
335 .fields
336 .iter()
337 .find(|rule| pattern_matches(&rule.pattern, key_path))
338}
339
340// ---------------------------------------------------------------------------
341// Shared tree walker
342// ---------------------------------------------------------------------------
343
344/// Visitor interface over a structured value tree.
345///
346/// Implemented by [`serde_json::Value`], [`serde_yaml_ng::Value`], and
347/// [`toml::Value`] so that [`walk_tree`] can drive sanitization without
348/// knowing the format it is operating on.
349pub(crate) trait TreeNode {
350 /// Call `f(key, child)` for every entry in this map node.
351 /// Is a no-op (returns `Ok(())`) if this node is not a map.
352 fn for_each_map_entry<F>(&mut self, f: F) -> Result<()>
353 where
354 F: FnMut(&str, &mut Self) -> Result<()>;
355
356 /// Call `f(item)` for every item in this sequence node.
357 /// Is a no-op (returns `Ok(())`) if this node is not a sequence.
358 fn for_each_seq_item<F>(&mut self, f: F) -> Result<()>
359 where
360 F: FnMut(&mut Self) -> Result<()>;
361
362 /// Mutable access to the inner `String` if this is a string node.
363 fn as_str_mut(&mut self) -> Option<&mut String>;
364
365 /// `true` if this is a non-string primitive scalar (number, bool, datetime, …).
366 fn is_scalar(&self) -> bool;
367
368 /// String representation used as the replacement input for scalar values.
369 fn scalar_to_string(&self) -> String;
370
371 /// Replace this node's content with a string value in-place.
372 fn set_string(&mut self, s: String);
373}
374
375/// Recursively walk a structured value tree, replacing matched leaf values.
376///
377/// This is the shared implementation for the JSON, YAML, and TOML processors.
378/// Each processor implements [`TreeNode`] for its own value type and wraps
379/// this call in a thin format-named function.
380pub(crate) fn walk_tree<V: TreeNode>(
381 value: &mut V,
382 prefix: &str,
383 profile: &FileTypeProfile,
384 store: &MappingStore,
385 depth: usize,
386 format_name: &str,
387) -> Result<()> {
388 if depth > limits::DEFAULT_DEPTH {
389 return Err(SanitizeError::RecursionDepthExceeded(format!(
390 "{format_name} recursion depth exceeds limit of {}",
391 limits::DEFAULT_DEPTH
392 )));
393 }
394 value.for_each_map_entry(|key, v| {
395 let path = build_path(prefix, key);
396 if let Some(s) = v.as_str_mut() {
397 if let Some(rule) = find_matching_rule(&path, profile) {
398 *s = replace_value(s, rule, store)?;
399 } else if let Some(sig) = find_field_signal(key, &profile.field_name_signals) {
400 if let Some(replaced) = replace_by_signal(s, sig, store)? {
401 *s = replaced;
402 }
403 }
404 } else if v.is_scalar() {
405 if let Some(rule) = find_matching_rule(&path, profile) {
406 let repr = v.scalar_to_string();
407 let replaced = replace_value(&repr, rule, store)?;
408 v.set_string(replaced);
409 } else if let Some(sig) = find_field_signal(key, &profile.field_name_signals) {
410 let repr = v.scalar_to_string();
411 if let Some(replaced) = replace_by_signal(&repr, sig, store)? {
412 v.set_string(replaced);
413 }
414 }
415 } else {
416 walk_tree(v, &path, profile, store, depth + 1, format_name)?;
417 }
418 Ok(())
419 })?;
420 value.for_each_seq_item(|item| walk_tree(item, prefix, profile, store, depth + 1, format_name))
421}
422
423// ---------------------------------------------------------------------------
424// Unit tests
425// ---------------------------------------------------------------------------
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::category::Category;
431
432 // ── shannon_entropy ──────────────────────────────────────────────────────
433
434 #[test]
435 #[allow(clippy::float_cmp)]
436 fn entropy_empty_is_zero() {
437 assert_eq!(shannon_entropy(b""), 0.0);
438 }
439
440 #[test]
441 #[allow(clippy::float_cmp)]
442 fn entropy_single_byte_is_zero() {
443 // All characters the same → zero entropy.
444 assert_eq!(shannon_entropy(b"aaaa"), 0.0);
445 }
446
447 #[test]
448 fn entropy_two_equal_symbols_is_one_bit() {
449 // "ab" repeated — 2 equally likely symbols → exactly 1.0 bit.
450 assert!((shannon_entropy(b"abababab") - 1.0).abs() < 1e-10);
451 }
452
453 #[test]
454 fn entropy_high_for_random_hex() {
455 // 32-char hex string should be well above 3.5 bits/char.
456 let h = shannon_entropy(b"a3f8c2d1e9b7f4a2c8d3e1b9f7a4c2d1");
457 assert!(h > 3.5, "expected entropy > 3.5, got {h}");
458 }
459
460 #[test]
461 fn entropy_low_for_word() {
462 // "Bearer" uses only 5 distinct chars, should be below 3.0.
463 let h = shannon_entropy(b"Bearer");
464 assert!(h < 3.0, "expected entropy < 3.0, got {h}");
465 }
466
467 // ── FieldNameSignal::matches_key ─────────────────────────────────────────
468
469 #[test]
470 fn signal_matches_exact_key() {
471 let sig = FieldNameSignal::new("^password$", Category::AuthToken, None, 3.5).unwrap();
472 assert!(sig.matches_key("password"));
473 assert!(!sig.matches_key("db_password"));
474 assert!(!sig.matches_key("PASSWORD_HASH"));
475 }
476
477 #[test]
478 fn signal_match_is_case_insensitive() {
479 let sig = FieldNameSignal::new("^password$", Category::AuthToken, None, 3.5).unwrap();
480 assert!(sig.matches_key("PASSWORD"));
481 assert!(sig.matches_key("Password"));
482 }
483
484 #[test]
485 fn signal_alternation_pattern() {
486 let sig =
487 FieldNameSignal::new(r"^(password|secret|token)$", Category::AuthToken, None, 3.5)
488 .unwrap();
489 assert!(sig.matches_key("password"));
490 assert!(sig.matches_key("secret"));
491 assert!(sig.matches_key("token"));
492 assert!(!sig.matches_key("token_type"));
493 }
494
495 #[test]
496 fn signal_invalid_regex_returns_error() {
497 let result = FieldNameSignal::new("[invalid(", Category::AuthToken, None, 3.5);
498 assert!(result.is_err());
499 }
500
501 #[test]
502 fn signal_default_label_derived_from_pattern() {
503 let sig = FieldNameSignal::new("^secret$", Category::AuthToken, None, 3.5).unwrap();
504 assert_eq!(sig.label, "field-signal:^secret$");
505 }
506
507 #[test]
508 fn signal_custom_label_preserved() {
509 let sig = FieldNameSignal::new(
510 "^secret$",
511 Category::AuthToken,
512 Some("my-label".into()),
513 3.5,
514 )
515 .unwrap();
516 assert_eq!(sig.label, "my-label");
517 }
518
519 // ── find_field_signal ────────────────────────────────────────────────────
520
521 #[test]
522 fn find_returns_none_for_empty_signals() {
523 assert!(find_field_signal("password", &[]).is_none());
524 }
525
526 #[test]
527 fn find_returns_first_matching_signal() {
528 let s1 = FieldNameSignal::new("^password$", Category::AuthToken, Some("s1".into()), 3.0)
529 .unwrap();
530 let s2 =
531 FieldNameSignal::new("^token$", Category::AuthToken, Some("s2".into()), 3.5).unwrap();
532 let signals = vec![s1, s2];
533
534 let found = find_field_signal("token", &signals).unwrap();
535 assert_eq!(found.label, "s2");
536 }
537
538 #[test]
539 fn find_returns_none_when_no_match() {
540 let sig = FieldNameSignal::new("^password$", Category::AuthToken, None, 3.5).unwrap();
541 assert!(find_field_signal("hostname", &[sig]).is_none());
542 }
543}