Skip to main content

rust_sanitize/processor/
xml_proc.rs

1//! XML structured processor.
2//!
3//! The CLI uses [`process_to_edits`](XmlProcessor::process_to_edits): it walks
4//! the document with `quick-xml` and replaces each matched element-text and
5//! attribute value at its exact source span, preserving structure, comments, and
6//! the entity encoding of unrelated content (entity-encoded values are hit as
7//! written, so they never leak). `process` re-serializes via the quick-xml
8//! writer as a fallback.
9//!
10//! # Key Paths
11//!
12//! Element paths are slash-separated: `database/password`. Attributes
13//! are expressed as `element/@attr` (e.g. `connection/@host`).
14//!
15//! For simplicity this processor tracks the element stack and matches
16//! text content of elements and attribute values against field rules.
17
18use 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
28/// Scan a start/empty-tag's raw bytes (`<el a="v" b='w'/>`) for each attribute's
29/// **value** byte range (the content between the quotes), in source order.
30///
31/// XML attribute values cannot contain their own unescaped delimiter quote, so
32/// locating the closing quote is unambiguous. Returns ranges relative to `tag`.
33fn scan_attr_value_spans(tag: &[u8]) -> Vec<std::ops::Range<usize>> {
34    let mut spans = Vec::new();
35    let mut i = 0;
36    // Skip `<`, optional `/`, and the element name (up to whitespace or `>`/`/`).
37    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    // Walk attributes: name = (?:")value(?:") | name = '...'.
44    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        // attribute name
52        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; // '='
62        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; // closing quote
77        }
78    }
79    spans
80}
81
82/// Structured processor for XML files.
83pub 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        // F-04 fix: enforce input size limit.
110        if content.len() > DEFAULT_INPUT_SIZE {
111            return Err(SanitizeError::InputTooLarge {
112                size: content.len(),
113                limit: DEFAULT_INPUT_SIZE,
114            });
115        }
116
117        // Security: quick-xml disables external entity expansion by default,
118        // so XXE attacks are not possible with this configuration.
119        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                    // Process attributes.
139                    let current_path = element_stack.join("/");
140                    let new_elem = process_attributes(e, &current_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(&current_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    /// Span-based redaction: walk the document with `quick-xml`, recording an
215    /// edit for each matched element-text and attribute value at its exact
216    /// source byte span. Element text spans come from `buffer_position()`;
217    /// attribute value spans are located within each tag's bytes (using
218    /// quick-xml for the unescaped values and key/path matching). Structure,
219    /// comments, and unrelated bytes are preserved, and values are hit as
220    /// written so escaped/entity-encoded content never leaks.
221    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
314/// XML-escape a (safe-ASCII) token for insertion as text/attribute content.
315/// Tokens contain no markup characters in practice, but escape defensively.
316fn xml_escape_token(token: &str) -> String {
317    token
318        .replace('&', "&amp;")
319        .replace('<', "&lt;")
320        .replace('>', "&gt;")
321        .replace('"', "&quot;")
322}
323
324/// Emit edits for matched attribute values of a start/empty element. Uses
325/// quick-xml for the unescaped values (the mapping-store key) and a byte scan of
326/// the tag for the exact source spans, correlated in source order.
327#[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            // Span scan and quick-xml disagreed on attribute count — skip
349            // editing this attribute rather than risk a wrong splice.
350            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
371/// Process attributes of an element, replacing matched ones.
372fn 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        // Non-secret structure preserved: element name and attribute key remain.
520        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![]); // no field rules
530        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        // Build XML that exceeds XML_DEPTH (256) levels of nesting.
552        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    /// Edit-mode redacts element text and attribute values — including
572    /// entity-encoded content — preserving structure and non-matched values.
573    #[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&lt;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}