use core::fmt;
use std::collections::HashSet;
pub const MAX_XML_DEPTH: usize = 64;
pub const MAX_XML_SIZE: usize = 10 * 1024 * 1024;
const MAX_ENTITY_NAME_LEN: usize = 32;
const MAX_ATTRIBUTES: usize = 10_000;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum XmlParseError {
InputTooLarge,
MaxDepthExceeded,
DtdNotAllowed,
ProcessingInstructionNotAllowed,
CdataNotAllowed,
Syntax(String),
}
impl fmt::Display for XmlParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InputTooLarge => {
write!(f, "XML input exceeds maximum size of {MAX_XML_SIZE} bytes")
}
Self::MaxDepthExceeded => {
write!(f, "XML nesting exceeds maximum depth of {MAX_XML_DEPTH}")
}
Self::DtdNotAllowed => write!(f, "DTD declarations are not allowed"),
Self::ProcessingInstructionNotAllowed => {
write!(f, "processing instructions are not allowed")
}
Self::CdataNotAllowed => write!(f, "CDATA sections are not allowed"),
Self::Syntax(msg) => write!(f, "XML syntax error: {msg}"),
}
}
}
impl std::error::Error for XmlParseError {}
#[derive(Debug, Clone)]
pub struct XmlElement {
name: String,
namespace: Option<String>,
attributes: Vec<(String, String)>,
children: Vec<XmlElement>,
text: Option<String>,
}
impl XmlElement {
#[must_use]
#[inline]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
#[inline]
pub fn namespace(&self) -> Option<&str> {
self.namespace.as_deref()
}
#[must_use]
#[inline]
pub fn children(&self) -> &[XmlElement] {
&self.children
}
#[must_use]
pub fn find_child(&self, name: &str) -> Option<&XmlElement> {
self.children.iter().find(|c| c.name == name)
}
#[must_use]
pub fn find_child_ns(&self, namespace: &str, name: &str) -> Option<&XmlElement> {
self.children
.iter()
.find(|c| c.name == name && c.namespace.as_deref() == Some(namespace))
}
#[must_use]
pub fn find_children(&self, name: &str) -> Vec<&XmlElement> {
self.children.iter().filter(|c| c.name == name).collect()
}
#[must_use]
pub fn attribute(&self, name: &str) -> Option<&str> {
self.attributes
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn text_content(&self) -> Option<&str> {
self.text.as_deref()
}
}
type StringPairs = Vec<(String, String)>;
struct Parser<'a> {
input: &'a str,
pos: usize,
ns_stack: Vec<Vec<(String, String)>>,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
Self {
input,
pos: 0,
ns_stack: Vec::new(),
}
}
fn remaining(&self) -> &'a str {
&self.input[self.pos..]
}
fn peek(&self) -> Option<char> {
self.remaining().chars().next()
}
fn advance(&mut self, n: usize) {
self.pos += n;
}
fn skip_whitespace(&mut self) {
while let Some(c) = self.peek() {
if c.is_ascii_whitespace() {
self.advance(c.len_utf8());
} else {
break;
}
}
}
fn starts_with(&self, s: &str) -> bool {
self.remaining().starts_with(s)
}
fn expect(&mut self, s: &str) -> Result<(), XmlParseError> {
if self.starts_with(s) {
self.advance(s.len());
Ok(())
} else {
Err(XmlParseError::Syntax(format!("expected '{s}'")))
}
}
fn read_name(&mut self) -> Result<String, XmlParseError> {
let start = self.pos;
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':' {
self.advance(c.len_utf8());
} else {
break;
}
}
if self.pos == start {
return Err(XmlParseError::Syntax("expected name".to_string()));
}
let name = &self.input[start..self.pos];
let well_formed = name.split(':').count() <= 2
&& name
.split(':')
.all(|part| matches!(part.chars().next(), Some(c) if c.is_ascii_alphabetic() || c == '_'));
if !well_formed {
return Err(XmlParseError::Syntax("malformed XML name".to_string()));
}
Ok(name.to_string())
}
fn read_attribute_value(&mut self) -> Result<String, XmlParseError> {
let quote = self
.peek()
.ok_or_else(|| XmlParseError::Syntax("expected quote".to_string()))?;
if quote != '"' && quote != '\'' {
return Err(XmlParseError::Syntax("expected quote".to_string()));
}
self.advance(1);
let start = self.pos;
while let Some(c) = self.peek() {
if c == quote {
let raw = &self.input[start..self.pos];
self.advance(1);
let normalized: String = raw
.chars()
.map(|c| {
if matches!(c, '\t' | '\n' | '\r') {
' '
} else {
c
}
})
.collect();
return decode_entities(&normalized);
}
if c == '<' {
return Err(XmlParseError::Syntax(
"'<' is not allowed in an attribute value".to_string(),
));
}
self.advance(c.len_utf8());
}
Err(XmlParseError::Syntax(
"unterminated attribute value".to_string(),
))
}
fn read_text(&mut self) -> Result<String, XmlParseError> {
let start = self.pos;
while let Some(c) = self.peek() {
if c == '<' {
break;
}
self.advance(c.len_utf8());
}
let raw = &self.input[start..self.pos];
decode_entities(raw)
}
fn resolve_name(&self, full_name: &str) -> (String, Option<String>) {
if let Some((prefix, local)) = full_name.split_once(':') {
let uri = self.lookup_ns(prefix);
(local.to_string(), uri)
} else {
let uri = self.lookup_ns("");
(full_name.to_string(), uri)
}
}
fn lookup_ns(&self, prefix: &str) -> Option<String> {
for frame in self.ns_stack.iter().rev() {
for (p, uri) in frame.iter().rev() {
if p == prefix {
return if uri.is_empty() {
None
} else {
Some(uri.clone())
};
}
}
}
None
}
fn skip_xml_declaration(&mut self) -> Result<(), XmlParseError> {
self.skip_whitespace();
let is_declaration = self.remaining().strip_prefix("<?xml").is_some_and(|rest| {
rest.chars()
.next()
.is_none_or(|c| c == '?' || c.is_ascii_whitespace())
});
if is_declaration {
if let Some(end) = self.remaining().find("?>") {
self.advance(end + 2);
} else {
return Err(XmlParseError::Syntax(
"unterminated XML declaration".to_string(),
));
}
}
Ok(())
}
fn skip_comment(&mut self) -> Result<(), XmlParseError> {
if self.starts_with("<!--") {
if let Some(end) = self.remaining().find("-->") {
self.advance(end + 3);
Ok(())
} else {
Err(XmlParseError::Syntax("unterminated comment".to_string()))
}
} else {
Ok(())
}
}
fn parse_element(&mut self, depth: usize) -> Result<XmlElement, XmlParseError> {
if depth >= MAX_XML_DEPTH {
return Err(XmlParseError::MaxDepthExceeded);
}
self.expect("<")?;
if self.starts_with("!DOCTYPE") {
return Err(XmlParseError::DtdNotAllowed);
}
if self.starts_with("![CDATA[") {
return Err(XmlParseError::CdataNotAllowed);
}
let tag_name = self.read_name()?;
let (raw_attrs, ns_frame) = self.parse_attributes()?;
self.ns_stack.push(ns_frame);
let (local_name, namespace) = self.resolve_name(&tag_name);
let attributes: Vec<(String, String)> = raw_attrs
.into_iter()
.map(|(k, v)| {
let local = if let Some((_prefix, local)) = k.split_once(':') {
local.to_string()
} else {
k
};
(local, v)
})
.collect();
if self.peek() == Some('/') {
self.advance(1);
self.expect(">")?;
self.ns_stack.pop();
return Ok(XmlElement {
name: local_name,
namespace,
attributes,
children: Vec::new(),
text: None,
});
}
self.expect(">")?;
let (children, text_buf) = self.parse_children(depth, &local_name)?;
self.expect("</")?;
let close_name = self.read_name()?;
self.skip_whitespace();
self.expect(">")?;
if close_name != tag_name {
return Err(XmlParseError::Syntax(format!(
"mismatched closing tag: expected </{tag_name}>, found </{close_name}>"
)));
}
self.ns_stack.pop();
let text = if text_buf.is_empty() {
None
} else {
Some(text_buf)
};
Ok(XmlElement {
name: local_name,
namespace,
attributes,
children,
text,
})
}
fn parse_attributes(&mut self) -> Result<(StringPairs, StringPairs), XmlParseError> {
let mut raw_attrs: StringPairs = Vec::new();
let mut ns_frame: StringPairs = Vec::new();
let mut seen_locals: HashSet<String> = HashSet::new();
let mut seen_prefixes: HashSet<String> = HashSet::new();
loop {
self.skip_whitespace();
match self.peek() {
Some('>' | '/') => break,
None => return Err(XmlParseError::Syntax("unexpected end of input".to_string())),
_ => {}
}
let attr_name = self.read_name()?;
self.skip_whitespace();
self.expect("=")?;
self.skip_whitespace();
let attr_value = self.read_attribute_value()?;
if attr_name == "xmlns" {
if !seen_prefixes.insert(String::new()) {
return Err(XmlParseError::Syntax(
"duplicate 'xmlns' declaration".to_string(),
));
}
ns_frame.push((String::new(), attr_value));
} else if let Some(prefix) = attr_name.strip_prefix("xmlns:") {
if !seen_prefixes.insert(prefix.to_string()) {
return Err(XmlParseError::Syntax(
"duplicate namespace declaration".to_string(),
));
}
ns_frame.push((prefix.to_string(), attr_value));
} else {
let local = attr_name
.split_once(':')
.map_or(attr_name.as_str(), |(_, l)| l);
if !seen_locals.insert(local.to_string()) {
return Err(XmlParseError::Syntax(
"duplicate attribute name".to_string(),
));
}
raw_attrs.push((attr_name, attr_value));
}
if raw_attrs.len() + ns_frame.len() > MAX_ATTRIBUTES {
return Err(XmlParseError::Syntax(
"element exceeds maximum attribute count".to_string(),
));
}
}
Ok((raw_attrs, ns_frame))
}
fn parse_children(
&mut self,
depth: usize,
local_name: &str,
) -> Result<(Vec<XmlElement>, String), XmlParseError> {
let mut children = Vec::new();
let mut text_buf = String::new();
let mut comment_seen = false;
loop {
if self.starts_with("</") {
break;
}
if self.starts_with("<!--") {
self.skip_comment()?;
comment_seen = true;
continue;
}
if self.starts_with("<!DOCTYPE") {
return Err(XmlParseError::DtdNotAllowed);
}
if self.starts_with("<![CDATA[") {
return Err(XmlParseError::CdataNotAllowed);
}
if self.starts_with("<?") {
return Err(XmlParseError::ProcessingInstructionNotAllowed);
}
if self.starts_with("<") {
children.push(self.parse_element(depth + 1)?);
continue;
}
if self.peek().is_none() {
return Err(XmlParseError::Syntax(format!(
"unexpected end of input inside <{local_name}>"
)));
}
let t = self.read_text()?;
text_buf.push_str(&t);
}
if comment_seen && !text_buf.bytes().all(is_xml_space) {
return Err(XmlParseError::Syntax(
"comment interleaved with text content is not allowed".to_string(),
));
}
if !children.is_empty() && !text_buf.bytes().all(is_xml_space) {
return Err(XmlParseError::Syntax(
"text interleaved with child elements is not allowed".to_string(),
));
}
Ok((children, text_buf))
}
}
fn is_xml_space(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\r' | b'\n')
}
fn decode_entities(input: &str) -> Result<String, XmlParseError> {
if let Some(bad) = input.chars().find(|c| !is_xml_char(*c as u32)) {
return Err(XmlParseError::Syntax(format!(
"character U+{:04X} is not allowed in XML 1.0",
bad as u32
)));
}
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '&' {
let mut entity = String::new();
loop {
match chars.next() {
Some(';') => break,
Some(ec) => {
entity.push(ec);
if entity.len() > MAX_ENTITY_NAME_LEN {
return Err(XmlParseError::Syntax(
"entity name exceeds maximum length".to_string(),
));
}
}
None => {
return Err(XmlParseError::Syntax(
"unterminated entity reference".to_string(),
));
}
}
}
match entity.as_str() {
"amp" => result.push('&'),
"lt" => result.push('<'),
"gt" => result.push('>'),
"apos" => result.push('\''),
"quot" => result.push('"'),
_ if entity.starts_with('#') => {
let decoded = decode_char_reference(&entity[1..])?;
result.push(decoded);
}
_ => {
return Err(XmlParseError::Syntax(format!(
"unknown entity reference: &{entity};"
)));
}
}
} else {
result.push(c);
}
}
Ok(result)
}
fn is_xml_char(code: u32) -> bool {
matches!(code,
0x9 | 0xA | 0xD |
0x20..=0xD7FF |
0xE000..=0xFFFD |
0x1_0000..=0x10_FFFF
)
}
fn decode_char_reference(s: &str) -> Result<char, XmlParseError> {
let code = if let Some(hex) = s.strip_prefix('x') {
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(XmlParseError::Syntax(format!(
"invalid hex char ref: &#x{hex};"
)));
}
u32::from_str_radix(hex, 16)
.map_err(|_| XmlParseError::Syntax(format!("invalid hex char ref: &#x{hex};")))?
} else {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(XmlParseError::Syntax(format!(
"invalid decimal char ref: &#{s};"
)));
}
s.parse::<u32>()
.map_err(|_| XmlParseError::Syntax(format!("invalid decimal char ref: &#{s};")))?
};
let ch = char::from_u32(code)
.ok_or_else(|| XmlParseError::Syntax(format!("invalid Unicode code point: {code}")))?;
if !is_xml_char(code) {
return Err(XmlParseError::Syntax(format!(
"character reference &#x{code:X}; is not allowed in XML 1.0"
)));
}
Ok(ch)
}
#[must_use]
pub fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
pub fn parse_xml(input: &str) -> Result<XmlElement, XmlParseError> {
if input.len() > MAX_XML_SIZE {
return Err(XmlParseError::InputTooLarge);
}
let mut parser = Parser::new(input);
parser.skip_xml_declaration()?;
parser.skip_whitespace();
while parser.starts_with("<!--") {
parser.skip_comment()?;
parser.skip_whitespace();
}
if parser.starts_with("<!DOCTYPE") {
return Err(XmlParseError::DtdNotAllowed);
}
let element = parser.parse_element(0)?;
parser.skip_whitespace();
while parser.starts_with("<!--") {
parser.skip_comment()?;
parser.skip_whitespace();
}
if !parser.remaining().is_empty() {
return Err(XmlParseError::Syntax(
"unexpected content after root element".to_string(),
));
}
Ok(element)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Write as _;
#[test]
fn parse_simple_element() {
let xml = "<root/>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.name(), "root");
assert!(el.children().is_empty());
assert!(el.text_content().is_none());
}
#[test]
fn parse_element_with_text() {
let xml = "<greeting>hello</greeting>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.name(), "greeting");
assert_eq!(el.text_content(), Some("hello"));
}
#[test]
fn trailing_content_after_root_is_rejected() {
assert!(parse_xml("<root/><evil/>").is_err());
assert!(parse_xml("<root/>junk").is_err());
assert!(parse_xml("<a>x</a><b>y</b>").is_err());
}
#[test]
fn trailing_whitespace_and_comments_after_root_are_allowed() {
assert!(parse_xml("<root/>\n ").is_ok());
assert!(parse_xml("<root/><!-- trailing -->").is_ok());
assert!(parse_xml("<root/>\n<!-- c -->\n").is_ok());
}
#[test]
fn duplicate_attribute_is_rejected() {
assert!(parse_xml(r#"<a Recipient="good" Recipient="evil"/>"#).is_err());
assert!(parse_xml(r#"<a x="1" y="2" x="3"/>"#).is_err());
assert!(parse_xml(r#"<a x="1" y="2"/>"#).is_ok());
assert!(parse_xml(r#"<a Recipient="A" x:Recipient="B"/>"#).is_err());
}
#[test]
fn duplicate_namespace_declaration_is_rejected() {
assert!(parse_xml(r#"<a xmlns="urn:1" xmlns="urn:2"/>"#).is_err());
assert!(parse_xml(r#"<a xmlns:p="urn:1" xmlns:p="urn:2"/>"#).is_err());
assert!(parse_xml(r#"<a xmlns="urn:1" xmlns:p="urn:2"/>"#).is_ok());
}
#[test]
fn malformed_element_names_are_rejected() {
assert!(parse_xml("<:Audience/>").is_err()); assert!(parse_xml("<Audience:/>").is_err()); assert!(parse_xml("<a:b:c/>").is_err()); assert!(parse_xml("<1abc/>").is_err()); assert!(parse_xml("<-x/>").is_err()); assert!(parse_xml("<saml:Assertion/>").is_ok());
assert!(parse_xml("<_foo/>").is_ok());
}
#[test]
fn declaration_with_tab_separator_is_skipped() {
let el = parse_xml("<?xml\tversion=\"1.0\"?><root/>").unwrap();
assert_eq!(el.name(), "root");
}
#[test]
fn comment_interleaved_with_text_is_rejected() {
let xml = "<a>good@x<!---->.evil</a>";
assert!(
parse_xml(xml).is_err(),
"comment inside text content must be rejected, not merged",
);
assert!(parse_xml("<a>x<!-- c --></a>").is_err());
assert!(parse_xml("<a><!-- c -->x</a>").is_err());
}
#[test]
fn text_interleaved_with_child_elements_is_rejected() {
assert!(
parse_xml("<NameID>good@evil.com<x/></NameID>").is_err(),
"text alongside a child element must be rejected, not merged",
);
assert!(parse_xml("<a><x/>trailing</a>").is_err());
assert!(parse_xml("<a>lead<x/></a>").is_err());
}
#[test]
fn non_xml_whitespace_text_is_not_treated_as_whitespace() {
assert!(
parse_xml("<a>\u{A0}<x/></a>").is_err(),
"U+00A0 is XML content, not whitespace, and must not slip past the guard",
);
assert!(parse_xml("<a>\u{A0}<!-- c --></a>").is_err());
}
#[test]
fn whitespace_between_child_elements_is_allowed() {
let xml = "<root>\n <a/>\n <b/>\n</root>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.children().len(), 2);
}
#[test]
fn comment_between_child_elements_is_allowed() {
let xml = "<root><a/><!-- between --><b/></root>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.children().len(), 2);
assert!(el.text_content().is_none());
}
#[test]
fn parse_nested_elements() {
let xml = "<root><child1/><child2>text</child2></root>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.children().len(), 2);
assert_eq!(el.children()[0].name(), "child1");
assert_eq!(el.children()[1].name(), "child2");
assert_eq!(el.children()[1].text_content(), Some("text"));
}
#[test]
fn parse_deeply_nested() {
let xml = "<a><b><c><d>deep</d></c></b></a>";
let el = parse_xml(xml).unwrap();
let d = el
.find_child("b")
.unwrap()
.find_child("c")
.unwrap()
.find_child("d")
.unwrap();
assert_eq!(d.text_content(), Some("deep"));
}
#[test]
fn parse_attributes() {
let xml = r#"<tag id="123" class="main"/>"#;
let el = parse_xml(xml).unwrap();
assert_eq!(el.attribute("id"), Some("123"));
assert_eq!(el.attribute("class"), Some("main"));
}
#[test]
fn parse_single_quoted_attributes() {
let xml = "<tag attr='value'/>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.attribute("attr"), Some("value"));
}
#[test]
fn parse_default_namespace() {
let xml = r#"<root xmlns="urn:example">text</root>"#;
let el = parse_xml(xml).unwrap();
assert_eq!(el.name(), "root");
assert_eq!(el.namespace(), Some("urn:example"));
}
#[test]
fn parse_prefixed_namespace() {
let xml = r#"<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"/>"#;
let el = parse_xml(xml).unwrap();
assert_eq!(el.name(), "Assertion");
assert_eq!(
el.namespace(),
Some("urn:oasis:names:tc:SAML:2.0:assertion")
);
}
#[test]
fn find_child_ns() {
let xml = r#"<root xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"><saml:Issuer>idp</saml:Issuer><other>x</other></root>"#;
let el = parse_xml(xml).unwrap();
let issuer = el
.find_child_ns("urn:oasis:names:tc:SAML:2.0:assertion", "Issuer")
.unwrap();
assert_eq!(issuer.text_content(), Some("idp"));
}
#[test]
fn decode_xml_entities() {
let xml = "<tag>& < > ' "</tag>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.text_content(), Some("& < > ' \""));
}
#[test]
fn decode_numeric_entities() {
let xml = "<tag>AB</tag>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.text_content(), Some("AB"));
}
#[test]
fn entity_in_attribute() {
let xml = r#"<tag val="a&b"/>"#;
let el = parse_xml(xml).unwrap();
assert_eq!(el.attribute("val"), Some("a&b"));
}
#[test]
fn reject_unknown_entity() {
let err = parse_xml("<tag> </tag>").unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn reject_unterminated_entity() {
let err = parse_xml("<tag>&</tag>").unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn reject_overlong_entity_name() {
let long = "a".repeat(MAX_ENTITY_NAME_LEN + 1);
let err = parse_xml(&format!("<tag>&{long};</tag>")).unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn reject_out_of_range_numeric_char_ref() {
let err = parse_xml("<tag>�</tag>").unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
let err = parse_xml("<tag>�</tag>").unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn reject_signed_numeric_char_ref() {
for input in ["<t>&#x+41;</t>", "<t>&#+65;</t>", "<t>&#x-41;</t>"] {
let err = parse_xml(input).unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)), "input: {input}");
}
}
#[test]
fn multibyte_text_and_attribute_round_trip() {
let doc = parse_xml("<a x=\"café\">naïve—€</a>").unwrap();
assert_eq!(doc.attribute("x"), Some("café"));
assert_eq!(doc.text_content(), Some("naïve—€"));
}
#[test]
fn reject_disallowed_control_char_ref() {
let err = parse_xml("<tag>�</tag>").unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
let err = parse_xml("<tag></tag>").unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn reject_excessive_attribute_count() {
let mut xml = String::from("<r");
for i in 0..=MAX_ATTRIBUTES {
write!(xml, " a{i}=\"v\"").unwrap();
}
xml.push_str("/>");
let err = parse_xml(&xml).unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn reject_dtd_declaration() {
let xml = "<!DOCTYPE foo [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><root/>";
let err = parse_xml(xml).unwrap_err();
assert_eq!(err, XmlParseError::DtdNotAllowed);
}
#[test]
fn reject_dtd_after_xml_declaration() {
let xml = "<?xml version=\"1.0\"?><!DOCTYPE foo><root/>";
let err = parse_xml(xml).unwrap_err();
assert_eq!(err, XmlParseError::DtdNotAllowed);
}
#[test]
fn reject_processing_instruction_in_content() {
let xml = "<root><?php echo 'hi'; ?></root>";
let err = parse_xml(xml).unwrap_err();
assert_eq!(err, XmlParseError::ProcessingInstructionNotAllowed);
}
#[test]
fn reject_cdata() {
let xml = "<root><![CDATA[data]]></root>";
let err = parse_xml(xml).unwrap_err();
assert_eq!(err, XmlParseError::CdataNotAllowed);
}
fn nested_xml(levels: usize) -> String {
let open = (0..levels).fold(String::new(), |mut acc, i| {
write!(acc, "<d{i}>").unwrap();
acc
});
let close = (0..levels).rev().fold(String::new(), |mut acc, i| {
write!(acc, "</d{i}>").unwrap();
acc
});
format!("{open}{close}")
}
#[test]
fn reject_excessive_depth() {
let err = parse_xml(&nested_xml(MAX_XML_DEPTH + 1)).unwrap_err();
assert_eq!(err, XmlParseError::MaxDepthExceeded);
}
#[test]
fn accept_exactly_max_depth() {
assert!(parse_xml(&nested_xml(MAX_XML_DEPTH)).is_ok());
}
#[test]
fn reject_oversized_input() {
let xml = format!("<r>{}</r>", "x".repeat(MAX_XML_SIZE + 1));
let err = parse_xml(&xml).unwrap_err();
assert_eq!(err, XmlParseError::InputTooLarge);
}
#[test]
fn skip_xml_declaration() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root>ok</root>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.name(), "root");
assert_eq!(el.text_content(), Some("ok"));
}
#[test]
fn skip_comments() {
let xml = "<!-- comment --><root><!-- inner --><child/></root>";
let el = parse_xml(xml).unwrap();
assert_eq!(el.name(), "root");
assert_eq!(el.children().len(), 1);
}
#[test]
fn find_children_multiple() {
let xml = "<root><item>a</item><item>b</item><other/></root>";
let el = parse_xml(xml).unwrap();
let items = el.find_children("item");
assert_eq!(items.len(), 2);
assert_eq!(items[0].text_content(), Some("a"));
assert_eq!(items[1].text_content(), Some("b"));
}
#[test]
fn mismatched_tags() {
let xml = "<a></b>";
let err = parse_xml(xml).unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn unterminated_element() {
let xml = "<a>";
let err = parse_xml(xml).unwrap_err();
assert!(matches!(err, XmlParseError::Syntax(_)));
}
#[test]
fn rejects_raw_markup_and_controls() {
assert!(parse_xml(r#"<a b="x<y"/>"#).is_err());
assert!(parse_xml("<a>\u{0}</a>").is_err());
assert!(parse_xml("<a b=\"\u{b}\"/>").is_err());
assert!(parse_xml("<a>�</a>").is_err());
}
#[test]
fn normalizes_literal_whitespace_in_attribute_values() {
let doc = parse_xml("<a b=\"x\ty\nz\"/>").unwrap();
assert_eq!(doc.attribute("b"), Some("x y z"));
let doc = parse_xml(r#"<a b="x
y"/>"#).unwrap();
assert_eq!(doc.attribute("b"), Some("x\ny"));
}
}