1use crate::error::{Result, SanitizeError};
19use crate::processor::limits::{DEFAULT_INPUT_SIZE, XML_DEPTH};
20use crate::processor::{
21 edit_token, find_matching_rule, replace_value, FileTypeProfile, Processor, Replacement,
22};
23use crate::store::MappingStore;
24use quick_xml::events::{BytesStart, BytesText, Event};
25use quick_xml::{Reader, Writer};
26use std::io::Cursor;
27
28fn scan_attr_value_spans(tag: &[u8]) -> Vec<std::ops::Range<usize>> {
34 let mut spans = Vec::new();
35 let mut i = 0;
36 while i < tag.len() && (tag[i] == b'<' || tag[i] == b'/' || tag[i] == b'?') {
38 i += 1;
39 }
40 while i < tag.len() && !tag[i].is_ascii_whitespace() && tag[i] != b'>' && tag[i] != b'/' {
41 i += 1;
42 }
43 while i < tag.len() {
45 while i < tag.len() && tag[i].is_ascii_whitespace() {
46 i += 1;
47 }
48 if i >= tag.len() || tag[i] == b'>' || tag[i] == b'/' || tag[i] == b'?' {
49 break;
50 }
51 while i < tag.len() && tag[i] != b'=' && !tag[i].is_ascii_whitespace() && tag[i] != b'>' {
53 i += 1;
54 }
55 while i < tag.len() && tag[i].is_ascii_whitespace() {
56 i += 1;
57 }
58 if i >= tag.len() || tag[i] != b'=' {
59 continue;
60 }
61 i += 1; while i < tag.len() && tag[i].is_ascii_whitespace() {
63 i += 1;
64 }
65 if i >= tag.len() || (tag[i] != b'"' && tag[i] != b'\'') {
66 continue;
67 }
68 let quote = tag[i];
69 i += 1;
70 let val_start = i;
71 while i < tag.len() && tag[i] != quote {
72 i += 1;
73 }
74 spans.push(val_start..i);
75 if i < tag.len() {
76 i += 1; }
78 }
79 spans
80}
81
82pub struct XmlProcessor;
84
85impl Processor for XmlProcessor {
86 fn name(&self) -> &'static str {
87 "xml"
88 }
89
90 fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
91 if profile.processor == "xml" {
92 return true;
93 }
94 let trimmed = content
95 .iter()
96 .copied()
97 .skip_while(|b| b.is_ascii_whitespace())
98 .take(5)
99 .collect::<Vec<u8>>();
100 trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<")
101 }
102
103 fn process(
104 &self,
105 content: &[u8],
106 profile: &FileTypeProfile,
107 store: &MappingStore,
108 ) -> Result<Vec<u8>> {
109 if content.len() > DEFAULT_INPUT_SIZE {
111 return Err(SanitizeError::InputTooLarge {
112 size: content.len(),
113 limit: DEFAULT_INPUT_SIZE,
114 });
115 }
116
117 let mut reader = Reader::from_reader(content);
120 reader.trim_text(false);
121
122 let mut writer = Writer::new(Cursor::new(Vec::new()));
123 let mut element_stack: Vec<String> = Vec::new();
124 let mut buf = Vec::new();
125
126 loop {
127 match reader.read_event_into(&mut buf) {
128 Ok(Event::Start(ref e)) => {
129 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
130 element_stack.push(name.clone());
131
132 if element_stack.len() > XML_DEPTH {
133 return Err(SanitizeError::RecursionDepthExceeded(format!(
134 "XML element depth exceeds limit of {XML_DEPTH}"
135 )));
136 }
137
138 let current_path = element_stack.join("/");
140 let new_elem = process_attributes(e, ¤t_path, profile, store)?;
141 writer.write_event(Event::Start(new_elem)).map_err(|e| {
142 SanitizeError::IoError(std::io::Error::other(format!(
143 "XML write error: {e}"
144 )))
145 })?;
146 }
147 Ok(Event::End(ref e)) => {
148 writer.write_event(Event::End(e.clone())).map_err(|e| {
149 SanitizeError::IoError(std::io::Error::other(format!(
150 "XML write error: {e}"
151 )))
152 })?;
153 element_stack.pop();
154 }
155 Ok(Event::Empty(ref e)) => {
156 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
157 let path = if element_stack.is_empty() {
158 name.clone()
159 } else {
160 format!("{}/{}", element_stack.join("/"), name)
161 };
162 let new_elem = process_attributes(e, &path, profile, store)?;
163 writer.write_event(Event::Empty(new_elem)).map_err(|e| {
164 SanitizeError::IoError(std::io::Error::other(format!(
165 "XML write error: {e}"
166 )))
167 })?;
168 }
169 Ok(Event::Text(ref e)) => {
170 let current_path = element_stack.join("/");
171 if let Some(rule) = find_matching_rule(¤t_path, profile) {
172 let text = e.unescape().map_err(|e| SanitizeError::ParseError {
173 format: "XML".into(),
174 message: format!("XML decode error: {}", e),
175 })?;
176 let replaced = replace_value(&text, rule, store)?;
177 writer
178 .write_event(Event::Text(BytesText::new(&replaced)))
179 .map_err(|e| {
180 SanitizeError::IoError(std::io::Error::other(format!(
181 "XML write error: {e}"
182 )))
183 })?;
184 } else {
185 writer.write_event(Event::Text(e.clone())).map_err(|e| {
186 SanitizeError::IoError(std::io::Error::other(format!(
187 "XML write error: {e}"
188 )))
189 })?;
190 }
191 }
192 Ok(Event::Eof) => break,
193 Ok(e) => {
194 writer.write_event(e).map_err(|er| {
195 SanitizeError::IoError(std::io::Error::other(format!(
196 "XML write error: {er}"
197 )))
198 })?;
199 }
200 Err(e) => {
201 return Err(SanitizeError::ParseError {
202 format: "XML".into(),
203 message: format!("XML parse error: {}", e),
204 });
205 }
206 }
207 buf.clear();
208 }
209
210 let result = writer.into_inner().into_inner();
211 Ok(result)
212 }
213
214 fn process_to_edits(
222 &self,
223 content: &[u8],
224 profile: &FileTypeProfile,
225 store: &MappingStore,
226 ) -> Result<Option<Vec<Replacement>>> {
227 if content.len() > DEFAULT_INPUT_SIZE {
228 return Err(SanitizeError::InputTooLarge {
229 size: content.len(),
230 limit: DEFAULT_INPUT_SIZE,
231 });
232 }
233 let mut reader = Reader::from_reader(content);
234 reader.trim_text(false);
235 let mut edits = Vec::new();
236 let mut stack: Vec<String> = Vec::new();
237 let mut buf = Vec::new();
238
239 loop {
240 let before = reader.buffer_position();
241 match reader.read_event_into(&mut buf) {
242 Ok(Event::Start(e)) => {
243 let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
244 stack.push(name);
245 if stack.len() > XML_DEPTH {
246 return Err(SanitizeError::RecursionDepthExceeded(format!(
247 "XML element depth exceeds limit of {XML_DEPTH}"
248 )));
249 }
250 let path = stack.join("/");
251 collect_attr_edits(
252 &e,
253 content,
254 before,
255 reader.buffer_position(),
256 &path,
257 profile,
258 store,
259 &mut edits,
260 )?;
261 }
262 Ok(Event::Empty(e)) => {
263 let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
264 let path = if stack.is_empty() {
265 name
266 } else {
267 format!("{}/{name}", stack.join("/"))
268 };
269 collect_attr_edits(
270 &e,
271 content,
272 before,
273 reader.buffer_position(),
274 &path,
275 profile,
276 store,
277 &mut edits,
278 )?;
279 }
280 Ok(Event::Text(e)) => {
281 let end = reader.buffer_position();
282 let path = stack.join("/");
283 let key = stack.last().map_or("", String::as_str);
284 let text = e.unescape().map_err(|e| SanitizeError::ParseError {
285 format: "XML".into(),
286 message: format!("XML decode error: {e}"),
287 })?;
288 if let Some(token) = edit_token(key, &path, &text, profile, store)? {
289 edits.push(Replacement {
290 start: before,
291 end,
292 value: xml_escape_token(&token),
293 });
294 }
295 }
296 Ok(Event::End(_)) => {
297 stack.pop();
298 }
299 Ok(Event::Eof) => break,
300 Ok(_) => {}
301 Err(e) => {
302 return Err(SanitizeError::ParseError {
303 format: "XML".into(),
304 message: format!("XML parse error: {e}"),
305 });
306 }
307 }
308 buf.clear();
309 }
310 Ok(Some(edits))
311 }
312}
313
314fn xml_escape_token(token: &str) -> String {
317 token
318 .replace('&', "&")
319 .replace('<', "<")
320 .replace('>', ">")
321 .replace('"', """)
322}
323
324#[allow(clippy::too_many_arguments)]
328fn collect_attr_edits(
329 elem: &BytesStart<'_>,
330 content: &[u8],
331 tag_start: usize,
332 tag_end: usize,
333 element_path: &str,
334 profile: &FileTypeProfile,
335 store: &MappingStore,
336 edits: &mut Vec<Replacement>,
337) -> Result<()> {
338 let tag_end = tag_end.min(content.len());
339 let tag = &content[tag_start..tag_end];
340 let value_spans = scan_attr_value_spans(tag);
341
342 for (idx, attr_result) in elem.attributes().enumerate() {
343 let attr = attr_result.map_err(|e| SanitizeError::ParseError {
344 format: "XML".into(),
345 message: format!("XML attribute error: {e}"),
346 })?;
347 let Some(span) = value_spans.get(idx) else {
348 continue;
351 };
352 let key = String::from_utf8_lossy(attr.key.as_ref()).into_owned();
353 let attr_path = format!("{element_path}/@{key}");
354 let value = attr
355 .unescape_value()
356 .map_err(|e| SanitizeError::ParseError {
357 format: "XML".into(),
358 message: format!("XML attr decode error: {e}"),
359 })?;
360 if let Some(token) = edit_token(&key, &attr_path, &value, profile, store)? {
361 edits.push(Replacement {
362 start: tag_start + span.start,
363 end: tag_start + span.end,
364 value: xml_escape_token(&token),
365 });
366 }
367 }
368 Ok(())
369}
370
371fn process_attributes(
373 elem: &BytesStart<'_>,
374 element_path: &str,
375 profile: &FileTypeProfile,
376 store: &MappingStore,
377) -> Result<BytesStart<'static>> {
378 let name = elem.name();
379 let mut new_elem = BytesStart::new(String::from_utf8_lossy(name.as_ref()).to_string());
380
381 for attr_result in elem.attributes() {
382 let attr = attr_result.map_err(|e| SanitizeError::ParseError {
383 format: "XML".into(),
384 message: format!("XML attribute error: {}", e),
385 })?;
386 let attr_key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
387 let attr_path = format!("{}/@{}", element_path, attr_key);
388
389 if let Some(rule) = find_matching_rule(&attr_path, profile) {
390 let attr_value = attr
391 .unescape_value()
392 .map_err(|e| SanitizeError::ParseError {
393 format: "XML".into(),
394 message: format!("XML attr decode error: {}", e),
395 })?;
396 let replaced = replace_value(&attr_value, rule, store)?;
397 new_elem.push_attribute((attr_key.as_str(), replaced.as_str()));
398 } else {
399 let attr_value = attr
400 .unescape_value()
401 .map_err(|e| SanitizeError::ParseError {
402 format: "XML".into(),
403 message: format!("XML attr decode error: {}", e),
404 })?;
405 new_elem.push_attribute((attr_key.as_str(), attr_value.as_ref()));
406 }
407 }
408
409 Ok(new_elem)
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::category::Category;
416 use crate::generator::HmacGenerator;
417 use crate::processor::profile::FieldRule;
418 use std::fmt::Write as _;
419 use std::sync::Arc;
420
421 fn make_store() -> MappingStore {
422 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
423 MappingStore::new(gen, None)
424 }
425
426 #[test]
427 fn basic_xml_text_replacement() {
428 let store = make_store();
429 let proc = XmlProcessor;
430
431 let content =
432 b"<config><database><password>s3cret</password><port>5432</port></database></config>";
433 let profile = FileTypeProfile::new(
434 "xml",
435 vec![FieldRule::new("config/database/password")
436 .with_category(Category::Custom("pw".into()))],
437 );
438
439 let result = proc.process(content, &profile, &store).unwrap();
440 let out = String::from_utf8(result).unwrap();
441
442 assert!(!out.contains("s3cret"));
443 assert!(out.contains("<port>5432</port>"));
444 }
445
446 #[test]
447 fn xml_attribute_replacement() {
448 let store = make_store();
449 let proc = XmlProcessor;
450
451 let content = b"<config><connection host=\"db.corp.com\" port=\"5432\"/></config>";
452 let profile = FileTypeProfile::new(
453 "xml",
454 vec![FieldRule::new("config/connection/@host").with_category(Category::Hostname)],
455 );
456
457 let result = proc.process(content, &profile, &store).unwrap();
458 let out = String::from_utf8(result).unwrap();
459
460 assert!(!out.contains("db.corp.com"));
461 assert!(out.contains("5432"));
462 }
463
464 #[test]
465 fn can_handle_xml_declaration() {
466 let proc = XmlProcessor;
467 let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
468 assert!(proc.can_handle(b"<?xml version=\"1.0\"?><root/>", &profile));
469 }
470
471 #[test]
472 fn can_handle_bare_tag() {
473 let proc = XmlProcessor;
474 let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
475 assert!(proc.can_handle(b"<root><child/></root>", &profile));
476 }
477
478 #[test]
479 fn can_handle_by_profile_name() {
480 let proc = XmlProcessor;
481 let profile = FileTypeProfile::new("xml", vec![]).with_extension(".xml");
482 assert!(proc.can_handle(b"not xml at all", &profile));
483 }
484
485 #[test]
486 fn can_handle_rejects_plaintext() {
487 let proc = XmlProcessor;
488 let profile = FileTypeProfile::new("json", vec![]).with_extension(".json");
489 assert!(!proc.can_handle(b"just some plain text", &profile));
490 }
491
492 #[test]
493 fn empty_element_attributes_replaced() {
494 let store = make_store();
495 let proc = XmlProcessor;
496 let content = b"<config><server host=\"prod.corp.com\" port=\"443\"/></config>";
497 let profile = FileTypeProfile::new(
498 "xml",
499 vec![FieldRule::new("config/server/@host").with_category(Category::Hostname)],
500 );
501 let result = proc.process(content, &profile, &store).unwrap();
502 let out = String::from_utf8(result).unwrap();
503 assert!(!out.contains("prod.corp.com"));
504 assert!(out.contains("443"));
505 }
506
507 #[test]
508 fn empty_element_at_root_level() {
509 let store = make_store();
510 let proc = XmlProcessor;
511 let content = b"<server host=\"root.corp.com\"/>";
512 let profile = FileTypeProfile::new(
513 "xml",
514 vec![FieldRule::new("server/@host").with_category(Category::Hostname)],
515 );
516 let result = proc.process(content, &profile, &store).unwrap();
517 let out = String::from_utf8(result).unwrap();
518 assert!(!out.contains("root.corp.com"));
519 assert!(out.contains("<server"));
521 assert!(out.contains("host="));
522 }
523
524 #[test]
525 fn unmatched_attributes_pass_through() {
526 let store = make_store();
527 let proc = XmlProcessor;
528 let content = b"<config><db host=\"db.corp.com\" port=\"5432\"/></config>";
529 let profile = FileTypeProfile::new("xml", vec![]); let result = proc.process(content, &profile, &store).unwrap();
531 let out = String::from_utf8(result).unwrap();
532 assert!(out.contains("db.corp.com"));
533 assert!(out.contains("5432"));
534 }
535
536 #[test]
537 fn other_xml_events_pass_through() {
538 let store = make_store();
539 let proc = XmlProcessor;
540 let content = b"<?xml version=\"1.0\"?><!-- comment --><root><child>value</child></root>";
541 let profile = FileTypeProfile::new("xml", vec![]);
542 let result = proc.process(content, &profile, &store).unwrap();
543 let out = String::from_utf8(result).unwrap();
544 assert!(out.contains("value"));
545 }
546
547 #[test]
548 fn depth_limit_exceeded_returns_error() {
549 let store = make_store();
550 let proc = XmlProcessor;
551 let open: String = (0..260).fold(String::new(), |mut s, i| {
553 write!(s, "<l{i}>").unwrap();
554 s
555 });
556 let close: String = (0..260).rev().fold(String::new(), |mut s, i| {
557 write!(s, "</l{i}>").unwrap();
558 s
559 });
560 let content = format!("{open}secret{close}");
561 let profile = FileTypeProfile::new("xml", vec![]);
562 let err = proc
563 .process(content.as_bytes(), &profile, &store)
564 .unwrap_err();
565 assert!(matches!(
566 err,
567 crate::error::SanitizeError::RecursionDepthExceeded(_)
568 ));
569 }
570
571 #[test]
574 fn edits_redact_text_attr_and_entities() {
575 let store = make_store();
576 let proc = XmlProcessor;
577 let content = b"<c><db pw=\"a<b-SEC1\" host=\"keep\"/><t>tok-SEC2</t><k>ok</k></c>";
578 let profile = FileTypeProfile::new(
579 "xml",
580 vec![
581 FieldRule::new("c/db/@pw").with_category(Category::Custom("k".into())),
582 FieldRule::new("c/t").with_category(Category::Custom("k".into())),
583 ],
584 );
585 let edits = proc
586 .process_to_edits(content, &profile, &store)
587 .unwrap()
588 .unwrap();
589 let out = crate::processor::apply_edits(content, edits);
590 let text = String::from_utf8(out).unwrap();
591 assert!(!text.contains("SEC1"), "entity-encoded attr leaked: {text}");
592 assert!(!text.contains("SEC2"), "element text leaked: {text}");
593 assert!(
594 text.contains("host=\"keep\""),
595 "non-matched attr changed: {text}"
596 );
597 assert!(
598 text.contains("<k>ok</k>"),
599 "non-matched text changed: {text}"
600 );
601 }
602}