Skip to main content

rust_sanitize/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
36impl Processor for TomlProcessor {
37    fn name(&self) -> &'static str {
38        "toml"
39    }
40
41    fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
42        profile.processor == "toml"
43    }
44
45    fn process(
46        &self,
47        content: &[u8],
48        profile: &FileTypeProfile,
49        store: &MappingStore,
50    ) -> Result<Vec<u8>> {
51        let text = crate::processor::check_size_and_decode(content, "TOML", DEFAULT_INPUT_SIZE)?;
52
53        let mut value: Value = toml::from_str(text).map_err(|e| SanitizeError::ParseError {
54            format: "TOML".into(),
55            message: format!("TOML parse error: {}", e),
56        })?;
57
58        walk_toml(&mut value, "", profile, store, 0)?;
59
60        let output = toml::to_string_pretty(&value).map_err(|e| {
61            SanitizeError::IoError(std::io::Error::other(format!("TOML serialize error: {e}")))
62        })?;
63
64        Ok(output.into_bytes())
65    }
66
67    /// Span-based redaction: parse with `toml_edit` (which retains byte spans),
68    /// then emit an edit replacing each matched value's source span with a
69    /// quoted token. Comments, key order, whitespace, and unrelated escaping are
70    /// preserved exactly, and the real source bytes are hit regardless of how
71    /// the value was quoted/escaped.
72    fn process_to_edits(
73        &self,
74        content: &[u8],
75        profile: &FileTypeProfile,
76        store: &MappingStore,
77    ) -> Result<Option<Vec<Replacement>>> {
78        let text = crate::processor::check_size_and_decode(content, "TOML", DEFAULT_INPUT_SIZE)?;
79        // `ImDocument` (immutable) retains byte spans for parsed values;
80        // `DocumentMut` drops them. We only read spans, never mutate the tree.
81        let doc = ImDocument::parse(text.to_string()).map_err(|e| SanitizeError::ParseError {
82            format: "TOML".into(),
83            message: format!("TOML parse error: {e}"),
84        })?;
85        let mut edits = Vec::new();
86        collect_table_edits(doc.as_table(), "", profile, store, &mut edits)?;
87        Ok(Some(edits))
88    }
89}
90
91/// String form of a scalar `toml_edit` value, used as the mapping-store key.
92fn edit_scalar_string(value: &EditValue) -> Option<String> {
93    match value {
94        EditValue::String(f) => Some(f.value().clone()),
95        EditValue::Integer(f) => Some(f.value().to_string()),
96        EditValue::Float(f) => Some(f.value().to_string()),
97        EditValue::Boolean(f) => Some(f.value().to_string()),
98        EditValue::Datetime(f) => Some(f.value().to_string()),
99        EditValue::Array(_) | EditValue::InlineTable(_) => None,
100    }
101}
102
103fn collect_table_edits(
104    table: &Table,
105    prefix: &str,
106    profile: &FileTypeProfile,
107    store: &MappingStore,
108    edits: &mut Vec<Replacement>,
109) -> Result<()> {
110    for (key, item) in table {
111        let path = build_path(prefix, key);
112        match item {
113            Item::Value(v) => collect_value_edits(key, &path, v, profile, store, edits)?,
114            Item::Table(t) => collect_table_edits(t, &path, profile, store, edits)?,
115            Item::ArrayOfTables(aot) => {
116                // Array elements are path-transparent (mirrors the tree walk).
117                for t in aot {
118                    collect_table_edits(t, &path, profile, store, edits)?;
119                }
120            }
121            Item::None => {}
122        }
123    }
124    Ok(())
125}
126
127fn collect_value_edits(
128    key: &str,
129    path: &str,
130    value: &EditValue,
131    profile: &FileTypeProfile,
132    store: &MappingStore,
133    edits: &mut Vec<Replacement>,
134) -> Result<()> {
135    match value {
136        EditValue::Array(arr) => {
137            // Path-transparent: scalar/inline-table items keep the parent key/path.
138            for item in arr {
139                collect_value_edits(key, path, item, profile, store, edits)?;
140            }
141        }
142        EditValue::InlineTable(it) => {
143            for (k, v) in it {
144                let p = build_path(path, k);
145                collect_value_edits(k, &p, v, profile, store, edits)?;
146            }
147        }
148        scalar => {
149            let Some(s) = edit_scalar_string(scalar) else {
150                return Ok(());
151            };
152            if let Some(token) = edit_token(key, path, &s, profile, store)? {
153                if let Some(span) = value.span() {
154                    edits.push(Replacement {
155                        start: span.start,
156                        end: span.end,
157                        // Token is safe ASCII (no quote/backslash), so a basic
158                        // double-quoted string is always valid here.
159                        value: format!("\"{token}\""),
160                    });
161                }
162            }
163        }
164    }
165    Ok(())
166}
167
168impl TreeNode for Value {
169    fn for_each_map_entry<F>(&mut self, mut f: F) -> Result<()>
170    where
171        F: FnMut(&str, &mut Self) -> Result<()>,
172    {
173        if let Self::Table(map) = self {
174            let keys: Vec<String> = map.keys().cloned().collect();
175            for key in keys {
176                if let Some(v) = map.get_mut(&key) {
177                    f(&key, v)?;
178                }
179            }
180        }
181        Ok(())
182    }
183
184    fn for_each_seq_item<F>(&mut self, mut f: F) -> Result<()>
185    where
186        F: FnMut(&mut Self) -> Result<()>,
187    {
188        if let Self::Array(arr) = self {
189            for item in arr.iter_mut() {
190                f(item)?;
191            }
192        }
193        Ok(())
194    }
195
196    fn as_str_mut(&mut self) -> Option<&mut String> {
197        if let Self::String(s) = self {
198            Some(s)
199        } else {
200            None
201        }
202    }
203
204    fn is_scalar(&self) -> bool {
205        // Non-string scalars are converted to string replacements. This changes
206        // the TOML type for matched keys but keeps the file syntactically valid.
207        matches!(
208            self,
209            Self::Integer(_) | Self::Float(_) | Self::Boolean(_) | Self::Datetime(_)
210        )
211    }
212
213    fn scalar_to_string(&self) -> String {
214        self.to_string()
215    }
216
217    fn set_string(&mut self, s: String) {
218        *self = Self::String(s);
219    }
220}
221
222/// Recursively walk a TOML value tree, replacing matched field values.
223fn walk_toml(
224    value: &mut Value,
225    prefix: &str,
226    profile: &FileTypeProfile,
227    store: &MappingStore,
228    depth: usize,
229) -> Result<()> {
230    walk_tree(value, prefix, profile, store, depth, "TOML")
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::category::Category;
237    use crate::generator::HmacGenerator;
238    use crate::processor::profile::FieldRule;
239    use std::sync::Arc;
240
241    fn make_store() -> MappingStore {
242        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
243        MappingStore::new(gen, None)
244    }
245
246    #[test]
247    fn basic_toml_replacement() {
248        let store = make_store();
249        let proc = TomlProcessor;
250        let content = br#"[database]
251host = "db.corp.com"
252password = "s3cret"
253port = 5432
254
255[smtp]
256user = "admin@corp.com"
257"#;
258        let profile = FileTypeProfile::new(
259            "toml",
260            vec![
261                FieldRule::new("database.password"),
262                FieldRule::new("smtp.user").with_category(Category::Email),
263            ],
264        );
265        let output = proc.process(content, &profile, &store).unwrap();
266        let text = String::from_utf8(output).unwrap();
267        // Password replaced, host and port preserved.
268        assert!(!text.contains("s3cret"));
269        assert!(text.contains("db.corp.com"));
270        assert!(text.contains("5432"));
271        // Email replaced.
272        assert!(!text.contains("admin@corp.com"));
273    }
274
275    #[test]
276    fn wildcard_replaces_all_strings() {
277        let store = make_store();
278        let proc = TomlProcessor;
279        let content = b"api_key = \"secret\"\ndb_url = \"postgres://user:pass@host/db\"\n";
280        let profile = FileTypeProfile::new("toml", vec![FieldRule::new("*")]);
281        let output = proc.process(content, &profile, &store).unwrap();
282        let text = String::from_utf8(output).unwrap();
283        assert!(!text.contains("secret"));
284        assert!(!text.contains("postgres://user:pass@host/db"));
285        // Non-secret structure preserved: the keys remain (only values changed).
286        assert!(text.contains("api_key"));
287        assert!(text.contains("db_url"));
288    }
289
290    #[test]
291    fn invalid_toml_returns_parse_error() {
292        let store = make_store();
293        let proc = TomlProcessor;
294        let content = b"this is not valid toml [[[";
295        let profile = FileTypeProfile::new("toml", vec![FieldRule::new("*")]);
296        let result = proc.process(content, &profile, &store);
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn deeply_nested_toml() {
302        let store = make_store();
303        let proc = TomlProcessor;
304        let content = b"[a.b.c]\nkey = \"value\"\n";
305        let profile = FileTypeProfile::new("toml", vec![FieldRule::new("a.b.c.key")]);
306        let output = proc.process(content, &profile, &store).unwrap();
307        let text = String::from_utf8(output).unwrap();
308        assert!(!text.contains("value"));
309        // Non-secret structure preserved: only the value changed, the nested
310        // table and key remain.
311        let parsed: toml::Value = toml::from_str(&text).unwrap();
312        assert!(parsed["a"]["b"]["c"]["key"].as_str().is_some());
313    }
314
315    // ── process_to_edits (span-based, format-preserving) ─────────────────────
316
317    /// Edit-mode alone (no scanner) must redact a value that is **escaped** in
318    /// the source — the exact case the literal-scan approach leaks.
319    #[test]
320    fn edits_redact_escaped_basic_string() {
321        let store = make_store();
322        let proc = TomlProcessor;
323        // Source bytes contain a\"b\"c-SECRET; the parsed value is a"b"c-SECRET.
324        let content = br#"key = "a\"b\"c-SECRET""#;
325        let profile = FileTypeProfile::new(
326            "toml",
327            vec![FieldRule::new("key").with_category(Category::Custom("k".into()))],
328        );
329        let edits = proc
330            .process_to_edits(content, &profile, &store)
331            .unwrap()
332            .unwrap();
333        let out = crate::processor::apply_edits(content, edits);
334        let text = String::from_utf8(out).unwrap();
335        assert!(
336            !text.contains("SECRET"),
337            "escaped value leaked via edits: {text}"
338        );
339    }
340
341    /// Edits preserve comments, key order, whitespace, and non-matched values.
342    #[test]
343    fn edits_preserve_comments_and_layout() {
344        let store = make_store();
345        let proc = TomlProcessor;
346        let content =
347            b"# top\n[db]\npassword = \"SECRETpw\"  # inline\nhost = \"keep.local\"\nport = 5432\n";
348        let profile = FileTypeProfile::new(
349            "toml",
350            vec![FieldRule::new("db.password").with_category(Category::Custom("pw".into()))],
351        );
352        let edits = proc
353            .process_to_edits(content, &profile, &store)
354            .unwrap()
355            .unwrap();
356        let out = crate::processor::apply_edits(content, edits);
357        let text = String::from_utf8(out).unwrap();
358        assert!(!text.contains("SECRETpw"), "secret leaked: {text}");
359        assert!(text.contains("# top"), "top comment dropped: {text}");
360        assert!(text.contains("# inline"), "inline comment dropped: {text}");
361        assert!(
362            text.contains("host = \"keep.local\""),
363            "non-secret changed: {text}"
364        );
365        assert!(text.contains("port = 5432"), "non-secret changed: {text}");
366        assert!(
367            toml::from_str::<toml::Value>(&text).is_ok(),
368            "invalid TOML: {text}"
369        );
370    }
371}