1use 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
33pub struct TomlProcessor;
35
36fn 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 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 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
104fn 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 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 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 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 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
235fn 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 assert!(!text.contains("s3cret"));
282 assert!(text.contains("db.corp.com"));
283 assert!(text.contains("5432"));
284 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 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 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 let parsed: toml::Value = toml::from_str(&text).unwrap();
357 assert!(parsed["a"]["b"]["c"]["key"].as_str().is_some());
358 }
359
360 #[test]
365 fn edits_redact_escaped_basic_string() {
366 let store = make_store();
367 let proc = TomlProcessor;
368 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 #[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}