use roxmltree::{Document, ParsingOptions};
use super::XmlLimits;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
pub(crate) fn parse<'a>(
text: &'a str,
limits: XmlLimits,
stage: &'static str,
) -> Result<Document<'a>> {
if text.len() > limits.max_bytes {
return Err(fail(
stage,
format!(
"XML document exceeds the {} byte limit ({} bytes)",
limits.max_bytes,
text.len()
),
));
}
prescan(text.as_bytes(), limits, stage)?;
let options = ParsingOptions {
allow_dtd: false,
nodes_limit: limits.max_nodes,
..ParsingOptions::default()
};
Document::parse_with_options(text, options).map_err(|e| match e {
roxmltree::Error::NodesLimitReached => fail(
stage,
format!(
"XML document exceeds the node limit of {} nodes",
limits.max_nodes
),
),
roxmltree::Error::DtdDetected => fail(
stage,
"XML document type declarations (DOCTYPE) are not allowed".to_string(),
),
other => fail(stage, format!("XML is not well-formed: {other}")),
})
}
fn prescan(bytes: &[u8], limits: XmlLimits, stage: &'static str) -> Result<()> {
let skip_to = |from: usize, needle: &[u8]| -> usize {
memchr::memmem::find(&bytes[from..], needle)
.map(|at| from + at + needle.len())
.unwrap_or(bytes.len())
};
let mut depth: usize = 0;
let mut i = 0;
while let Some(at) = memchr::memchr(b'<', &bytes[i..]) {
let start = i + at;
let rest = &bytes[start..];
if rest.starts_with(b"<!--") {
i = skip_to(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = skip_to(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = skip_to(start + 2, b"?>");
} else if rest.starts_with(b"<!") {
return Err(fail(
stage,
"XML document type declarations (DOCTYPE) are not allowed".to_string(),
));
} else if rest.starts_with(b"</") {
depth = depth.saturating_sub(1);
i = skip_to(start + 2, b">");
} else {
let mut j = start + 1;
let mut quote: Option<u8> = None;
let mut attributes: usize = 0;
let mut self_closing = false;
while j < bytes.len() {
let b = bytes[j];
match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'=' => attributes += 1,
b'>' => {
self_closing = bytes[j - 1] == b'/';
break;
}
_ => {}
},
}
j += 1;
}
if attributes > usize::from(limits.max_attributes) {
return Err(fail(
stage,
format!(
"XML element has more than {} attributes (attribute limit)",
limits.max_attributes
),
));
}
if !self_closing {
depth += 1;
if depth > usize::from(limits.max_depth) {
return Err(fail(
stage,
format!(
"XML nesting exceeds the depth limit of {} levels",
limits.max_depth
),
));
}
}
i = (j + 1).min(bytes.len());
}
if i >= bytes.len() {
break;
}
}
Ok(())
}
pub(crate) fn parse_bytes<'a>(
bytes: &'a [u8],
limits: XmlLimits,
stage: &'static str,
) -> Result<Document<'a>> {
let text = std::str::from_utf8(bytes)
.map_err(|e| fail(stage, format!("XML document is not valid UTF-8: {e}")))?;
parse(text.strip_prefix('\u{feff}').unwrap_or(text), limits, stage)
}
fn fail(stage: &'static str, message: String) -> AsxError {
AsxError::new(ErrorCode::ParseFailed, message, ErrorContext::new(stage))
}
#[cfg(test)]
mod tests {
use super::*;
const STAGE: &str = "xml_parse_test";
fn on_small_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
std::thread::Builder::new()
.stack_size(256 * 1024)
.spawn(f)
.expect("spawn")
.join()
.expect("the parser must not overflow a 256 KiB stack")
}
fn expect_limit(xml: String, limits: XmlLimits, needle: &'static str) {
let err = on_small_stack(move || parse(&xml, limits, STAGE).map(|_| ()).unwrap_err());
assert_eq!(err.code, ErrorCode::ParseFailed);
assert!(
err.message.contains(needle),
"error must name the {needle} limit: {}",
err.message
);
}
#[test]
fn rejects_doctype_p1() {
let xml = r#"<?xml version="1.0"?><!DOCTYPE r [<!ENTITY e "x">]><r>&e;</r>"#.to_string();
expect_limit(xml, XmlLimits::ENVELOPE, "DOCTYPE");
expect_limit(
"<!DOCTYPE r><r/>".to_string(),
XmlLimits::ENVELOPE,
"DOCTYPE",
);
}
#[test]
fn rejects_oversize_p2() {
let limits = XmlLimits::payload(64);
let xml = format!("<r>{}</r>", "x".repeat(64));
expect_limit(xml, limits, "byte limit");
}
#[test]
fn rejects_deep_nesting_on_a_small_stack_p3() {
let depth = 5_000;
let xml = format!("{}{}", "<a>".repeat(depth), "</a>".repeat(depth));
expect_limit(xml.clone(), XmlLimits::ENVELOPE, "depth limit");
expect_limit(xml, XmlLimits::payload(1 << 20), "depth limit");
let ok = format!("{}{}", "<a>".repeat(64), "</a>".repeat(64));
assert!(parse(&ok, XmlLimits::ENVELOPE, STAGE).is_ok());
let over = format!("{}{}", "<a>".repeat(65), "</a>".repeat(65));
expect_limit(over, XmlLimits::ENVELOPE, "depth limit");
}
#[test]
fn rejects_node_limit_p4() {
let xml = format!("<r>{}</r>", "<a/>".repeat(20_001));
expect_limit(xml, XmlLimits::ENVELOPE, "node limit");
}
#[test]
fn rejects_attribute_limit_p5() {
let attrs: String = (0..1_000).map(|i| format!(" a{i}=\"v\"")).collect();
expect_limit(
format!("<r{attrs}/>"),
XmlLimits::ENVELOPE,
"attribute limit",
);
let decls: String = (0..1_000)
.map(|i| format!(" xmlns:p{i}=\"urn:x:{i}\""))
.collect();
expect_limit(
format!("<r{decls}/>"),
XmlLimits::ENVELOPE,
"attribute limit",
);
}
#[test]
fn accepts_markup_characters_in_comments_cdata_pis_and_attribute_values() {
let xml = concat!(
"<?xml version=\"1.0\"?>",
"<!-- <a><b><c> -->",
"<r a=\"x>y\" b='<&'>",
"<![CDATA[<x><y><z>]]>",
"<?pi <a><b>?>",
"<s><t></s>",
"</r>"
);
let doc = parse(xml, XmlLimits::ENVELOPE, STAGE).expect("parse");
let root = doc.root_element();
assert_eq!(root.attribute("a"), Some("x>y"));
assert_eq!(root.attribute("b"), Some("<&"));
let tight = XmlLimits {
max_depth: 2,
..XmlLimits::ENVELOPE
};
assert!(parse(xml, tight, STAGE).is_ok());
}
#[test]
fn rejects_malformed_input() {
for bad in ["<r>", "<r></s>", "", "<a/><b/>", "<r a=\"1\" a=\"2\"/>"] {
let err = parse(bad, XmlLimits::ENVELOPE, STAGE).unwrap_err();
assert_eq!(err.code, ErrorCode::ParseFailed, "{bad}");
}
}
#[test]
fn parse_bytes_requires_utf8_and_skips_a_bom() {
assert!(parse_bytes(b"\xef\xbb\xbf<r/>", XmlLimits::ENVELOPE, STAGE).is_ok());
let err = parse_bytes(b"<r>\xff</r>", XmlLimits::ENVELOPE, STAGE).unwrap_err();
assert_eq!(err.code, ErrorCode::ParseFailed);
}
#[test]
fn repository_fixtures_parse_to_the_same_tree() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let mut files = Vec::new();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("fixtures dir") {
let path = entry.expect("entry").path();
if path.is_dir() {
stack.push(path);
} else if path
.extension()
.is_some_and(|e| e == "xml" || e == "golden")
{
files.push(path);
}
}
}
let mut checked = 0;
for path in files {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(reference) = roxmltree::Document::parse(&text) else {
continue;
};
let ours = parse(&text, XmlLimits::payload(16 << 20), STAGE)
.unwrap_or_else(|e| panic!("{}: {}", path.display(), e.message));
let render = |d: &roxmltree::Document<'_>| {
d.descendants()
.map(|n| format!("{:?}{:?}{:?}", n.node_type(), n.tag_name(), n.text()))
.collect::<Vec<_>>()
};
assert_eq!(render(&ours), render(&reference), "{}", path.display());
if text.len() <= XmlLimits::ENVELOPE.max_bytes {
assert!(
parse(&text, XmlLimits::ENVELOPE, STAGE).is_ok(),
"{} must fit the envelope class",
path.display()
);
}
checked += 1;
}
assert!(checked >= 5, "expected XML fixtures, found {checked}");
}
}