Skip to main content

rust_sanitize/processor/
mod.rs

1//! Structured processors for format-aware sanitization.
2//!
3//! # Architecture
4//!
5//! ```text
6//! ┌──────────────────┐     ┌───────────────────┐     ┌──────────────────┐
7//! │  Input bytes     │ ──▶ │ ProcessorRegistry  │ ──▶ │  Output bytes    │
8//! │  (file content)  │     │ (profile matching) │     │  (sanitized)     │
9//! └──────────────────┘     └────────┬───────────┘     └──────────────────┘
10//!                                   │
11//!                          ┌────────▼────────┐
12//!                          │ dyn Processor    │
13//!                          │                  │
14//!                          │  KeyValue        │ ← gitlab.rb-style
15//!                          │  JsonProcessor   │ ← JSON files
16//!                          │  YamlProcessor   │ ← YAML files
17//!                          │  XmlProcessor    │ ← XML files
18//!                          │  CsvProcessor    │ ← CSV/TSV files
19//!                          └────────┬────────┘
20//!                                   │
21//!                          ┌────────▼────────┐
22//!                          │  MappingStore    │
23//!                          │  (one-way dedup) │
24//!                          └─────────────────┘
25//! ```
26//!
27//! # File-Type Profiles
28//!
29//! A [`FileTypeProfile`] specifies which processor to use and what
30//! fields/keys to sanitize. Users provide profiles to control which
31//! parts of a structured file are replaced. If no profile matches,
32//! the caller falls back to the streaming scanner.
33//!
34//! # Extensibility
35//!
36//! Implement the [`Processor`] trait and register it with the
37//! [`ProcessorRegistry`]. The registry matches profiles to processors
38//! by name and dispatches processing.
39
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}