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
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 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 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
91fn 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 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 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 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 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
222fn 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 assert!(!text.contains("s3cret"));
269 assert!(text.contains("db.corp.com"));
270 assert!(text.contains("5432"));
271 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 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 let parsed: toml::Value = toml::from_str(&text).unwrap();
312 assert!(parsed["a"]["b"]["c"]["key"].as_str().is_some());
313 }
314
315 #[test]
320 fn edits_redact_escaped_basic_string() {
321 let store = make_store();
322 let proc = TomlProcessor;
323 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 #[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}