use crate::error::{Result, SanitizeError};
use crate::processor::limits::{DEFAULT_INPUT_SIZE, XML_DEPTH};
use crate::processor::{
edit_token, find_matching_rule, replace_value, FileTypeProfile, Processor, Replacement,
};
use crate::store::MappingStore;
use quick_xml::events::{BytesStart, BytesText, Event};
use quick_xml::{Reader, Writer, XmlVersion};
use std::io::Cursor;
fn xml_parse_error(kind: &str, byte_pos: Option<usize>) -> SanitizeError {
let loc = byte_pos.map_or_else(String::new, |p| format!(" at byte {p}"));
SanitizeError::ParseError {
format: "XML".into(),
message: format!(
"XML {kind} error{loc} \
(parser details withheld — input content is never echoed)"
),
}
}
fn scan_attr_value_spans(tag: &[u8]) -> Vec<std::ops::Range<usize>> {
let mut spans = Vec::new();
let mut i = 0;
while i < tag.len() && (tag[i] == b'<' || tag[i] == b'/' || tag[i] == b'?') {
i += 1;
}
while i < tag.len() && !tag[i].is_ascii_whitespace() && tag[i] != b'>' && tag[i] != b'/' {
i += 1;
}
while i < tag.len() {
while i < tag.len() && tag[i].is_ascii_whitespace() {
i += 1;
}
if i >= tag.len() || tag[i] == b'>' || tag[i] == b'/' || tag[i] == b'?' {
break;
}
while i < tag.len() && tag[i] != b'=' && !tag[i].is_ascii_whitespace() && tag[i] != b'>' {
i += 1;
}
while i < tag.len() && tag[i].is_ascii_whitespace() {
i += 1;
}
if i >= tag.len() || tag[i] != b'=' {
continue;
}
i += 1; while i < tag.len() && tag[i].is_ascii_whitespace() {
i += 1;
}
if i >= tag.len() || (tag[i] != b'"' && tag[i] != b'\'') {
continue;
}
let quote = tag[i];
i += 1;
let val_start = i;
while i < tag.len() && tag[i] != quote {
i += 1;
}
spans.push(val_start..i);
if i < tag.len() {
i += 1; }
}
spans
}
pub struct XmlProcessor;
impl Processor for XmlProcessor {
fn name(&self) -> &'static str {
"xml"
}
fn can_handle(&self, content: &[u8], profile: &FileTypeProfile) -> bool {
if profile.processor == "xml" {
return true;
}
let trimmed = content
.iter()
.copied()
.skip_while(|b| b.is_ascii_whitespace())
.take(5)
.collect::<Vec<u8>>();
trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<")
}
fn process(
&self,
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<Vec<u8>> {
if content.len() > DEFAULT_INPUT_SIZE {
return Err(SanitizeError::InputTooLarge {
size: content.len(),
limit: DEFAULT_INPUT_SIZE,
});
}
let mut reader = Reader::from_reader(content);
reader.config_mut().trim_text(false);
let mut writer = Writer::new(Cursor::new(Vec::new()));
let mut element_stack: Vec<String> = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
element_stack.push(name.clone());
if element_stack.len() > XML_DEPTH {
return Err(SanitizeError::RecursionDepthExceeded(format!(
"XML element depth exceeds limit of {XML_DEPTH}"
)));
}
let current_path = element_stack.join("/");
let new_elem = process_attributes(e, ¤t_path, profile, store)?;
writer.write_event(Event::Start(new_elem)).map_err(|e| {
SanitizeError::IoError(std::io::Error::other(format!(
"XML write error: {e}"
)))
})?;
}
Ok(Event::End(ref e)) => {
writer.write_event(Event::End(e.clone())).map_err(|e| {
SanitizeError::IoError(std::io::Error::other(format!(
"XML write error: {e}"
)))
})?;
element_stack.pop();
}
Ok(Event::Empty(ref e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let path = if element_stack.is_empty() {
name.clone()
} else {
format!("{}/{}", element_stack.join("/"), name)
};
let new_elem = process_attributes(e, &path, profile, store)?;
writer.write_event(Event::Empty(new_elem)).map_err(|e| {
SanitizeError::IoError(std::io::Error::other(format!(
"XML write error: {e}"
)))
})?;
}
Ok(Event::Text(ref e)) => {
let current_path = element_stack.join("/");
if let Some(rule) = find_matching_rule(¤t_path, profile) {
let text = unescape_text(e)?;
let replaced = replace_value(&text, rule, store)?;
writer
.write_event(Event::Text(BytesText::new(&replaced)))
.map_err(|e| {
SanitizeError::IoError(std::io::Error::other(format!(
"XML write error: {e}"
)))
})?;
} else {
writer.write_event(Event::Text(e.clone())).map_err(|e| {
SanitizeError::IoError(std::io::Error::other(format!(
"XML write error: {e}"
)))
})?;
}
}
Ok(Event::Eof) => break,
Ok(e) => {
writer.write_event(e).map_err(|er| {
SanitizeError::IoError(std::io::Error::other(format!(
"XML write error: {er}"
)))
})?;
}
Err(_) => {
return Err(xml_parse_error(
"parse",
Some(to_pos(reader.buffer_position())),
));
}
}
buf.clear();
}
let result = writer.into_inner().into_inner();
Ok(result)
}
fn process_to_edits(
&self,
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<Option<Vec<Replacement>>> {
if content.len() > DEFAULT_INPUT_SIZE {
return Err(SanitizeError::InputTooLarge {
size: content.len(),
limit: DEFAULT_INPUT_SIZE,
});
}
let mut reader = Reader::from_reader(content);
reader.config_mut().trim_text(false);
let mut edits = Vec::new();
let mut stack: Vec<String> = Vec::new();
let mut buf = Vec::new();
loop {
let before = to_pos(reader.buffer_position());
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
stack.push(name);
if stack.len() > XML_DEPTH {
return Err(SanitizeError::RecursionDepthExceeded(format!(
"XML element depth exceeds limit of {XML_DEPTH}"
)));
}
let path = stack.join("/");
collect_attr_edits(
&e,
content,
before,
to_pos(reader.buffer_position()),
&path,
profile,
store,
&mut edits,
)?;
}
Ok(Event::Empty(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
let path = if stack.is_empty() {
name
} else {
format!("{}/{name}", stack.join("/"))
};
collect_attr_edits(
&e,
content,
before,
to_pos(reader.buffer_position()),
&path,
profile,
store,
&mut edits,
)?;
}
Ok(Event::Text(e)) => {
let end = to_pos(reader.buffer_position());
let path = stack.join("/");
let key = stack.last().map_or("", String::as_str);
let text = unescape_text(&e)?;
if let Some(token) = edit_token(key, &path, &text, profile, store)? {
edits.push(Replacement {
start: before,
end,
value: xml_escape_token(&token),
});
}
}
Ok(Event::End(_)) => {
stack.pop();
}
Ok(Event::Eof) => break,
Ok(_) => {}
Err(_) => {
return Err(xml_parse_error(
"parse",
Some(to_pos(reader.buffer_position())),
));
}
}
buf.clear();
}
Ok(Some(edits))
}
}
fn xml_escape_token(token: &str) -> String {
token
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn to_pos(p: u64) -> usize {
usize::try_from(p).expect("XML input is bounded by DEFAULT_INPUT_SIZE")
}
fn unescape_text(e: &quick_xml::events::BytesText<'_>) -> Result<String> {
let raw = e.decode().map_err(|_| xml_parse_error("decode", None))?;
let text = quick_xml::escape::unescape(&raw).map_err(|_| xml_parse_error("decode", None))?;
Ok(text.into_owned())
}
#[allow(clippy::too_many_arguments)]
fn collect_attr_edits(
elem: &BytesStart<'_>,
content: &[u8],
tag_start: usize,
tag_end: usize,
element_path: &str,
profile: &FileTypeProfile,
store: &MappingStore,
edits: &mut Vec<Replacement>,
) -> Result<()> {
let tag_end = tag_end.min(content.len());
let tag = &content[tag_start..tag_end];
let value_spans = scan_attr_value_spans(tag);
for (idx, attr_result) in elem.attributes().enumerate() {
let attr = attr_result.map_err(|_| xml_parse_error("attribute", None))?;
let Some(span) = value_spans.get(idx) else {
continue;
};
let key = String::from_utf8_lossy(attr.key.as_ref()).into_owned();
let attr_path = format!("{element_path}/@{key}");
let value = attr
.normalized_value(XmlVersion::Implicit1_0)
.map_err(|_| xml_parse_error("attribute decode", None))?;
if let Some(token) = edit_token(&key, &attr_path, &value, profile, store)? {
edits.push(Replacement {
start: tag_start + span.start,
end: tag_start + span.end,
value: xml_escape_token(&token),
});
}
}
Ok(())
}
fn process_attributes(
elem: &BytesStart<'_>,
element_path: &str,
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<BytesStart<'static>> {
let name = elem.name();
let mut new_elem = BytesStart::new(String::from_utf8_lossy(name.as_ref()).to_string());
for attr_result in elem.attributes() {
let attr = attr_result.map_err(|_| xml_parse_error("attribute", None))?;
let attr_key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
let attr_path = format!("{}/@{}", element_path, attr_key);
if let Some(rule) = find_matching_rule(&attr_path, profile) {
let attr_value = attr
.normalized_value(XmlVersion::Implicit1_0)
.map_err(|_| xml_parse_error("attribute decode", None))?;
let replaced = replace_value(&attr_value, rule, store)?;
new_elem.push_attribute((attr_key.as_str(), replaced.as_str()));
} else {
let attr_value = attr
.normalized_value(XmlVersion::Implicit1_0)
.map_err(|_| xml_parse_error("attribute decode", None))?;
new_elem.push_attribute((attr_key.as_str(), attr_value.as_ref()));
}
}
Ok(new_elem)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::category::Category;
use crate::generator::HmacGenerator;
use crate::processor::profile::FieldRule;
use std::fmt::Write as _;
use std::sync::Arc;
fn make_store() -> MappingStore {
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
MappingStore::new(gen, None)
}
#[test]
fn parse_error_omits_input_content() {
const LEAK_MARKER: &str = "SEKRET-TAG-0xD34DB33F";
let store = make_store();
let proc = XmlProcessor;
let content = format!("<config><{LEAK_MARKER}>v</WRONG></config>");
let profile = FileTypeProfile::new("xml", vec![FieldRule::new("*")]);
let msg = proc
.process(content.as_bytes(), &profile, &store)
.expect_err("malformed input must fail to parse")
.to_string();
assert!(
!msg.contains(LEAK_MARKER),
"parse error echoed input content: {msg}"
);
let msg = proc
.process_to_edits(content.as_bytes(), &profile, &store)
.expect_err("malformed input must fail to parse")
.to_string();
assert!(
!msg.contains(LEAK_MARKER),
"parse error echoed input content: {msg}"
);
}
#[test]
fn basic_xml_text_replacement() {
let store = make_store();
let proc = XmlProcessor;
let content =
b"<config><database><password>s3cret</password><port>5432</port></database></config>";
let profile = FileTypeProfile::new(
"xml",
vec![FieldRule::new("config/database/password")
.with_category(Category::Custom("pw".into()))],
);
let result = proc.process(content, &profile, &store).unwrap();
let out = String::from_utf8(result).unwrap();
assert!(!out.contains("s3cret"));
assert!(out.contains("<port>5432</port>"));
}
#[test]
fn xml_attribute_replacement() {
let store = make_store();
let proc = XmlProcessor;
let content = b"<config><connection host=\"db.corp.com\" port=\"5432\"/></config>";
let profile = FileTypeProfile::new(
"xml",
vec![FieldRule::new("config/connection/@host").with_category(Category::Hostname)],
);
let result = proc.process(content, &profile, &store).unwrap();
let out = String::from_utf8(result).unwrap();
assert!(!out.contains("db.corp.com"));
assert!(out.contains("5432"));
}
#[test]
fn can_handle_xml_declaration() {
let proc = XmlProcessor;
let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
assert!(proc.can_handle(b"<?xml version=\"1.0\"?><root/>", &profile));
}
#[test]
fn can_handle_bare_tag() {
let proc = XmlProcessor;
let profile = FileTypeProfile::new("other", vec![]).with_extension(".txt");
assert!(proc.can_handle(b"<root><child/></root>", &profile));
}
#[test]
fn can_handle_by_profile_name() {
let proc = XmlProcessor;
let profile = FileTypeProfile::new("xml", vec![]).with_extension(".xml");
assert!(proc.can_handle(b"not xml at all", &profile));
}
#[test]
fn can_handle_rejects_plaintext() {
let proc = XmlProcessor;
let profile = FileTypeProfile::new("json", vec![]).with_extension(".json");
assert!(!proc.can_handle(b"just some plain text", &profile));
}
#[test]
fn empty_element_attributes_replaced() {
let store = make_store();
let proc = XmlProcessor;
let content = b"<config><server host=\"prod.corp.com\" port=\"443\"/></config>";
let profile = FileTypeProfile::new(
"xml",
vec![FieldRule::new("config/server/@host").with_category(Category::Hostname)],
);
let result = proc.process(content, &profile, &store).unwrap();
let out = String::from_utf8(result).unwrap();
assert!(!out.contains("prod.corp.com"));
assert!(out.contains("443"));
}
#[test]
fn empty_element_at_root_level() {
let store = make_store();
let proc = XmlProcessor;
let content = b"<server host=\"root.corp.com\"/>";
let profile = FileTypeProfile::new(
"xml",
vec![FieldRule::new("server/@host").with_category(Category::Hostname)],
);
let result = proc.process(content, &profile, &store).unwrap();
let out = String::from_utf8(result).unwrap();
assert!(!out.contains("root.corp.com"));
assert!(out.contains("<server"));
assert!(out.contains("host="));
}
#[test]
fn unmatched_attributes_pass_through() {
let store = make_store();
let proc = XmlProcessor;
let content = b"<config><db host=\"db.corp.com\" port=\"5432\"/></config>";
let profile = FileTypeProfile::new("xml", vec![]); let result = proc.process(content, &profile, &store).unwrap();
let out = String::from_utf8(result).unwrap();
assert!(out.contains("db.corp.com"));
assert!(out.contains("5432"));
}
#[test]
fn other_xml_events_pass_through() {
let store = make_store();
let proc = XmlProcessor;
let content = b"<?xml version=\"1.0\"?><!-- comment --><root><child>value</child></root>";
let profile = FileTypeProfile::new("xml", vec![]);
let result = proc.process(content, &profile, &store).unwrap();
let out = String::from_utf8(result).unwrap();
assert!(out.contains("value"));
}
#[test]
fn depth_limit_exceeded_returns_error() {
let store = make_store();
let proc = XmlProcessor;
let open: String = (0..260).fold(String::new(), |mut s, i| {
write!(s, "<l{i}>").unwrap();
s
});
let close: String = (0..260).rev().fold(String::new(), |mut s, i| {
write!(s, "</l{i}>").unwrap();
s
});
let content = format!("{open}secret{close}");
let profile = FileTypeProfile::new("xml", vec![]);
let err = proc
.process(content.as_bytes(), &profile, &store)
.unwrap_err();
assert!(matches!(
err,
crate::error::SanitizeError::RecursionDepthExceeded(_)
));
}
#[test]
fn edits_redact_text_attr_and_entities() {
let store = make_store();
let proc = XmlProcessor;
let content = b"<c><db pw=\"a<b-SEC1\" host=\"keep\"/><t>tok-SEC2</t><k>ok</k></c>";
let profile = FileTypeProfile::new(
"xml",
vec![
FieldRule::new("c/db/@pw").with_category(Category::Custom("k".into())),
FieldRule::new("c/t").with_category(Category::Custom("k".into())),
],
);
let edits = proc
.process_to_edits(content, &profile, &store)
.unwrap()
.unwrap();
let out = crate::processor::apply_edits(content, edits);
let text = String::from_utf8(out).unwrap();
assert!(!text.contains("SEC1"), "entity-encoded attr leaked: {text}");
assert!(!text.contains("SEC2"), "element text leaked: {text}");
assert!(
text.contains("host=\"keep\""),
"non-matched attr changed: {text}"
);
assert!(
text.contains("<k>ok</k>"),
"non-matched text changed: {text}"
);
}
}