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