use std::{
borrow::Cow,
fmt::{self, Debug},
ops::Range,
slice,
};
use quick_xml::{
NsReader,
events::{BytesDecl, BytesEnd, BytesStart, Event},
name::{QName, ResolveResult},
};
use thiserror::Error;
pub(crate) const MAX_DEPTH: usize = 512;
pub(crate) const MAX_NAMESPACE_BINDINGS: usize = 128;
pub(crate) const MAX_NAME_LEN: usize = 256;
pub(crate) const NODE_BUDGET: usize = 4_000_000;
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
const DECLARATION: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#;
pub(crate) const W_URI: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
pub(crate) const WP_URI: &str =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
pub(crate) const MC_URI: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
pub(crate) const RELS_URI: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Ns {
None,
W,
Wp,
Mc,
Rels,
Other,
}
impl Ns {
const KNOWN: [Self; 4] = [Self::W, Self::Wp, Self::Mc, Self::Rels];
pub(crate) const fn uri(self) -> Option<&'static str> {
match self {
Self::W => Some(W_URI),
Self::Wp => Some(WP_URI),
Self::Mc => Some(MC_URI),
Self::Rels => Some(RELS_URI),
Self::None | Self::Other => None,
}
}
fn from_uri(uri: &str) -> Self {
Self::KNOWN
.into_iter()
.find(|ns| ns.uri() == Some(uri))
.unwrap_or(Self::Other)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct XmlDocument {
bom: bool,
prolog: Vec<Node>,
pub(crate) root: Element,
epilog: Vec<Node>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Node {
Element(Element),
Text(String),
CData(String),
Other(String),
}
impl Node {
pub(crate) const fn as_element(&self) -> Option<&Element> {
match self {
Self::Element(element) => Some(element),
Self::Text(_) | Self::CData(_) | Self::Other(_) => None,
}
}
pub(crate) const fn as_element_mut(&mut self) -> Option<&mut Element> {
match self {
Self::Element(element) => Some(element),
Self::Text(_) | Self::CData(_) | Self::Other(_) => None,
}
}
}
#[derive(PartialEq, Eq)]
pub(crate) struct Element {
tag: Box<str>,
ns: Ns,
pub(crate) children: Vec<Node>,
close: Close,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Close {
Auto,
EndTag(Box<str>),
}
impl Clone for Element {
fn clone(&self) -> Self {
let mut stack = Vec::new();
let mut current = (self.children.iter(), self.clone_shallow());
loop {
match current.0.next() {
Some(Node::Element(child)) => {
let copy = (child.children.iter(), child.clone_shallow());
stack.push(std::mem::replace(&mut current, copy));
}
Some(leaf) => current.1.children.push(leaf.clone()),
None => match stack.pop() {
Some(parent) => {
let (_, done) = std::mem::replace(&mut current, parent);
current.1.children.push(Node::Element(done));
}
None => return current.1,
},
}
}
}
}
impl Debug for Element {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Element")
.field(&self.ns)
.field(&self.to_xml())
.finish()
}
}
impl XmlDocument {
pub(crate) fn new(root: Element) -> Self {
Self {
bom: false,
prolog: vec![
Node::Other(DECLARATION.to_owned()),
Node::Text("\r\n".to_owned()),
],
root,
epilog: Vec::new(),
}
}
pub(crate) fn parse_with_budget(input: &[u8], budget: &mut usize) -> Result<Self, InvalidXml> {
let (bom, body) = match input.strip_prefix(UTF8_BOM) {
Some(body) => (true, body),
None => (false, input),
};
let base = if bom { UTF8_BOM.len() } else { 0 };
let xml = std::str::from_utf8(body).map_err(|err| {
InvalidXml(Reason::Utf8 {
offset: base + err.valid_up_to(),
})
})?;
let Tree {
prolog,
root,
epilog,
} = parse_tree(xml, base, false, budget).map_err(InvalidXml)?;
Ok(Self {
bom,
prolog,
root,
epilog,
})
}
#[cfg(test)]
pub(crate) fn parse(input: &[u8]) -> Result<Self, InvalidXml> {
let mut unlimited = usize::MAX;
Self::parse_with_budget(input, &mut unlimited)
}
pub(crate) fn to_bytes(&self) -> Vec<u8> {
let mut out = String::new();
if self.bom {
out.push('\u{FEFF}');
}
for node in &self.prolog {
write_node(&mut out, node);
}
write_element(&mut out, &self.root);
for node in &self.epilog {
write_node(&mut out, node);
}
out.into_bytes()
}
}
impl Element {
pub(crate) fn new(name: impl Into<String>, ns: Ns) -> Self {
let tag = name.into().into_boxed_str();
debug_assert!(is_qname(&tag), "invalid element name {tag:?}");
Self {
tag,
ns,
children: Vec::new(),
close: Close::Auto,
}
}
pub(crate) fn parse_fragment(xml: &str) -> Result<Self, InvalidXml> {
let mut unlimited = usize::MAX;
parse_tree(xml, 0, true, &mut unlimited)
.map(|tree| tree.root)
.map_err(InvalidXml)
}
pub(crate) fn namespace_declarations(&self) -> usize {
self.raw_attrs()
.filter(|attr| attr.name == "xmlns" || attr.name.starts_with("xmlns:"))
.count()
}
pub(crate) fn name(&self) -> &str {
self.tag.get(..self.name_len()).unwrap_or_default()
}
pub(crate) fn prefix(&self) -> Option<&str> {
self.name().split_once(':').map(|(prefix, _)| prefix)
}
pub(crate) fn local_name(&self) -> &str {
let name = self.name();
name.split_once(':').map_or(name, |(_, local)| local)
}
pub(crate) const fn ns(&self) -> Ns {
self.ns
}
pub(crate) fn is(&self, ns: Ns, local: &str) -> bool {
self.ns == ns && self.local_name() == local
}
pub(crate) fn attr_names(&self) -> impl Iterator<Item = &str> {
self.raw_attrs().map(|attr| attr.name)
}
pub(crate) fn attr(&self, name: &str) -> Option<Cow<'_, str>> {
self.raw_attrs()
.find(|attr| attr.name == name)
.map(|attr| unescape(attr.value, Unescape::Attr))
}
pub(crate) fn set_attr(&mut self, name: &str, value: &str) {
debug_assert!(is_qname(name), "invalid attribute name {name:?}");
let value = escape_attr(value);
let mut existing = None;
let mut end = self.name_len();
for attr in self.raw_attrs() {
if attr.name == name {
existing = Some(attr.value_range);
break;
}
end = attr.value_range.end + 1;
}
let mut tag = String::from(std::mem::take(&mut self.tag));
match existing {
Some(range) => tag.replace_range(range, &value),
None => tag.insert_str(end, &[" ", name, "=\"", &value, "\""].concat()),
}
self.tag = tag.into_boxed_str();
}
pub(crate) fn text(&self) -> Cow<'_, str> {
let mut parts = self.children.iter().filter_map(|node| match node {
Node::Text(raw) => Some(unescape(raw, Unescape::Text)),
Node::CData(content) => Some(unescape(content, Unescape::CData)),
Node::Element(_) | Node::Other(_) => None,
});
let Some(mut text) = parts.next() else {
return Cow::Borrowed("");
};
for part in parts {
text.to_mut().push_str(&part);
}
text
}
pub(crate) fn child_elements(&self) -> impl Iterator<Item = &Element> {
self.children.iter().filter_map(Node::as_element)
}
pub(crate) fn child_elements_mut(&mut self) -> impl Iterator<Item = &mut Element> {
self.children.iter_mut().filter_map(Node::as_element_mut)
}
pub(crate) fn find_child(&self, ns: Ns, local: &str) -> Option<&Element> {
self.child_elements().find(|child| child.is(ns, local))
}
pub(crate) fn descendants(&self) -> impl Iterator<Item = &Element> {
let mut stack = vec![self.children.iter()];
std::iter::from_fn(move || {
while let Some(children) = stack.last_mut() {
match children.next() {
Some(Node::Element(child)) => {
stack.push(child.children.iter());
return Some(child);
}
Some(_) => {}
None => {
stack.pop();
}
}
}
None
})
}
pub(crate) fn to_xml(&self) -> String {
let mut out = String::new();
write_element(&mut out, self);
out
}
fn clone_shallow(&self) -> Self {
Self {
tag: self.tag.clone(),
ns: self.ns,
children: Vec::with_capacity(self.children.len()),
close: self.close.clone(),
}
}
fn name_len(&self) -> usize {
self.tag
.bytes()
.position(is_whitespace)
.unwrap_or(self.tag.len())
}
fn raw_attrs(&self) -> impl Iterator<Item = RawAttr<'_>> {
RawAttrs {
tag: &self.tag,
pos: self.name_len(),
}
.map_while(Result::ok)
}
}
struct Tree {
prolog: Vec<Node>,
root: Element,
epilog: Vec<Node>,
}
fn parse_tree(xml: &str, base: usize, fragment: bool, budget: &mut usize) -> Result<Tree, Reason> {
if xml.starts_with('\u{FEFF}') {
return Err(Reason::OutsideRoot { offset: base });
}
let mut reader = NsReader::from_str(xml);
let config = reader.config_mut();
config.trim_text(false);
config.expand_empty_elements = false;
config.check_end_names = false;
config.trim_markup_names_in_closing_tags = false;
reader
.resolver_mut()
.set_max_namespace_bindings(MAX_NAMESPACE_BINDINGS);
let mut builder = Builder {
reader,
base,
fragment,
prolog: Vec::new(),
root: None,
epilog: Vec::new(),
open: Vec::new(),
text: None,
nodes_left: *budget,
names: Vec::new(),
};
loop {
let offset = builder.offset(builder.reader.buffer_position());
let event = match builder.reader.read_event() {
Ok(event) => event,
Err(source) => {
let offset = match source {
quick_xml::Error::Namespace(_) => offset,
_ => builder.offset(builder.reader.error_position()),
};
return Err(Reason::Syntax { offset, source });
}
};
if !matches!(event, Event::Text(_) | Event::GeneralRef(_)) {
builder.flush_text()?;
}
match event {
Event::Text(text) => builder.push_text(offset, text.into_inner()),
Event::GeneralRef(reference) => builder.push_reference(offset, &reference)?,
Event::Start(start) => {
let element = builder.start(offset, &start)?;
builder.open.push(element);
}
Event::Empty(start) => {
let element = builder.start(offset, &start)?;
builder.attach(element);
}
Event::End(end) => builder.end(offset, &end)?,
Event::CData(cdata) => {
let content = cdata.into_inner().into_owned();
builder.inside(offset, Node::CData(content))?;
}
Event::Comment(comment) => builder.misc(offset, ["<!--", &*comment, "-->"].concat())?,
Event::PI(pi) => builder.misc(offset, ["<?", &*pi, "?>"].concat())?,
Event::Decl(decl) => builder.declaration(offset, &decl)?,
Event::DocType(_) => return Err(Reason::Doctype { offset }),
Event::Eof => break,
}
}
let nodes_left = builder.nodes_left;
let tree = builder.finish()?;
*budget = nodes_left;
Ok(tree)
}
struct Builder<'a> {
reader: NsReader<&'a [u8]>,
base: usize,
fragment: bool,
prolog: Vec<Node>,
root: Option<Element>,
epilog: Vec<Node>,
open: Vec<Element>,
text: Option<(usize, Cow<'a, str>)>,
nodes_left: usize,
names: Vec<Range<usize>>,
}
impl<'a> Builder<'a> {
fn offset(&self, position: u64) -> usize {
usize::try_from(position).map_or(usize::MAX, |position| position.saturating_add(self.base))
}
fn count_node(&mut self, offset: usize) -> Result<(), Reason> {
self.nodes_left = self
.nodes_left
.checked_sub(1)
.ok_or(Reason::TooManyNodes { offset })?;
Ok(())
}
fn push_text(&mut self, offset: usize, raw: Cow<'a, str>) {
match &mut self.text {
Some((_, pending)) => pending.to_mut().push_str(&raw),
None => self.text = Some((offset, raw)),
}
}
fn push_reference(&mut self, offset: usize, name: &str) -> Result<(), Reason> {
if resolve_reference(name).is_none() {
return Err(Reason::Reference { offset });
}
let (_, pending) = self.text.get_or_insert((offset, Cow::Borrowed("")));
let pending = pending.to_mut();
pending.push('&');
pending.push_str(name);
pending.push(';');
Ok(())
}
fn flush_text(&mut self) -> Result<(), Reason> {
let Some((start, raw)) = self.text.take() else {
return Ok(());
};
if !self.open.is_empty() {
self.count_node(start)?;
if let Some(parent) = self.open.last_mut() {
parent.children.push(Node::Text(raw.into_owned()));
}
} else if !raw.bytes().all(is_whitespace) {
return Err(Reason::OutsideRoot { offset: start });
} else if !self.fragment {
self.count_node(start)?;
self.outside(Node::Text(raw.into_owned()));
}
Ok(())
}
fn start(&mut self, offset: usize, start: &BytesStart<'_>) -> Result<Element, Reason> {
if self.open.is_empty() && self.root.is_some() {
return Err(Reason::MultipleRoots { offset });
}
if self.open.len() >= MAX_DEPTH {
return Err(Reason::TooDeep { offset });
}
self.count_node(offset)?;
let name = start.name();
if name.into_inner().len() > MAX_NAME_LEN {
return Err(Reason::LongName { offset });
}
if !is_qname(name.into_inner()) || name.prefix().is_some_and(|prefix| prefix.is_xmlns()) {
return Err(Reason::Name { offset });
}
let ns = match self.reader.resolver().resolve_element(name).0 {
ResolveResult::Bound(uri) => Ns::from_uri(uri.into_inner()),
ResolveResult::Unbound => Ns::None,
ResolveResult::Unknown(_) => return Err(Reason::UnknownPrefix { offset }),
};
let tag: &str = start;
self.check_attributes(offset, tag, name.into_inner().len())?;
Ok(Element {
tag: tag.into(),
ns,
children: Vec::new(),
close: Close::Auto,
})
}
fn check_attributes(
&mut self,
offset: usize,
tag: &str,
name_len: usize,
) -> Result<(), Reason> {
self.names.clear();
let attrs = RawAttrs { tag, pos: name_len };
for attr in attrs {
let attr = attr.map_err(|()| Reason::Attribute { offset })?;
if attr.name.len() > MAX_NAME_LEN {
return Err(Reason::LongName { offset });
}
if !is_qname(attr.name) {
return Err(Reason::Name { offset });
}
check_attr_value(offset, attr.value)?;
let (resolved, _) = self.reader.resolver().resolve_attribute(QName(attr.name));
if matches!(resolved, ResolveResult::Unknown(_)) {
return Err(Reason::UnknownPrefix { offset });
}
self.names.push(attr.name_range);
}
if has_duplicates(tag, &mut self.names) {
return Err(Reason::Attribute { offset });
}
Ok(())
}
fn end(&mut self, offset: usize, end: &BytesEnd<'_>) -> Result<(), Reason> {
let raw: &str = end;
let name = raw.trim_end_matches([' ', '\t', '\r', '\n']);
let Some(mut element) = self.open.pop() else {
return Err(Reason::MismatchedEndTag { offset });
};
if element.name() != name {
return Err(Reason::MismatchedEndTag { offset });
}
element.close = Close::EndTag(raw.get(name.len()..).unwrap_or_default().into());
element.children.shrink_to_fit();
self.attach(element);
Ok(())
}
fn attach(&mut self, element: Element) {
match self.open.last_mut() {
Some(parent) => parent.children.push(Node::Element(element)),
None => self.root = Some(element),
}
}
fn inside(&mut self, offset: usize, node: Node) -> Result<(), Reason> {
if self.open.is_empty() {
return Err(Reason::OutsideRoot { offset });
}
self.count_node(offset)?;
if let Some(parent) = self.open.last_mut() {
parent.children.push(node);
}
Ok(())
}
fn misc(&mut self, offset: usize, markup: String) -> Result<(), Reason> {
if self.open.is_empty() && self.fragment {
return Err(Reason::OutsideRoot { offset });
}
self.count_node(offset)?;
match self.open.last_mut() {
Some(parent) => parent.children.push(Node::Other(markup)),
None => self.outside(Node::Other(markup)),
}
Ok(())
}
fn outside(&mut self, node: Node) {
if self.root.is_none() {
self.prolog.push(node);
} else {
self.epilog.push(node);
}
}
fn declaration(&mut self, offset: usize, decl: &BytesDecl<'_>) -> Result<(), Reason> {
if offset != self.base || self.fragment {
return Err(Reason::Declaration { offset });
}
let content: &str = decl;
let attrs = RawAttrs {
tag: content,
pos: "xml".len(),
};
for attr in attrs {
let attr = attr.map_err(|()| Reason::Declaration { offset })?;
if attr.name == "encoding" && !attr.value.eq_ignore_ascii_case("UTF-8") {
return Err(Reason::Encoding);
}
}
self.count_node(offset)?;
self.prolog
.push(Node::Other(["<?", content, "?>"].concat()));
Ok(())
}
fn finish(self) -> Result<Tree, Reason> {
if !self.open.is_empty() {
let offset = self.offset(self.reader.buffer_position());
return Err(Reason::Unclosed { offset });
}
let root = self.root.ok_or(Reason::NoRoot)?;
Ok(Tree {
prolog: self.prolog,
root,
epilog: self.epilog,
})
}
}
fn check_attr_value(offset: usize, value: &str) -> Result<(), Reason> {
if value.contains('<') {
return Err(Reason::Attribute { offset });
}
let mut rest = value;
while let Some((_, after)) = rest.split_once('&') {
let Some((name, tail)) = after.split_once(';') else {
return Err(Reason::Reference { offset });
};
if resolve_reference(name).is_none() {
return Err(Reason::Reference { offset });
}
rest = tail;
}
Ok(())
}
fn has_duplicates(tag: &str, names: &mut [Range<usize>]) -> bool {
let name = |range: &Range<usize>| tag.get(range.clone());
if names.len() <= 16 {
return names
.iter()
.enumerate()
.any(|(i, a)| names.iter().take(i).any(|b| name(a) == name(b)));
}
names.sort_unstable_by(|a, b| name(a).cmp(&name(b)));
names
.windows(2)
.any(|pair| matches!(pair, [a, b] if name(a) == name(b)))
}
fn is_qname(name: &str) -> bool {
let mut parts = name.split(':');
match (parts.next(), parts.next(), parts.next()) {
(Some(local), None, _) => is_ncname(local),
(Some(prefix), Some(local), None) => is_ncname(prefix) && is_ncname(local),
_ => false,
}
}
fn is_ncname(name: &str) -> bool {
let mut bytes = name.bytes();
bytes
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == b'_' || !first.is_ascii())
&& bytes
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.') || !b.is_ascii())
}
const fn is_whitespace(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\r' | b'\n')
}
struct RawAttr<'a> {
name: &'a str,
name_range: Range<usize>,
value: &'a str,
value_range: Range<usize>,
}
struct RawAttrs<'a> {
tag: &'a str,
pos: usize,
}
impl<'a> Iterator for RawAttrs<'a> {
type Item = Result<RawAttr<'a>, ()>;
fn next(&mut self) -> Option<Self::Item> {
let bytes = self.tag.as_bytes();
if self.pos >= bytes.len() {
return None;
}
let name_start = skip_whitespace(bytes, self.pos);
if name_start >= bytes.len() {
self.pos = usize::MAX;
return None;
}
let attr = if name_start > self.pos {
scan_attr(self.tag, name_start)
} else {
None
};
self.pos = attr
.as_ref()
.map_or(usize::MAX, |attr| attr.value_range.end + 1);
Some(attr.ok_or(()))
}
}
fn scan_attr(tag: &str, start: usize) -> Option<RawAttr<'_>> {
let bytes = tag.as_bytes();
let name_len = bytes
.get(start..)?
.iter()
.position(|&b| b == b'=' || is_whitespace(b))?;
let name_end = start + name_len;
let eq = skip_whitespace(bytes, name_end);
if name_len == 0 || bytes.get(eq) != Some(&b'=') {
return None;
}
let quote_pos = skip_whitespace(bytes, eq + 1);
let quote = *bytes.get(quote_pos).filter(|&&b| b == b'"' || b == b'\'')?;
let value_start = quote_pos + 1;
let value_len = bytes.get(value_start..)?.iter().position(|&b| b == quote)?;
let value_end = value_start + value_len;
Some(RawAttr {
name: tag.get(start..name_end)?,
name_range: start..name_end,
value: tag.get(value_start..value_end)?,
value_range: value_start..value_end,
})
}
fn skip_whitespace(bytes: &[u8], pos: usize) -> usize {
bytes.get(pos..).map_or(pos, |rest| {
pos + rest.iter().take_while(|&&b| is_whitespace(b)).count()
})
}
fn resolve_reference(name: &str) -> Option<char> {
match name {
"lt" => Some('<'),
"gt" => Some('>'),
"amp" => Some('&'),
"apos" => Some('\''),
"quot" => Some('"'),
_ => {
let number = name.strip_prefix('#')?;
let (digits, radix) = match number.strip_prefix('x') {
Some(hex) => (hex, 16),
None => (number, 10),
};
if digits.is_empty() {
return None;
}
let code = digits.chars().try_fold(0_u32, |code, c| {
code.checked_mul(radix)?.checked_add(c.to_digit(radix)?)
})?;
char::from_u32(code).filter(|&c| is_xml_char(c))
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Unescape {
Text,
CData,
Attr,
}
fn unescape(raw: &str, mode: Unescape) -> Cow<'_, str> {
let special = |b: u8| match mode {
Unescape::Text => b == b'&' || b == b'\r',
Unescape::CData => b == b'\r',
Unescape::Attr => matches!(b, b'&' | b'\r' | b'\n' | b'\t'),
};
if !raw.bytes().any(special) {
return Cow::Borrowed(raw);
}
let line_break = if mode == Unescape::Attr { ' ' } else { '\n' };
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(at) = rest.bytes().position(special) {
let (plain, tail) = rest.split_at_checked(at).unwrap_or((rest, ""));
out.push_str(plain);
rest = if let Some(after) = tail.strip_prefix('&') {
let resolved = after
.split_once(';')
.and_then(|(name, tail)| Some((resolve_reference(name)?, tail)));
if let Some((c, tail)) = resolved {
out.push(c);
tail
} else {
out.push('&');
after
}
} else if let Some(after) = tail
.strip_prefix("\r\n")
.or_else(|| tail.strip_prefix('\r'))
{
out.push(line_break);
after
} else if let Some(after) = tail.strip_prefix(['\n', '\t']) {
out.push(' ');
after
} else {
out.push_str(tail);
""
};
}
out.push_str(rest);
Cow::Owned(out)
}
fn write_node(out: &mut String, node: &Node) {
match node {
Node::Element(element) => write_element(out, element),
Node::Text(raw) | Node::Other(raw) => out.push_str(raw),
Node::CData(content) => {
out.push_str("<![CDATA[");
out.push_str(content);
out.push_str("]]>");
}
}
}
fn write_element(out: &mut String, element: &Element) {
let mut stack: Vec<(&Element, slice::Iter<'_, Node>)> = Vec::new();
if write_start_tag(out, element) {
stack.push((element, element.children.iter()));
}
while let Some((parent, children)) = stack.last_mut() {
let parent = *parent;
match children.next() {
Some(Node::Element(child)) => {
if write_start_tag(out, child) {
stack.push((child, child.children.iter()));
}
}
Some(node) => write_node(out, node),
None => {
write_end_tag(out, parent);
stack.pop();
}
}
}
}
fn write_start_tag(out: &mut String, element: &Element) -> bool {
out.push('<');
out.push_str(&element.tag);
if element.children.is_empty() && element.close == Close::Auto {
out.push_str("/>");
false
} else {
out.push('>');
true
}
}
fn write_end_tag(out: &mut String, element: &Element) {
out.push_str("</");
out.push_str(element.name());
if let Close::EndTag(space) = &element.close {
out.push_str(space);
}
out.push('>');
}
pub(crate) fn qualified_name(prefix: Option<&str>, local: &str) -> String {
match prefix {
Some(prefix) => format!("{prefix}:{local}"),
None => local.to_owned(),
}
}
pub(crate) fn escape_text(s: &str) -> Cow<'_, str> {
escape(s, false)
}
pub(crate) fn escape_attr(s: &str) -> Cow<'_, str> {
escape(s, true)
}
fn escape(s: &str, attr: bool) -> Cow<'_, str> {
let replacement = |c: char| -> Option<&'static str> {
match c {
'&' => Some("&"),
'<' => Some("<"),
'>' => Some(">"),
'\r' => Some(" "),
'"' if attr => Some("""),
'\'' if attr => Some("'"),
'\t' if attr => Some("	"),
'\n' if attr => Some(" "),
c if is_xml_char(c) => None,
_ => Some(""),
}
};
let Some(start) = s.find(|c| replacement(c).is_some()) else {
return Cow::Borrowed(s);
};
let (plain, rest) = s.split_at(start);
let mut out = String::with_capacity(s.len() + 16);
out.push_str(plain);
for c in rest.chars() {
match replacement(c) {
Some(escaped) => out.push_str(escaped),
None => out.push(c),
}
}
Cow::Owned(out)
}
fn is_xml_char(c: char) -> bool {
matches!(
c,
'\t' | '\n'
| '\r'
| '\u{20}'..='\u{D7FF}'
| '\u{E000}'..='\u{FFFD}'
| '\u{10000}'..='\u{10FFFF}'
)
}
#[derive(Debug, Error)]
#[error(transparent)]
#[non_exhaustive]
pub struct InvalidXml(Reason);
#[derive(Debug, Error)]
enum Reason {
#[error("invalid UTF-8 at byte {offset}")]
Utf8 {
offset: usize,
},
#[error("XML declaration names an encoding other than UTF-8")]
Encoding,
#[error("malformed XML at byte {offset}")]
Syntax {
offset: usize,
#[source]
source: quick_xml::Error,
},
#[error("invalid XML declaration at byte {offset}")]
Declaration {
offset: usize,
},
#[error("document type declaration at byte {offset}")]
Doctype {
offset: usize,
},
#[error("invalid name in the tag at byte {offset}")]
Name {
offset: usize,
},
#[error("invalid attribute in the tag at byte {offset}")]
Attribute {
offset: usize,
},
#[error("unknown entity or invalid character reference at byte {offset}")]
Reference {
offset: usize,
},
#[error("unknown namespace prefix in the tag at byte {offset}")]
UnknownPrefix {
offset: usize,
},
#[error("elements nested deeper than {max} levels at byte {offset}", max = MAX_DEPTH)]
TooDeep {
offset: usize,
},
#[error("name longer than {max} bytes in the tag at byte {offset}", max = MAX_NAME_LEN)]
LongName {
offset: usize,
},
#[error("too many XML nodes at byte {offset}")]
TooManyNodes {
offset: usize,
},
#[error("mismatched end tag at byte {offset}")]
MismatchedEndTag {
offset: usize,
},
#[error("unclosed element at the end of the input (byte {offset})")]
Unclosed {
offset: usize,
},
#[error("second root element at byte {offset}")]
MultipleRoots {
offset: usize,
},
#[error("no root element")]
NoRoot,
#[error("content outside the root element at byte {offset}")]
OutsideRoot {
offset: usize,
},
}
#[cfg(test)]
mod tests {
use super::*;
const WORD_DOCUMENT: &str = concat!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
"\r\n",
"<w:document",
r#" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#,
r#" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex""#,
r#" xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex""#,
r#" xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex""#,
r#" xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex""#,
r#" xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex""#,
r#" xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex""#,
r#" xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex""#,
r#" xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex""#,
r#" xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex""#,
r#" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006""#,
r#" xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink""#,
r#" xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d""#,
r#" xmlns:o="urn:schemas-microsoft-com:office:office""#,
r#" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#,
r#" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#,
r#" xmlns:v="urn:schemas-microsoft-com:vml""#,
r#" xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing""#,
r#" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing""#,
r#" xmlns:w10="urn:schemas-microsoft-com:office:word""#,
r#" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#,
r#" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml""#,
r#" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#,
r#" xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid""#,
r#" xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex""#,
r#" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup""#,
r#" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk""#,
r#" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml""#,
r#" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape""#,
r#" mc:Ignorable="w14 w15 w16se wp14">"#,
"<w:body><!-- Template for {{customer}} -->",
r#"<w:p w14:paraId="0A1B2C3D" w14:textId="77777777" w:rsidR="00B57C6E""#,
r#" w:rsidRDefault="00B57C6E" w:rsidP="00B57C6E">"#,
r#"<w:pPr><w:pStyle w:val="Title"/><w:jc w:val="center"/></w:pPr>"#,
r#"<w:r><w:rPr><w:b/><w:bCs/></w:rPr><w:t>Invoice {{</w:t></w:r>"#,
r#"<w:proofErr w:type="spellStart"/>"#,
r#"<w:r w:rsidRPr="00A13F2E"><w:rPr><w:b/></w:rPr><w:t>number</w:t></w:r>"#,
r#"<w:proofErr w:type="spellEnd"/>"#,
r#"<w:r><w:rPr><w:b/></w:rPr><w:t>}}</w:t></w:r></w:p>"#,
r#"<w:p w:rsidR="00B57C6E" w:rsidRDefault="00B57C6E">"#,
r#"<w:r><w:t xml:space="preserve">Fish & chips <3 </w:t></w:r>"#,
r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#,
r#"<w:hyperlink r:id="rId5" w:history="1"><w:r><w:rPr>"#,
r#"<w:rStyle w:val="Hyperlink"/></w:rPr><w:t>our site</w:t></w:r></w:hyperlink>"#,
r#"<w:r><w:t>two lines</w:t></w:r>"#,
"<w:r><w:t><![CDATA[a < b && c]]></w:t></w:r>",
r#"<w:r><w:tab/><w:t>tabbed</w:t></w:r></w:p>"#,
r#"<w:sdt><w:sdtPr><w:alias w:val="Say "hi" twice"/>"#,
r#"<w:tag w:val='a "quoted" tag'/><w:id w:val="-1234567"/></w:sdtPr>"#,
r#"<w:sdtContent><w:p><w:r><w:t>{{field}}</w:t></w:r></w:p></w:sdtContent></w:sdt>"#,
r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:t>new</w:t>"#,
r#"</mc:Choice><mc:Fallback><w:t>old</w:t></mc:Fallback></mc:AlternateContent>"#,
"</w:r></w:p>",
r#"<w:sectPr w:rsidR="00B57C6E"><w:pgSz w:w="11906" w:h="16838"/>"#,
r#"<w:pgMar w:top="1417" w:right="1134" w:bottom="1134" w:left="1134""#,
r#" w:header="708" w:footer="708" w:gutter="0"/><w:cols w:space="708"/>"#,
r#"<w:docGrid w:linePitch="360"/></w:sectPr></w:body></w:document>"#,
);
const PRETTY_DOCUMENT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!-- edited by hand -->
<w:document
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:body>
<w:p>
<w:pPr><w:pStyle w:val="Say "hi""/></w:pPr>
<w:r>
<w:t>Hello {{name}} & ☺<![CDATA[ <ok> ]]></w:t>
</w:r>
<w:r >
<w:br />
<w:t xml:space = 'preserve' > tail </w:t >
</w:r>
</w:p>
<?app-hint keep me?>
</w:body>
</w:document>
"#;
const LO_DOCUMENT: &[u8] = include_bytes!("testdata/libreoffice/document.xml");
const LO_DOCUMENT_IMAGE: &[u8] = include_bytes!("testdata/libreoffice/document_image.xml");
const LO_STYLES: &[u8] = include_bytes!("testdata/libreoffice/styles.xml");
const LO_CONTENT_TYPES: &[u8] = include_bytes!("testdata/libreoffice/content_types.xml");
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
fn round_trip(xml: &[u8]) -> XmlDocument {
let doc = XmlDocument::parse(xml).unwrap_or_else(|err| {
panic!("{err} ({:?}): {}", err.0, String::from_utf8_lossy(xml));
});
let out = doc.to_bytes();
assert!(
out == xml,
"round trip changed the document:\n{}\n---\n{}",
String::from_utf8_lossy(xml),
String::from_utf8_lossy(&out)
);
doc
}
fn reason(xml: impl AsRef<[u8]>) -> Reason {
let xml = xml.as_ref();
match XmlDocument::parse(xml) {
Ok(_) => panic!("accepted {:?}", String::from_utf8_lossy(xml)),
Err(InvalidXml(reason)) => reason,
}
}
fn fragment_reason(xml: &str) -> Reason {
match Element::parse_fragment(xml) {
Ok(_) => panic!("accepted {xml:?}"),
Err(InvalidXml(reason)) => reason,
}
}
fn fragment(xml: &str) -> Element {
Element::parse_fragment(xml).unwrap_or_else(|err| panic!("{err} ({:?}): {xml}", err.0))
}
fn descendant<'a>(root: &'a Element, ns: Ns, local: &str) -> &'a Element {
root.descendants()
.find(|e| e.is(ns, local))
.unwrap_or_else(|| panic!("no {local} in {root:?}"))
}
fn find_mut<'a>(
element: &'a mut Element,
pred: &impl Fn(&Element) -> bool,
) -> Option<&'a mut Element> {
if pred(element) {
return Some(element);
}
element
.child_elements_mut()
.find_map(|child| find_mut(child, pred))
}
fn nested(depth: usize) -> String {
"<a>".repeat(depth) + &"</a>".repeat(depth)
}
fn parse_without_panicking(input: &[u8]) {
if let Ok(doc) = XmlDocument::parse(input) {
assert!(
doc.to_bytes() == input,
"{:?}",
String::from_utf8_lossy(input)
);
}
}
#[test]
fn word_document_round_trips_byte_for_byte() {
let doc = round_trip(WORD_DOCUMENT.as_bytes());
assert!(!doc.bom);
assert!(doc.root.is(Ns::W, "document"));
assert_eq!(doc.prolog.len(), 2, "declaration and CRLF");
assert_eq!(doc.epilog, [] as [Node; 0]);
let body = doc.root.find_child(Ns::W, "body").unwrap();
assert!(matches!(body.children.first(), Some(Node::Other(c)) if c.starts_with("<!--")));
let texts: Vec<_> = doc
.root
.descendants()
.filter(|e| e.is(Ns::W, "t"))
.map(Element::text)
.collect();
assert_eq!(
texts,
[
"Invoice {{",
"number",
"}}",
"Fish & chips <3 ",
" ",
"our site",
"two\nlines",
"a < b && c",
"tabbed",
"{{field}}",
"new",
"old",
]
);
let alias = descendant(&doc.root, Ns::W, "alias");
assert_eq!(alias.attr("w:val").unwrap(), "Say \"hi\"\ntwice");
let tag = descendant(&doc.root, Ns::W, "tag");
assert_eq!(tag.attr("w:val").unwrap(), "a \"quoted\" tag");
}
#[test]
fn byte_order_mark_is_preserved() {
let mut xml = UTF8_BOM.to_vec();
xml.extend_from_slice(WORD_DOCUMENT.as_bytes());
let doc = round_trip(&xml);
assert!(doc.bom);
assert!(doc.root.is(Ns::W, "document"));
}
#[test]
fn pretty_printed_document_round_trips() {
let doc = round_trip(PRETTY_DOCUMENT.as_bytes());
assert_eq!(doc.epilog, [Node::Text("\n".to_owned())]);
let style = descendant(&doc.root, Ns::W, "pStyle");
assert_eq!(style.attr("w:val").unwrap(), "Say \"hi\"");
let texts: Vec<_> = doc
.root
.descendants()
.filter(|e| e.is(Ns::W, "t"))
.collect();
assert_eq!(texts[0].text(), "Hello {{name}} & \u{263A} <ok> ");
assert_eq!(texts[1].text(), " tail ");
assert_eq!(texts[1].attr("xml:space").unwrap(), "preserve");
assert_eq!(texts[1].name(), "w:t");
round_trip(PRETTY_DOCUMENT.replace('\n', "\r\n").as_bytes());
}
#[test]
fn libreoffice_parts_round_trip() {
let doc = round_trip(LO_DOCUMENT);
assert!(doc.root.is(Ns::W, "document"));
let table = descendant(&doc.root, Ns::W, "tbl");
assert_eq!(table.descendants().filter(|e| e.is(Ns::W, "tc")).count(), 4);
let link = descendant(&doc.root, Ns::W, "hyperlink");
assert_eq!(link.attr("r:id").unwrap(), "rId2");
let doc = round_trip(LO_DOCUMENT_IMAGE);
let graphic = descendant(&doc.root, Ns::Other, "graphic");
assert!(graphic.find_child(Ns::Other, "graphicData").is_some());
let pic = descendant(&doc.root, Ns::Other, "pic");
assert_eq!(pic.prefix(), Some("pic"));
let doc_pr = descendant(&doc.root, Ns::Wp, "docPr");
assert_eq!(doc_pr.attr("descr").unwrap(), "Red \"logo\"");
let doc = round_trip(LO_STYLES);
assert!(doc.root.is(Ns::W, "styles"));
let doc = round_trip(LO_CONTENT_TYPES);
assert!(doc.root.is(Ns::Other, "Types"));
assert!(doc.root.child_elements().all(|e| e.ns() == Ns::Other));
let png = doc
.root
.child_elements()
.find(|e| e.attr("Extension").as_deref() == Some("png"))
.unwrap();
assert_eq!(png.attr("ContentType").unwrap(), "image/png");
}
#[test]
fn unusual_but_well_formed_markup_round_trips() {
for xml in [
"<a/>",
"<a />",
"<a></a>",
"<a>\n</a>",
"<a\n\tb = '1'\r\n c=\"2\" ></a >",
"<a b=''\"' c=\"'"\"/>",
"<a>]]> 😀 \u{1F600} \u{FEFF}</a>",
"<a><![CDATA[]]><![CDATA[<&>]]></a>",
"<?xml version='1.0'?><a/>",
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- c -->\n<?pi data?>\n\
<a/>\n<!-- x -->\n",
"<a><!----><?pi?><b/>text<c></c></a>",
"<a xmlns=\"urn:x\" xmlns:b=\"urn:y\" b:c=\"1\"><b:d/></a>",
"<a xml:lang=\"en\" xml:space=\"default\"/>",
"<a\u{e9}\u{4e2d}:b xmlns:a\u{e9}\u{4e2d}=\"urn:x\"/>",
"\r\n\t <a/> \r\n",
] {
round_trip(xml.as_bytes());
}
}
#[test]
fn edits_change_only_the_edited_bytes() {
let mut doc = XmlDocument::parse(LO_DOCUMENT).unwrap();
find_mut(&mut doc.root, &|e| {
e.is(Ns::W, "t") && e.text() == "{{customer}}"
})
.unwrap()
.children = vec![Node::Text(escape_text("ACME & Co").into_owned())];
let expected = String::from_utf8(LO_DOCUMENT.to_vec())
.unwrap()
.replace("{{customer}}", "ACME & Co");
assert_eq!(String::from_utf8(doc.to_bytes()).unwrap(), expected);
}
#[test]
fn namespaces_resolve_through_scopes() {
let xml = r#"<Relationships
xmlns="http://schemas.openxmlformats.org/package/2006/relationships"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:R="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<Relationship Id="rId1" R:id="x"/>
<w:p><child/></w:p>
<inner xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default/></inner>
<w:p xmlns:w="urn:not-word"><w:r/></w:p>
<reset xmlns=""><plain/></reset>
<after/><w:p/>
</Relationships>"#;
let doc = round_trip(xml.as_bytes());
let ns: Vec<_> = std::iter::once(&doc.root)
.chain(doc.root.descendants())
.map(|e| (e.name(), e.ns()))
.collect();
assert_eq!(
ns,
[
("Relationships", Ns::Rels),
("Relationship", Ns::Rels),
("w:p", Ns::W),
("child", Ns::Rels),
("inner", Ns::Other),
("Default", Ns::Other),
("w:p", Ns::Other),
("w:r", Ns::Other),
("reset", Ns::None),
("plain", Ns::None),
("after", Ns::Rels),
("w:p", Ns::W),
]
);
}
#[test]
fn wordprocessingml_under_another_prefix_resolves_to_w() {
let xml = format!(
"<ns0:document xmlns:ns0=\"{W}\"><ns0:body><ns0:p ns0:rsidR=\"1\"/>\
</ns0:body></ns0:document>"
);
let doc = round_trip(xml.as_bytes());
let p = doc
.root
.find_child(Ns::W, "body")
.unwrap()
.find_child(Ns::W, "p")
.unwrap();
assert!(p.is(Ns::W, "p"));
assert_eq!((p.prefix(), p.local_name()), (Some("ns0"), "p"));
assert_eq!(p.attr("ns0:rsidR").unwrap(), "1");
let p = fragment(&format!("<p xmlns=\"{W}\"/>"));
assert!(p.is(Ns::W, "p"));
assert_eq!((p.prefix(), p.local_name()), (None, "p"));
let escaped = W.replace("main", "main");
assert!(fragment(&format!("<w:p xmlns:w=\"{escaped}\"/>")).is(Ns::Other, "p"));
}
#[test]
fn unqualified_names_have_no_namespace() {
let doc = round_trip(b"<root><child a=\"1\"/></root>");
assert_eq!(doc.root.ns(), Ns::None);
assert!(doc.root.child_elements().all(|e| e.is(Ns::None, "child")));
}
#[test]
fn known_namespaces_map_to_their_uri() {
for ns in Ns::KNOWN {
let uri = ns.uri().unwrap();
assert_eq!(Ns::from_uri(uri), ns);
assert_eq!(fragment(&format!("<x:a xmlns:x=\"{uri}\"/>")).ns(), ns);
}
assert_eq!(Ns::from_uri("urn:other"), Ns::Other);
assert_eq!(Ns::None.uri(), None);
assert_eq!(Ns::Other.uri(), None);
}
#[test]
fn unknown_prefixes_are_rejected() {
for xml in [
"<w:p/>",
"<a w:b=\"1\"/>",
"<a><b xmlns:x=\"urn:x\"/><x:c/></a>",
"<a xmlns:x=\"\"><x:b/></a>",
] {
assert!(matches!(reason(xml), Reason::UnknownPrefix { .. }), "{xml}");
}
assert!(matches!(
fragment_reason("<w:drawing><wp:inline/></w:drawing>"),
Reason::UnknownPrefix { offset: 0 }
));
}
#[test]
fn attr_returns_the_normalized_value() {
let el = fragment(concat!(
r#"<a b="x & <y> "z" 'AB" c='say "hi"'"#,
" d=\"tab	lf cr \" e=\"raw\ttab\nlf\r\ncrlf\"",
" f=\"\" xml:space=\"preserve\"/>"
));
assert_eq!(el.attr("b").unwrap(), "x & <y> \"z\" 'AB");
assert_eq!(el.attr("c").unwrap(), "say \"hi\"");
assert_eq!(el.attr("d").unwrap(), "tab\tlf\ncr\r");
assert_eq!(el.attr("e").unwrap(), "raw tab lf crlf");
assert_eq!(el.attr("f").unwrap(), "");
assert!(matches!(
el.attr("xml:space"),
Some(Cow::Borrowed("preserve"))
));
assert_eq!(el.attr("space"), None);
assert_eq!(el.attr("missing"), None);
}
#[test]
fn set_attr_edits_the_tag_in_place() {
let mut el = fragment(&format!("<w:t xmlns:w=\"{W}\" w:a='1' w:b=\"2\" />"));
el.set_attr("w:a", "x\"y'z<&\n");
el.set_attr("xml:space", "preserve");
el.set_attr("w:b", "3");
let xml = el.to_xml();
assert_eq!(
xml,
format!(
"<w:t xmlns:w=\"{W}\" w:a='x"y'z<& ' \
w:b=\"3\" xml:space=\"preserve\" />"
)
);
let el = fragment(&xml);
assert_eq!(el.attr("w:a").unwrap(), "x\"y'z<&\n");
assert_eq!(el.attr("xml:space").unwrap(), "preserve");
let mut el = Element::new("Override", Ns::Other);
el.set_attr("PartName", "/word/document.xml");
el.set_attr("ContentType", "application/xml");
assert_eq!(
el.to_xml(),
r#"<Override PartName="/word/document.xml" ContentType="application/xml"/>"#
);
}
#[test]
fn text_concatenates_unescaped_character_data() {
let el = fragment(
"<w:t xmlns:w=\"urn:w\">a & b<![CDATA[ <c>\r\n ]]>d<!-- x -->e<x/>\
f \r\ng\rh</w:t>",
);
assert_eq!(el.text(), "a & b <c>\n def\r\ng\nh");
assert!(matches!(
fragment("<t>plain</t>").text(),
Cow::Borrowed("plain")
));
assert!(matches!(fragment("<t/>").text(), Cow::Borrowed("")));
assert!(matches!(
fragment("<t><x>nested</x></t>").text(),
Cow::Borrowed("")
));
}
#[test]
fn emptied_elements_keep_how_they_were_closed() {
let mut el = fragment("<w:t xmlns:w=\"urn:w\">old<w:x/><!-- c --></w:t>");
el.children.clear();
assert_eq!(el.to_xml(), "<w:t xmlns:w=\"urn:w\"></w:t>");
let mut el = fragment("<t>x</t >");
el.children.clear();
assert_eq!(el.to_xml(), "<t></t >");
let mut el = fragment("<t/>");
el.children
.push(Node::Text(escape_text("a < b").into_owned()));
assert_eq!(el.to_xml(), "<t>a < b</t>");
el.children.clear();
assert_eq!(el.to_xml(), "<t/>");
}
#[test]
fn new_elements_build_valid_markup() {
let mut run = fragment(&format!("<x:r xmlns:x=\"{W}\"><x:rPr><x:b/></x:rPr></x:r>"));
let prefix = run.prefix().unwrap().to_owned();
let mut t = Element::new(format!("{prefix}:t"), Ns::W);
t.set_attr("xml:space", "preserve");
t.children
.push(Node::Text(escape_text(" {{name}} & co ").into_owned()));
run.children.push(Node::Element(t));
run.children
.push(Node::Element(Element::new(format!("{prefix}:br"), Ns::W)));
let xml = run.to_xml();
assert_eq!(
xml,
format!(
"<x:r xmlns:x=\"{W}\"><x:rPr><x:b/></x:rPr>\
<x:t xml:space=\"preserve\"> {{{{name}}}} & co </x:t><x:br/></x:r>"
)
);
let parsed = fragment(&xml);
let t = parsed.find_child(Ns::W, "t").unwrap();
assert_eq!(t.text(), " {{name}} & co ");
assert_eq!(parsed.to_xml(), xml);
}
#[test]
fn child_iterators_skip_other_nodes() {
let mut p = fragment(&format!(
"<w:p xmlns:w=\"{W}\"> <w:pPr/><!-- c --><w:r><w:t>a</w:t></w:r>text<w:r/></w:p>"
));
let names: Vec<_> = p.child_elements().map(Element::local_name).collect();
assert_eq!(names, ["pPr", "r", "r"]);
assert_eq!(p.find_child(Ns::W, "r").unwrap().children.len(), 1);
assert!(p.find_child(Ns::W, "t").is_none(), "not a direct child");
assert!(p.find_child(Ns::Wp, "r").is_none(), "wrong namespace");
for run in p.child_elements_mut().filter(|e| e.is(Ns::W, "r")) {
run.set_attr("w:rsidR", "00AB");
}
let properties = p.child_elements_mut().find(|e| e.is(Ns::W, "pPr"));
properties
.unwrap()
.children
.push(Node::Text("x".to_owned()));
assert_eq!(
p.to_xml(),
format!(
"<w:p xmlns:w=\"{W}\"> <w:pPr>x</w:pPr><!-- c -->\
<w:r w:rsidR=\"00AB\"><w:t>a</w:t></w:r>text<w:r w:rsidR=\"00AB\"/></w:p>"
)
);
let all: Vec<_> = p.descendants().map(Element::local_name).collect();
assert_eq!(all, ["pPr", "r", "t", "r"]);
}
#[test]
fn new_document_has_the_word_declaration() {
let root = fragment(
"<Relationships \
xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>",
);
assert!(root.is(Ns::Rels, "Relationships"));
let bytes = XmlDocument::new(root).to_bytes();
assert_eq!(
String::from_utf8(bytes.clone()).unwrap(),
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n\
<Relationships \
xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>"
);
round_trip(&bytes);
}
#[test]
fn fragments_hold_exactly_one_element() {
let el = fragment("\n <a><b/></a>\n");
assert_eq!(el.to_xml(), "<a><b/></a>");
assert!(matches!(fragment_reason(""), Reason::NoRoot));
assert!(matches!(
fragment_reason("<a/><b/>"),
Reason::MultipleRoots { offset: 4 }
));
assert!(matches!(
fragment_reason("<!-- c --><a/>"),
Reason::OutsideRoot { offset: 0 }
));
assert!(matches!(
fragment_reason("<a/><?pi?>"),
Reason::OutsideRoot { offset: 4 }
));
assert!(matches!(
fragment_reason("<a/>x"),
Reason::OutsideRoot { offset: 4 }
));
assert!(matches!(
fragment_reason("\u{FEFF}<a/>"),
Reason::OutsideRoot { offset: 0 }
));
assert!(matches!(
fragment_reason("<?xml version=\"1.0\"?><a/>"),
Reason::Declaration { offset: 0 }
));
}
#[test]
fn misplaced_content_is_rejected() {
assert!(matches!(
reason("<a/><b/>"),
Reason::MultipleRoots { offset: 4 }
));
assert!(matches!(
reason("<a></a>\n<b>"),
Reason::MultipleRoots { offset: 8 }
));
assert!(matches!(reason("x<a/>"), Reason::OutsideRoot { offset: 0 }));
assert!(matches!(
reason("<a/> x"),
Reason::OutsideRoot { offset: 4 }
));
assert!(matches!(
reason("<a/>&"),
Reason::OutsideRoot { offset: 4 }
));
assert!(matches!(
reason("<![CDATA[x]]><a/>"),
Reason::OutsideRoot { offset: 0 }
));
assert!(matches!(
reason("\u{FEFF}\u{FEFF}<a/>"),
Reason::OutsideRoot { offset: 3 }
));
for xml in ["", " \n", "<!-- only -->", "<?xml version=\"1.0\"?>"] {
assert!(matches!(reason(xml), Reason::NoRoot), "{xml:?}");
}
}
#[test]
fn mismatched_and_unclosed_tags_are_rejected() {
assert!(matches!(
reason("<a></b>"),
Reason::MismatchedEndTag { offset: 3 }
));
assert!(matches!(
reason("<a><b></a></b>"),
Reason::MismatchedEndTag { offset: 6 }
));
assert!(matches!(
reason("<a></ a>"),
Reason::MismatchedEndTag { .. }
));
assert!(matches!(
reason("<a></a b=\"1\">"),
Reason::MismatchedEndTag { .. }
));
assert!(matches!(reason("<a>"), Reason::Unclosed { offset: 3 }));
assert!(matches!(
reason("<a><b></b>text"),
Reason::Unclosed { offset: 14 }
));
for xml in [
"</a>",
"<a",
"<a b=\"1>",
"<a></a",
"<a><!-- x</a>",
"<a><![CDATA[x</a>",
"<a>&</a>",
"<a>&</a>",
"<a><?pi</a>",
"<a><!x></a>",
] {
assert!(matches!(reason(xml), Reason::Syntax { .. }), "{xml}");
}
}
#[test]
fn unknown_entities_and_invalid_character_references_are_rejected() {
for reference in [
"foo",
"nbsp",
"AMP",
"#0",
"#1",
"#x1F",
"#xD800",
"#xDFFF",
"#xFFFE",
"#xFFFF",
"#x110000",
"#99999999999999999999",
"#X41",
"#",
"#x",
"#-65",
"#+65",
"#6 5",
"#x4G",
"#١٢",
] {
let text = format!("<a>x&{reference};</a>");
assert!(
matches!(reason(&text), Reason::Reference { offset: 4 }),
"{text}"
);
let attr = format!("<a b=\"&{reference};\"/>");
assert!(
matches!(reason(&attr), Reason::Reference { offset: 0 }),
"{attr}"
);
}
assert!(matches!(
reason("<a b=\"&\"/>"),
Reason::Reference { .. }
));
assert!(matches!(
reason("<a b=\"x & y\"/>"),
Reason::Reference { .. }
));
let ok = "<a b=\"AAA\">	  ퟿\
�𐀀<>&'"</a>";
let doc = round_trip(ok.as_bytes());
assert_eq!(doc.root.attr("b").unwrap(), "AA\u{10FFFF}A");
assert_eq!(
doc.root.text(),
"\t\n\r \u{D7FF}\u{E000}\u{FFFD}\u{10000}<>&'\""
);
}
#[test]
fn unsupported_encodings_are_rejected() {
assert!(matches!(
reason(b"\xFF\xFE<\x00a\x00/\x00>\x00"),
Reason::Utf8 { offset: 0 }
));
assert!(matches!(
reason(b"\xFE\xFF\x00<\x00a\x00/\x00>"),
Reason::Utf8 { offset: 0 }
));
assert!(matches!(reason(b"<a>\xFF</a>"), Reason::Utf8 { offset: 3 }));
assert!(matches!(
reason(b"\xEF\xBB\xBF<a>\xC3</a>"),
Reason::Utf8 { offset: 6 }
));
for encoding in ["ISO-8859-1", "UTF-16", "US-ASCII", "utf8"] {
let xml = format!("<?xml version=\"1.0\" encoding=\"{encoding}\"?><a/>");
assert!(matches!(reason(&xml), Reason::Encoding), "{xml}");
}
for encoding in ["UTF-8", "utf-8", "Utf-8"] {
round_trip(format!("<?xml version='1.0' encoding='{encoding}'?><a/>").as_bytes());
}
}
#[test]
fn misplaced_or_malformed_declarations_are_rejected() {
for xml in [
" <?xml version=\"1.0\"?><a/>",
"<!-- c --><?xml version=\"1.0\"?><a/>",
"<a/><?xml version=\"1.0\"?>",
"<a><?xml version=\"1.0\"?></a>",
"<?xml version=1.0?><a/>",
"<?xml version=\"1.0\"encoding=\"UTF-8\"?><a/>",
] {
assert!(matches!(reason(xml), Reason::Declaration { .. }), "{xml}");
}
let mut xml = UTF8_BOM.to_vec();
xml.extend_from_slice(b"<?xml version=\"1.0\"?><a/>");
round_trip(&xml);
}
#[test]
fn document_type_declarations_are_rejected() {
assert!(matches!(
reason("<!DOCTYPE a><a/>"),
Reason::Doctype { offset: 0 }
));
assert!(matches!(
reason("<?xml version=\"1.0\"?>\n<!DOCTYPE a [<!ENTITY x \"y\">]><a>&x;</a>"),
Reason::Doctype { offset: 22 }
));
}
#[test]
fn invalid_names_and_attributes_are_rejected() {
for xml in [
"<1a/>",
"<-a/>",
"<a:b:c xmlns:a=\"urn:x\"/>",
"<:a/>",
"<a:/>",
"<xmlns:a/>",
"<a\u{0}/>",
"<a/ >",
"< a/>",
"<a 1b=\"x\"/>",
"<a b:c:d=\"x\"/>",
"<a =\"x\"/>",
] {
assert!(
matches!(reason(xml), Reason::Name { .. } | Reason::Attribute { .. }),
"{xml:?}"
);
}
for xml in [
"<a b/>",
"<a b=c/>",
"<a b=\"1\"c=\"2\"/>",
"<a b=\"<\"/>",
"<a b=\"1\" =\"2\"/>",
] {
assert!(
matches!(reason(xml), Reason::Attribute { offset: 0 }),
"{xml:?}"
);
}
}
#[test]
fn duplicate_attributes_are_rejected() {
let rels = "http://schemas.openxmlformats.org/package/2006/relationships";
for xml in [
"<a b=\"1\" b=\"2\"/>".to_owned(),
"<a b=\"1\" c=\"\" b=\"1\"></a>".to_owned(),
"<a xmlns:x=\"urn:x\" xmlns:x=\"urn:y\"/>".to_owned(),
format!("<w:t xmlns:w=\"{W}\" xml:space=\"default\" xml:space=\"preserve\">x</w:t>"),
format!(
"<Relationships xmlns=\"{rels}\"><Relationship Id=\"rId1\" Type=\"t\" Target=\"a.xml\" Target=\"b.xml\"/></Relationships>"
),
format!(
"<Relationships xmlns=\"{rels}\"><Relationship Id=\"rId1\" Type=\"t\" Type=\"u\" Target=\"a.xml\"/></Relationships>"
),
] {
assert!(matches!(reason(&xml), Reason::Attribute { .. }), "{xml:?}");
assert!(Element::parse_fragment(&xml).is_err(), "{xml:?}");
}
round_trip(format!("<a xmlns:w=\"{W}\" xmlns:x=\"{W}\" w:b=\"1\" x:b=\"2\"/>").as_bytes());
let many = (0..40)
.map(|i| format!(" a{i}=\"{i}\""))
.collect::<Vec<_>>()
.concat();
round_trip(format!("<a{many}/>").as_bytes());
assert!(matches!(
reason(format!("<a{many} a17=\"x\"/>")),
Reason::Attribute { offset: 0 }
));
}
#[test]
fn node_budget_counts_every_node() {
let xml = b"<?xml version=\"1.0\"?>\n<a><b/>x<!--c--><![CDATA[d]]><?pi?></a>\n";
let mut budget = 9;
XmlDocument::parse_with_budget(xml, &mut budget).unwrap();
assert_eq!(budget, 0);
let mut budget = 8;
let Err(InvalidXml(reason)) = XmlDocument::parse_with_budget(xml, &mut budget) else {
panic!("over budget");
};
assert!(
matches!(reason, Reason::TooManyNodes { offset } if offset == xml.len() - 1),
"{reason:?}"
);
assert_eq!(budget, 8, "unchanged on error");
let mut budget = 5;
XmlDocument::parse_with_budget(b"<a><b/></a>", &mut budget).unwrap();
assert_eq!(budget, 3);
assert!(XmlDocument::parse_with_budget(b"<a>x<b/>y</a>", &mut budget).is_err());
fragment("<a><b/>x</a>");
}
#[test]
fn long_namespace_uris_take_linear_time() {
let uri = "u".repeat(1 << 20);
let xml = format!("<x:a xmlns:x=\"{uri}\">{}</x:a>", "<x:b/>".repeat(50_000));
let start = std::time::Instant::now();
let doc = round_trip(xml.as_bytes());
let elapsed = start.elapsed();
assert!(doc.root.descendants().all(|e| e.is(Ns::Other, "b")));
assert!(elapsed < std::time::Duration::from_secs(10), "{elapsed:?}");
}
#[test]
fn nesting_is_limited_to_512_levels() {
let doc = round_trip(nested(MAX_DEPTH).as_bytes());
assert_eq!(doc.root.descendants().count(), MAX_DEPTH - 1);
let innermost_empty = "<a>".repeat(MAX_DEPTH - 1) + "<a/>" + &"</a>".repeat(MAX_DEPTH - 1);
round_trip(innermost_empty.as_bytes());
let offset = 3 * MAX_DEPTH;
assert!(
matches!(reason(nested(MAX_DEPTH + 1)), Reason::TooDeep { offset: o } if o == offset)
);
let too_deep_empty = "<a>".repeat(MAX_DEPTH) + "<a/>" + &"</a>".repeat(MAX_DEPTH);
assert!(matches!(reason(too_deep_empty), Reason::TooDeep { offset: o } if o == offset));
assert!(matches!(
fragment_reason(&nested(MAX_DEPTH + 1)),
Reason::TooDeep { .. }
));
}
#[test]
fn errors_describe_the_problem() {
let err = XmlDocument::parse(b"<a></b>").unwrap_err();
assert_eq!(err.to_string(), "mismatched end tag at byte 3");
let err = XmlDocument::parse(b"<a").unwrap_err();
assert_eq!(err.to_string(), "malformed XML at byte 0");
assert!(std::error::Error::source(&err).is_some());
let err = XmlDocument::parse(nested(600).as_bytes()).unwrap_err();
assert_eq!(
err.to_string(),
"elements nested deeper than 512 levels at byte 1536"
);
let err =
XmlDocument::parse(format!("<r><{}/></r>", "a".repeat(300)).as_bytes()).unwrap_err();
assert_eq!(
err.to_string(),
"name longer than 256 bytes in the tag at byte 3"
);
}
#[test]
fn names_are_limited_to_256_bytes() {
let name = "a".repeat(MAX_NAME_LEN);
round_trip(format!("<{name} {name}=\"1\"/>").as_bytes());
let long = "a".repeat(MAX_NAME_LEN + 1);
assert!(matches!(
reason(format!("<r><{long}/></r>")),
Reason::LongName { offset: 3 }
));
assert!(matches!(
reason(format!("<r><a {long}=\"1\"/></r>")),
Reason::LongName { offset: 3 }
));
let prefix = "p".repeat(MAX_NAME_LEN - "xmlns:".len());
round_trip(format!("<{prefix}:a xmlns:{prefix}=\"urn:p\"/>").as_bytes());
let prefix = "p".repeat(MAX_NAME_LEN - "xmlns:".len() + 1);
assert!(matches!(
reason(format!("<{prefix}:a xmlns:{prefix}=\"urn:p\"/>")),
Reason::LongName { offset: 0 }
));
}
#[test]
fn namespace_declarations_in_scope_are_limited() {
use std::fmt::Write as _;
let declarations = |count: usize| -> String {
let mut declarations = String::new();
for index in 0..count {
write!(declarations, " xmlns:n{index}=\"urn:n\"").unwrap();
}
declarations
};
round_trip(format!("<r{}><a/></r>", declarations(MAX_NAMESPACE_BINDINGS)).as_bytes());
round_trip(
format!(
"<r{}><a xmlns:x=\"urn:x\"/><a xmlns:x=\"urn:x\"/></r>",
declarations(MAX_NAMESPACE_BINDINGS - 1)
)
.as_bytes(),
);
for more in ["xmlns:x=\"urn:x\"", "xmlns:n0=\"urn:n\"", "xmlns=\"urn:d\""] {
let xml = format!(
"<r{}><a/><a {more}/></r>",
declarations(MAX_NAMESPACE_BINDINGS)
);
let Reason::Syntax { offset, source } = reason(&xml) else {
panic!("{more}");
};
assert_eq!(offset, xml.find("<a ").unwrap(), "{more}");
assert!(matches!(source, quick_xml::Error::Namespace(_)), "{more}");
}
}
#[test]
fn namespace_declarations_are_counted() {
let element = fragment(
"<a xmlns=\"urn:a\" xmlns:b=\"urn:b\" b:c=\"1\" xmlnsx=\"2\" xml:space=\"preserve\"/>",
);
assert_eq!(element.namespace_declarations(), 2);
assert_eq!(fragment("<a x=\"xmlns\"/>").namespace_declarations(), 0);
}
#[test]
fn every_truncation_parses_without_panicking() {
let mut with_bom = UTF8_BOM.to_vec();
with_bom.extend_from_slice(WORD_DOCUMENT.as_bytes());
for input in [
WORD_DOCUMENT.as_bytes(),
&with_bom,
PRETTY_DOCUMENT.as_bytes(),
] {
for len in 0..=input.len() {
parse_without_panicking(&input[..len]);
}
}
let snippet = fragment(&format!(
"<w:p xmlns:w=\"{W}\"><w:r><w:t a='&'>x</w:t></w:r></w:p>"
))
.to_xml();
for (end, _) in snippet.char_indices() {
let _ = Element::parse_fragment(&snippet[..end]);
}
}
#[test]
fn byte_flips_parse_without_panicking() {
let replacements = [
0x00, b'\t', b'\n', b' ', b'!', b'"', b'#', b'&', b'\'', b'-', b'/', b':', b';', b'<',
b'=', b'>', b'?', b'[', b']', b'x', 0x80, 0xBF, 0xC3, 0xEF, 0xFF,
];
let mut input = UTF8_BOM.to_vec();
input.extend_from_slice(PRETTY_DOCUMENT.as_bytes());
let mut mutated = input.clone();
for i in 0..input.len() {
for &b in &replacements {
mutated[i] = b;
parse_without_panicking(&mutated);
}
mutated[i] = input[i];
}
}
#[test]
fn deepest_tree_is_handled_on_a_small_stack() {
let xml = nested(MAX_DEPTH);
std::thread::Builder::new()
.stack_size(512 * 1024)
.spawn(move || {
let doc = XmlDocument::parse(xml.as_bytes()).unwrap();
let copy = doc.clone();
assert_eq!(copy, doc);
assert_eq!(copy.to_bytes(), xml.as_bytes());
assert!(format!("{copy:?}").contains(&xml));
drop(copy);
drop(doc);
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn text_passthrough_borrows() {
assert!(matches!(escape_text("plain text"), Cow::Borrowed(_)));
assert!(matches!(escape_attr("plain value"), Cow::Borrowed(_)));
}
#[test]
fn text_escapes_markup() {
assert_eq!(escape_text("a < b & c > d"), "a < b & c > d");
assert_eq!(escape_text("quote \" ' stays"), "quote \" ' stays");
assert_eq!(escape_text("cr\r"), "cr ");
}
#[test]
fn attr_escapes_quotes_and_whitespace() {
assert_eq!(
escape_attr("\"x\"\t'y'\n\r"),
""x"	'y' "
);
}
#[test]
fn illegal_characters_are_dropped() {
assert_eq!(escape_text("a\u{0}b\u{b}c\u{FFFE}d"), "abcd");
assert_eq!(escape_attr("\u{1F}x"), "x");
assert_eq!(escape_text("tab\tand\nnewline"), "tab\tand\nnewline");
}
#[test]
fn unescaping_inverts_escaping() {
let s = "a < b & c > d \"e\" 'f'\tg\nh\r\ni\rj \u{1F600}";
assert_eq!(unescape(&escape_text(s), Unescape::Text), s);
assert_eq!(unescape(&escape_attr(s), Unescape::Attr), s);
assert_eq!(
unescape("a b\r\nc\rd", Unescape::Text),
"a\r\nb\nc\nd"
);
assert_eq!(
unescape("a b\r\nc\rd\te", Unescape::Attr),
"a\r\nb c d e"
);
assert_eq!(unescape("a&\r\n", Unescape::CData), "a&\n");
assert_eq!(unescape("&foo; & &", Unescape::Text), "&foo; & &");
}
}