use quick_xml::events::Event;
pub(crate) const MAX_DEPTH: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum XmlError {
#[error("malformed XML: {0}")]
Malformed(String),
#[error("XML DOCTYPE declarations are not accepted in ISO 20022 messages")]
DoctypeNotAllowed,
#[error("XML nesting deeper than {MAX_DEPTH} levels")]
TooDeep,
#[error("XML document has no root element")]
NoRootElement,
#[error("XML document has more than one root element")]
MultipleRootElements,
#[error("unknown XML entity reference: &{0};")]
UnknownEntity(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Node {
pub(crate) name: String,
pub(crate) text: String,
pub(crate) attrs: Vec<(String, String)>,
pub(crate) children: Vec<Node>,
}
impl Node {
pub(crate) fn child(&self, name: &str) -> Option<&Node> {
self.children.iter().find(|c| c.name == name)
}
pub(crate) fn children_named<'s>(&'s self, name: &'s str) -> impl Iterator<Item = &'s Node> {
self.children.iter().filter(move |c| c.name == name)
}
pub(crate) fn descendant(&self, name: &str) -> Option<&Node> {
let mut stack: Vec<&Node> = self.children.iter().rev().collect();
while let Some(n) = stack.pop() {
if n.name == name {
return Some(n);
}
stack.extend(n.children.iter().rev());
}
None
}
pub(crate) fn path(&self, names: &[&str]) -> Option<&Node> {
names.iter().try_fold(self, |node, n| node.child(n))
}
pub(crate) fn text_at(&self, path: &[&str]) -> Option<&str> {
self.path(path)
.map(|n| n.text.as_str())
.filter(|t| !t.is_empty())
}
pub(crate) fn text_of(&self, name: &str) -> Option<&str> {
self.text_at(&[name])
}
pub(crate) fn text_of_descendant(&self, name: &str) -> Option<&str> {
self.descendant(name)
.map(|n| n.text.as_str())
.filter(|t| !t.is_empty())
}
pub(crate) fn attr(&self, name: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
pub(crate) fn code(&self) -> Option<&str> {
self.text_of("Cd")
.or_else(|| self.text_of("Prtry"))
.or_else(|| {
Some(&self.text)
.filter(|t| !t.is_empty())
.map(String::as_str)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Document {
pub(crate) root: Node,
pub(crate) namespace: Option<String>,
}
impl Document {
pub(crate) fn parse(xml: &str) -> Result<Self, XmlError> {
let mut reader = quick_xml::Reader::from_str(xml);
let config = reader.config_mut();
config.trim_text(false);
config.expand_empty_elements = false;
config.check_end_names = true;
let mut stack: Vec<Node> = Vec::new();
let mut root: Option<Node> = None;
loop {
match reader
.read_event()
.map_err(|e| XmlError::Malformed(e.to_string()))?
{
Event::Start(e) => {
if stack.len() >= MAX_DEPTH {
return Err(XmlError::TooDeep);
}
stack.push(element(&e)?);
}
Event::Empty(e) => {
let node = element(&e)?;
match stack.last_mut() {
Some(parent) => parent.children.push(node),
None => root = Some(node),
}
}
Event::End(_) => {
let Some(mut node) = stack.pop() else {
continue;
};
let trimmed = node.text.trim();
if trimmed.len() != node.text.len() {
let start = trimmed.as_ptr() as usize - node.text.as_ptr() as usize;
let end = start + trimmed.len();
node.text.replace_range(..start, "");
node.text.truncate(end - start);
}
match stack.last_mut() {
Some(parent) => parent.children.push(node),
None => set_root(&mut root, node)?,
}
}
Event::Text(e) => {
if let Some(node) = stack.last_mut() {
let decoded = e
.decode()
.map_err(|err| XmlError::Malformed(err.to_string()))?;
node.text.push_str(&decoded);
}
}
Event::GeneralRef(e) => {
if let Some(node) = stack.last_mut() {
let raw = e
.decode()
.map_err(|err| XmlError::Malformed(err.to_string()))?;
if let Some(ch) = e
.resolve_char_ref()
.map_err(|err| XmlError::Malformed(err.to_string()))?
{
node.text.push(ch);
} else if let Some(text) =
quick_xml::escape::resolve_predefined_entity(&raw)
{
node.text.push_str(text);
} else {
return Err(XmlError::UnknownEntity(raw.into_owned()));
}
}
}
Event::CData(e) => {
if let Some(node) = stack.last_mut() {
let decoded = e
.decode()
.map_err(|err| XmlError::Malformed(err.to_string()))?;
node.text.push_str(&decoded);
}
}
Event::DocType(_) => return Err(XmlError::DoctypeNotAllowed),
Event::Comment(_) | Event::PI(_) | Event::Decl(_) => {}
Event::Eof => break,
}
}
let root = root.ok_or(XmlError::NoRootElement)?;
let namespace = root
.attrs
.iter()
.find(|(k, _)| k == "xmlns")
.map(|(_, v)| v.clone());
Ok(Self { root, namespace })
}
}
fn set_root(root: &mut Option<Node>, node: Node) -> Result<(), XmlError> {
if root.is_some() {
return Err(XmlError::MultipleRootElements);
}
*root = Some(node);
Ok(())
}
fn element(e: &quick_xml::events::BytesStart<'_>) -> Result<Node, XmlError> {
let name = local_name(e.name().as_ref())?.to_owned();
let mut attrs = Vec::new();
for attr in e.attributes() {
let attr = attr.map_err(|err| XmlError::Malformed(err.to_string()))?;
let key = local_name(attr.key.as_ref())?.to_owned();
let value = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map_err(|err| XmlError::Malformed(err.to_string()))?
.into_owned();
attrs.push((key, value));
}
Ok(Node {
name,
attrs,
..Node::default()
})
}
fn local_name(raw: &[u8]) -> Result<&str, XmlError> {
let s = std::str::from_utf8(raw)
.map_err(|_| XmlError::Malformed("element name is not valid UTF-8".to_owned()))?;
Ok(match s.split_once(':') {
Some((_, local)) => local,
None => s,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(xml: &str) -> Node {
Document::parse(xml).unwrap().root
}
#[test]
fn strips_namespace_prefixes() {
let a = parse(r#"<Doc xmlns="urn:x"><MsgId>M1</MsgId></Doc>"#);
let b = parse(r#"<n:Doc xmlns:n="urn:x"><n:MsgId>M1</n:MsgId></n:Doc>"#);
assert_eq!(a.text_of("MsgId"), Some("M1"));
assert_eq!(b.text_of("MsgId"), Some("M1"));
assert_eq!(a.name, "Doc");
assert_eq!(b.name, "Doc");
}
#[test]
fn comments_are_not_elements() {
let n = parse("<R><!-- <MsgId>COMMENTED</MsgId> --><MsgId>REAL</MsgId></R>");
assert_eq!(n.text_of("MsgId"), Some("REAL"));
assert_eq!(n.children_named("MsgId").count(), 1);
}
#[test]
fn entities_are_decoded() {
let n = parse("<R><Nm>Ben & Jerry's <GmbH></Nm></R>");
assert_eq!(n.text_of("Nm"), Some("Ben & Jerry's <GmbH>"));
}
#[test]
fn numeric_character_references_are_decoded() {
let n = parse("<R><Nm>Müller & Co</Nm></R>");
assert_eq!(n.text_of("Nm"), Some("Müller & Co"));
}
#[test]
fn unknown_entity_is_an_error() {
assert_eq!(
Document::parse("<R><Nm>&oops;</Nm></R>"),
Err(XmlError::UnknownEntity("oops".to_owned()))
);
}
#[test]
fn cdata_is_read_as_text() {
let n = parse("<R><Nm><![CDATA[A & B <raw>]]></Nm></R>");
assert_eq!(n.text_of("Nm"), Some("A & B <raw>"));
}
#[test]
fn self_closing_element_is_empty_not_a_swallowed_sibling() {
let n = parse("<R><Dbtr><Nm/></Dbtr><Cdtr><Nm>Real Name</Nm></Cdtr></R>");
assert_eq!(n.path(&["Dbtr", "Nm"]).map(|x| x.text.as_str()), Some(""));
assert_eq!(n.text_of("Dbtr"), None);
assert_eq!(n.path(&["Cdtr", "Nm"]).unwrap().text, "Real Name");
}
#[test]
fn attribute_values_containing_markup_do_not_confuse_the_parser() {
let n = parse(r#"<R><Amt Ccy="EUR" Note="a < b">100.00</Amt></R>"#);
let amt = n.child("Amt").unwrap();
assert_eq!(amt.text, "100.00");
assert_eq!(amt.attr("Ccy"), Some("EUR"));
assert_eq!(amt.attr("Note"), Some("a < b"));
}
#[test]
fn child_is_direct_only_but_descendant_searches_deep() {
let n = parse("<R><Acct><Id><IBAN>DE00</IBAN></Id></Acct></R>");
assert!(n.child("IBAN").is_none());
assert_eq!(n.descendant("IBAN").map(|x| x.text.as_str()), Some("DE00"));
assert_eq!(n.text_at(&["Acct", "Id", "IBAN"]), Some("DE00"));
}
#[test]
fn child_does_not_match_a_deeper_like_named_element() {
let n = parse("<Stmt><Acct><Id><IBAN>DE00</IBAN></Id></Acct></Stmt>");
assert_eq!(n.text_of("Id"), None);
}
#[test]
fn nested_same_name_elements_keep_their_structure() {
let n = parse("<Dt><Dt>2026-07-13</Dt></Dt>");
assert_eq!(n.text_of("Dt"), Some("2026-07-13"));
}
#[test]
fn repeated_children_are_all_kept_in_order() {
let n = parse("<R><Ntry><Amt>1.00</Amt></Ntry><Ntry><Amt>2.00</Amt></Ntry></R>");
let amounts: Vec<&str> = n
.children_named("Ntry")
.filter_map(|e| e.text_of("Amt"))
.collect();
assert_eq!(amounts, ["1.00", "2.00"]);
}
#[test]
fn structured_code_wins_over_stray_text() {
let mixed = parse("<Sts>STRAY<Cd>BOOK</Cd></Sts>");
assert_eq!(mixed.code(), Some("BOOK"));
}
#[test]
fn descendant_returns_the_first_match_in_document_order() {
let n = parse("<R><A><X>first</X></A><X>second</X></R>");
assert_eq!(n.descendant("X").map(|x| x.text.as_str()), Some("first"));
}
#[test]
fn multiple_root_elements_are_rejected() {
let xml = "<Doc xmlns='urn:x'><MsgId>REAL</MsgId></Doc>\
<Doc xmlns='urn:evil'><MsgId>EVIL</MsgId></Doc>";
assert_eq!(Document::parse(xml), Err(XmlError::MultipleRootElements));
}
#[test]
fn unmatched_end_tag_is_rejected() {
assert!(matches!(
Document::parse("</oops><R>x</R>"),
Err(XmlError::Malformed(_))
));
}
#[test]
fn code_accepts_bare_and_choice_forms() {
let old = parse("<Sts>BOOK</Sts>");
assert_eq!(old.code(), Some("BOOK"));
let new = parse("<Sts><Cd>BOOK</Cd></Sts>");
assert_eq!(new.code(), Some("BOOK"));
let prtry = parse("<Rsn><Prtry>BANK-007</Prtry></Rsn>");
assert_eq!(prtry.code(), Some("BANK-007"));
}
#[test]
fn namespace_is_detected_for_both_shapes() {
assert_eq!(
Document::parse(r#"<Document xmlns="urn:iso:x"><A/></Document>"#)
.unwrap()
.namespace
.as_deref(),
Some("urn:iso:x")
);
let doc =
Document::parse(r#"<n:Document xmlns:n="urn:iso:y"><n:A/></n:Document>"#).unwrap();
assert_eq!(doc.root.attr("n"), Some("urn:iso:y"));
}
#[test]
fn doctype_is_rejected() {
let xml = "<!DOCTYPE r [<!ENTITY a 'boom'>]><r>&a;</r>";
assert_eq!(Document::parse(xml), Err(XmlError::DoctypeNotAllowed));
}
#[test]
fn excessive_nesting_is_rejected() {
let deep = "<a>".repeat(MAX_DEPTH + 10);
assert_eq!(Document::parse(&deep), Err(XmlError::TooDeep));
}
#[test]
fn malformed_xml_is_rejected() {
assert!(matches!(
Document::parse("<a><b></a></b>"),
Err(XmlError::Malformed(_))
));
assert!(matches!(Document::parse(""), Err(XmlError::NoRootElement)));
}
#[test]
fn entity_in_the_middle_of_a_word_is_not_split() {
assert_eq!(parse("<Nm>AT&T</Nm>").text, "AT&T");
assert_eq!(parse("<Nm>Max & Co</Nm>").text, "Max & Co");
assert_eq!(parse("<Nm> Max & Co </Nm>").text, "Max & Co");
}
#[test]
fn whitespace_is_trimmed() {
let n = parse("<R><MsgId> HELLO-123 </MsgId></R>");
assert_eq!(n.text_of("MsgId"), Some("HELLO-123"));
}
#[test]
fn empty_text_reads_as_none() {
let n = parse("<R><MsgId></MsgId><Other/></R>");
assert_eq!(n.text_of("MsgId"), None);
assert_eq!(n.text_of("Other"), None);
}
}