* 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>
18 KiB
craft-xml
Bounded XML for hostile input: an owned element tree, streaming of repeated elements, a pull reader, escaping and a writer. It replaces the small XML helper each Craft app carried.
It is built on quick-xml's tokenizer (MIT). Everything above the tokens is here: limits,
references, namespaces, recovery, the tree and the writer.
use craft_xml::{Local, Options};
let root = craft_xml::parse(b"<p:sp id='5'><p:t>one & two</p:t></p:sp>").unwrap();
assert_eq!(root.attr_u32("id"), Some(5));
assert_eq!(root.child(Local("t")).map(|t| t.deep_text()).as_deref(), Some("one & two"));
// Rows go to the callback one at a time; the tree keeps the rest.
let part = b"<sheetData><row r='1'/><row r='2'/></sheetData>";
let mut rows = 0;
let sheet = Options::local().parse_streaming(part, &["row"], |row| {
rows += row.attr_u32("r").unwrap_or(0);
Ok::<(), craft_xml::Error>(())
});
assert_eq!((rows, sheet.map(|s| s.children.len())), (3, Ok(0)));
Reading
parse(bytes) reads with Options::default(). Options is a plain struct; set what differs.
| Option | Default | Meaning |
|---|---|---|
limits |
see below | What the reader refuses to exceed. |
names |
AsWritten |
AsWritten keeps p:sp. Local keeps sp. Canonical(&c) rewrites to the caller's prefix for the namespace URI. |
ns_decls |
true |
Keep xmlns declarations as attributes. Options::local() and Options::canonical(..) turn it off. |
strict |
false |
See below. .strict() also makes an undefined entity an error. |
unknown_entity |
Drop |
Drop, Literal (keep &name; as text) or Error. |
doctype |
Ignore |
Ignore, RejectInternalSubset or Reject. Never processed, whichever is chosen. |
cdata, pis, comments |
false |
Keep these as their own nodes. CDATA content is otherwise joined to the text around it. |
drop_blank_text |
false |
Drop the text of an element with child elements when all of it is whitespace (indentation). |
split_depth |
2 |
For streaming: how far below the root an element may be and still be handed out. |
Limits
| Limit | Default | Error |
|---|---|---|
max_input |
256 MiB | TooLarge |
max_depth |
256 levels (the root is 1) | TooDeep |
max_nodes |
2,000,000 nodes kept in the tree: elements, text stretches, kept CDATA, instructions and comments | TooManyNodes |
max_attrs |
1024 on one start tag, namespace declarations and malformed ones included; with canonical names, also the declarations in scope at once | TooManyAttrs |
max_attrs_total |
4,000,000 attributes kept in the tree, over all its elements | TooManyAttrsTotal |
A limit exceeded is an error, never a panic and never a truncated tree. parse_streaming_partial
returns the error together with what had been read, for a caller that wants the part's other
children when its rows cannot all be read; that tree is marked as partial by the error beside it. Elements handed to a
streaming callback leave the node and attribute counts, so a streamed part may be of any length. Text is not
counted separately: nothing expands, so it is never more than three times the input (the worst
case is input that is not UTF-8, where each bad byte becomes U+FFFD).
What is never done
- No DTD processing. A
<!DOCTYPE>is skipped or refused; its entity and element declarations are not read. - No entity expansion beyond
&<>"'and numeric character references. A reference to a declared entity is an undefined entity. Nothing external is ever fetched. - No validation, no XPath, no XInclude, no schema.
- No encodings other than UTF-8 and UTF-16. The encoding an XML declaration names is not consulted.
- Names are not checked on reading. The writer checks them (below).
Lenient and strict
A lenient read keeps what a damaged document had:
- an end tag closes the innermost open element, whatever its name; an end tag with nothing open is skipped;
- elements still open at the end of the input are closed (a truncated part);
- reading stops when the root element ends: nothing after it is looked at;
- a malformed attribute is skipped; bytes that are not UTF-8 become U+FFFD.
A strict read makes each of those an error, and also a second root element and text outside the
root, in the pull reader as in the tree. Markup the tokenizer cannot read (<a b='1, an unterminated comment) is an error either
way. A repeated attribute is kept each time in both.
Text
- Literal CR LF and CR in text and CDATA are read as LF (XML 1.0 §2.11).
stays a CR. - Literal tab, LF and CR in an attribute value are read as spaces. Character references stay.
- A numeric reference to a character XML 1.0 cannot carry (
�,, a surrogate) is an undefined entity. - UTF-16 is recognised by its byte-order mark, or without one by the zero bytes of its first two characters. An unpaired surrogate becomes U+FFFD, also in a strict read.
Namespaces
Canonical { namespaces: &[(prefix, uri)], unknown, element, attr_value }:
- an element in a listed namespace is
prefix:local; in an unlisted one,unknown:local(?:localby default); in none, or with a prefix nothing declares,local; - a prefixed attribute likewise; an unprefixed attribute keeps its name;
xml:needs no entry;elementoverrides the prefix by element and parent;attr_valuerewrites a value that holds prefixes while the scope is known (Scope::uri).
? cannot be part of an XML name, so the writer leaves unknown-namespace elements and attributes out.
The tree
El { name, attrs: Vec<(String, String)>, children: Vec<Node> }, Node::{El, Text, CData, Pi, Comment}. Fields are public.
Where the apps disagreed, there are two spellings, not one behaviour:
| Question | Spellings | Which app used which |
|---|---|---|
| Is a name matched whole or by local part? | child("w:p") / child(Local("p")). Every method that takes a name takes either: child, children, path, find, find_all, child_attr, child_text, child_mut, is, first_start. |
Whole: WordCraft, GridCraft, LightCraft, AniCraft. Local: DeckCraft, DesignCraft. |
| How is an attribute looked up? | attr (the exact name). attr_or_plain("w:val") (exact, else the unprefixed val). attr_local("id") (exact, else any prefix; attr_local("r:id") is any prefixed id). |
attr: GridCraft, DesignCraft, LightCraft, AniCraft. attr_or_plain: WordCraft. attr_local: DeckCraft. |
Does text include descendants? |
text() is the element's own text and CDATA. deep_text() adds every descendant's. |
Own: WordCraft text, GridCraft and LightCraft .text. Deep: DeckCraft text, DesignCraft text_content, WordCraft deep_text. |
| Is a number read exactly? | attr_u32, attr_i32, attr_i64, attr_f64 read exactly (value::to_*). value::to_i64_lossy, to_i32_lossy, to_u32_lossy truncate a fraction and clamp. |
Exact: GridCraft, AniCraft. Lossy: DeckCraft. |
Booleans (attr_bool, flag, value::to_bool): 1, true, on, t and 0, false, off,
f, trimmed, case-sensitive. That is GridCraft's set; DeckCraft's lacks t/f and IDML only
writes true/false, so both are covered. attr_f64 is finite values only, as in every app.
descendants, find, find_all, deep_text, node_count and the writer do not recurse.
node_count() is what max_nodes counted for an element. A reader of a package of many parts
keeps one budget with it: charge each tree it keeps, and read the next part with what is left as
max_nodes, so a part over the budget stops before its tree is built.
Writing
esc,push_esc: text.&,<,>escaped; CR written .esc_attr,push_esc_attr: a double-quoted attribute value. Also", and tab, LF, CR as	 .- Both drop characters XML 1.0 cannot carry (
is_xml_char). El::to_xml,write_xml: one line.El::to_xml_pretty(indent),write_xml_pretty: an element whose children are all elements has them one per line; any other element is on one line exactly asto_xmlwrites it.- An element or attribute whose name is not an XML name (
is_name) is not written, with everything inside it. A name can never inject markup. Writer:Writer::document()(with thestandalone="yes"declaration),Writer::new()(a fragment),Writer::pretty(indent);decl,doctype,pi;tag(name).attr(k, v).attr_opt(k, v).open()/.empty()/.text(t);open,empty,leafwith a slice of attributes;close()(the writer knows the name);text,cdata,raw,tree;finishcloses what is open.
Round trip
Reading what to_xml wrote gives an equal tree. Reading a document and writing it keeps the
elements, attributes in order, text, and with the options on, CDATA sections, processing
instructions and comments. It does not keep:
- the XML declaration, the DOCTYPE, and anything outside the root element;
- quoting and spacing inside tags, and how a character was written (
AagainstA); <a></a>against<a/>;- comments, processing instructions and CDATA sections as sections, unless the options keep them;
- a literal CR; literal tab, LF and CR in attribute values (they were read as spaces);
- with
to_xml_pretty, whitespace-only text between the children of an element-only element; - namespace declarations, when names are read as
LocalorCanonical.
Migration
WordCraft crates/docx/src/xml.rs
| WordCraft | craft-xml |
|---|---|
MAX_DEPTH, MAX_ELEMENTS, MAX_ATTRS |
Limits. max_nodes counts text nodes too: use twice the element count. |
NAMESPACES, OTHER_NAMESPACES, STRICT, prefix_for |
The tables stay in WordCraft, as Canonical::namespaces; Canonical::prefix. |
element_prefix, drawing_prefix |
Canonical::element. The rules stay in WordCraft. |
requirement_prefix and the Requires rewrite |
Canonical::attr_value with Scope::uri. |
parse, parse_ns(extra), parse_strict |
Options::canonical(&c).parse; extra entries first in the table; .strict(). |
Node, El { name, attrs, kids } |
Node, El { name, attrs, children } |
attr |
attr_or_plain |
els, child, children, find, deep_text, local, to_xml |
elements, then the same names |
text |
text (a Cow) |
child_val(n) |
child(n).and_then(|c| c.attr_or_plain("w:val")) |
utf16_to_utf8 |
to_utf8 |
esc |
esc for text, esc_attr for attribute values |
W::new, open, empty, leaf, text, raw, into_bytes |
Writer::document, then the same names |
W::close(name), W::val(name, v), w.s |
close(), empty(name, &[("w:val", v)]), as_str() |
is_rel_attr, REL_*, DECORATIVE_*, body_ns |
Stay in WordCraft. |
GridCraft crates/xlsx/src/xml.rs
| GridCraft | craft-xml |
|---|---|
MAX_DEPTH, MAX_XML_NODES |
Limits |
parse |
Options { unknown_entity: Literal, ..Options::local() }.parse |
parse_streaming(xml, split, cb) |
.parse_streaming(xml, split, cb); cb returns any error that is From<craft_xml::Error> |
first_start(xml, name) |
Options::local().first_start(xml, name) |
El.children (elements), El.text |
elements(), text() |
attr, attr_u32, attr_i64, attr_f64, attr_bool, flag, child, path |
The same names |
kids(n), child_val(n) |
children(n), child_attr(n, "val") |
to_utf8, esc, push_esc, esc_attr |
The same names |
push_u64, push_a1, num, push_num, rich_text, phonetic_data, encode_xstring, decode_xstring, t_el, si_body |
Stay in GridCraft. |
DeckCraft crates/pptx/src/xml.rs
| DeckCraft | craft-xml |
|---|---|
MAX_DEPTH, MAX_NODES |
Limits |
parse(bytes)?.root, Doc::ns_decls |
craft_xml::parse(bytes)?, root.ns_decls() (prefix and URI) |
attr(n), has_attr(n) |
attr_local(n), attr_local(n).is_some() |
i64(n), i32(n), u32(n) |
attr_local(n).and_then(value::to_i64_lossy) and to_i32_lossy, to_u32_lossy |
f64(n), bool(n) |
attr_local(n).and_then(value::to_f64) and to_bool |
is(l), child(l), children_named(l), path(ls), find(l) |
is(Local(l)), child(Local(l)), children(Local(l)), path(&[Local(..)]), find(Local(l)) |
find_all(l, &mut out) |
out.extend(e.find_all(Local(l))) |
text() |
deep_text() |
child_val(l) |
child(Local(l)).and_then(|c| c.attr_local("val")) |
local, prefix, set_attr, elements, elements_mut, to_xml, esc, esc_attr |
The same names |
W::new, W::frag, finish |
Writer::document, Writer::new, into_bytes |
A::new().a(k, v), .o(k, opt) |
tag(name).attr(k, v), .attr_opt(k, opt) |
A::b(k, opt_bool), A::t(k, bool) |
.attr_opt(k, opt.map(u8::from)), .attr_opt(k, flag.then_some(1)) |
open(name, a), open0, empty(name, a), empty0 |
tag(name)….open() / .empty(), or open(name, &[]), empty(name, &[]) |
close(name), val(name, v), elt(name, t) |
close(), tag(name).attr("val", v).empty(), leaf(name, &[], t) |
DesignCraft crates/idml/src/xml.rs, crates/pptx/src/xml.rs, crates/engine/src/cmd/xml.rs
| DesignCraft | craft-xml |
|---|---|
parse |
Options { strict: true, pis: true, ..Options::default() }.parse |
Node::Pi(target, content) |
Node::Pi { target, data } |
El::new(n).attr(k, v).child(c).text(t) |
El::new(n).with_attr(k, v).with_child(c).with_text(t) |
set, push, get, local, elements |
set_attr, push, attr, local, elements |
find(l), find_all(l) (direct children) |
child(Local(l)), children(Local(l)) |
text_content |
deep_text |
write(out, depth) |
write_xml_pretty(out, "\t") |
escape_into(out, s, attr) |
push_esc(out, s), push_esc_attr(out, s) |
document(root), document_with_pi(root, pi) |
Writer::pretty("\t"), then decl(true), pi(target, data) or raw(pi), tree(root), into_bytes() |
prop, prop_el, num, boolean (IDML's <Properties> form) |
Stay in DesignCraft, on attr and child. |
pptx esc |
esc for text, esc_attr for attribute values. emu, emu_len, angle, pct, hex stay. |
cmd esc, valid_name |
esc/esc_attr; is_name (which also accepts :). The rest is the app's structure commands and stays. |
The others
| App helper | craft-xml |
|---|---|
WordCraft formats/src/odt/xml.rs: events, events_at, Ev, Attrs, get, local |
Options::events(utf8), Events::next, Events::span, Event. Event::End names the start tag. An error is returned, not swallowed. length stays. |
AniCraft crates/xml: parse |
Options { strict: true, drop_blank_text: true, unknown_entity: Literal, ..Options::default() }.parse |
AniCraft parse_mixed, TEXT_NODE |
Not needed: text nodes keep their place among the children. |
AniCraft Element::attr, u32, child, f32, list(n) |
attr, attr_u32, child, attr_f64(n) as f32, child(n) then elements() |
LightCraft lensdb/src/xml.rs: parse, Node, attr, num, kids, kid, kid_text, entity, unescape |
Options::parse with its limits (max_depth: 32), El, attr, attr_f64, children, child, child_text (trim it), entity, unescape |
FilmCraft interchange/src/xml.rs: escape, escape_attr, XmlWriter |
esc, esc_attr, Writer::pretty(" ") with decl(false) and doctype(name); text_attrs is leaf |
FilmCraft parse, child, children, elements, text, path_text, child_text, child_i64, child_f64 |
Options { doctype: RejectInternalSubset, .. }.parse and the tree's child, children, elements, text, path, child_text, value::to_i64_lossy, value::to_f64. The result is an El, not a roxmltree::Document. |
FigCraft vectordrawable/src/xml.rs: esc |
esc_attr |
MailCraft dav, ews readers |
Options { doctype: Reject, .. }.strict(), with Canonical for the DAV and EWS namespaces |
Behaviour that changes on migration
- Bytes written.
escno longer escapes quotes in text; WordCraft, FilmCraft, FigCraft and DesignCraft's pptx writer escaped'and this crate never does (values are double-quoted); DesignCraft's IDML writer wrote	where this writes	, and wrote a literal CR in text where this writes . All read back the same, except the CR, which now survives. - Bytes that are not UTF-8 in text were an error in WordCraft, GridCraft and DesignCraft's lenient readers. Here a lenient read replaces them and a strict read refuses them.
- GridCraft kept an attribute value raw when it held an undefined entity; here the rest of the value is still resolved. It did not normalise line ends in CDATA; here they are.
- DeckCraft kept reading after the root element and could fail on what followed; a lenient read here stops.
- Node counts include text, so an element limit becomes about twice the number.
- LightCraft did not normalise literal tabs and line ends in attribute values to spaces; here they are.
- FilmCraft rounded a fractional
child_i64;value::to_i64_lossytruncates. Itschild_f64acceptedNaNand infinities;value::to_f64does not. - AniCraft kept attributes in a map (the last of a repeated name won); here they are a list and
attrreturns the first.
Left to the apps
- FigCraft's
Outlayout (one attribute per line, as Android Studio formats drawables). - EffectCraft's
xml_project.rs: a project codec with its own reader. It writes control characters as&#N;, which XML 1.0 forbids and this crate does not read. - VectorCraft's
xml_id.rs: object names as XML IDs (_xHH_escapes), a naming rule and not XML syntax. - PhotoCraft's
io/src/xml.rsand FilmCraft'sparseguardroxmltreeand return its document. Code that moves toElgets the same guards fromLimitsandDoctype; code that stays onroxmltreekeeps its guard. - Booleans that are not
xsd:boolean: FilmCraft'sTRUE/FALSE, AniCraft's "anything buttrueis false". - Number formatting (
num), units, colours, and every format's own vocabulary.