Skip to main content

scour_secrets/processor/
toml_proc.rs

1//! TOML structured processor.
2//!
3//! The CLI uses [`process_to_edits`](TomlProcessor::process_to_edits): it parses
4//! with `toml_edit` (which retains byte spans) and replaces each matched value
5//! at its exact source span, preserving comments, key order, quote style, and
6//! whitespace byte-for-byte. `process` is the re-serializing fallback.
7//!
8//! # Key Paths
9//!
10//! Nested keys use the same dot-separated convention as the JSON processor:
11//! `database.password`, `server.credentials.token`.
12//!
13//! Array elements are traversed transparently — a rule for `servers.host`
14//! matches the `host` field inside every table in the `servers` array.
15//!
16//! # Non-String Scalars
17//!
18//! When a FieldRule matches an integer, float, boolean, or datetime value,
19//! that value is converted to a string replacement. This changes the TOML
20//! type for that key but keeps the file syntactically valid. Use specific
21//! field rules (e.g. `"database.password"`) rather than `"*"` if you want
22//! to avoid replacing non-sensitive numeric values.
23
24use crate::error::{Result, SanitizeError};
25use crate::processor::limits::DEFAULT_INPUT_SIZE;
26use crate::processor::{
27    build_path, edit_token, walk_tree, FileTypeProfile, Processor, Replacement, TreeNode,
28};
29use crate::store::MappingStore;
30use toml::Value;
31use toml_edit::{ImDocument, Item, Table, Value as EditValue};
32
33/// Structured processor for TOML configuration files.
34pub struct TomlProcessor;
35
36/// Content-free TOML parse error. The `toml`/`toml_edit` error `Display`
37/// renders the offending source line verbatim, which would echo secret values
38/// from the input into stderr and logs — report only the position.
39fn toml_parse_error(text: &str, span: Option<std::ops::Range<usize>>) -> SanitizeError {
40    let loc = span.map_or_else(String::new, |s| {
41        let (line, col) = crate::secrets::line_col_at(text, s.start);
42        format!(" at line {line}, column {col}")
43    });
44    SanitizeError::ParseError {
45        format: "TOML".into(),
46        message: format!(
47            "TOML parse error{loc} \
48             (parser details withheld — input content is never echoed)"
49        ),
50    }
51}
52
53impl Processor for TomlProcessor {
54    fn name(&self) -> &'static str {
55        "toml"
56    }
57
58    fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
59        profile.processor == "toml"
60    }
61
62    fn process(
63        &self,
64        content: &[u8],
65        profile: &FileTypeProfile,
66        store: &MappingStore,
67    ) -> Result<Vec<u8>> {
68        let text = crate::processor::check_size_and_decode(content, "TOML", DEFAULT_INPUT_SIZE)?;
69
70        let mut value: Value =
71            toml::from_str(text).map_err(|e| toml_parse_error(text, e.span()))?;
72
73        walk_toml(&mut value, "", profile, store, 0)?;
74
75        let output = toml::to_string_pretty(&value).map_err(|e| {
76            SanitizeError::IoError(std::io::Error::other(format!("TOML serialize error: {e}")))
77        })?;
78
79        Ok(output.into_bytes())
80    }
81
82    /// Span-based redaction: parse with `toml_edit` (which retains byte spans),
83    /// then emit an edit replacing each matched value's source span with a
84    /// quoted token. Comments, key order, whitespace, and unrelated escaping are
85    /// preserved exactly, and the real source bytes are hit regardless of how
86    /// the value was quoted/escaped.
87    fn process_to_edits(
88        &self,
89        content: &[u8],
90        profile: &FileTypeProfile,
91        store: &MappingStore,
92    ) -> Result<Option<Vec<Replacement>>> {
93        let text = crate::processor::check_size_and_decode(content, "TOML", DEFAULT_INPUT_SIZE)?;
94        // `ImDocument` (immutable) retains byte spans for parsed values;
95        // `DocumentMut` drops them. We only read spans, never mutate the tree.
96        let doc =
97            ImDocument::parse(text.to_string()).map_err(|e| toml_parse_error(text, e.span()))?;
98        let mut edits = Vec::new();
99        collect_table_edits(doc.as_table(), "", profile, store, &mut edits)?;
100        Ok(Some(edits))
101    }
102}
103
104/// String form of a scalar `toml_edit` value, used as the mapping-store key.
105fn edit_scalar_string(value: &EditValue) -> Option<String> {
106    match value {
107        EditValue::String(f) => Some(f.value().clone()),
108        EditValue::Integer(f) => Some(f.value().to_string()),
109        EditValue::Float(f) => Some(f.value().to_string()),
110        EditValue::Boolean(f) => Some(f.value().to_string()),
111        EditValue::Datetime(f) => Some(f.value().to_string()),
112        EditValue::Array(_) | EditValue::InlineTable(_) => None,
113    }
114}
115
116fn collect_table_edits(
117    table: &Table,
118    prefix: &str,
119    profile: &FileTypeProfile,
120    store: &MappingStore,
121    edits: &mut Vec<Replacement>,
122) -> Result<()> {
123    for (key, item) in table {
124        let path = build_path(prefix, key);
125        match item {
126            Item::Value(v) => collect_value_edits(key, &path, v, profile, store, edits)?,
127            Item::Table(t) => collect_table_edits(t, &path, profile, store, edits)?,
128            Item::ArrayOfTables(aot) => {
129                // Array elements are path-transparent (mirrors the tree walk).
130                for t in aot {
131                    collect_table_edits(t, &path, profile, store, edits)?;
132                }
133            }
134            Item::None => {}
135        }
136    }
137    Ok(())
138}
139
140fn collect_value_edits(
141    key: &str,
142    path: &str,
143    value: &EditValue,
144    profile: &FileTypeProfile,
145    store: &MappingStore,
146    edits: &mut Vec<Replacement>,
147) -> Result<()> {
148    match value {
149        EditValue::Array(arr) => {
150            // Path-transparent: scalar/inline-table items keep the parent key/path.
151            for item in arr {
152                collect_value_edits(key, path, item, profile, store, edits)?;
153            }
154        }
155        EditValue::InlineTable(it) => {
156            for (k, v) in it {
157                let p = build_path(path, k);
158                collect_value_edits(k, &p, v, profile, store, edits)?;
159            }
160        }
161        scalar => {
162            let Some(s) = edit_scalar_string(scalar) else {
163                return Ok(());
164            };
165            if let Some(token) = edit_token(key, path, &s, profile, store)? {
166                if let Some(span) = value.span() {
167                    edits.push(Replacement {
168                        start: span.start,
169                        end: span.end,
170                        // Token is safe ASCII (no quote/backslash), so a basic
171                        // double-quoted string is always valid here.
172                        value: format!("\"{token}\""),
173                    });
174                }
175            }
176        }
177    }
178    Ok(())
179}
180
181impl TreeNode for Value {
182    fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
183    where
184        F: FnMut(&str, &mut Self) -> Result<()>,
185    {
186        if let Self::Table(map) = self {
187            let keys: Vec<String> = map.keys().cloned().collect();
188            for key in keys {
189                if let Some(v) = map.get_mut(&key) {
190                    f(&key, v)?;
191                }
192            }
193        }
194        Ok(())
195    }
196
197    fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
198    where
199        F: FnMut(&mut Self) -> Result<()>,
200    {
201        if let Self::Array(arr) = self {
202            for item in arr.iter_mut() {
203                f(item)?;
204            }
205        }
206        Ok(())
207    }
208
209    fn as_str_mut(&mut self) -> Option<&mut String> {
210        if let Self::String(s) = self {
211            Some(s)
212        } else {
213            None
214        }
215    }
216
217    fn is_scalar(&self) -> bool {
218        // Non-string scalars are converted to string replacements. This changes
219        // the TOML type for matched keys but keeps the file syntactically valid.
220        matches!(
221            self,
222            Self::Integer(_) | Self::Float(_) | Self::Boolean(_) | Self::Datetime(_)
223        )
224    }
225
226    fn scalar_to_string(&self) -> String {
227        self.to_string()
228    }
229
230    fn set_string(&mut self, s: String) {
231        *self = Self::String(s);
232    }
233}
234
235/// Recursively walk a TOML value tree, replacing matched field values.
236fn walk_toml(
237    value: &mut Value,
238    prefix: &str,
239    profile: &FileTypeProfile,
240    store: &MappingStore,
241    depth: usize,
242) -> Result<()> {
243    walk_tree(value, prefix, profile, store, depth, "TOML")
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::category::Category;
250    use crate::generator::HmacGenerator;
251    use crate::processor::profile::FieldRule;
252    use std::sync::Arc;
253
254    fn make_store() -> MappingStore {
255        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
256        MappingStore::new(gen, None)
257    }
258
259    #[test]
260    fn basic_toml_replacement() {
261        let store = make_store();
262        let proc = TomlProcessor;
263        let content = br#"[database]
264host = "db.corp.com"
265password = "s3cret"
266port = 5432
267
268[smtp]
269user = "admin@corp.com"
270"#;
271        let profile = FileTypeProfile::new(
272            "toml",
273            vec![
274                FieldRule::new("database.password"),
275                FieldRule::new("smtp.user").with_category(Category::Email),
276            ],
277        );
278        let output = proc.process(content, &profile, &store).unwrap();
279        let text = String::from_utf8(output).unwrap();
280        // Password replaced, host and port preserved.
281        assert!(!text.contains("s3cret"));
282        assert!(text.contains("db.corp.com"));
283        assert!(text.contains("5432"));
284        // Email replaced.
285        assert!(!text.contains("admin@corp.com"));
286    }
287
288    #[test]
289    fn wildcard_replaces_all_strings() {
290        let store = make_store();
291        let proc = TomlProcessor;
292        let content = b"api_key = \"secret\"\ndb_url = \"postgres://user:pass@host/db\"\n";
293        let profile = FileTypeProfile::new("toml", vec![FieldRule::new("*")]);
294        let output = proc.process(content, &profile, &store).unwrap();
295        let text = String::from_utf8(output).unwrap();
296        assert!(!text.contains("secret"));
297        assert!(!text.contains("postgres://user:pass@host/db"));
298        // Non-secret structure preserved: the keys remain (only values changed).
299        assert!(text.contains("api_key"));
300        assert!(text.contains("db_url"));
301    }
302
303    #[test]
304    fn invalid_toml_returns_parse_error() {
305        let store = make_store();
306        let proc = TomlProcessor;
307        let content = b"this is not valid toml [[[";
308        let profile = FileTypeProfile::new("toml", vec![FieldRule::new("*")]);
309        let result = proc.process(content, &profile, &store);
310        assert!(result.is_err());
311    }
312
313    #[test]
314    fn parse_error_omits_input_content() {
315        // An unterminated string puts the secret on the failing line; the
316        // toml/toml_edit Display would render that line verbatim. Both the
317        // re-serializing and the span-edit paths must report position only.
318        const LEAK_MARKER: &str = "SEKRET-MARKER-0xD34DB33F";
319        let store = make_store();
320        let proc = TomlProcessor;
321        let content = format!("password = \"{LEAK_MARKER}\nbroken");
322        let profile = FileTypeProfile::new("toml", vec![FieldRule::new("*")]);
323
324        let msg = proc
325            .process(content.as_bytes(), &profile, &store)
326            .expect_err("malformed input must fail to parse")
327            .to_string();
328        assert!(
329            !msg.contains(LEAK_MARKER),
330            "parse error echoed input content: {msg}"
331        );
332        assert!(msg.contains("line"), "expected location info, got: {msg}");
333
334        let msg = proc
335            .process_to_edits(content.as_bytes(), &profile, &store)
336            .expect_err("malformed input must fail to parse")
337            .to_string();
338        assert!(
339            !msg.contains(LEAK_MARKER),
340            "parse error echoed input content: {msg}"
341        );
342        assert!(msg.contains("line"), "expected location info, got: {msg}");
343    }
344
345    #[test]
346    fn deeply_nested_toml() {
347        let store = make_store();
348        let proc = TomlProcessor;
349        let content = b"[a.b.c]\nkey = \"value\"\n";
350        let profile = FileTypeProfile::new("toml", vec![FieldRule::new("a.b.c.key")]);
351        let output = proc.process(content, &profile, &store).unwrap();
352        let text = String::from_utf8(output).unwrap();
353        assert!(!text.contains("value"));
354        // Non-secret structure preserved: only the value changed, the nested
355        // table and key remain.
356        let parsed: toml::Value = toml::from_str(&text).unwrap();
357        assert!(parsed["a"]["b"]["c"]["key"].as_str().is_some());
358    }
359
360    // ── process_to_edits (span-based, format-preserving) ─────────────────────
361
362    /// Edit-mode alone (no scanner) must redact a value that is **escaped** in
363    /// the source — the exact case the literal-scan approach leaks.
364    #[test]
365    fn edits_redact_escaped_basic_string() {
366        let store = make_store();
367        let proc = TomlProcessor;
368        // Source bytes contain a\"b\"c-SECRET; the parsed value is a"b"c-SECRET.
369        let content = br#"key = "a\"b\"c-SECRET""#;
370        let profile = FileTypeProfile::new(
371            "toml",
372            vec![FieldRule::new("key").with_category(Category::Custom("k".into()))],
373        );
374        let edits = proc
375            .process_to_edits(content, &profile, &store)
376            .unwrap()
377            .unwrap();
378        let out = crate::processor::apply_edits(content, edits);
379        let text = String::from_utf8(out).unwrap();
380        assert!(
381            !text.contains("SECRET"),
382            "escaped value leaked via edits: {text}"
383        );
384    }
385
386    /// Edits preserve comments, key order, whitespace, and non-matched values.
387    #[test]
388    fn edits_preserve_comments_and_layout() {
389        let store = make_store();
390        let proc = TomlProcessor;
391        let content =
392            b"# top\n[db]\npassword = \"SECRETpw\"  # inline\nhost = \"keep.local\"\nport = 5432\n";
393        let profile = FileTypeProfile::new(
394            "toml",
395            vec![FieldRule::new("db.password").with_category(Category::Custom("pw".into()))],
396        );
397        let edits = proc
398            .process_to_edits(content, &profile, &store)
399            .unwrap()
400            .unwrap();
401        let out = crate::processor::apply_edits(content, edits);
402        let text = String::from_utf8(out).unwrap();
403        assert!(!text.contains("SECRETpw"), "secret leaked: {text}");
404        assert!(text.contains("# top"), "top comment dropped: {text}");
405        assert!(text.contains("# inline"), "inline comment dropped: {text}");
406        assert!(
407            text.contains("host = \"keep.local\""),
408            "non-secret changed: {text}"
409        );
410        assert!(text.contains("port = 5432"), "non-secret changed: {text}");
411        assert!(
412            toml::from_str::<toml::Value>(&text).is_ok(),
413            "invalid TOML: {text}"
414        );
415    }
416}