Files
craft-libs/crates/xml/src/lib.rs
T
8f1f35aa19 craft-xml: whole-tree attribute limit, node_count, parse_streaming_partial, strict pull reader (from open app PRs) (#850)
* craft-xml: a limit on the attributes a tree holds over all its elements

max_attrs bounds one start tag and max_nodes counts elements and text, so
attributes were the one thing nothing counted across the tree: 1,025
elements of 1,024 attributes each are 1,025 nodes and over a million
(String, String) pairs, ten times the input in headers alone.

Limits::max_attrs_total (default 4,000,000) bounds the attributes kept at
once; elements handed to a streaming callback take theirs out of the
count. Error::TooManyAttrsTotal.

Ported from storytold/wordcraft#4320, which added the same budget to
WordCraft's DOCX reader.

* craft-xml tests: an internal DTD subset is refused wherever it hides, in linear time

The cases of storytold/filmcraft#4139 (a marker inside a comment or a
processing instruction, a '>' inside the system literal, 100,000 markers
that never close) against Doctype::RejectInternalSubset. The reader already
handles them; the tests keep it so.

* craft-xml: El::node_count, for one node budget over the parts of a package

max_nodes bounds one parse. A package of many parts, each inside the limit,
still costs a tree per part the reader keeps. node_count is what max_nodes
counted for an element, so a reader charges each tree against one budget
and reads the next part with what is left as its max_nodes: a part over
the budget stops before its tree is built.

Ported from storytold/wordcraft#5665, which adds a package-wide element
budget to WordCraft's DOCX reader (parse_counted with a max).

* craft-xml: parse_streaming_partial returns what was read when a streaming read fails

A worksheet whose rows cannot all be read (one row over the node limit, a
callback error, markup the tokenizer cannot read) lost everything else in
the part with it. parse_streaming_partial returns the error together with
the root as far as it was read: the children complete before the failure
and the elements open at that point. parse_streaming is unchanged.

Ported from storytold/gridcraft#3317 (xml::parse_streaming_partial).

* craft-xml: a strict pull reader refuses a second root and text outside the root

Options::events builds no tree, so the checks the tree reader makes for a
strict read (a second root element, character data outside the root) were
not made at all: a strict pull over '<a/><b/>' or '<a/>text' ended without
an error. A reader that walks events would take a damaged file's first
element for the whole document. The reader now makes both checks itself,
so the pull reader and the tree agree.

Ported from storytold/wordcraft#1862, which made WordCraft's ODT event
reader report malformed XML instead of stopping silently.

---------

Co-authored-by: Oliwer Urbaniak <oliwerx12@gmail.com>
Co-authored-by: Bra1nZ <95569966+Bra1nZ@users.noreply.github.com>
Co-authored-by: Neil Cawse <neilcawse@geotab.com>
Co-authored-by: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com>
2026-11-11 14:58:15 -05:00

82 lines
3.7 KiB
Rust

#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Bounded XML for hostile input.
//!
//! - [`parse`] and [`Options::parse`] read bytes into an owned tree of [`El`] and [`Node`], with
//! limits on input size, nesting, node count and attributes per element ([`Limits`]).
//! - [`Options::parse_streaming`] hands repeated elements to a callback, so a large part never
//! becomes a whole tree. [`Options::events`] is the pull reader underneath.
//! - [`Names`] chooses how names are read: as written, by local name, or rewritten to the
//! caller's canonical prefixes by namespace URI.
//! - [`esc`], [`esc_attr`], [`El::to_xml`], [`El::to_xml_pretty`] and [`Writer`] write XML.
//!
//! No document type definition is processed, and no entity is expanded except the five
//! predefined ones (`&amp;` `&lt;` `&gt;` `&quot;` `&apos;`) and numeric character references.
//! The text the reader produces is therefore never more than three times its input.
//!
//! ```
//! let root = craft_xml::parse(b"<a x='1'><b>one &amp; two</b><b/></a>")?;
//! assert_eq!(root.attr_u32("x"), Some(1));
//! assert_eq!(root.child("b").map(|b| b.text().into_owned()).as_deref(), Some("one & two"));
//! assert_eq!(root.to_xml(), r#"<a x="1"><b>one &amp; two</b><b/></a>"#);
//! # Ok::<(), craft_xml::Error>(())
//! ```
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeExamples;
mod read;
mod tree;
pub mod value;
mod write;
pub use read::{
AttrValueHook, Canonical, Doctype, ElementHook, Event, Events, Limits, Names, NsName, Options, Scope, UnknownEntity, XML_NS, entity, parse,
to_utf8, unescape,
};
pub use tree::{Descendants, El, Local, Node, Pat};
pub use write::{DECL, DECL_STANDALONE, Tag, Writer, esc, esc_attr, is_name, is_xml_char, push_esc, push_esc_attr};
/// Why a document was not read.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The input is longer than [`Limits::max_input`].
#[error("the XML is larger than {0} bytes")]
TooLarge(usize),
/// Elements nest deeper than [`Limits::max_depth`].
#[error("XML elements nest deeper than {0} levels")]
TooDeep(usize),
/// The tree would hold more than [`Limits::max_nodes`] nodes.
#[error("the XML holds more than {0} nodes")]
TooManyNodes(usize),
/// One start tag has more than [`Limits::max_attrs`] attributes.
#[error("an XML element has more than {0} attributes")]
TooManyAttrs(usize),
/// The tree would hold more than [`Limits::max_attrs_total`] attributes.
#[error("the XML holds more than {0} attributes")]
TooManyAttrsTotal(usize),
/// The document has a document type declaration that [`Options::doctype`] refuses.
#[error("the XML has a document type declaration, which is not accepted")]
Doctype,
/// A reference to an entity that is neither predefined nor numeric, with
/// [`UnknownEntity::Error`].
#[error("the entity `&{name};` at byte {at} is not defined")]
UnknownEntity { at: usize, name: String },
/// Text that is not UTF-8, in a strict parse.
#[error("the XML is not valid UTF-8 at byte {0}")]
Encoding(usize),
/// Markup that cannot be read. `at` is a byte offset into the UTF-8 text ([`to_utf8`]).
#[error("malformed XML at byte {at}: {msg}")]
Syntax { at: usize, msg: String },
/// There is no root element.
#[error("the XML has no root element")]
NoRoot,
}
impl Error {
/// Was a limit exceeded (as opposed to the markup being wrong)?
pub fn is_limit(&self) -> bool {
matches!(self, Error::TooLarge(_) | Error::TooDeep(_) | Error::TooManyNodes(_) | Error::TooManyAttrs(_) | Error::TooManyAttrsTotal(_))
}
}