mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Add craft-xml: bounded XML tree, streaming and writer (#816)
Almost every Craft app carries its own small XML helper: a bounded element tree on a pull parser and an escaping string writer. This is the shared one, so each app can drop its copy. - An owned tree (El, Node) read from bytes with limits on input size, depth, node count and attributes per element. A limit exceeded is an error. No DTD is processed and no entity is expanded beyond the five predefined ones and numeric character references. UTF-8 and UTF-16. - Names as written, by local name, or rewritten to the caller's canonical prefixes by namespace URI, with hooks for a prefix that depends on the parent and for attribute values that hold prefixes. - Lenient and strict reading; streaming of repeated elements to a callback; first_start; a pull reader with byte ranges. - Accessors, typed attribute getters, mutation and a builder. Where the apps disagreed (whole or local names, own or deep text, exact or lossy numbers) there are two spellings. - esc, esc_attr, compact and pretty serialising, and a Writer. A name that is not an XML name is never written. Built on quick-xml 0.41's tokenizer (MIT), the version the apps use. The apps' own tests for their helpers are ported in tests/apps.rs. No app is switched here.
This commit is contained in:
1 parent
d4e3017c34
commit
8152fc0be0
13 files changed
+3179
No files matched your search
Generated
+8
@@ -1327,6 +1327,14 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
|
||||
@@ -59,6 +59,7 @@ craft-tiff = { path = "crates/tiff" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
craft-update = { path = "crates/update" }
|
||||
craft-vectorcraft-format = { path = "crates/vectorcraft-format" }
|
||||
craft-xml = { path = "crates/xml" }
|
||||
craft-video = { path = "crates/video" }
|
||||
craft-web-api = { path = "crates/web-api" }
|
||||
craft-window = { path = "crates/window" }
|
||||
|
||||
@@ -138,6 +138,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
`window.<app>` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's
|
||||
JSON control channel.
|
||||
- [`craft-web-host`](crates/web-host/README.md): the host messages between a Craft app's web build and the page that hosts it (`?host=parent` or `data-host="page"`): one contract and one bridge for every app.
|
||||
- [`craft-xml`](crates/xml/README.md): bounded XML for hostile input: an owned element tree with limits on size, depth, nodes and attributes, streaming of repeated elements, namespace handling (as written, by local name, or canonical prefixes by URI), escaping and a writer. No DTDs and no entity expansion.
|
||||
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
|
||||
and compatibility boundaries across the Craft suite.
|
||||
- [Theme infrastructure](docs/theme-infrastructure.md): eight-app comparison and the small shared
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "craft-xml"
|
||||
description = "Bounded XML for hostile input: an owned element tree, streaming of repeated elements, a pull reader, escaping and a writer. No DTDs, no entity expansion."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
quick-xml = { version = "0.41", default-features = false }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,242 @@
|
||||
# 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.
|
||||
|
||||
```rust
|
||||
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` |
|
||||
|
||||
A limit exceeded is an error, never a panic and never a truncated tree. Elements handed to a
|
||||
streaming callback leave the node count, 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. 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` (`?:local` by default); in none, or with a prefix nothing declares, `local`;
|
||||
- a prefixed attribute likewise; an unprefixed attribute keeps its name;
|
||||
- `xml:` needs no entry;
|
||||
- `element` overrides the prefix by element and parent; `attr_value` rewrites 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` and the writer do not recurse.
|
||||
|
||||
## 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 as `to_xml` writes 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 the `standalone="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`, `leaf` with a slice of attributes; `close()` (the writer knows the name); `text`, `cdata`, `raw`, `tree`; `finish` closes 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 (`A` against `A`);
|
||||
- `<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 `Local` or `Canonical`.
|
||||
|
||||
## 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.** `esc` no 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_lossy` truncates. Its `child_f64` accepted `NaN` and infinities; `value::to_f64` does not.
|
||||
- **AniCraft** kept attributes in a map (the last of a repeated name won); here they are a list and `attr` returns the first.
|
||||
|
||||
### Left to the apps
|
||||
|
||||
- FigCraft's `Out` layout (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.rs` and FilmCraft's `parse` guard `roxmltree` and return its document. Code that moves to `El` gets the same guards from `Limits` and `Doctype`; code that stays on `roxmltree` keeps its guard.
|
||||
- Booleans that are not `xsd:boolean`: FilmCraft's `TRUE`/`FALSE`, AniCraft's "anything but `true` is false".
|
||||
- Number formatting (`num`), units, colours, and every format's own vocabulary.
|
||||
@@ -0,0 +1,78 @@
|
||||
#![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 (`&` `<` `>` `"` `'`) 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 & 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 & 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 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(_))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
//! Reading: the pull reader over `quick-xml`, the tree builder on top of it, and streaming.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::{BytesStart, Event as Q};
|
||||
|
||||
use crate::Error;
|
||||
use crate::tree::{El, Node, Pat, local_of};
|
||||
use crate::write::is_xml_char;
|
||||
|
||||
/// The namespace the `xml` prefix is always bound to.
|
||||
pub const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
/// What the reader refuses to exceed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Most bytes of input. Default 256 MiB.
|
||||
pub max_input: usize,
|
||||
/// Deepest element nesting: the root is at depth 1. Default 256.
|
||||
pub max_depth: usize,
|
||||
/// Most nodes in a tree: elements, text stretches and whatever else is kept. Elements
|
||||
/// handed to a streaming callback leave the count with everything inside them. Default
|
||||
/// 2,000,000: a tree node costs about 72 bytes before its strings, and text lies between
|
||||
/// elements at most, so this is about a million elements of an indented document.
|
||||
pub max_nodes: usize,
|
||||
/// Most attributes on one start tag, namespace declarations and malformed ones included.
|
||||
/// With [`Names::Canonical`] it is also the most namespace declarations in scope at once.
|
||||
/// Default 1024.
|
||||
pub max_attrs: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub const DEFAULT: Limits = Limits { max_input: 256 * 1024 * 1024, max_depth: 256, max_nodes: 2_000_000, max_attrs: 1024 };
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Limits {
|
||||
Limits::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// How element and attribute names are read.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub enum Names<'a> {
|
||||
/// The qualified name as written: `p:sp`, `r:id`. Match with a `&str` when the prefixes
|
||||
/// are known, or with [`crate::Local`] and [`El::attr_local`] when they are not.
|
||||
#[default]
|
||||
AsWritten,
|
||||
/// The local name only: `sp`, `id`.
|
||||
Local,
|
||||
/// The caller's prefix for the name's namespace and the local name: `w:p`, whatever
|
||||
/// prefix the document used. See [`Canonical`].
|
||||
Canonical(&'a Canonical<'a>),
|
||||
}
|
||||
|
||||
/// An element name being given its canonical prefix.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NsName<'s> {
|
||||
/// The prefix [`Canonical::namespaces`] has for `uri`, or [`Canonical::unknown`].
|
||||
pub prefix: &'s str,
|
||||
pub uri: &'s str,
|
||||
pub local: &'s str,
|
||||
/// The canonical name of the enclosing element; `None` at the root.
|
||||
pub parent: Option<&'s str>,
|
||||
}
|
||||
|
||||
/// See [`Canonical::element`].
|
||||
pub type ElementHook<'a> = dyn Fn(&NsName<'_>) -> Option<&'static str> + 'a;
|
||||
/// See [`Canonical::attr_value`].
|
||||
pub type AttrValueHook<'a> = dyn Fn(&str, &str, &str, &Scope<'_>) -> Option<String> + 'a;
|
||||
|
||||
/// Canonical prefixes by namespace URI.
|
||||
///
|
||||
/// - An element in a listed namespace is named `prefix:local`; in an unlisted one,
|
||||
/// `unknown:local`; in no namespace (or with a prefix nothing declares), `local`.
|
||||
/// - A prefixed attribute is named the same way. An unprefixed attribute is in no namespace
|
||||
/// and keeps its name.
|
||||
/// - The `xml` prefix needs no entry: `xml:space` stays `xml:space`.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Canonical<'a> {
|
||||
/// (prefix, namespace URI). The first entry with a URI wins, so several URIs may share a
|
||||
/// prefix (the Strict and Transitional forms of a vocabulary).
|
||||
pub namespaces: &'a [(&'a str, &'a str)],
|
||||
/// The prefix for a namespace that is not listed. `"?"` by default: it cannot be part of an
|
||||
/// XML name, so such names never collide with real ones and [`El::write_xml`] leaves them
|
||||
/// out.
|
||||
pub unknown: &'a str,
|
||||
/// Overrides the prefix of an element, for vocabularies where one namespace is read as
|
||||
/// several depending on the element and its parent. `None` keeps [`NsName::prefix`]. The
|
||||
/// prefixes it returns are constants of the caller's.
|
||||
pub element: Option<&'a ElementHook<'a>>,
|
||||
/// Rewrites an attribute value while the namespace declarations in scope are still known:
|
||||
/// for values that hold prefixes (`mc:Choice/@Requires`, `xsi:type`). Called with the
|
||||
/// element's name, the attribute's name, the value and the scope; `None` keeps the value.
|
||||
pub attr_value: Option<&'a AttrValueHook<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Canonical<'a> {
|
||||
pub const fn new(namespaces: &'a [(&'a str, &'a str)]) -> Canonical<'a> {
|
||||
Canonical { namespaces, unknown: "?", element: None, attr_value: None }
|
||||
}
|
||||
/// The prefix for `uri`: the listed one, `xml` for [`XML_NS`], else [`Canonical::unknown`].
|
||||
pub fn prefix(&self, uri: &str) -> &'a str {
|
||||
match self.namespaces.iter().find(|(_, u)| *u == uri) {
|
||||
Some((p, _)) => p,
|
||||
None if uri == XML_NS => "xml",
|
||||
None => self.unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The namespace declarations in scope at an element.
|
||||
pub struct Scope<'s> {
|
||||
bindings: &'s [(String, String)],
|
||||
}
|
||||
|
||||
impl<'s> Scope<'s> {
|
||||
/// The namespace URI `prefix` is bound to; `""` is the default namespace.
|
||||
pub fn uri(&self, prefix: &str) -> Option<&'s str> {
|
||||
if prefix == "xml" {
|
||||
return Some(XML_NS);
|
||||
}
|
||||
self.bindings.iter().rev().find(|(p, _)| p == prefix).map(|(_, u)| u.as_str()).filter(|u| !u.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do with a reference to an entity that is neither predefined nor numeric. Entities a
|
||||
/// document declares are never expanded, so `&name;` for a declared entity is one of these too.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum UnknownEntity {
|
||||
/// Leave it out.
|
||||
#[default]
|
||||
Drop,
|
||||
/// Keep it as the text `&name;`.
|
||||
Literal,
|
||||
/// Refuse the document ([`Error::UnknownEntity`]).
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What to do with a document type declaration. Whatever is chosen, the declaration is never
|
||||
/// processed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Doctype {
|
||||
/// Skip it.
|
||||
#[default]
|
||||
Ignore,
|
||||
/// Refuse a declaration with an internal subset (`<!DOCTYPE x [ … ]>`), skip any other.
|
||||
RejectInternalSubset,
|
||||
/// Refuse any declaration.
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// How to read a document. `Options::default()` keeps names as written and is lenient.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Options<'a> {
|
||||
pub limits: Limits,
|
||||
pub names: Names<'a>,
|
||||
/// Keep namespace declarations (`xmlns`, `xmlns:p`) as attributes, as written. On by
|
||||
/// default; [`Options::local`] and [`Options::canonical`] turn it off.
|
||||
pub ns_decls: bool,
|
||||
/// Refuse what a lenient read repairs. Lenient: 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, reading stops when the root element ends, a
|
||||
/// malformed attribute is skipped, and bytes that are not UTF-8 become U+FFFD. Strict: each
|
||||
/// of those is an error, as are a second root element and text outside the root.
|
||||
pub strict: bool,
|
||||
pub unknown_entity: UnknownEntity,
|
||||
pub doctype: Doctype,
|
||||
/// Keep CDATA sections as [`Node::CData`] instead of joining them to the text around them.
|
||||
pub cdata: bool,
|
||||
/// Keep processing instructions inside the root element as [`Node::Pi`].
|
||||
pub pis: bool,
|
||||
/// Keep comments inside the root element as [`Node::Comment`].
|
||||
pub comments: bool,
|
||||
/// Drop the text of an element that has child elements when all of it is whitespace: the
|
||||
/// indentation of an indented document.
|
||||
pub drop_blank_text: bool,
|
||||
/// For [`Options::parse_streaming`]: how far below the root an element may be and still be
|
||||
/// handed out. 2 by default: children and grandchildren of the root.
|
||||
pub split_depth: usize,
|
||||
}
|
||||
|
||||
impl Default for Options<'_> {
|
||||
fn default() -> Self {
|
||||
Options {
|
||||
limits: Limits::DEFAULT,
|
||||
names: Names::AsWritten,
|
||||
ns_decls: true,
|
||||
strict: false,
|
||||
unknown_entity: UnknownEntity::Drop,
|
||||
doctype: Doctype::Ignore,
|
||||
cdata: false,
|
||||
pis: false,
|
||||
comments: false,
|
||||
drop_blank_text: false,
|
||||
split_depth: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `bytes` with [`Options::default`].
|
||||
pub fn parse(bytes: &[u8]) -> Result<El, Error> {
|
||||
Options::default().parse(bytes)
|
||||
}
|
||||
|
||||
impl<'a> Options<'a> {
|
||||
/// Names by local part, namespace declarations dropped.
|
||||
pub fn local() -> Options<'static> {
|
||||
Options { names: Names::Local, ns_decls: false, ..Options::default() }
|
||||
}
|
||||
/// Names with canonical prefixes, namespace declarations dropped.
|
||||
pub fn canonical(canonical: &'a Canonical<'a>) -> Options<'a> {
|
||||
Options { names: Names::Canonical(canonical), ns_decls: false, ..Options::default() }
|
||||
}
|
||||
/// [`Options::strict`] on, and an undefined entity an error.
|
||||
#[must_use]
|
||||
pub fn strict(self) -> Self {
|
||||
Options { strict: true, unknown_entity: UnknownEntity::Error, ..self }
|
||||
}
|
||||
#[must_use]
|
||||
pub fn with_limits(self, limits: Limits) -> Self {
|
||||
Options { limits, ..self }
|
||||
}
|
||||
|
||||
/// Reads a document into its root element.
|
||||
pub fn parse(&self, bytes: &[u8]) -> Result<El, Error> {
|
||||
self.parse_streaming(bytes, &[], |_| Ok::<(), Error>(()))
|
||||
}
|
||||
|
||||
/// Reads a document, handing each complete element whose name is in `split` to `each`
|
||||
/// instead of keeping it in the tree: worksheet rows, shared strings. Only elements at
|
||||
/// most [`Options::split_depth`] levels below the root are handed out; a deeper element of
|
||||
/// the same name stays in the tree. `split` names are matched against names as this reader
|
||||
/// produces them. Elements handed out do not count toward [`Limits::max_nodes`] once they
|
||||
/// are gone, so a part of any length can be read; each one is itself held to the limit.
|
||||
///
|
||||
/// An error from `each` stops the read and is returned.
|
||||
pub fn parse_streaming<E: From<Error>>(&self, bytes: &[u8], split: &[&str], mut each: impl FnMut(El) -> Result<(), E>) -> Result<El, E> {
|
||||
if bytes.len() > self.limits.max_input {
|
||||
return Err(Error::TooLarge(self.limits.max_input).into());
|
||||
}
|
||||
let text = to_utf8(bytes);
|
||||
let mut events = Events::new(*self, &text, false);
|
||||
let mut tree = Builder { opts: self, split, stack: Vec::new(), marks: Vec::new(), kept: 0, root: None, at: 0 };
|
||||
while let Some(ev) = events.next()? {
|
||||
tree.at = events.span().start;
|
||||
tree.event(ev, &mut each)?;
|
||||
if !self.strict && tree.root.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A lenient read keeps what a truncated document had; a strict one failed above.
|
||||
while let Some(el) = tree.stack.pop() {
|
||||
tree.finish(el, &mut each)?;
|
||||
}
|
||||
tree.root.ok_or_else(|| Error::NoRoot.into())
|
||||
}
|
||||
|
||||
/// The first element that matches, from its start tag alone: its name and attributes, no
|
||||
/// children. Nothing after the tag is read, so unbalanced markup around it neither hides
|
||||
/// nor moves it. `None` when there is none or the markup before it cannot be read.
|
||||
pub fn first_start(&self, bytes: &[u8], pat: impl Pat) -> Option<El> {
|
||||
if bytes.len() > self.limits.max_input {
|
||||
return None;
|
||||
}
|
||||
let text = to_utf8(bytes);
|
||||
// No tree is built, and markup that never closes its tags would otherwise look deep.
|
||||
let opts = Options { limits: Limits { max_depth: usize::MAX, ..self.limits }, ..*self };
|
||||
let mut events = Events::new(opts, &text, false);
|
||||
loop {
|
||||
match events.next().ok()?? {
|
||||
Event::Start { name, attrs } if pat.matches(&name) => return Some(El { name, attrs, children: Vec::new() }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pull reader over `utf8`, which is UTF-8 text: pass other input through [`to_utf8`]
|
||||
/// first. No tree is built, so [`Limits::max_nodes`] and [`Limits::max_input`] do not
|
||||
/// apply; depth and attribute limits do.
|
||||
pub fn events<'i>(&self, utf8: &'i [u8]) -> Events<'a, 'i> {
|
||||
Events::new(*self, utf8, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of a document. See [`Options::events`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
/// A start tag. An empty element (`<a/>`) is a `Start` followed by its `End`.
|
||||
Start {
|
||||
name: String,
|
||||
attrs: Vec<(String, String)>,
|
||||
},
|
||||
/// The end of the element named `name` (the name of its start tag).
|
||||
End {
|
||||
name: String,
|
||||
},
|
||||
/// Character data, references resolved. One stretch of text may arrive in several events.
|
||||
Text(String),
|
||||
/// The content of a CDATA section.
|
||||
CData(String),
|
||||
Pi {
|
||||
target: String,
|
||||
data: String,
|
||||
},
|
||||
Comment(String),
|
||||
}
|
||||
|
||||
struct Open {
|
||||
/// Where this element's namespace declarations start in the scope.
|
||||
scope: usize,
|
||||
/// The name as written, kept for a strict read to check the end tag against.
|
||||
raw: Vec<u8>,
|
||||
/// The name as produced, kept when an end event or a canonical parent needs it.
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// A pull reader. See [`Options::events`].
|
||||
pub struct Events<'o, 'i> {
|
||||
reader: Reader<&'i [u8]>,
|
||||
opts: Options<'o>,
|
||||
/// Whether [`Event::End`] carries the name.
|
||||
end_names: bool,
|
||||
open: Vec<Open>,
|
||||
scope: Vec<(String, String)>,
|
||||
/// The end of an empty element, due after its start.
|
||||
pending: bool,
|
||||
span: Range<usize>,
|
||||
}
|
||||
|
||||
fn at(pos: u64) -> usize {
|
||||
usize::try_from(pos).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
fn line_ends(s: Cow<'_, str>) -> String {
|
||||
// XML 1.0 §2.11: a literal CR LF or CR is read as LF.
|
||||
if s.contains('\r') { s.replace("\r\n", "\n").replace('\r', "\n") } else { s.into_owned() }
|
||||
}
|
||||
|
||||
fn is_ns_decl(key: &[u8]) -> bool {
|
||||
key == b"xmlns" || key.starts_with(b"xmlns:")
|
||||
}
|
||||
|
||||
impl<'o, 'i> Events<'o, 'i> {
|
||||
fn new(opts: Options<'o>, utf8: &'i [u8], end_names: bool) -> Self {
|
||||
let mut reader = Reader::from_reader(utf8);
|
||||
let cfg = reader.config_mut();
|
||||
// The checks a strict read wants are made here, where the answer can be lenient.
|
||||
cfg.check_end_names = false;
|
||||
cfg.allow_unmatched_ends = true;
|
||||
cfg.allow_dangling_amp = true;
|
||||
cfg.trim_text_start = false;
|
||||
cfg.trim_text_end = false;
|
||||
cfg.expand_empty_elements = false;
|
||||
Events { reader, opts, end_names, open: Vec::new(), scope: Vec::new(), pending: false, span: 0..0 }
|
||||
}
|
||||
|
||||
/// The bytes of the input the last event was read from. Both events of an empty element
|
||||
/// have the whole element.
|
||||
pub fn span(&self) -> Range<usize> {
|
||||
self.span.clone()
|
||||
}
|
||||
|
||||
fn syntax<T>(&self, msg: impl Into<String>) -> Result<T, Error> {
|
||||
Err(Error::Syntax { at: self.span.start, msg: msg.into() })
|
||||
}
|
||||
|
||||
fn decode<'b>(&self, bytes: &'b [u8]) -> Result<Cow<'b, str>, Error> {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Ok(Cow::Borrowed(s)),
|
||||
Err(e) if self.opts.strict => Err(Error::Encoding(self.span.start.saturating_add(e.valid_up_to()))),
|
||||
Err(_) => Ok(String::from_utf8_lossy(bytes)),
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown(&self, name: &str) -> Result<Option<String>, Error> {
|
||||
match self.opts.unknown_entity {
|
||||
UnknownEntity::Drop => Ok(None),
|
||||
UnknownEntity::Literal => Ok(Some(format!("&{name};"))),
|
||||
UnknownEntity::Error => Err(Error::UnknownEntity { at: self.span.start, name: name.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next event, or `None` at the end of the input.
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn next(&mut self) -> Result<Option<Event>, Error> {
|
||||
if self.pending {
|
||||
self.pending = false;
|
||||
return Ok(Some(self.end()));
|
||||
}
|
||||
loop {
|
||||
let from = at(self.reader.buffer_position());
|
||||
let ev = match self.reader.read_event() {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => return Err(Error::Syntax { at: at(self.reader.error_position()), msg: e.to_string() }),
|
||||
};
|
||||
self.span = from..at(self.reader.buffer_position());
|
||||
if let Some(out) = self.step(ev)? {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` to read on, `Some(None)` at the end of the input.
|
||||
fn step(&mut self, ev: Q<'i>) -> Result<Option<Option<Event>>, Error> {
|
||||
let event = match ev {
|
||||
Q::Start(e) => self.start(&e)?,
|
||||
Q::Empty(e) => {
|
||||
self.pending = true;
|
||||
self.start(&e)?
|
||||
}
|
||||
Q::End(e) => {
|
||||
let Some(top) = self.open.last() else {
|
||||
return if self.opts.strict { self.syntax("an end tag has no start tag") } else { Ok(None) };
|
||||
};
|
||||
if self.opts.strict && top.raw != e.name().as_ref() {
|
||||
return self.syntax(format!("the end tag `{}` does not match its start tag", String::from_utf8_lossy(e.name().as_ref())));
|
||||
}
|
||||
self.end()
|
||||
}
|
||||
Q::Text(t) => {
|
||||
if t.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Event::Text(line_ends(self.decode(&t)?))
|
||||
}
|
||||
Q::CData(t) => Event::CData(line_ends(self.decode(&t)?)),
|
||||
Q::GeneralRef(r) => {
|
||||
let name = self.decode(&r)?;
|
||||
match entity(&name) {
|
||||
Some(c) => Event::Text(c.to_string()),
|
||||
None => match self.unknown(&name)? {
|
||||
Some(literal) => Event::Text(literal),
|
||||
None => return Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
Q::PI(p) => Event::Pi { target: self.decode(p.target())?.into_owned(), data: self.decode(p.content())?.trim_start().to_string() },
|
||||
Q::Comment(c) => Event::Comment(self.decode(&c)?.into_owned()),
|
||||
Q::DocType(d) => {
|
||||
let refuse = match self.opts.doctype {
|
||||
Doctype::Ignore => false,
|
||||
// A `[` in a quoted identifier is refused too, which fails safe.
|
||||
Doctype::RejectInternalSubset => d.contains(&b'['),
|
||||
Doctype::Reject => true,
|
||||
};
|
||||
return if refuse { Err(Error::Doctype) } else { Ok(None) };
|
||||
}
|
||||
Q::Decl(_) => return Ok(None),
|
||||
Q::Eof => {
|
||||
return if self.opts.strict && !self.open.is_empty() { self.syntax("an element is not closed") } else { Ok(Some(None)) };
|
||||
}
|
||||
};
|
||||
Ok(Some(Some(event)))
|
||||
}
|
||||
|
||||
fn end(&mut self) -> Event {
|
||||
let (scope, name) = self.open.pop().map_or((0, String::new()), |o| (o.scope, o.name));
|
||||
self.scope.truncate(scope);
|
||||
Event::End { name }
|
||||
}
|
||||
|
||||
/// The URI `prefix` is bound to here; `""` is the default namespace.
|
||||
fn uri(&self, prefix: &str) -> Option<&str> {
|
||||
Scope { bindings: &self.scope }.uri(prefix)
|
||||
}
|
||||
|
||||
fn element_name(&self, raw: &str) -> String {
|
||||
match self.opts.names {
|
||||
Names::AsWritten => raw.to_string(),
|
||||
Names::Local => local_of(raw).to_string(),
|
||||
Names::Canonical(canon) => {
|
||||
let (prefix, local) = raw.split_once(':').unwrap_or(("", raw));
|
||||
let Some(uri) = self.uri(prefix) else { return local.to_string() };
|
||||
let listed = canon.prefix(uri);
|
||||
let parent = self.open.last().map(|o| o.name.as_str());
|
||||
let chosen = canon.element.and_then(|hook| hook(&NsName { prefix: listed, uri, local, parent })).unwrap_or(listed);
|
||||
format!("{chosen}:{local}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attr_name(&self, raw: &str) -> String {
|
||||
match self.opts.names {
|
||||
Names::AsWritten => raw.to_string(),
|
||||
Names::Local => local_of(raw).to_string(),
|
||||
Names::Canonical(canon) => match raw.split_once(':') {
|
||||
Some((prefix, local)) => match self.uri(prefix) {
|
||||
Some(uri) => format!("{}:{local}", canon.prefix(uri)),
|
||||
None => local.to_string(),
|
||||
},
|
||||
None => raw.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn attr_value(&self, raw: &[u8]) -> Result<String, Error> {
|
||||
match decode_refs(&self.decode(raw)?, true, self.opts.unknown_entity) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(name) => Err(Error::UnknownEntity { at: self.span.start, name }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the tag's namespace declarations into the scope, so that the tag's own name and
|
||||
/// attributes resolve against them.
|
||||
fn declare(&mut self, e: &BytesStart<'_>) -> Result<(), Error> {
|
||||
let mut attrs = e.attributes();
|
||||
attrs.with_checks(false);
|
||||
// The attribute pass that follows counts every attribute; this one only has to stop.
|
||||
for a in attrs.take(self.opts.limits.max_attrs).flatten() {
|
||||
let key = a.key.as_ref();
|
||||
if is_ns_decl(key) {
|
||||
let prefix = self.decode(key.get(6..).unwrap_or_default())?.into_owned();
|
||||
let uri = self.attr_value(&a.value)?;
|
||||
self.scope.push((prefix, uri));
|
||||
}
|
||||
}
|
||||
// Every name is resolved against the scope, so the scope as a whole is bounded too:
|
||||
// declarations piled up over many levels would make each lookup long.
|
||||
if self.scope.len() > self.opts.limits.max_attrs {
|
||||
return Err(Error::TooManyAttrs(self.opts.limits.max_attrs));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start(&mut self, e: &BytesStart<'_>) -> Result<Event, Error> {
|
||||
let limits = self.opts.limits;
|
||||
if self.open.len() >= limits.max_depth {
|
||||
return Err(Error::TooDeep(limits.max_depth));
|
||||
}
|
||||
let canonical = match self.opts.names {
|
||||
Names::Canonical(c) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
let scope = self.scope.len();
|
||||
if canonical.is_some() {
|
||||
self.declare(e)?;
|
||||
}
|
||||
let raw = e.name();
|
||||
let name = self.element_name(&self.decode(raw.as_ref())?);
|
||||
let mut out = Vec::new();
|
||||
let mut attrs = e.attributes();
|
||||
attrs.with_checks(false);
|
||||
for (seen, a) in attrs.enumerate() {
|
||||
if seen >= limits.max_attrs {
|
||||
return Err(Error::TooManyAttrs(limits.max_attrs));
|
||||
}
|
||||
let a = match a {
|
||||
Ok(a) => a,
|
||||
Err(err) if self.opts.strict => return self.syntax(err.to_string()),
|
||||
Err(_) => continue,
|
||||
};
|
||||
let key = a.key.as_ref();
|
||||
let decl = is_ns_decl(key);
|
||||
if decl && !self.opts.ns_decls {
|
||||
continue;
|
||||
}
|
||||
let key = self.decode(key)?;
|
||||
let key = if decl { key.into_owned() } else { self.attr_name(&key) };
|
||||
let mut value = self.attr_value(&a.value)?;
|
||||
if let Some(hook) = canonical.and_then(|c| c.attr_value)
|
||||
&& let Some(rewritten) = hook(&name, &key, &value, &Scope { bindings: &self.scope })
|
||||
{
|
||||
value = rewritten;
|
||||
}
|
||||
out.push((key, value));
|
||||
}
|
||||
let keep_name = self.end_names || canonical.is_some_and(|c| c.element.is_some());
|
||||
self.open.push(Open {
|
||||
scope,
|
||||
raw: if self.opts.strict { raw.as_ref().to_vec() } else { Vec::new() },
|
||||
name: if keep_name { name.clone() } else { String::new() },
|
||||
});
|
||||
Ok(Event::Start { name, attrs: out })
|
||||
}
|
||||
}
|
||||
|
||||
struct Builder<'b, 'o> {
|
||||
opts: &'b Options<'o>,
|
||||
split: &'b [&'b str],
|
||||
stack: Vec<El>,
|
||||
/// The node count when each open element started.
|
||||
marks: Vec<usize>,
|
||||
kept: usize,
|
||||
root: Option<El>,
|
||||
/// Where the event being handled starts, for errors.
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl Builder<'_, '_> {
|
||||
fn count(&mut self) -> Result<(), Error> {
|
||||
self.kept = self.kept.saturating_add(1);
|
||||
if self.kept > self.opts.limits.max_nodes { Err(Error::TooManyNodes(self.opts.limits.max_nodes)) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn push(&mut self, node: Node) -> Result<(), Error> {
|
||||
if !self.stack.is_empty() {
|
||||
self.count()?;
|
||||
}
|
||||
if let Some(top) = self.stack.last_mut() {
|
||||
top.children.push(node);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn text(&mut self, text: String) -> Result<(), Error> {
|
||||
match self.stack.last_mut() {
|
||||
Some(top) => {
|
||||
if let Some(Node::Text(prev)) = top.children.last_mut() {
|
||||
prev.push_str(&text);
|
||||
return Ok(());
|
||||
}
|
||||
self.push(Node::Text(text))
|
||||
}
|
||||
None if self.opts.strict && !text.trim().is_empty() => Err(Error::Syntax { at: self.at, msg: "text outside the root element".into() }),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn event<E: From<Error>>(&mut self, ev: Event, each: &mut impl FnMut(El) -> Result<(), E>) -> Result<(), E> {
|
||||
match ev {
|
||||
Event::Start { name, attrs } => {
|
||||
if self.root.is_some() {
|
||||
return Err(Error::Syntax { at: self.at, msg: "more than one root element".into() }.into());
|
||||
}
|
||||
self.stack.push(El { name, attrs, children: Vec::new() });
|
||||
self.marks.push(self.kept);
|
||||
}
|
||||
Event::End { .. } => {
|
||||
if let Some(el) = self.stack.pop() {
|
||||
self.finish(el, each)?;
|
||||
}
|
||||
}
|
||||
Event::Text(t) => self.text(t)?,
|
||||
Event::CData(t) if self.opts.cdata => self.push(Node::CData(t))?,
|
||||
Event::CData(t) => self.text(t)?,
|
||||
Event::Pi { target, data } if self.opts.pis => self.push(Node::Pi { target, data })?,
|
||||
Event::Comment(c) if self.opts.comments => self.push(Node::Comment(c))?,
|
||||
Event::Pi { .. } | Event::Comment(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attaches a complete element to its parent, hands it out, or makes it the root.
|
||||
fn finish<E: From<Error>>(&mut self, mut el: El, each: &mut impl FnMut(El) -> Result<(), E>) -> Result<(), E> {
|
||||
let mark = self.marks.pop().unwrap_or(0);
|
||||
if self.opts.drop_blank_text && el.children.iter().any(|n| matches!(n, Node::El(_))) {
|
||||
let is_text = |n: &Node| matches!(n, Node::Text(_));
|
||||
if el.children.iter().all(|n| !is_text(n) || matches!(n, Node::Text(t) if t.trim().is_empty())) {
|
||||
let before = el.children.len();
|
||||
el.children.retain(|n| !is_text(n));
|
||||
self.kept = self.kept.saturating_sub(before - el.children.len());
|
||||
}
|
||||
}
|
||||
let depth = self.stack.len();
|
||||
if depth == 0 {
|
||||
self.count()?;
|
||||
self.root = Some(el);
|
||||
} else if depth <= self.opts.split_depth && self.split.contains(&el.name.as_str()) {
|
||||
each(el)?;
|
||||
// The element and everything inside it have left the tree.
|
||||
self.kept = mark;
|
||||
} else {
|
||||
self.push(Node::El(el))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The input as UTF-8 text for the reader: a UTF-8 byte-order mark is removed, and UTF-16 (with
|
||||
/// a byte-order mark, or recognisable without one by the zero bytes of its first two
|
||||
/// characters) is transcoded. An unpaired surrogate becomes U+FFFD and a trailing odd byte is
|
||||
/// dropped. The result is at most 1.5 times the input. Any other input is returned as it is;
|
||||
/// the encoding an XML declaration names is not consulted.
|
||||
pub fn to_utf8(bytes: &[u8]) -> Cow<'_, [u8]> {
|
||||
let (little_endian, body) = match bytes {
|
||||
[0xEF, 0xBB, 0xBF, rest @ ..] => return Cow::Borrowed(rest),
|
||||
[0xFF, 0xFE, rest @ ..] => (true, rest),
|
||||
[0xFE, 0xFF, rest @ ..] => (false, rest),
|
||||
[a, 0, b, 0, ..] if *a != 0 && *b != 0 => (true, bytes),
|
||||
[0, a, 0, b, ..] if *a != 0 && *b != 0 => (false, bytes),
|
||||
_ => return Cow::Borrowed(bytes),
|
||||
};
|
||||
let units = body.as_chunks::<2>().0.iter().map(|pair| if little_endian { u16::from_le_bytes(*pair) } else { u16::from_be_bytes(*pair) });
|
||||
Cow::Owned(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::<String>().into_bytes())
|
||||
}
|
||||
|
||||
/// The character a reference names, without its `&` and `;`: one of the five predefined
|
||||
/// entities (`amp`, `lt`, `gt`, `quot`, `apos`) or a numeric character reference (`#65`,
|
||||
/// `#x41`, zero-padded or not). `None` for anything else, for a number that is not a character
|
||||
/// XML can carry, and for a number with a sign.
|
||||
pub fn entity(name: &str) -> Option<char> {
|
||||
let number = match name {
|
||||
"amp" => return Some('&'),
|
||||
"lt" => return Some('<'),
|
||||
"gt" => return Some('>'),
|
||||
"quot" => return Some('"'),
|
||||
"apos" => return Some('\''),
|
||||
_ => name.strip_prefix('#')?,
|
||||
};
|
||||
let (digits, radix) = match number.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => (hex, 16),
|
||||
None => (number, 10),
|
||||
};
|
||||
if digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let code = digits.chars().try_fold(0u32, |n, c| n.checked_mul(radix)?.checked_add(c.to_digit(radix)?))?;
|
||||
char::from_u32(code).filter(|c| is_xml_char(*c))
|
||||
}
|
||||
|
||||
/// Resolves the references in `s`, and with `attr` turns literal whitespace into spaces as
|
||||
/// attribute-value normalisation does. An `&` that starts no reference stays as it is. `Err` is
|
||||
/// the name of an unknown entity under [`UnknownEntity::Error`].
|
||||
fn decode_refs(s: &str, attr: bool, unknown: UnknownEntity) -> Result<String, String> {
|
||||
let special = |c: char| c == '&' || (attr && matches!(c, '\t' | '\n' | '\r'));
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find(special) {
|
||||
out.push_str(rest.get(..i).unwrap_or_default());
|
||||
let tail = rest.get(i..).unwrap_or_default();
|
||||
let Some(after) = tail.strip_prefix('&') else {
|
||||
out.push(' ');
|
||||
rest = tail.strip_prefix("\r\n").or_else(|| tail.get(1..)).unwrap_or_default();
|
||||
continue;
|
||||
};
|
||||
// Only the bytes a name or a number can have are scanned, so a run of `&` costs one
|
||||
// look each however far away the next `;` is.
|
||||
let len = after.bytes().take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'#' | b'_' | b'-' | b'.' | b':')).count();
|
||||
let name = after.get(..len).unwrap_or_default();
|
||||
if len == 0 || after.as_bytes().get(len) != Some(&b';') {
|
||||
out.push('&');
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
match (entity(name), unknown) {
|
||||
(Some(c), _) => out.push(c),
|
||||
(None, UnknownEntity::Drop) => {}
|
||||
(None, UnknownEntity::Literal) => {
|
||||
out.push('&');
|
||||
out.push_str(name);
|
||||
out.push(';');
|
||||
}
|
||||
(None, UnknownEntity::Error) => return Err(name.to_string()),
|
||||
}
|
||||
rest = after.get(len + 1..).unwrap_or_default();
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `s` with its predefined and numeric references resolved ([`entity`]). Anything else that
|
||||
/// starts with `&` stays as it is.
|
||||
pub fn unescape(s: &str) -> String {
|
||||
decode_refs(s, false, UnknownEntity::Literal).unwrap_or_else(|_| s.to_string())
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! The owned element tree.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::value;
|
||||
|
||||
/// An element: its name, its attributes in document order and its children in document order.
|
||||
///
|
||||
/// What `name` and the attribute names hold depends on [`crate::Names`]: the qualified name as
|
||||
/// written (`p:sp`), the local name (`sp`), or a canonical prefix and the local name (`w:p`).
|
||||
///
|
||||
/// A tree the reader built is never deeper than [`crate::Limits::max_depth`]. A tree built by
|
||||
/// hand is as deep as its builder made it; the methods here do not recurse, but dropping a tree
|
||||
/// does, once per level.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct El {
|
||||
pub name: String,
|
||||
/// Attributes in document order. A name the document repeats is kept each time.
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
|
||||
/// A child of an element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Node {
|
||||
El(El),
|
||||
/// Character data, references resolved. Adjacent stretches are one node.
|
||||
Text(String),
|
||||
/// A CDATA section. The reader makes these only with [`crate::Options::cdata`]; otherwise a
|
||||
/// section's content is part of the surrounding [`Node::Text`].
|
||||
CData(String),
|
||||
/// A processing instruction (`<?target data?>`), kept only with [`crate::Options::pis`].
|
||||
Pi {
|
||||
target: String,
|
||||
data: String,
|
||||
},
|
||||
/// A comment, kept only with [`crate::Options::comments`].
|
||||
Comment(String),
|
||||
}
|
||||
|
||||
impl From<El> for Node {
|
||||
fn from(e: El) -> Node {
|
||||
Node::El(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Node {
|
||||
fn from(t: &str) -> Node {
|
||||
Node::Text(t.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Node {
|
||||
fn from(t: String) -> Node {
|
||||
Node::Text(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a name is matched against: a `&str` is the whole name, [`Local`] the part after the
|
||||
/// prefix.
|
||||
pub trait Pat: Copy {
|
||||
fn matches(self, name: &str) -> bool;
|
||||
}
|
||||
|
||||
impl Pat for &str {
|
||||
fn matches(self, name: &str) -> bool {
|
||||
self == name
|
||||
}
|
||||
}
|
||||
|
||||
impl Pat for &String {
|
||||
fn matches(self, name: &str) -> bool {
|
||||
self == name
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches a name by its local part, whatever its prefix: `Local("sp")` matches `sp` and `p:sp`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Local<'a>(pub &'a str);
|
||||
|
||||
impl Pat for Local<'_> {
|
||||
fn matches(self, name: &str) -> bool {
|
||||
local_of(name) == self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local_of(name: &str) -> &str {
|
||||
name.rsplit_once(':').map_or(name, |(_, l)| l)
|
||||
}
|
||||
|
||||
fn is_ns_decl(name: &str) -> bool {
|
||||
name == "xmlns" || name.starts_with("xmlns:")
|
||||
}
|
||||
|
||||
impl El {
|
||||
/// An element with no attributes and no children.
|
||||
pub fn new(name: impl Into<String>) -> El {
|
||||
El { name: name.into(), attrs: Vec::new(), children: Vec::new() }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- building and changing
|
||||
|
||||
/// [`El::set_attr`], for chaining.
|
||||
#[must_use]
|
||||
pub fn with_attr(mut self, name: &str, value: impl ToString) -> El {
|
||||
self.set_attr(name, value);
|
||||
self
|
||||
}
|
||||
/// Appends a child element.
|
||||
#[must_use]
|
||||
pub fn with_child(mut self, child: El) -> El {
|
||||
self.children.push(Node::El(child));
|
||||
self
|
||||
}
|
||||
/// Appends text.
|
||||
#[must_use]
|
||||
pub fn with_text(mut self, text: impl Into<String>) -> El {
|
||||
self.children.push(Node::Text(text.into()));
|
||||
self
|
||||
}
|
||||
/// Appends any node (a CDATA section, a processing instruction…).
|
||||
#[must_use]
|
||||
pub fn with_node(mut self, node: impl Into<Node>) -> El {
|
||||
self.children.push(node.into());
|
||||
self
|
||||
}
|
||||
/// Sets the attribute named exactly `name`, replacing its first occurrence or appending it.
|
||||
pub fn set_attr(&mut self, name: &str, value: impl ToString) {
|
||||
let value = value.to_string();
|
||||
match self.attrs.iter_mut().find(|(k, _)| k == name) {
|
||||
Some(slot) => slot.1 = value,
|
||||
None => self.attrs.push((name.to_string(), value)),
|
||||
}
|
||||
}
|
||||
/// Removes every attribute named exactly `name`; the value of the first, if there was one.
|
||||
pub fn remove_attr(&mut self, name: &str) -> Option<String> {
|
||||
let first = self.attrs.iter().position(|(k, _)| k == name)?;
|
||||
let (_, value) = self.attrs.remove(first);
|
||||
self.attrs.retain(|(k, _)| k != name);
|
||||
Some(value)
|
||||
}
|
||||
/// Appends a child node.
|
||||
pub fn push(&mut self, node: impl Into<Node>) {
|
||||
self.children.push(node.into());
|
||||
}
|
||||
/// Child elements, for changing them.
|
||||
pub fn elements_mut(&mut self) -> impl Iterator<Item = &mut El> {
|
||||
self.children.iter_mut().filter_map(|n| match n {
|
||||
Node::El(e) => Some(e),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
/// The first child element that matches, for changing it.
|
||||
pub fn child_mut(&mut self, pat: impl Pat) -> Option<&mut El> {
|
||||
self.elements_mut().find(|e| pat.matches(&e.name))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- names
|
||||
|
||||
/// The name without its prefix.
|
||||
pub fn local(&self) -> &str {
|
||||
local_of(&self.name)
|
||||
}
|
||||
/// The prefix, or `""`.
|
||||
pub fn prefix(&self) -> &str {
|
||||
self.name.split_once(':').map_or("", |(p, _)| p)
|
||||
}
|
||||
/// Does the name match?
|
||||
pub fn is(&self, pat: impl Pat) -> bool {
|
||||
pat.matches(&self.name)
|
||||
}
|
||||
/// The namespace declarations on this element as (prefix, URI); the default namespace has
|
||||
/// the prefix `""`. They are attributes, so the reader must have kept them
|
||||
/// ([`crate::Options::ns_decls`]).
|
||||
pub fn ns_decls(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.attrs.iter().filter(|(k, _)| is_ns_decl(k)).map(|(k, v)| (k.strip_prefix("xmlns:").unwrap_or(""), v.as_str()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- attributes
|
||||
|
||||
/// The first attribute named exactly `name`.
|
||||
pub fn attr(&self, name: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
/// [`El::attr`]; failing that, for a prefixed `name`, the unprefixed attribute with the same
|
||||
/// local name. Some producers leave the prefix out (`val` for `w:val`).
|
||||
pub fn attr_or_plain(&self, name: &str) -> Option<&str> {
|
||||
self.attr(name).or_else(|| {
|
||||
let local = local_of(name);
|
||||
if local == name { None } else { self.attr(local) }
|
||||
})
|
||||
}
|
||||
/// An attribute by local name, for names kept as written. An unprefixed `name` is the
|
||||
/// attribute of exactly that name, else the first one with that local name under any
|
||||
/// prefix. A prefixed `name` (`r:id`) is the first *prefixed* attribute with that local
|
||||
/// name, whatever its prefix. Namespace declarations never match.
|
||||
pub fn attr_local(&self, name: &str) -> Option<&str> {
|
||||
let by_local = |local: &str, prefixed_only: bool| {
|
||||
self.attrs.iter().find(|(k, _)| !is_ns_decl(k) && local_of(k) == local && (!prefixed_only || k.contains(':'))).map(|(_, v)| v.as_str())
|
||||
};
|
||||
match name.split_once(':') {
|
||||
Some((_, local)) => by_local(local, true),
|
||||
None => self.attr(name).or_else(|| by_local(name, false)),
|
||||
}
|
||||
}
|
||||
pub fn has_attr(&self, name: &str) -> bool {
|
||||
self.attr(name).is_some()
|
||||
}
|
||||
/// [`El::attr`] as a number: see [`value::to_u32`].
|
||||
pub fn attr_u32(&self, name: &str) -> Option<u32> {
|
||||
self.attr(name).and_then(value::to_u32)
|
||||
}
|
||||
/// See [`value::to_i32`].
|
||||
pub fn attr_i32(&self, name: &str) -> Option<i32> {
|
||||
self.attr(name).and_then(value::to_i32)
|
||||
}
|
||||
/// See [`value::to_i64`].
|
||||
pub fn attr_i64(&self, name: &str) -> Option<i64> {
|
||||
self.attr(name).and_then(value::to_i64)
|
||||
}
|
||||
/// See [`value::to_f64`]: finite values only.
|
||||
pub fn attr_f64(&self, name: &str) -> Option<f64> {
|
||||
self.attr(name).and_then(value::to_f64)
|
||||
}
|
||||
/// See [`value::to_bool`].
|
||||
pub fn attr_bool(&self, name: &str) -> Option<bool> {
|
||||
self.attr(name).and_then(value::to_bool)
|
||||
}
|
||||
/// [`El::attr_bool`], or `default` when the attribute is missing or not a boolean.
|
||||
pub fn flag(&self, name: &str, default: bool) -> bool {
|
||||
self.attr_bool(name).unwrap_or(default)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- children
|
||||
|
||||
/// Child elements in document order.
|
||||
pub fn elements(&self) -> impl Iterator<Item = &El> {
|
||||
self.children.iter().filter_map(|n| match n {
|
||||
Node::El(e) => Some(e),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
/// The first child element that matches.
|
||||
pub fn child(&self, pat: impl Pat) -> Option<&El> {
|
||||
self.elements().find(|e| pat.matches(&e.name))
|
||||
}
|
||||
/// Every child element that matches.
|
||||
pub fn children<'a, P: Pat + 'a>(&'a self, pat: P) -> impl Iterator<Item = &'a El> + 'a {
|
||||
self.elements().filter(move |e| pat.matches(&e.name))
|
||||
}
|
||||
/// Follows a chain of child names, taking the first match at each step.
|
||||
pub fn path<P: Pat>(&self, names: &[P]) -> Option<&El> {
|
||||
names.iter().try_fold(self, |e, n| e.child(*n))
|
||||
}
|
||||
/// An attribute of the first matching child (`<x val="…"/>`).
|
||||
pub fn child_attr(&self, pat: impl Pat, attr: &str) -> Option<&str> {
|
||||
self.child(pat).and_then(|c| c.attr(attr))
|
||||
}
|
||||
/// [`El::text`] of the first matching child.
|
||||
pub fn child_text(&self, pat: impl Pat) -> Option<Cow<'_, str>> {
|
||||
self.child(pat).map(El::text)
|
||||
}
|
||||
/// Every descendant element, depth first in document order, without this element.
|
||||
pub fn descendants(&self) -> Descendants<'_> {
|
||||
Descendants { stack: vec![self.children.iter()] }
|
||||
}
|
||||
/// The first descendant that matches, depth first.
|
||||
pub fn find(&self, pat: impl Pat) -> Option<&El> {
|
||||
self.descendants().find(|e| pat.matches(&e.name))
|
||||
}
|
||||
/// Every descendant that matches, depth first. A match inside a match is found too.
|
||||
pub fn find_all<'a, P: Pat + 'a>(&'a self, pat: P) -> impl Iterator<Item = &'a El> + 'a {
|
||||
self.descendants().filter(move |e| pat.matches(&e.name))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- text
|
||||
|
||||
/// This element's own character data: its text and CDATA children joined, not its
|
||||
/// descendants'. Borrowed when there is at most one such child.
|
||||
pub fn text(&self) -> Cow<'_, str> {
|
||||
let mut parts = self.children.iter().filter_map(|n| match n {
|
||||
Node::Text(t) | Node::CData(t) => Some(t.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
let first = parts.next().unwrap_or("");
|
||||
match parts.next() {
|
||||
None => Cow::Borrowed(first),
|
||||
Some(second) => {
|
||||
let mut s = String::from(first);
|
||||
s.push_str(second);
|
||||
parts.for_each(|t| s.push_str(t));
|
||||
Cow::Owned(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The character data of this element and every descendant, in document order.
|
||||
pub fn deep_text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
walk(self, |step| {
|
||||
if let Step::Leaf(Node::Text(t) | Node::CData(t)) = step {
|
||||
s.push_str(t);
|
||||
}
|
||||
true
|
||||
});
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`El::descendants`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Descendants<'a> {
|
||||
stack: Vec<std::slice::Iter<'a, Node>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Descendants<'a> {
|
||||
type Item = &'a El;
|
||||
|
||||
fn next(&mut self) -> Option<&'a El> {
|
||||
loop {
|
||||
match self.stack.last_mut()?.next() {
|
||||
Some(Node::El(e)) => {
|
||||
self.stack.push(e.children.iter());
|
||||
return Some(e);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum Step<'a> {
|
||||
Enter(&'a El),
|
||||
Leave(&'a El),
|
||||
Leaf(&'a Node),
|
||||
}
|
||||
|
||||
/// Walks `root` in document order without recursion. `f` returns whether to go into the element
|
||||
/// it was just given; an element it declines gets no [`Step::Leave`].
|
||||
pub(crate) fn walk<'a>(root: &'a El, mut f: impl FnMut(Step<'a>) -> bool) {
|
||||
if !f(Step::Enter(root)) {
|
||||
return;
|
||||
}
|
||||
let mut stack = vec![(root, root.children.iter())];
|
||||
while let Some((el, rest)) = stack.last_mut() {
|
||||
match rest.next() {
|
||||
Some(Node::El(child)) => {
|
||||
if f(Step::Enter(child)) {
|
||||
stack.push((child, child.children.iter()));
|
||||
}
|
||||
}
|
||||
Some(leaf) => {
|
||||
f(Step::Leaf(leaf));
|
||||
}
|
||||
None => {
|
||||
f(Step::Leave(el));
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Attribute and text values as numbers and booleans. Every function trims surrounding
|
||||
//! whitespace and returns `None` for anything it does not read.
|
||||
|
||||
/// A decimal `u32`. Signs other than `+`, fractions and out-of-range values are `None`.
|
||||
pub fn to_u32(s: &str) -> Option<u32> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// A decimal `i32`.
|
||||
pub fn to_i32(s: &str) -> Option<i32> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// A decimal `i64`.
|
||||
pub fn to_i64(s: &str) -> Option<i64> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// A finite `f64`. `NaN` and the infinities are `None`.
|
||||
pub fn to_f64(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// A boolean in any spelling the office formats use: `1`, `true`, `on`, `t` and `0`, `false`,
|
||||
/// `off`, `f`. Case matters, as in `xsd:boolean`.
|
||||
pub fn to_bool(s: &str) -> Option<bool> {
|
||||
match s.trim() {
|
||||
"1" | "true" | "on" | "t" => Some(true),
|
||||
"0" | "false" | "off" | "f" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An integer that may be written as a decimal fraction or lie out of range: a finite fraction
|
||||
/// is truncated toward zero and anything beyond ±9.0e18 is clamped.
|
||||
pub fn to_i64_lossy(s: &str) -> Option<i64> {
|
||||
to_i64(s).or_else(|| to_f64(s).map(|f| f.clamp(-9.0e18, 9.0e18) as i64))
|
||||
}
|
||||
|
||||
/// [`to_i64_lossy`], clamped to `i32`.
|
||||
pub fn to_i32_lossy(s: &str) -> Option<i32> {
|
||||
to_i64_lossy(s).map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32)
|
||||
}
|
||||
|
||||
/// [`to_i64_lossy`], clamped to `u32` (a negative value reads as 0).
|
||||
pub fn to_u32_lossy(s: &str) -> Option<u32> {
|
||||
to_i64_lossy(s).map(|v| v.clamp(0, i64::from(u32::MAX)) as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_and_lossy_numbers() {
|
||||
assert_eq!(to_u32(" 42 "), Some(42));
|
||||
assert_eq!((to_u32("-1"), to_u32("1.5"), to_u32("4294967296"), to_u32("")), (None, None, None, None));
|
||||
assert_eq!((to_i32("-7"), to_i64("-9000000000")), (Some(-7), Some(-9_000_000_000)));
|
||||
assert_eq!((to_f64("2.5"), to_f64("NaN"), to_f64("inf"), to_f64("-inf"), to_f64("x")), (Some(2.5), None, None, None, None));
|
||||
assert_eq!(
|
||||
(to_i64_lossy("12.9"), to_i64_lossy("-12.9"), to_i64_lossy("1e30"), to_i64_lossy("NaN")),
|
||||
(Some(12), Some(-12), Some(9_000_000_000_000_000_000), None)
|
||||
);
|
||||
assert_eq!((to_i32_lossy("99999999999"), to_u32_lossy("-5"), to_u32_lossy("99999999999")), (Some(i32::MAX), Some(0), Some(u32::MAX)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_spellings() {
|
||||
for t in ["1", "true", "on", "t", " true "] {
|
||||
assert_eq!(to_bool(t), Some(true), "{t}");
|
||||
}
|
||||
for f in ["0", "false", "off", "f"] {
|
||||
assert_eq!(to_bool(f), Some(false), "{f}");
|
||||
}
|
||||
for other in ["", "TRUE", "yes", "2"] {
|
||||
assert_eq!(to_bool(other), None, "{other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
//! Writing: escaping, serialising a tree, and a writer for documents built without a tree.
|
||||
|
||||
use std::fmt::{self, Display, Write as _};
|
||||
|
||||
use crate::tree::{El, Node, Step, walk};
|
||||
|
||||
/// The XML declaration, with a newline.
|
||||
pub const DECL: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
||||
/// The XML declaration the office formats use (`standalone="yes"`), with a newline.
|
||||
pub const DECL_STANDALONE: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
|
||||
|
||||
/// May `c` appear in an XML 1.0 document? Control characters other than tab, LF and CR, and
|
||||
/// U+FFFE and U+FFFF, may not, even as character references.
|
||||
pub fn is_xml_char(c: char) -> bool {
|
||||
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
fn is_name_start(c: char) -> bool {
|
||||
matches!(c,
|
||||
':' | '_' | 'A'..='Z' | 'a'..='z'
|
||||
| '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}'
|
||||
| '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}'
|
||||
| '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')
|
||||
}
|
||||
|
||||
/// Is `s` an XML 1.0 name (an element, attribute or processing-instruction target name)?
|
||||
pub fn is_name(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
chars.next().is_some_and(is_name_start)
|
||||
&& chars.all(|c| is_name_start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}'))
|
||||
}
|
||||
|
||||
/// `s` as element content: `&`, `<` and `>` escaped, a carriage return written as ` ` (a
|
||||
/// literal one would be read back as a line feed), characters XML cannot carry dropped.
|
||||
pub fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
push_esc(&mut o, s);
|
||||
o
|
||||
}
|
||||
|
||||
/// Appends [`esc`]`(s)` to `o`.
|
||||
pub fn push_esc(o: &mut String, s: &str) {
|
||||
// Most text is plain ASCII with nothing to escape.
|
||||
if s.bytes().all(|b| matches!(b, b'\t' | b'\n' | 0x20..=0x7E) && !matches!(b, b'&' | b'<' | b'>')) {
|
||||
o.push_str(s);
|
||||
return;
|
||||
}
|
||||
o.reserve(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'\r' => o.push_str(" "),
|
||||
c if is_xml_char(c) => o.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as a double-quoted attribute value: `&`, `<`, `>` and `"` escaped, tab, LF and CR written
|
||||
/// as character references (literal ones would be read back as spaces), characters XML cannot
|
||||
/// carry dropped.
|
||||
pub fn esc_attr(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
push_esc_attr(&mut o, s);
|
||||
o
|
||||
}
|
||||
|
||||
/// Appends [`esc_attr`]`(s)` to `o`.
|
||||
pub fn push_esc_attr(o: &mut String, s: &str) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\t' => o.push_str("	"),
|
||||
'\n' => o.push_str(" "),
|
||||
'\r' => o.push_str(" "),
|
||||
c if is_xml_char(c) => o.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes whatever is formatted into it as an attribute value.
|
||||
struct AttrSink<'a>(&'a mut String);
|
||||
|
||||
impl fmt::Write for AttrSink<'_> {
|
||||
fn write_str(&mut self, s: &str) -> fmt::Result {
|
||||
push_esc_attr(self.0, s);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn push_attr(o: &mut String, name: &str, value: impl Display) {
|
||||
o.push(' ');
|
||||
o.push_str(name);
|
||||
o.push_str("=\"");
|
||||
let _ = write!(AttrSink(o), "{value}");
|
||||
o.push('"');
|
||||
}
|
||||
|
||||
fn push_pi(o: &mut String, target: &str, data: &str) {
|
||||
// A target that is not a name, the reserved target `xml`, or data that would end the
|
||||
// instruction early cannot be written.
|
||||
if !is_name(target) || target.eq_ignore_ascii_case("xml") || data.contains("?>") {
|
||||
return;
|
||||
}
|
||||
o.push_str("<?");
|
||||
o.push_str(target);
|
||||
if !data.is_empty() {
|
||||
o.push(' ');
|
||||
o.extend(data.chars().filter(|c| is_xml_char(*c)));
|
||||
}
|
||||
o.push_str("?>");
|
||||
}
|
||||
|
||||
fn push_cdata(o: &mut String, text: &str) {
|
||||
o.push_str("<![CDATA[");
|
||||
// `]]>` cannot appear in a section: end the section inside it and start another.
|
||||
for (i, part) in text.split("]]>").enumerate() {
|
||||
if i > 0 {
|
||||
o.push_str("]]]]><![CDATA[>");
|
||||
}
|
||||
o.extend(part.chars().filter(|c| is_xml_char(*c)));
|
||||
}
|
||||
o.push_str("]]>");
|
||||
}
|
||||
|
||||
fn push_leaf(o: &mut String, node: &Node) {
|
||||
match node {
|
||||
Node::El(_) => {}
|
||||
Node::Text(t) => push_esc(o, t),
|
||||
Node::CData(t) => push_cdata(o, t),
|
||||
Node::Pi { target, data } => push_pi(o, target, data),
|
||||
// `--` cannot appear in a comment and a comment cannot end with `-`.
|
||||
Node::Comment(c) if c.contains("--") || c.ends_with('-') => {}
|
||||
Node::Comment(c) => {
|
||||
o.push_str("<!--");
|
||||
o.extend(c.chars().filter(|ch| is_xml_char(*ch)));
|
||||
o.push_str("-->");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_start(o: &mut String, e: &El) {
|
||||
o.push('<');
|
||||
o.push_str(&e.name);
|
||||
for (k, v) in e.attrs.iter().filter(|(k, _)| is_name(k)) {
|
||||
push_attr(o, k, v);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_end(o: &mut String, e: &El) {
|
||||
o.push_str("</");
|
||||
o.push_str(&e.name);
|
||||
o.push('>');
|
||||
}
|
||||
|
||||
fn push_el(o: &mut String, root: &El) {
|
||||
// Where each open element's content starts: one that wrote none is closed as `<a/>`, so
|
||||
// that what is written reads back as the same text however much of it was left out.
|
||||
let mut content = Vec::new();
|
||||
walk(root, |step| {
|
||||
match step {
|
||||
Step::Enter(e) => {
|
||||
if !is_name(&e.name) {
|
||||
return false;
|
||||
}
|
||||
push_start(o, e);
|
||||
if e.children.is_empty() {
|
||||
o.push_str("/>");
|
||||
return false;
|
||||
}
|
||||
o.push('>');
|
||||
content.push(o.len());
|
||||
}
|
||||
Step::Leave(e) => {
|
||||
if content.pop() == Some(o.len()) {
|
||||
o.pop();
|
||||
o.push_str("/>");
|
||||
} else {
|
||||
push_end(o, e);
|
||||
}
|
||||
}
|
||||
Step::Leaf(n) => push_leaf(o, n),
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn is_blank(n: &Node) -> bool {
|
||||
matches!(n, Node::Text(t) if t.chars().all(|c| matches!(c, ' ' | '\t' | '\n' | '\r')))
|
||||
}
|
||||
|
||||
/// Children that are all elements (with at most blank text between them) go one per line.
|
||||
fn is_block(e: &El) -> bool {
|
||||
let mut any = false;
|
||||
for n in &e.children {
|
||||
match n {
|
||||
Node::El(c) => any |= is_name(&c.name),
|
||||
n if is_blank(n) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
fn push_pretty(o: &mut String, root: &El, indent: &str, base: usize) {
|
||||
let mut depth = base;
|
||||
let pad = |o: &mut String, depth: usize| (0..depth).for_each(|_| o.push_str(indent));
|
||||
walk(root, |step| {
|
||||
match step {
|
||||
Step::Enter(e) => {
|
||||
if !is_name(&e.name) {
|
||||
return false;
|
||||
}
|
||||
pad(o, depth);
|
||||
if !is_block(e) {
|
||||
push_el(o, e);
|
||||
o.push('\n');
|
||||
return false;
|
||||
}
|
||||
push_start(o, e);
|
||||
o.push_str(">\n");
|
||||
depth += 1;
|
||||
}
|
||||
Step::Leave(e) => {
|
||||
depth = depth.saturating_sub(1);
|
||||
pad(o, depth);
|
||||
push_end(o, e);
|
||||
o.push('\n');
|
||||
}
|
||||
// Blank text between the children of a block.
|
||||
Step::Leaf(_) => {}
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
impl El {
|
||||
/// The element as XML on one line, with no declaration. See [`El::write_xml`].
|
||||
pub fn to_xml(&self) -> String {
|
||||
let mut s = String::new();
|
||||
self.write_xml(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
/// Appends the element as XML. Text is escaped with [`esc`] and attribute values with
|
||||
/// [`esc_attr`]; an element without children is written `<a/>`. An element or attribute
|
||||
/// whose name is not an XML name ([`is_name`]) is left out with everything inside it, so a
|
||||
/// name can never inject markup. Reading the result back gives an equal tree, apart from
|
||||
/// such names and characters XML cannot carry.
|
||||
pub fn write_xml(&self, out: &mut String) {
|
||||
push_el(out, self);
|
||||
}
|
||||
|
||||
/// The element as indented XML, ending with a newline. See [`El::write_xml_pretty`].
|
||||
pub fn to_xml_pretty(&self, indent: &str) -> String {
|
||||
let mut s = String::new();
|
||||
self.write_xml_pretty(&mut s, indent);
|
||||
s
|
||||
}
|
||||
|
||||
/// Appends the element as indented XML: an element whose children are all elements has them
|
||||
/// one per line, `indent` deeper each level, and any other element is written on one line
|
||||
/// exactly as [`El::write_xml`] writes it. Character data is therefore never changed, but
|
||||
/// whitespace-only text between the children of an element-only element is not kept.
|
||||
pub fn write_xml_pretty(&self, out: &mut String, indent: &str) {
|
||||
push_pretty(out, self, indent, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds XML text without a tree. Element names are closed for the caller, attribute values and
|
||||
/// text are escaped, and with [`Writer::pretty`] each element goes on its own line.
|
||||
///
|
||||
/// Names are written as given: they are the caller's own constants, not checked.
|
||||
///
|
||||
/// ```
|
||||
/// let mut w = craft_xml::Writer::document();
|
||||
/// w.tag("w:p").attr("w:id", 7).open();
|
||||
/// w.leaf("w:t", &[("xml:space", "preserve")], " a < b ");
|
||||
/// w.empty("w:br", &[]);
|
||||
/// assert_eq!(
|
||||
/// w.finish(),
|
||||
/// "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:p w:id=\"7\"><w:t xml:space=\"preserve\"> a < b </w:t><w:br/></w:p>"
|
||||
/// );
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Writer {
|
||||
out: String,
|
||||
/// The names of the open elements, one after another, and where each starts.
|
||||
names: String,
|
||||
open: Vec<usize>,
|
||||
indent: Option<String>,
|
||||
}
|
||||
|
||||
/// A start tag being written: add attributes, then end it with [`Tag::open`], [`Tag::empty`] or
|
||||
/// [`Tag::text`].
|
||||
#[must_use = "end the tag with `open`, `empty` or `text`"]
|
||||
pub struct Tag<'w> {
|
||||
w: &'w mut Writer,
|
||||
name: &'w str,
|
||||
}
|
||||
|
||||
impl Tag<'_> {
|
||||
/// An attribute; `value` is formatted and escaped.
|
||||
pub fn attr(self, name: &str, value: impl Display) -> Self {
|
||||
push_attr(&mut self.w.out, name, value);
|
||||
self
|
||||
}
|
||||
/// An attribute when there is a value.
|
||||
pub fn attr_opt(self, name: &str, value: Option<impl Display>) -> Self {
|
||||
match value {
|
||||
Some(v) => self.attr(name, v),
|
||||
None => self,
|
||||
}
|
||||
}
|
||||
/// Several attributes.
|
||||
pub fn attrs(self, attrs: &[(&str, &str)]) -> Self {
|
||||
attrs.iter().fold(self, |tag, (k, v)| tag.attr(k, v))
|
||||
}
|
||||
/// `<name …>`: the element stays open until [`Writer::close`].
|
||||
pub fn open(self) {
|
||||
self.w.out.push('>');
|
||||
self.w.open.push(self.w.names.len());
|
||||
self.w.names.push_str(self.name);
|
||||
self.w.newline();
|
||||
}
|
||||
/// `<name …/>`.
|
||||
pub fn empty(self) {
|
||||
self.w.out.push_str("/>");
|
||||
self.w.newline();
|
||||
}
|
||||
/// `<name …>text</name>`.
|
||||
pub fn text(self, text: &str) {
|
||||
self.w.out.push('>');
|
||||
push_esc(&mut self.w.out, text);
|
||||
self.w.out.push_str("</");
|
||||
self.w.out.push_str(self.name);
|
||||
self.w.out.push('>');
|
||||
self.w.newline();
|
||||
}
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// An empty writer, for a fragment.
|
||||
pub fn new() -> Writer {
|
||||
Writer::default()
|
||||
}
|
||||
/// A writer that starts with [`DECL_STANDALONE`].
|
||||
pub fn document() -> Writer {
|
||||
Writer { out: String::from(DECL_STANDALONE), ..Writer::default() }
|
||||
}
|
||||
/// A writer that puts each element on its own line, `indent` deeper per open element.
|
||||
/// Elements written with [`Writer::leaf`] or [`Tag::text`] stay on one line. Text written
|
||||
/// with [`Writer::text`] is not indented.
|
||||
pub fn pretty(indent: &str) -> Writer {
|
||||
Writer { indent: Some(indent.to_string()), ..Writer::default() }
|
||||
}
|
||||
|
||||
fn pad(&mut self) {
|
||||
if let Some(indent) = &self.indent {
|
||||
(0..self.open.len()).for_each(|_| self.out.push_str(indent));
|
||||
}
|
||||
}
|
||||
fn newline(&mut self) {
|
||||
if self.indent.is_some() {
|
||||
self.out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the XML declaration: [`DECL_STANDALONE`] or [`DECL`].
|
||||
pub fn decl(&mut self, standalone: bool) {
|
||||
self.out.push_str(if standalone { DECL_STANDALONE } else { DECL });
|
||||
}
|
||||
/// Writes `<!DOCTYPE name>` and a newline. Nothing is written if `name` is not a name.
|
||||
pub fn doctype(&mut self, name: &str) {
|
||||
if is_name(name) {
|
||||
let _ = writeln!(self.out, "<!DOCTYPE {name}>");
|
||||
}
|
||||
}
|
||||
/// Writes a processing instruction, and a newline when no element is open. Nothing is
|
||||
/// written if `target` is not a name or `data` holds `?>`.
|
||||
pub fn pi(&mut self, target: &str, data: &str) {
|
||||
let before = self.out.len();
|
||||
push_pi(&mut self.out, target, data);
|
||||
if self.open.is_empty() && self.out.len() > before {
|
||||
self.out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a tag: `w.tag("a:off").attr("x", 1).attr("y", 2).empty()`.
|
||||
pub fn tag<'w>(&'w mut self, name: &'w str) -> Tag<'w> {
|
||||
self.pad();
|
||||
self.out.push('<');
|
||||
self.out.push_str(name);
|
||||
Tag { w: self, name }
|
||||
}
|
||||
/// `<name attrs>`.
|
||||
pub fn open(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
||||
self.tag(name).attrs(attrs).open();
|
||||
}
|
||||
/// `<name attrs/>`.
|
||||
pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
||||
self.tag(name).attrs(attrs).empty();
|
||||
}
|
||||
/// `<name attrs>text</name>`.
|
||||
pub fn leaf(&mut self, name: &str, attrs: &[(&str, &str)], text: &str) {
|
||||
self.tag(name).attrs(attrs).text(text);
|
||||
}
|
||||
/// Closes the innermost open element. Nothing happens when none is open.
|
||||
pub fn close(&mut self) {
|
||||
let Some(start) = self.open.pop() else { return };
|
||||
self.pad();
|
||||
self.out.push_str("</");
|
||||
self.out.push_str(self.names.get(start..).unwrap_or_default());
|
||||
self.out.push('>');
|
||||
self.names.truncate(start);
|
||||
self.newline();
|
||||
}
|
||||
/// Escaped text.
|
||||
pub fn text(&mut self, text: &str) {
|
||||
push_esc(&mut self.out, text);
|
||||
}
|
||||
/// A CDATA section.
|
||||
pub fn cdata(&mut self, text: &str) {
|
||||
push_cdata(&mut self.out, text);
|
||||
}
|
||||
/// Markup the caller has already written, as it is.
|
||||
pub fn raw(&mut self, xml: &str) {
|
||||
self.out.push_str(xml);
|
||||
}
|
||||
/// A tree: on one line, or indented to the current depth with [`Writer::pretty`].
|
||||
pub fn tree(&mut self, el: &El) {
|
||||
match &self.indent {
|
||||
Some(indent) => push_pretty(&mut self.out, el, indent, self.open.len()),
|
||||
None => push_el(&mut self.out, el),
|
||||
}
|
||||
}
|
||||
/// What has been written so far.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.out
|
||||
}
|
||||
/// Closes every element still open and returns the text.
|
||||
pub fn finish(mut self) -> String {
|
||||
while !self.open.is_empty() {
|
||||
self.close();
|
||||
}
|
||||
self.out
|
||||
}
|
||||
/// [`Writer::finish`], as bytes.
|
||||
pub fn into_bytes(self) -> Vec<u8> {
|
||||
self.finish().into_bytes()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
//! The tests the Craft apps had for their own XML helpers, against this crate: each app's
|
||||
//! expectations, read the way that app reads (see the migration table in the README).
|
||||
|
||||
use craft_xml::{Canonical, Doctype, El, Error, Event, Limits, Local, Node, NsName, Options, Scope, Writer, esc, esc_attr, parse, to_utf8, unescape};
|
||||
|
||||
// ------------------------------------------------------------------ WordCraft: canonical prefixes
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const WORD_NS: &[(&str, &str)] = &[
|
||||
("w", W),
|
||||
("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
|
||||
("wp", "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"),
|
||||
("a", "http://schemas.openxmlformats.org/drawingml/2006/main"),
|
||||
("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
|
||||
("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"),
|
||||
("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"),
|
||||
// ISO/IEC 29500 Strict namespaces map onto the same prefixes.
|
||||
("w", "http://purl.oclc.org/ooxml/wordprocessingml/main"),
|
||||
("wp", "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing"),
|
||||
("a", "http://purl.oclc.org/ooxml/drawingml/main"),
|
||||
];
|
||||
const WORD: Canonical<'static> = Canonical::new(WORD_NS);
|
||||
|
||||
fn word(bytes: &[u8]) -> Result<El, Error> {
|
||||
Options::canonical(&WORD).with_limits(Limits { max_nodes: 8_000_000, ..Limits::DEFAULT }).parse(bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_parses_with_namespaces() {
|
||||
let x = br#"<?xml version="1.0"?><x:document xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:rr="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><x:p rr:id="r1" x:val="a&b"><x:t xml:space="preserve"> hi A< </x:t></x:p></x:document>"#;
|
||||
let e = word(x).unwrap();
|
||||
assert_eq!(e.name, "w:document");
|
||||
assert!(e.attrs.is_empty(), "namespace declarations are not attributes of this tree");
|
||||
let p = e.child("w:p").unwrap();
|
||||
assert_eq!(p.attr("r:id"), Some("r1"));
|
||||
assert_eq!(p.attr("w:val"), Some("a&b"));
|
||||
let t = p.child("w:t").unwrap();
|
||||
assert_eq!(t.text(), " hi A< ");
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
assert_eq!((t.local(), t.prefix()), ("t", "w"));
|
||||
assert_eq!(e.child_attr("w:p", "w:val"), Some("a&b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_attr_falls_back_to_the_unqualified_name() {
|
||||
let x = format!(r#"<w:p xmlns:w="{W}"><w:a w:val="1"/><w:b val="2"/><w:c other="3"/></w:p>"#);
|
||||
let p = word(x.as_bytes()).unwrap();
|
||||
let val = |name: &str| p.child(name).and_then(|c| c.attr_or_plain("w:val"));
|
||||
assert_eq!((val("w:a"), val("w:b"), val("w:c")), (Some("1"), Some("2"), None));
|
||||
assert_eq!(p.child("w:b").and_then(|c| c.attr("w:val")), None, "the exact lookup does not fall back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_unknown_namespaces_read_behind_a_question_mark_and_are_not_written() {
|
||||
let x = format!(
|
||||
r#"<w:body xmlns:w="{W}" xmlns:z="urn:future" xmlns:u="urn:other"><w:p z:new="1" w:old="2"><z:thing><w:r/></z:thing><w:r>t</w:r></w:p><undeclared:x/><plain/></w:body>"#
|
||||
);
|
||||
let body = word(x.as_bytes()).unwrap();
|
||||
let p = body.child("w:p").unwrap();
|
||||
assert_eq!(p.attr("?:new"), Some("1"));
|
||||
assert!(p.child("?:thing").is_some());
|
||||
assert!(body.child("x").is_some(), "a prefix nothing declares is no namespace");
|
||||
assert!(body.child("plain").is_some());
|
||||
assert_eq!(body.to_xml(), r#"<w:body><w:p w:old="2"><w:r>t</w:r></w:p><x/><plain/></w:body>"#);
|
||||
}
|
||||
|
||||
/// WordCraft reads the one Strict WordprocessingDrawing namespace as Word 2010's several, by
|
||||
/// element and parent: the element hook.
|
||||
#[test]
|
||||
fn word_strict_drawing_names_read_as_word_2010_names() {
|
||||
const WNE: &str = "http://schemas.microsoft.com/office/word/2006/wordml";
|
||||
fn drawing(n: &NsName<'_>) -> Option<&'static str> {
|
||||
match (n.prefix, n.parent, n.local) {
|
||||
("wp", _, "wsp") => Some("wps"),
|
||||
("wp", Some("wps:wsp"), "spPr" | "txbx" | "bodyPr") => Some("wps"),
|
||||
("wp", Some("wps:txbx"), "txbxContent") => Some("w"),
|
||||
("?", Some("wps:txbx"), "txbxContent") if n.uri == WNE => Some("w"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let canon = Canonical { element: Some(&drawing), ..WORD };
|
||||
let x = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
|
||||
<d:anchor><d:docPr/><d:wsp><d:spPr/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp><d:wsp><d:txbx><d:txbxContent/></d:txbx></d:wsp></d:anchor>
|
||||
<wne:txbxContent/><d:inline><d:bodyPr/></d:inline></w:body>"#;
|
||||
let root = Options::canonical(&canon).parse(x).unwrap();
|
||||
let names: Vec<&str> = std::iter::once(&root).chain(root.descendants()).map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names.join(" "),
|
||||
"w:body wp:anchor wp:docPr wps:wsp wps:spPr wps:txbx w:txbxContent w:p wps:bodyPr wps:wsp wps:txbx w:txbxContent ?:txbxContent wp:inline wp:bodyPr"
|
||||
);
|
||||
}
|
||||
|
||||
/// `mc:Choice/@Requires` holds prefixes, not names: WordCraft resolves them while the scope is
|
||||
/// known. The attribute-value hook.
|
||||
#[test]
|
||||
fn word_choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
fn requires(el: &str, attr: &str, value: &str, scope: &Scope<'_>) -> Option<String> {
|
||||
(el == "mc:Choice" && attr == "Requires").then(|| {
|
||||
let prefixes = value.split_whitespace().map(|p| scope.uri(p).map_or("?", |uri| WORD.prefix(uri)));
|
||||
prefixes.collect::<Vec<_>>().join(" ")
|
||||
})
|
||||
}
|
||||
let canon = Canonical { attr_value: Some(&requires), ..WORD };
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/><mc:Choice Requires="pen mc"/></mc:AlternateContent>"#;
|
||||
let e = Options::canonical(&canon).parse(x).unwrap();
|
||||
let values: Vec<_> = e.children("mc:Choice").map(|c| c.attr("Requires")).collect();
|
||||
assert_eq!(values, [Some("wpi"), Some("?"), Some("?"), Some("wpi mc")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
|
||||
let strict = Options::canonical(&WORD).strict();
|
||||
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>", b"<a><b></a></b>", b"<a>", b"</a>", b"<a/>text"];
|
||||
for xml in bad {
|
||||
assert!(strict.parse(xml).is_err(), "{}", String::from_utf8_lossy(xml));
|
||||
}
|
||||
assert!(strict.parse(b"<a>x & y</a>\n").is_ok());
|
||||
assert!(strict.parse(b"<?xml version='1.0'?>\n<!-- c -->\n<a/>\n<!-- d -->\n").is_ok());
|
||||
assert!(word(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
assert_eq!(word(b"<w:t>before &unknown; after</w:t>").unwrap().text(), "before after", "and drops an undefined entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_utf16_parts_parse_and_odd_ones_never_panic() {
|
||||
let src = r#"<?xml version="1.0" encoding="UTF-16"?><r:Relationships xmlns:r="urn:x"><r:Relationship Id="rId1" Target="café😀.xml"/></r:Relationships>"#;
|
||||
for le in [true, false] {
|
||||
let mut b = if le { vec![0xFF, 0xFE] } else { vec![0xFE, 0xFF] };
|
||||
b.extend(src.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }));
|
||||
let root = word(&b).unwrap();
|
||||
assert_eq!(root.elements().next().and_then(|e| e.attr("Target")), Some("café😀.xml"), "le {le}");
|
||||
}
|
||||
// Odd length, unpaired surrogates, a bare BOM: an error or a tree, never a panic.
|
||||
for b in [&[0xFF, 0xFE][..], &[0xFE, 0xFF, 0x00], &[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'a', 0, b'/', 0, b'>', 0], &[0xFF, 0xFE, 0x00, 0xDC, 0x00]]
|
||||
{
|
||||
let _ = word(b);
|
||||
}
|
||||
assert_eq!(to_utf8(&[0xFF, 0xFE, b'a', 0, 0x00, 0xD8, b'b']).as_ref(), "a\u{FFFD}".as_bytes());
|
||||
assert_eq!(to_utf8(b"<a/>").as_ref(), b"<a/>");
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR.
|
||||
#[test]
|
||||
fn word_line_ends_are_normalised_but_a_cr_reference_stays() {
|
||||
let x = format!("<x:document xmlns:x=\"{W}\"><x:t>a\r\nb\rc d<![CDATA[e\r\nf]]></x:t></x:document>");
|
||||
assert_eq!(word(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
|
||||
// The writer escapes a CR, so a CR in the text survives a save.
|
||||
let x = format!("<x:document xmlns:x=\"{W}\"><x:t>{}</x:t></x:document>", esc("a\r\nb\rc"));
|
||||
assert_eq!(word(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
|
||||
// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||||
let mut x = format!("<x:document xmlns:x=\"{W}\"><x:t><![CDATA[a").into_bytes();
|
||||
x.extend_from_slice(b"\xff\r\nb\rc]]></x:t></x:document>");
|
||||
assert_eq!(word(&x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_deep_text_and_find() {
|
||||
let x = format!(r#"<w:p xmlns:w="{W}">a<w:r><w:t>b</w:t><w:r><w:t>c</w:t></w:r></w:r>d</w:p>"#);
|
||||
let p = word(x.as_bytes()).unwrap();
|
||||
assert_eq!(p.text(), "ad");
|
||||
assert_eq!(p.deep_text(), "abcd");
|
||||
assert_eq!(p.find("w:t").map(|t| t.text().into_owned()).as_deref(), Some("b"));
|
||||
assert_eq!(p.find_all("w:t").count(), 2);
|
||||
assert!(p.find("w:p").is_none(), "the element itself is not its own descendant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_hostile_parts_are_errors() {
|
||||
assert_eq!(word("<a>".repeat(1000).as_bytes()), Err(Error::TooDeep(256)));
|
||||
// Thousands of `xmlns:` declarations on one tag are refused, not bound (RUSTSEC-2026-0195).
|
||||
let decls: String = (0..10_000).map(|i| format!(" xmlns:a{i}=\"urn:a{i}\"")).collect();
|
||||
assert_eq!(word(format!("<a{decls}><b/></a>").as_bytes()), Err(Error::TooManyAttrs(1024)));
|
||||
// One start tag with 200,000 attributes, distinct or duplicate, is refused (wordcraft#3702).
|
||||
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
|
||||
let duplicate = " a=\"\"".repeat(200_000);
|
||||
for attrs in [distinct, duplicate] {
|
||||
assert_eq!(word(format!("<w:p xmlns:w=\"{W}\"{attrs}/>").as_bytes()), Err(Error::TooManyAttrs(1024)));
|
||||
}
|
||||
// Far more than any real producer writes still reads.
|
||||
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
|
||||
let p = word(format!("<p{many}/>").as_bytes()).unwrap();
|
||||
assert_eq!((p.attrs.len(), p.attr("a255")), (256, Some("255")));
|
||||
assert!(word(b"").is_err());
|
||||
assert!(word(b"\xff\xfe<").is_err());
|
||||
let _ = word(b"<a><b></a>");
|
||||
assert_eq!(word(b"<a x='1' x='2'/>").unwrap().attrs.len(), 2, "a repeated attribute is kept each time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_writer() {
|
||||
assert_eq!(esc_attr("a<b&\"\u{1}"), "a<b&"");
|
||||
let mut w = Writer::document();
|
||||
w.open("w:p", &[("w:rsidR", "00A1")]);
|
||||
w.empty("w:jc", &[("w:val", "center")]);
|
||||
w.leaf("w:t", &[], "x & y");
|
||||
w.raw("<w:br/>");
|
||||
w.text("tail\r");
|
||||
w.close();
|
||||
let expect = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:p w:rsidR=\"00A1\"><w:jc w:val=\"center\"/><w:t>x & y</w:t><w:br/>tail </w:p>";
|
||||
assert_eq!(String::from_utf8(w.into_bytes()).unwrap(), expect);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ GridCraft: local names, streaming
|
||||
|
||||
fn grid(bytes: &[u8]) -> Result<El, Error> {
|
||||
Options { unknown_entity: craft_xml::UnknownEntity::Literal, ..Options::local() }.parse(bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_parse_basic() {
|
||||
let x = br#"<?xml version="1.0"?><a:root xmlns:a="x" k="v & w" r:id="rId1"><b>t < uA</b><c/><![CDATA[<raw>]]></a:root>"#;
|
||||
let e = grid(x).unwrap();
|
||||
assert_eq!(e.name, "root");
|
||||
assert_eq!(e.attr("k"), Some("v & w"));
|
||||
assert_eq!(e.attr("id"), Some("rId1"), "attributes are named by their local name");
|
||||
assert_eq!(e.attrs.len(), 2, "namespace declarations carry no data");
|
||||
assert_eq!(e.child("b").unwrap().text(), "t < uA");
|
||||
assert!(e.child("c").is_some());
|
||||
assert_eq!(e.text(), "<raw>");
|
||||
assert_eq!(grid(b"<a>&unknown; &</a>").unwrap().text(), "&unknown; &", "an undefined entity stays as written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_typed_attributes_paths_and_values() {
|
||||
let x = br#"<sheet n=" 7 " neg="-3" f="1.5" inf="inf" on="on" t="t" no="false" odd="yes"><a><b><c val="deep"/></b></a><v val="top"/></sheet>"#;
|
||||
let e = grid(x).unwrap();
|
||||
assert_eq!((e.attr_u32("n"), e.attr_u32("neg"), e.attr_u32("f"), e.attr_u32("missing")), (Some(7), None, None, None));
|
||||
assert_eq!((e.attr_i64("neg"), e.attr_i32("neg"), e.attr_f64("f"), e.attr_f64("inf")), (Some(-3), Some(-3), Some(1.5), None));
|
||||
assert_eq!((e.attr_bool("on"), e.attr_bool("t"), e.attr_bool("no"), e.attr_bool("odd")), (Some(true), Some(true), Some(false), None));
|
||||
assert!(e.flag("odd", true) && !e.flag("no", true) && !e.flag("missing", false));
|
||||
assert_eq!(e.path(&["a", "b", "c"]).and_then(|c| c.attr("val")), Some("deep"));
|
||||
assert!(e.path(&["a", "c"]).is_none());
|
||||
assert_eq!(e.child_attr("v", "val"), Some("top"));
|
||||
assert_eq!(e.children("v").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_streaming_split() {
|
||||
let x = b"<r><row n='1'/><row n='2'><c/></row><other/><g><row n='3'/><h><row n='4'/></h></g></r>";
|
||||
let mut seen = Vec::new();
|
||||
let root = Options::local()
|
||||
.parse_streaming(x, &["row"], |e| {
|
||||
assert_eq!(e.name, "row");
|
||||
seen.push(e.attr_u32("n"));
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(seen, [Some(1), Some(2), Some(3)], "children and grandchildren of the root are handed out");
|
||||
assert_eq!(root.elements().count(), 2);
|
||||
assert_eq!(root.find("row").and_then(|r| r.attr("n")), Some("4"), "a deeper element of the same name stays in the tree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_a_streaming_error_stops_the_read() {
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum AppError {
|
||||
Xml(Error),
|
||||
Row(u32),
|
||||
}
|
||||
impl From<Error> for AppError {
|
||||
fn from(e: Error) -> Self {
|
||||
AppError::Xml(e)
|
||||
}
|
||||
}
|
||||
let mut rows = 0;
|
||||
let read = |xml: &[u8], rows: &mut u32| {
|
||||
Options::local().parse_streaming(xml, &["row"], |_| {
|
||||
*rows += 1;
|
||||
if *rows == 2 { Err(AppError::Row(2)) } else { Ok(()) }
|
||||
})
|
||||
};
|
||||
assert_eq!(read(b"<r><row/><row/><row/></r>", &mut rows), Err(AppError::Row(2)));
|
||||
assert_eq!(rows, 2);
|
||||
assert_eq!(read(b"", &mut rows), Err(AppError::Xml(Error::NoRoot)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_a_streamed_part_may_hold_more_elements_than_the_cap() {
|
||||
// 501 rows of 10 cells is 10,020 `c` and `v` elements, more than this cap, but the rows are
|
||||
// streamed out one by one and never pile up in the tree. Kept in a tree, the part is refused.
|
||||
let opts = Options::local().with_limits(Limits { max_nodes: 10_000, ..Limits::DEFAULT });
|
||||
let row = format!("<row>{}</row>", "<c><v>1</v></c>".repeat(10));
|
||||
let xml = format!("<worksheet><sheetData>{}</sheetData></worksheet>", row.repeat(501));
|
||||
let mut rows = 0usize;
|
||||
let root = opts
|
||||
.parse_streaming(xml.as_bytes(), &["row"], |r| {
|
||||
assert_eq!(r.elements().count(), 10);
|
||||
rows += 1;
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(rows, 501);
|
||||
assert_eq!(root.child("sheetData").map(|d| d.children.len()), Some(0));
|
||||
assert_eq!(opts.parse(xml.as_bytes()), Err(Error::TooManyNodes(10_000)));
|
||||
// One row that is itself over the cap is refused while it is being read.
|
||||
let huge = format!("<sheetData><row>{}</row></sheetData>", "<c/>".repeat(10_001));
|
||||
assert_eq!(opts.parse_streaming(huge.as_bytes(), &["row"], |_| Ok::<(), Error>(())), Err(Error::TooManyNodes(10_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_first_start_reads_one_tag_and_nothing_after_it() {
|
||||
let vml = b"<xml xmlns:v='urn:v'><v:shape id='a'><div>one<br>two<br></div><x:ClientData ObjectType=\"Note\" r:id='7'><broken";
|
||||
let found = Options::local().first_start(vml, "ClientData").unwrap();
|
||||
assert_eq!((found.name.as_str(), found.attr("ObjectType"), found.attr("id")), ("ClientData", Some("Note"), Some("7")));
|
||||
assert!(found.children.is_empty());
|
||||
assert_eq!(Options::default().first_start(vml, Local("ClientData")).map(|e| e.name), Some("x:ClientData".to_string()));
|
||||
assert!(Options::local().first_start(vml, "missing").is_none());
|
||||
// Tags that are never closed are not nesting.
|
||||
let unclosed = format!("{}<hit/>", "<br>".repeat(1000));
|
||||
assert!(Options::local().first_start(unclosed.as_bytes(), "hit").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_utf16_without_a_byte_order_mark() {
|
||||
let src = "<?xml version=\"1.0\" encoding=\"UTF-16\"?><a k=\"ü\"/>";
|
||||
for le in [true, false] {
|
||||
let b: Vec<u8> = src.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect();
|
||||
assert_eq!(grid(&b).unwrap().attr("k"), Some("ü"), "le {le}");
|
||||
}
|
||||
assert_eq!(grid(b"\xEF\xBB\xBF<a/>").unwrap().name, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_garbage_and_depth() {
|
||||
assert!(grid("<a>".repeat(10_000).as_bytes()).is_err());
|
||||
assert!(grid(b"").is_err());
|
||||
let _ = grid(b"\x00\x01\x02 not xml");
|
||||
let _ = grid(b"<a><b></a>");
|
||||
let _ = grid(b"<a attr='&bogus;'>&unknown;</a>");
|
||||
}
|
||||
|
||||
/// The appending escape writes what the character loop GridCraft checked it against writes.
|
||||
#[test]
|
||||
fn grid_escaping() {
|
||||
assert_eq!(esc("a\rb"), "a b");
|
||||
assert_eq!(esc_attr("a\rb"), "a b");
|
||||
for t in ["plain", "", "a & <b> > c \"q\"", "tab\tnew\nline\rcr", "\u{1}\u{8}ctl", "日本語 ü", "\u{FFFE}\u{FFFF}x", "~\u{7F}"] {
|
||||
let mut reference = String::new();
|
||||
for c in t.chars() {
|
||||
match c {
|
||||
'&' => reference.push_str("&"),
|
||||
'<' => reference.push_str("<"),
|
||||
'>' => reference.push_str(">"),
|
||||
'\r' => reference.push_str(" "),
|
||||
c if craft_xml::is_xml_char(c) => reference.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut s = String::from("x");
|
||||
craft_xml::push_esc(&mut s, t);
|
||||
assert_eq!(s, format!("x{reference}"), "{t:?}");
|
||||
assert_eq!(esc(t), reference);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ DeckCraft: as written, matched by local name
|
||||
|
||||
#[test]
|
||||
fn deck_parse_basic_and_entities() {
|
||||
let root =
|
||||
parse(b"<?xml version=\"1.0\"?><a:p xmlns:a=\"x\"><a:r><a:t>A & B A <</a:t></a:r><a:x r:id=\"rId1\" id=\"5\"/></a:p>").unwrap();
|
||||
assert_eq!((root.name.as_str(), root.local(), root.prefix()), ("a:p", "p", "a"));
|
||||
assert!(root.is(Local("p")) && root.is("a:p") && !root.is("p"));
|
||||
assert_eq!(root.find(Local("t")).map(El::deep_text), Some("A & B A <".to_string()));
|
||||
let x = root.child(Local("x")).unwrap();
|
||||
assert_eq!(x.attr_local("id"), Some("5"));
|
||||
assert_eq!(x.attr_local("r:id"), Some("rId1"));
|
||||
assert_eq!(x.attr_local("q:id"), Some("rId1"), "a qualified lookup takes the local name under any prefix");
|
||||
assert_eq!(root.ns_decls().collect::<Vec<_>>(), [("a", "x")]);
|
||||
assert_eq!(root.attr_local("a"), None, "a namespace declaration is not an attribute by local name");
|
||||
// Without a plain `id`, the unqualified lookup takes the prefixed one.
|
||||
let only = parse(b"<a r:id='rId9' xmlns='urn:d'/>").unwrap();
|
||||
assert_eq!(only.attr_local("id"), Some("rId9"));
|
||||
assert_eq!(only.ns_decls().collect::<Vec<_>>(), [("", "urn:d")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_lossy_integers_and_booleans() {
|
||||
use craft_xml::value::{to_bool, to_i32_lossy, to_i64_lossy, to_u32_lossy};
|
||||
let e = parse(b"<a:off x='12.7' y='-3' big='1e40' on='on' t='t'/>").unwrap();
|
||||
assert_eq!(e.attr_local("x").and_then(to_i64_lossy), Some(12));
|
||||
assert_eq!(e.attr_local("y").and_then(to_u32_lossy), Some(0));
|
||||
assert_eq!(e.attr_local("big").and_then(to_i32_lossy), Some(i32::MAX));
|
||||
assert_eq!(e.attr_local("on").and_then(to_bool), Some(true));
|
||||
assert_eq!(e.attr_i64("x"), None, "the exact getter does not accept a fraction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_line_ends_and_carriage_returns() {
|
||||
let root = parse(b"<a:p><a:t>one\r\ntwo\rthree four</a:t><a:t><![CDATA[x\r\ny]]></a:t></a:p>").unwrap();
|
||||
let t: Vec<String> = root.children(Local("t")).map(El::deep_text).collect();
|
||||
assert_eq!(t, ["one\ntwo\nthree\rfour", "x\ny"]);
|
||||
let mut w = Writer::new();
|
||||
w.tag("a:t").text("one\rtwo\r\nthree");
|
||||
assert_eq!(parse(w.as_str().as_bytes()).unwrap().deep_text(), "one\rtwo\r\nthree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_truncated_and_garbage() {
|
||||
let kept = parse(b"<a><b><c>text").unwrap();
|
||||
assert_eq!(kept.path(&["b", "c"]).map(|c| c.text().into_owned()).as_deref(), Some("text"), "a truncated document keeps what it had");
|
||||
assert!(parse(b"").is_err());
|
||||
assert!(parse("<a>".repeat(256 + 10).as_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_escape_drops_control_chars() {
|
||||
assert_eq!(esc("a\u{b}b<\u{0}"), "ab<");
|
||||
assert_eq!(esc_attr("\"x\"\n"), ""x" ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_roundtrip_and_mutation() {
|
||||
let mut root = parse(b"<p:sp a=\"1\" xmlns:p='urn:p'><p:t>x & y</p:t><e/></p:sp>").unwrap();
|
||||
assert_eq!(root.to_xml(), "<p:sp a=\"1\" xmlns:p=\"urn:p\"><p:t>x & y</p:t><e/></p:sp>");
|
||||
root.set_attr("a", "2");
|
||||
root.set_attr("b", 3);
|
||||
for e in root.elements_mut() {
|
||||
e.set_attr("seen", true);
|
||||
}
|
||||
if let Some(e) = root.child_mut(Local("e")) {
|
||||
e.push("text");
|
||||
e.push(El::new("f"));
|
||||
}
|
||||
assert_eq!(root.remove_attr("xmlns:p").as_deref(), Some("urn:p"));
|
||||
assert_eq!(root.remove_attr("xmlns:p"), None);
|
||||
assert!(root.has_attr("b") && !root.has_attr("c"));
|
||||
assert_eq!(root.to_xml(), "<p:sp a=\"2\" b=\"3\"><p:t seen=\"true\">x & y</p:t><e seen=\"true\">text<f/></e></p:sp>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_writer_attributes() {
|
||||
let name: Option<&str> = None;
|
||||
let mut w = Writer::new();
|
||||
w.tag("p:sp").attr("id", 5).attr_opt("name", name).attr_opt("bold", Some(u8::from(true))).attr("t", "a\"b").open();
|
||||
w.tag("a:off").attr("x", -1.5).attr("y", 2).empty();
|
||||
w.tag("a:t").text("hi");
|
||||
assert_eq!(w.finish(), "<p:sp id=\"5\" bold=\"1\" t=\"a"b\"><a:off x=\"-1.5\" y=\"2\"/><a:t>hi</a:t></p:sp>");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ DesignCraft: builder, PIs, pretty documents
|
||||
|
||||
fn design() -> Options<'static> {
|
||||
Options { strict: true, pis: true, ..Options::default() }
|
||||
}
|
||||
|
||||
/// designcraft#712: nesting past the budget is an error from the reader, not a stack overflow.
|
||||
#[test]
|
||||
fn design_nesting_past_the_budget_is_refused() {
|
||||
let nested = |n: usize| format!("{}{}", "<A>".repeat(n), "</A>".repeat(n)).into_bytes();
|
||||
assert!(design().parse(&nested(256)).is_ok(), "the budget itself parses");
|
||||
assert_eq!(design().parse(&nested(257)), Err(Error::TooDeep(256)));
|
||||
assert!(design().parse(&nested(100_000)).is_err());
|
||||
// An empty element is a level too.
|
||||
let leaf = |n: usize| format!("{}<B/>{}", "<A>".repeat(n), "</A>".repeat(n)).into_bytes();
|
||||
assert!(design().parse(&leaf(255)).is_ok());
|
||||
assert_eq!(design().parse(&leaf(256)), Err(Error::TooDeep(256)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_roundtrip_text_pi_and_entities() {
|
||||
let page = El::new("Content").with_text("Page ").with_node(Node::Pi { target: "ACE".into(), data: "18".into() });
|
||||
let root = El::new("Root").with_attr("A", "x\"<&\t").with_attr("N", 9).with_child(El::new("Content").with_text("a\tb & <c>")).with_child(page);
|
||||
let mut w = Writer::pretty("\t");
|
||||
w.decl(true);
|
||||
w.pi("aid", "style=\"50\" type=\"document\"");
|
||||
w.tree(&root);
|
||||
let text = w.finish();
|
||||
assert_eq!(
|
||||
text,
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?aid style=\"50\" type=\"document\"?>\n<Root A=\"x"<&	\" N=\"9\">\n\t<Content>a\tb & <c></Content>\n\t<Content>Page <?ACE 18?></Content>\n</Root>\n"
|
||||
);
|
||||
let back = design().parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.attr("A"), Some("x\"<&\t"));
|
||||
let contents: Vec<&El> = back.children(Local("Content")).collect();
|
||||
assert_eq!(contents[0].deep_text(), "a\tb & <c>");
|
||||
assert_eq!(contents[1].children[1], Node::Pi { target: "ACE".into(), data: "18".into() });
|
||||
assert_eq!(Options { drop_blank_text: true, ..design() }.parse(text.as_bytes()).unwrap(), root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_unbalanced_documents_are_errors() {
|
||||
for bad in [&b"<A><B></A>"[..], b"<A>", b"</A>", b"", b"<A></A></A>"] {
|
||||
assert!(design().parse(bad).is_err(), "{}", String::from_utf8_lossy(bad));
|
||||
}
|
||||
assert_eq!(design().parse(b"<A>x y</A>").unwrap().text(), "xy", "an undefined entity is dropped");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ AniCraft: strict, indentation dropped, mixed content
|
||||
|
||||
fn ani() -> Options<'static> {
|
||||
Options { strict: true, drop_blank_text: true, unknown_entity: craft_xml::UnknownEntity::Literal, ..Options::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ani_elements_attributes_and_entities() {
|
||||
let x = br#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- a comment -->
|
||||
<DOMDocument xmlns="http://ns.adobe.com/xfl/2008/" width="640" frameRate='30'>
|
||||
<timelines><DOMTimeline name="Scene & "1" AB"><layers/></DOMTimeline></timelines>
|
||||
<![CDATA[ <ignored/> ]]>
|
||||
text is kept
|
||||
</DOMDocument>"#;
|
||||
let root = ani().parse(x).unwrap();
|
||||
assert_eq!(root.name, "DOMDocument");
|
||||
assert_eq!((root.attr_f64("width"), root.attr_u32("frameRate")), (Some(640.0), Some(30)));
|
||||
let tl = root.child("timelines").and_then(|t| t.elements().next()).unwrap();
|
||||
assert_eq!(tl.attr("name"), Some("Scene & \"1\" AB"));
|
||||
assert_eq!(tl.child("layers").map(|l| l.children.len()), Some(0));
|
||||
assert_eq!(root.elements().count(), 1, "CDATA and text are not elements");
|
||||
assert!(root.text().contains(" <ignored/> ") && root.text().contains("text is kept"), "{:?}", root.text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ani_character_data_is_kept_and_indentation_dropped() {
|
||||
let x = b"<t>\n <characters>a <b>
line 2</characters>\n <characters> </characters><c><![CDATA[x<y]]>z</c>\n</t>";
|
||||
let root = ani().parse(x).unwrap();
|
||||
assert_eq!(root.text(), "", "whitespace between elements");
|
||||
assert_eq!(root.children.len(), 3);
|
||||
let texts: Vec<String> = root.elements().map(|c| c.text().into_owned()).collect();
|
||||
assert_eq!(texts, ["a <b>\rline 2", " ", "x<yz"]);
|
||||
}
|
||||
|
||||
/// Mixed content needs no special mode: text nodes keep their place among the elements.
|
||||
#[test]
|
||||
fn ani_mixed_content_keeps_text_in_order() {
|
||||
let x = b"<svg><text x='1'>Hello <tspan>big</tspan> world&<!-- c --> more</text>\n <g>\n</g></svg>";
|
||||
let root = ani().parse(x).unwrap();
|
||||
let t = root.child("text").unwrap();
|
||||
let parts: Vec<Node> = t.children.clone();
|
||||
assert_eq!(parts, [Node::Text("Hello ".into()), Node::El(El::new("tspan").with_text("big")), Node::Text(" world& more".into())]);
|
||||
assert_eq!(t.text(), "Hello world& more");
|
||||
assert_eq!(root.children.len(), 2, "indentation between elements is dropped");
|
||||
assert_eq!(root.child("g").map(|g| g.children.len()), Some(1), "an element without child elements keeps its whitespace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ani_malformed_input_is_an_error_not_a_panic() {
|
||||
for bad in [&b""[..], b"<", b"<a", b"<a b>", b"<a b='1>", b"<a></b>", b"<a><b></a>", b"<!-- open", b"<a>\xff\xfe", b"<a>\xff</a>"] {
|
||||
assert!(ani().parse(bad).is_err(), "{:?}", String::from_utf8_lossy(bad));
|
||||
}
|
||||
let deep = "<a>".repeat(400) + &"</a>".repeat(400);
|
||||
assert_eq!(ani().parse(deep.as_bytes()), Err(Error::TooDeep(256)));
|
||||
let big = Options::default().with_limits(Limits { max_input: 16, ..Limits::DEFAULT });
|
||||
assert_eq!(big.parse(b"<a>0123456789abc</a>"), Err(Error::TooLarge(16)));
|
||||
assert!(big.parse(b"<a>012345678</a>").is_ok());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ LightCraft, EffectCraft: references
|
||||
|
||||
#[test]
|
||||
fn light_references_are_resolved_once_and_strictly() {
|
||||
// 200 000 `&` then a `;`: each `&` is looked at once, not searched from for the `;`.
|
||||
let hostile = format!("{};&", "&".repeat(200_000));
|
||||
assert_eq!(unescape(&hostile), format!("{};&", "&".repeat(200_000)));
|
||||
assert_eq!(unescape("<a>  &unknownreference;"), "<a> \u{10FFFF} &unknownreference;");
|
||||
assert_eq!(unescape("Canon A A B"), "Canon A A B");
|
||||
assert_eq!(unescape("&abc&#xyz;&;"), "&abc&#xyz;&;");
|
||||
assert_eq!(unescape("a &unknown; b & c"), "a &unknown; b & c");
|
||||
use craft_xml::entity;
|
||||
assert_eq!((entity("#+65"), entity("#x+41"), entity("#"), entity("#x"), entity("#-1")), (None, None, None, None, None));
|
||||
assert_eq!((entity("#65"), entity("#x41"), entity("apos"), entity("nbsp")), (Some('A'), Some('A'), Some('\''), None));
|
||||
assert_eq!((entity("#0"), entity("#1"), entity("#xD800"), entity("#x110000"), entity("#99999999999999999999")), (None, None, None, None, None));
|
||||
// The same holds in a document: attribute values and text.
|
||||
let e = parse(format!("<lens maker='{hostile}' model='Canon'>A</lens>").as_bytes()).unwrap();
|
||||
assert_eq!(e.attr("maker").map(str::len), Some(200_002));
|
||||
assert_eq!((e.attr("model"), e.text().as_ref()), (Some("Canon"), "A"));
|
||||
assert_eq!(e.child_text("missing"), None);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ FilmCraft: pretty writer, DOCTYPE
|
||||
|
||||
#[test]
|
||||
fn film_writer_roundtrip() {
|
||||
let mut w = Writer::pretty(" ");
|
||||
w.decl(false);
|
||||
w.doctype("xmeml");
|
||||
w.open("a", &[("x", "1 & 2")]);
|
||||
w.leaf("b", &[], "<hi>");
|
||||
w.empty("c", &[]);
|
||||
w.open("d", &[]);
|
||||
w.tag("e").attr("n", 1.5).text("t");
|
||||
let s = w.finish();
|
||||
assert_eq!(
|
||||
s,
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE xmeml>\n<a x=\"1 & 2\">\n <b><hi></b>\n <c/>\n <d>\n <e n=\"1.5\">t</e>\n </d>\n</a>\n"
|
||||
);
|
||||
let film = Options { doctype: Doctype::RejectInternalSubset, drop_blank_text: true, ..Options::default() };
|
||||
let a = film.parse(s.as_bytes()).unwrap();
|
||||
assert_eq!(a.attr("x"), Some("1 & 2"));
|
||||
assert_eq!(a.child_text("b").as_deref(), Some("<hi>"));
|
||||
assert!(a.child("c").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_attr_whitespace_roundtrip() {
|
||||
let mut w = Writer::pretty(" ");
|
||||
w.open("a", &[("name", "x\ny\tz\r")]);
|
||||
w.leaf("b", &[], "a\nb");
|
||||
let a = parse(w.finish().as_bytes()).unwrap();
|
||||
assert_eq!(a.attr("name"), Some("x\ny\tz\r"));
|
||||
assert_eq!(a.child_text("b").as_deref(), Some("a\nb"));
|
||||
// Literal whitespace in an attribute value is normalised to spaces, as XML says.
|
||||
assert_eq!(parse(b"<a v='x\ny\tz\r\nw'/>").unwrap().attr("v"), Some("x y z w"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn film_rejects_internal_dtd_entity_expansion() {
|
||||
let film = Options { doctype: Doctype::RejectInternalSubset, ..Options::default() };
|
||||
assert_eq!(film.parse(b"<!DOCTYPE root [<!ENTITY word 'expanded'>]><root>&word;</root>"), Err(Error::Doctype));
|
||||
assert!(film.parse(b"<!DOCTYPE xmeml><xmeml/>").is_ok());
|
||||
assert!(film.parse(b"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\"><svg/>").is_ok());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ MailCraft: no DOCTYPE at all
|
||||
|
||||
#[test]
|
||||
fn mail_a_document_with_a_doctype_is_not_read() {
|
||||
let mail = Options { doctype: Doctype::Reject, ..Options::default() }.strict();
|
||||
assert_eq!(mail.parse(b"<!DOCTYPE s><s/>"), Err(Error::Doctype));
|
||||
assert_eq!(
|
||||
mail.parse(b"<?xml version=\"1.0\"?><!DOCTYPE s [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b \"&a;&a;&a;&a;&a;&a;&a;&a;\">]><s>&b;</s>"),
|
||||
Err(Error::Doctype)
|
||||
);
|
||||
assert!(matches!(mail.parse(b"<s>&b;</s>"), Err(Error::UnknownEntity { name, .. }) if name == "b"));
|
||||
assert!(mail.parse(b"<d:multistatus xmlns:d='DAV:'><d:href>/a&b</d:href></d:multistatus>").is_ok());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ WordCraft ODT: events with byte ranges
|
||||
|
||||
#[test]
|
||||
fn odt_events_with_byte_ranges() {
|
||||
let xml = b"<office:text><text:p text:style-name=\"P1\">a & b<text:s/></text:p></office:text>";
|
||||
let opts = Options::default();
|
||||
let mut events = opts.events(xml);
|
||||
let mut seen = Vec::new();
|
||||
while let Some(ev) = events.next().unwrap() {
|
||||
seen.push((ev, events.span()));
|
||||
}
|
||||
let start = |name: &str, attrs: &[(&str, &str)]| Event::Start {
|
||||
name: name.into(),
|
||||
attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
|
||||
};
|
||||
let end = |name: &str| Event::End { name: name.into() };
|
||||
let expect = vec![
|
||||
(start("office:text", &[]), 0..13),
|
||||
(start("text:p", &[("text:style-name", "P1")]), 13..42),
|
||||
(Event::Text("a ".into()), 42..44),
|
||||
(Event::Text("&".into()), 44..49),
|
||||
(Event::Text(" b".into()), 49..51),
|
||||
(start("text:s", &[]), 51..60),
|
||||
(end("text:s"), 51..60),
|
||||
(end("text:p"), 60..69),
|
||||
(end("office:text"), 69..83),
|
||||
];
|
||||
assert_eq!(seen, expect);
|
||||
assert_eq!((&xml[51..60], xml.len()), (&b"<text:s/>"[..], 83));
|
||||
// Malformed markup ends the stream with an error; what came before it was delivered.
|
||||
let mut events = opts.events(b"<a><b x='1'>t</b><");
|
||||
let mut count = 0;
|
||||
let err = loop {
|
||||
match events.next() {
|
||||
Ok(Some(_)) => count += 1,
|
||||
Ok(None) => break None,
|
||||
Err(e) => break Some(e),
|
||||
}
|
||||
};
|
||||
assert_eq!(count, 4);
|
||||
assert!(matches!(err, Some(Error::Syntax { .. })), "{err:?}");
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Hostile input: every limit, entity expansion, truncation, wrong encodings, wrong nesting.
|
||||
//! Nothing here may panic, and nothing may grow beyond a small multiple of its input.
|
||||
|
||||
use craft_xml::{Canonical, Doctype, El, Error, Limits, Options, UnknownEntity, parse, to_utf8};
|
||||
|
||||
const CANON: Canonical<'static> = Canonical::new(&[("w", "urn:w"), ("r", "urn:r")]);
|
||||
|
||||
/// Every way of reading, lenient and strict.
|
||||
fn all_options() -> Vec<Options<'static>> {
|
||||
let full =
|
||||
Options { cdata: true, pis: true, comments: true, drop_blank_text: true, unknown_entity: UnknownEntity::Literal, ..Options::default() };
|
||||
let base = [Options::default(), Options::local(), Options::canonical(&CANON), full, Options { doctype: Doctype::Reject, ..Options::default() }];
|
||||
base.iter().flat_map(|o| [*o, o.strict()]).collect()
|
||||
}
|
||||
|
||||
fn text_len(e: &El) -> usize {
|
||||
let own: usize = e.attrs.iter().map(|(k, v)| k.len() + v.len()).sum::<usize>() + e.name.len() + e.text().len();
|
||||
own + e.descendants().map(|d| d.name.len() + d.text().len() + d.attrs.iter().map(|(k, v)| k.len() + v.len()).sum::<usize>()).sum::<usize>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_defaults_are_the_documented_ones() {
|
||||
assert_eq!(Limits::default(), Limits { max_input: 256 * 1024 * 1024, max_depth: 256, max_nodes: 2_000_000, max_attrs: 1024 });
|
||||
for e in [Error::TooLarge(1), Error::TooDeep(1), Error::TooManyNodes(1), Error::TooManyAttrs(1)] {
|
||||
assert!(e.is_limit(), "{e}");
|
||||
}
|
||||
assert!(!Error::NoRoot.is_limit() && !Error::Doctype.is_limit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_depth_bomb_is_refused_at_the_limit() {
|
||||
let limits = Limits { max_depth: 8, ..Limits::DEFAULT };
|
||||
let nested = |n: usize| format!("{}{}", "<a>".repeat(n), "</a>".repeat(n));
|
||||
for opts in all_options() {
|
||||
let opts = opts.with_limits(limits);
|
||||
assert!(opts.parse(nested(8).as_bytes()).is_ok());
|
||||
assert_eq!(opts.parse(nested(9).as_bytes()), Err(Error::TooDeep(8)));
|
||||
assert_eq!(opts.parse("<a>".repeat(9).as_bytes()), Err(Error::TooDeep(8)), "unclosed tags are depth too");
|
||||
assert_eq!(opts.parse_streaming(nested(9).as_bytes(), &["a"], |_| Ok::<(), Error>(())), Err(Error::TooDeep(8)));
|
||||
}
|
||||
// A megabyte of open tags against the default limit.
|
||||
assert_eq!(parse("<a>".repeat(350_000).as_bytes()), Err(Error::TooDeep(256)));
|
||||
// Text and deep traversals of the deepest tree the reader builds do not recurse.
|
||||
let deepest = parse(format!("{}x{}", "<a>".repeat(256), "</a>".repeat(256)).as_bytes()).unwrap();
|
||||
assert_eq!((deepest.deep_text().as_str(), deepest.descendants().count()), ("x", 255));
|
||||
assert_eq!(parse(deepest.to_xml().as_bytes()).unwrap(), deepest);
|
||||
assert_eq!(Options { drop_blank_text: true, ..Options::default() }.parse(deepest.to_xml_pretty(" ").as_bytes()).unwrap(), deepest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_count_bomb_is_refused_at_the_limit() {
|
||||
let limits = Limits { max_nodes: 100, ..Limits::DEFAULT };
|
||||
for opts in all_options() {
|
||||
let opts = opts.with_limits(limits);
|
||||
// The root and 99 children are 100 nodes.
|
||||
assert!(opts.parse(format!("<r>{}</r>", "<x/>".repeat(99)).as_bytes()).is_ok());
|
||||
assert_eq!(opts.parse(format!("<r>{}</r>", "<x/>".repeat(100)).as_bytes()), Err(Error::TooManyNodes(100)));
|
||||
// Text between elements is nodes too.
|
||||
assert_eq!(opts.parse(format!("<r>{}</r>", "t<x/>".repeat(50)).as_bytes()), Err(Error::TooManyNodes(100)));
|
||||
assert_eq!(opts.parse(format!("<r>{}", "<x>".repeat(100)).as_bytes()).err().map(|e| e.is_limit() || opts.strict), Some(true));
|
||||
}
|
||||
// Kept comments, processing instructions and CDATA sections count.
|
||||
let keep = Options { cdata: true, pis: true, comments: true, ..Options::default() }.with_limits(limits);
|
||||
for filler in ["<!--c-->", "<?p d?>", "<![CDATA[x]]><x/>"] {
|
||||
assert_eq!(keep.parse(format!("<r>{}</r>", filler.repeat(100)).as_bytes()), Err(Error::TooManyNodes(100)), "{filler}");
|
||||
assert!(Options::default().with_limits(limits).parse(format!("<r>{}</r>", filler.repeat(40)).as_bytes()).is_ok(), "{filler}");
|
||||
}
|
||||
// Indentation that is dropped does not count against the tree that is kept.
|
||||
let indented = format!("<r>{}</r>", "\n <x/>".repeat(99));
|
||||
assert!(
|
||||
Options { drop_blank_text: true, ..Options::default() }
|
||||
.with_limits(Limits { max_nodes: 200, ..Limits::DEFAULT })
|
||||
.parse(indented.as_bytes())
|
||||
.is_ok()
|
||||
);
|
||||
// The default limit, on a part of endless empty elements.
|
||||
let endless = format!("<r>{}</r>", "<x/>".repeat(2_000_000));
|
||||
assert_eq!(parse(endless.as_bytes()), Err(Error::TooManyNodes(2_000_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attribute_flood_is_refused_at_the_limit() {
|
||||
let limits = Limits { max_attrs: 16, ..Limits::DEFAULT };
|
||||
let tag = |n: usize, attr: &dyn Fn(usize) -> String| format!("<a{}/>", (0..n).map(attr).collect::<String>());
|
||||
let kinds: [&dyn Fn(usize) -> String; 4] =
|
||||
[&|i| format!(" a{i}='v'"), &|_| " a='v'".to_string(), &|i| format!(" xmlns:p{i}='urn:{i}'"), &|i| format!(" bad{i}")];
|
||||
for opts in all_options() {
|
||||
let opts = opts.with_limits(limits);
|
||||
for (k, kind) in kinds.iter().enumerate() {
|
||||
let _ = opts.parse(tag(16, kind).as_bytes());
|
||||
assert_eq!(opts.parse(tag(17, kind).as_bytes()).err().map(|e| e.is_limit() || (opts.strict && k == 3)), Some(true), "kind {k}");
|
||||
assert!(opts.first_start(tag(17, kind).as_bytes(), "a").is_none());
|
||||
}
|
||||
assert_eq!(opts.parse(tag(17, kinds[0]).as_bytes()), Err(Error::TooManyAttrs(16)));
|
||||
}
|
||||
assert_eq!(parse(tag(200_000, kinds[0]).as_bytes()), Err(Error::TooManyAttrs(1024)));
|
||||
// Namespace declarations spread over many levels are bounded as a whole where names are
|
||||
// resolved against them: 3 levels of 6 is over 16, though no tag has more than 6.
|
||||
let level = |d: usize| format!("<w:a{}>", (0..6).map(|i| format!(" xmlns:p{d}_{i}='urn:x'")).collect::<String>());
|
||||
let spread = |levels: usize| format!("<w:r xmlns:w='urn:w'>{}", (0..levels).map(level).collect::<String>());
|
||||
let canonical = Options::canonical(&CANON).with_limits(limits);
|
||||
assert_eq!(canonical.parse(spread(2).as_bytes()).map(|r| r.name), Ok("w:r".to_string()));
|
||||
assert_eq!(canonical.parse(spread(3).as_bytes()), Err(Error::TooManyAttrs(16)));
|
||||
assert!(Options::default().with_limits(limits).parse(spread(3).as_bytes()).is_ok(), "names as written resolve nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_over_the_size_limit_is_refused_before_it_is_read() {
|
||||
let opts = Options::default().with_limits(Limits { max_input: 1000, ..Limits::DEFAULT });
|
||||
let doc = |len: usize| format!("<a>{}</a>", "x".repeat(len - 7));
|
||||
assert!(opts.parse(doc(1000).as_bytes()).is_ok());
|
||||
assert_eq!(opts.parse(doc(1001).as_bytes()), Err(Error::TooLarge(1000)));
|
||||
assert_eq!(opts.parse_streaming(doc(1001).as_bytes(), &[], |_| Ok::<(), Error>(())), Err(Error::TooLarge(1000)));
|
||||
assert!(opts.first_start(doc(1001).as_bytes(), "a").is_none());
|
||||
}
|
||||
|
||||
/// The "billion laughs": entities declared in a DTD are never expanded, whatever the options.
|
||||
#[test]
|
||||
fn declared_entities_are_never_expanded() {
|
||||
let mut dtd = String::from("<?xml version=\"1.0\"?><!DOCTYPE lolz [<!ENTITY lol \"lol\">");
|
||||
for i in 1..10 {
|
||||
let prev = if i == 1 { "lol".to_string() } else { format!("lol{}", i - 1) };
|
||||
dtd.push_str(&format!("<!ENTITY lol{i} \"{}\">", format!("&{prev};").repeat(10)));
|
||||
}
|
||||
let bomb = format!("{dtd}]><lolz a=\"&lol9;\">&lol9;</lolz>");
|
||||
let quadratic = format!("<!DOCTYPE b [<!ENTITY a \"{}\">]><b>{}</b>", "a".repeat(50_000), "&a;".repeat(50_000));
|
||||
let external =
|
||||
"<!DOCTYPE x [<!ENTITY xxe SYSTEM \"file:///etc/passwd\"><!ENTITY % pe SYSTEM \"http://127.0.0.1/x.dtd\">%pe;]><x a=\"&xxe;\">&xxe;</x>";
|
||||
for doc in [bomb.as_str(), quadratic.as_str(), external] {
|
||||
for opts in all_options() {
|
||||
match opts.parse(doc.as_bytes()) {
|
||||
Ok(root) => {
|
||||
assert!(text_len(&root) <= doc.len(), "the tree is not larger than the document");
|
||||
let text = root.text().into_owned() + root.attr("a").unwrap_or("");
|
||||
assert!(!text.contains("lol") || text.contains("&lol9;"), "{text}");
|
||||
assert!(!text.contains("root:"), "an external entity is never fetched");
|
||||
match opts.unknown_entity {
|
||||
UnknownEntity::Drop => assert_eq!(text, ""),
|
||||
UnknownEntity::Literal => assert!(text.starts_with('&') && text.ends_with(';'), "{text}"),
|
||||
UnknownEntity::Error => panic!("an undefined entity was accepted"),
|
||||
}
|
||||
}
|
||||
Err(Error::Doctype) => assert_eq!(opts.doctype, Doctype::Reject),
|
||||
Err(Error::UnknownEntity { .. }) => assert_eq!(opts.unknown_entity, UnknownEntity::Error),
|
||||
Err(e) => panic!("unexpected error {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let refuse = Options { doctype: Doctype::RejectInternalSubset, ..Options::default() };
|
||||
assert_eq!(refuse.parse(bomb.as_bytes()), Err(Error::Doctype));
|
||||
assert_eq!(refuse.parse(external.as_bytes()), Err(Error::Doctype));
|
||||
// Predefined entities and character references are the only expansion, and they shrink.
|
||||
let refs = format!("<a v=\"{0}\">{0}</a>", "&A<".repeat(10_000));
|
||||
let root = parse(refs.as_bytes()).unwrap();
|
||||
assert_eq!(root.text().chars().count(), 40_000);
|
||||
assert!(text_len(&root) < refs.len());
|
||||
}
|
||||
|
||||
const DOCUMENTS: &[&str] = &[
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:document xmlns:w=\"urn:w\" xmlns:r=\"urn:r\"><w:body><w:p w:rsidR=\"00A1\" r:id='rId1'><w:r><w:t xml:space=\"preserve\"> a & b AA </w:t></w:r></w:p><w:sectPr/></w:body></w:document>\n",
|
||||
"<!DOCTYPE r [<!ENTITY e \"x\"><!ELEMENT r ANY>]>\n<r a=\"1\" b='2"'><!-- comment --><?pi data?><![CDATA[c <d> ]]> e]]>&e;&bogus;<x/>text<y>\r\n</y></r><!-- after --><z/>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 1 1\">\n <text x='1'>Héllo <tspan>日本語</tspan> wörld😀</text>\n <use xlink:href=\"#a\"/>\n</svg>",
|
||||
"<sst><si><t>one</t></si><si><r><t>two</t></r><r><t xml:space='preserve'> three</t></r></si><si/></sst>",
|
||||
];
|
||||
|
||||
/// Every prefix of a few documents, read every way: an error or a tree, never a panic. A strict
|
||||
/// read of a cut document is an error unless only what follows the root was cut.
|
||||
#[test]
|
||||
fn input_truncated_at_every_byte_never_panics() {
|
||||
for doc in DOCUMENTS {
|
||||
let bytes = doc.as_bytes();
|
||||
let whole_strict_ok = Options::default().strict().parse(bytes).is_ok();
|
||||
for cut in 0..=bytes.len() {
|
||||
let part = &bytes[..cut];
|
||||
for opts in all_options() {
|
||||
let tree = opts.parse(part);
|
||||
let _ = opts.parse_streaming(part, &["si", "w:p", "x", "y"], |_| Ok::<(), Error>(()));
|
||||
let _ = opts.first_start(part, "t");
|
||||
let mut events = opts.events(part);
|
||||
while let Ok(Some(_)) = events.next() {}
|
||||
if let Ok(root) = &tree {
|
||||
assert!(text_len(root) <= part.len() * 3 + 16);
|
||||
let _ = (root.to_xml(), root.to_xml_pretty(" "), root.deep_text());
|
||||
}
|
||||
}
|
||||
let strict = Options::default().strict().parse(part);
|
||||
let root_ended = doc.rfind("</").is_some_and(|i| doc[i..].find('>').is_some_and(|j| cut > i + j));
|
||||
if whole_strict_ok && !root_ended {
|
||||
assert!(strict.is_err(), "a strict read accepted {:?}", String::from_utf8_lossy(part));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same documents with bytes overwritten, deleted and doubled: a cheap deterministic fuzz.
|
||||
#[test]
|
||||
fn damaged_input_never_panics() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let junk = b"<>&;'\"/=?![]-\x00\xff\xfe\xc3\xed\xa0\x80 :x#";
|
||||
for doc in DOCUMENTS {
|
||||
for _ in 0..600 {
|
||||
let mut bytes = doc.as_bytes().to_vec();
|
||||
for _ in 0..(next() % 4 + 1) {
|
||||
let at = (next() % bytes.len() as u64) as usize;
|
||||
match next() % 3 {
|
||||
0 => bytes[at] = junk[(next() % junk.len() as u64) as usize],
|
||||
1 => {
|
||||
bytes.remove(at);
|
||||
}
|
||||
_ => bytes.insert(at, bytes[at]),
|
||||
}
|
||||
}
|
||||
for opts in all_options() {
|
||||
if let Ok(root) = opts.parse(&bytes) {
|
||||
assert!(text_len(&root) <= bytes.len() * 3 + 16);
|
||||
// Whatever was written reads back. (A damaged name that is no XML name is
|
||||
// not written at all: the reader does not check names, the writer does.)
|
||||
let written = root.to_xml();
|
||||
if !written.is_empty() {
|
||||
let again = Options { names: craft_xml::Names::AsWritten, strict: false, ns_decls: true, ..opts }.parse(written.as_bytes());
|
||||
assert!(again.is_ok(), "{written:?}");
|
||||
assert_eq!(again.map(|e| e.to_xml()), Ok(written));
|
||||
}
|
||||
}
|
||||
let _ = opts.parse_streaming(&bytes, &["si", "w:p"], |_| Ok::<(), Error>(()));
|
||||
let _ = opts.first_start(&bytes, "t");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_is_replaced_or_refused() {
|
||||
let cases: [&[u8]; 6] = [
|
||||
b"<a>\xff</a>",
|
||||
b"<a b='\xc3\x28'/>",
|
||||
b"<a><![CDATA[\xed\xa0\x80]]></a>",
|
||||
b"<\xff\xfe/>",
|
||||
b"<a \xf0\x9f='1'/>",
|
||||
b"<a><?p\xff \xfe?><!--\xff--></a>",
|
||||
];
|
||||
let keep = Options { cdata: true, pis: true, comments: true, ..Options::default() };
|
||||
for bytes in cases {
|
||||
for opts in [Options::default(), Options::local(), Options::canonical(&CANON), keep] {
|
||||
let lenient = opts.parse(bytes).unwrap();
|
||||
assert!(std::str::from_utf8(lenient.to_xml().as_bytes()).is_ok());
|
||||
assert!(matches!(opts.strict().parse(bytes), Err(Error::Encoding(_) | Error::Syntax { .. })), "{:?}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
assert!(format!("{:?}", keep.parse(bytes).unwrap()).contains('\u{FFFD}'), "each bad byte is U+FFFD where it is kept");
|
||||
}
|
||||
assert_eq!(parse(b"<a>\xff</a>").unwrap().text(), "\u{FFFD}");
|
||||
assert_eq!(Options::default().strict().parse(b"<a>ok\xff</a>"), Err(Error::Encoding(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf16_never_panics() {
|
||||
let good: Vec<u16> = "<a k='v'>t</a>".encode_utf16().collect();
|
||||
let le = |units: &[u16]| [0xFF, 0xFE].into_iter().chain(units.iter().flat_map(|u| u.to_le_bytes())).collect::<Vec<u8>>();
|
||||
let be = |units: &[u16]| [0xFE, 0xFF].into_iter().chain(units.iter().flat_map(|u| u.to_be_bytes())).collect::<Vec<u8>>();
|
||||
assert_eq!(parse(&le(&good)).unwrap().attr("k"), Some("v"));
|
||||
assert_eq!(parse(&be(&good)).unwrap().text(), "t");
|
||||
for at in 0..good.len() {
|
||||
for bad in [0xD800u16, 0xDC00, 0xFFFF, 0x0000] {
|
||||
let mut units = good.clone();
|
||||
units[at] = bad;
|
||||
for bytes in [le(&units), be(&units)] {
|
||||
for cut in [bytes.len(), bytes.len() - 1, bytes.len() / 2] {
|
||||
for opts in all_options() {
|
||||
let _ = opts.parse(&bytes[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// An unpaired surrogate is U+FFFD; a trailing odd byte is dropped.
|
||||
assert_eq!(parse(&le(&[0x3C, 0x61, 0x3E, 0xD800, 0x3C, 0x2F, 0x61, 0x3E])).unwrap().text(), "\u{FFFD}");
|
||||
assert_eq!(to_utf8(&[0xFE, 0xFF, 0x00, 0x41, 0x00]).as_ref(), b"A");
|
||||
for empty in [&[0xFF, 0xFE][..], &[0xFE, 0xFF], &[0xEF, 0xBB, 0xBF], &[0xFF], &[]] {
|
||||
assert_eq!(parse(empty), Err(Error::NoRoot));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_tags_are_repaired_or_refused() {
|
||||
let cases = ["<a><b></a>", "<a></b>", "<a><b></c></a>", "</a><a/>", "<a></a></a>", "<a><b></a></b>", "<a></a><b></b>", "<a><b>", "<a/><a/>"];
|
||||
for xml in cases {
|
||||
for opts in all_options() {
|
||||
let got = opts.parse(xml.as_bytes());
|
||||
if opts.strict {
|
||||
assert!(matches!(got, Err(Error::Syntax { .. })), "{xml}: {got:?}");
|
||||
} else {
|
||||
assert_eq!(got.map(|e| e.local().to_string()), Ok("a".to_string()), "{xml}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A lenient end tag closes the innermost open element, whatever its name.
|
||||
let root = parse(b"<a><b>x</c>y</a>").unwrap();
|
||||
assert_eq!(root.to_xml(), "<a><b>x</b>y</a>");
|
||||
// Nothing after the root element is read by a lenient parse, however broken.
|
||||
assert_eq!(parse(b"<a/><<<\xff&&&").unwrap().name, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_that_is_not_xml_is_an_error() {
|
||||
for bytes in [
|
||||
&b""[..],
|
||||
b" ",
|
||||
b"text",
|
||||
b"<",
|
||||
b"<a",
|
||||
b"<a b='1",
|
||||
b"<!-- open",
|
||||
b"<![CDATA[x",
|
||||
b"<?pi",
|
||||
b"<!DOCTYPE a [",
|
||||
b"\x00\x01\x02",
|
||||
b"<?xml version='1.0'?>",
|
||||
] {
|
||||
for opts in all_options() {
|
||||
assert!(opts.parse(bytes).is_err(), "{:?}", String::from_utf8_lossy(bytes));
|
||||
assert!(opts.first_start(bytes, "a").is_none() || bytes.starts_with(b"<a "));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Escaping, serialising, the writer, and what a round trip keeps.
|
||||
|
||||
use craft_xml::{El, Local, Node, Options, Writer, esc, esc_attr, is_name, is_xml_char, parse, push_esc_attr};
|
||||
|
||||
/// Reads keeping everything the tree can hold.
|
||||
fn full() -> Options<'static> {
|
||||
Options { cdata: true, pis: true, comments: true, ..Options::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_text_and_attributes() {
|
||||
assert_eq!(esc("a & b < c > d \"q\" 'r'"), "a & b < c > d \"q\" 'r'");
|
||||
assert_eq!(esc("tab\tlf\ncr\r]]>"), "tab\tlf\ncr ]]>");
|
||||
assert_eq!(esc_attr("a & b < c > d \"q\" 'r'"), "a & b < c > d "q" 'r'");
|
||||
assert_eq!(esc_attr("tab\tlf\ncr\r"), "tab	lf cr ");
|
||||
// Characters XML 1.0 cannot carry, even as references, are dropped.
|
||||
let forbidden = "a\u{0}\u{1}\u{8}\u{B}\u{C}\u{E}\u{1F}\u{FFFE}\u{FFFF}b";
|
||||
assert_eq!((esc(forbidden), esc_attr(forbidden)), ("ab".to_string(), "ab".to_string()));
|
||||
assert_eq!(esc("\u{7F}\u{85}é日😀\u{FFFD}"), "\u{7F}\u{85}é日😀\u{FFFD}");
|
||||
let mut s = String::from("k=");
|
||||
push_esc_attr(&mut s, "\"");
|
||||
assert_eq!(s, "k="");
|
||||
assert!(is_xml_char('\t') && is_xml_char('\u{10FFFF}') && !is_xml_char('\u{0}') && !is_xml_char('\u{FFFE}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_character_survives_a_round_trip_or_is_dropped() {
|
||||
let all: String = (0..0x3000u32).chain([0xD7FF, 0xE000, 0xFFFD, 0xFFFE, 0xFFFF, 0x1_0000, 0x10_FFFF]).filter_map(char::from_u32).collect();
|
||||
let kept: String = all.chars().filter(|c| is_xml_char(*c)).collect();
|
||||
let root = El::new("a").with_attr("v", &all).with_text(&all);
|
||||
let back = parse(root.to_xml().as_bytes()).unwrap();
|
||||
assert_eq!(back.attr("v"), Some(kept.as_str()));
|
||||
assert_eq!(back.text(), kept);
|
||||
let mut w = Writer::new();
|
||||
w.tag("a").attr("v", &all).text(&all);
|
||||
assert_eq!(parse(w.finish().as_bytes()).unwrap(), back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
for good in ["a", "A1", "_x", "w:p", ":odd", "a-b.c", "é", "日本", "a\u{B7}"] {
|
||||
assert!(is_name(good), "{good}");
|
||||
}
|
||||
for bad in ["", "1a", "-a", ".a", "a b", "a>", "a=\"", "?:x", "a/", "<a", "a\u{0}", "\u{B7}a", "a\n"] {
|
||||
assert!(!is_name(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name from outside cannot become markup: the element or attribute is left out.
|
||||
#[test]
|
||||
fn a_name_that_is_not_a_name_is_not_written() {
|
||||
let root = El::new("r")
|
||||
.with_attr("ok", 1)
|
||||
.with_attr("x onload=\"evil()\"", 2)
|
||||
.with_attr("", 3)
|
||||
.with_child(El::new("a><script").with_text("gone").with_child(El::new("inner")))
|
||||
.with_child(El::new("b").with_text("kept"))
|
||||
.with_node(Node::Pi { target: "bad target".into(), data: "x".into() })
|
||||
.with_node(Node::Pi { target: "xml".into(), data: "version='1.0'".into() })
|
||||
.with_node(Node::Pi { target: "p".into(), data: "a?>b".into() })
|
||||
.with_node(Node::Comment("a--b".into()))
|
||||
.with_node(Node::Comment("ends-".into()));
|
||||
assert_eq!(root.to_xml(), "<r ok=\"1\"><b>kept</b></r>");
|
||||
assert_eq!(root.to_xml_pretty(" "), "<r ok=\"1\"><b>kept</b></r>\n");
|
||||
assert_eq!(El::new("not a name").to_xml(), "");
|
||||
// An element whose content was all left out is written the way it reads back.
|
||||
let hollow = El::new("r").with_child(El::new("a").with_child(El::new("1")).with_text("\u{1}")).with_child(El::new("b").with_child(El::new("2")));
|
||||
assert_eq!(hollow.to_xml(), "<r><a/><b/></r>");
|
||||
assert_eq!(hollow.to_xml_pretty(" "), "<r>\n <a/>\n <b/>\n</r>\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_round_trip_keeps_what_the_tree_holds() {
|
||||
let docs = [
|
||||
"<a/>",
|
||||
"<p:sp a=\"1\" xmlns:p=\"urn:p\" xmlns=\"urn:d\"><p:t>x & y</p:t><e/></p:sp>",
|
||||
"<a> <b> leading</b>\n\t<b>trailing </b> mixed <c/> text </a>",
|
||||
"<a k=\""&<>	 '\">&<> \n\t</a>",
|
||||
"<a><![CDATA[<raw> & ]]]]><![CDATA[> end]]><?ACE 18?><?empty?><!-- note -->t<![CDATA[]]></a>",
|
||||
"<a x=\"1\" x=\"2\"><b><c><d>deep</d></c></b></a>",
|
||||
"<é:日本 ü=\"ö\">😀</é:日本>",
|
||||
];
|
||||
for doc in docs {
|
||||
let tree = full().parse(doc.as_bytes()).unwrap();
|
||||
assert_eq!(tree.to_xml(), doc, "these are already in the form the writer uses");
|
||||
assert_eq!(full().parse(tree.to_xml().as_bytes()).unwrap(), tree);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_round_trip_does_not_keep() {
|
||||
let doc = "<?xml version='1.0'?>\n<!DOCTYPE a>\n<?before x?>\n<!-- before -->\n<a b = '1'\n c=\"2\"><b></b><![CDATA[c]]>d<!-- gone --><?gone too?>\r\n</a>\n<!-- after -->";
|
||||
let tree = parse(doc.as_bytes()).unwrap();
|
||||
// The declaration, the DOCTYPE and everything outside the root are not part of the tree.
|
||||
// Inside it: quoting and spacing in tags, how a character was written, `<b></b>` against
|
||||
// `<b/>`, CDATA sections as sections, comments and processing instructions (unless the
|
||||
// options keep them) and a literal CR are not kept.
|
||||
assert_eq!(tree.to_xml(), "<a b=\"1\" c=\"2\"><b/>cd\n</a>");
|
||||
let kept = full().parse(doc.as_bytes()).unwrap();
|
||||
assert_eq!(kept.to_xml(), "<a b=\"1\" c=\"2\"><b/><![CDATA[c]]>d<!-- gone --><?gone too?>\n</a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretty_output() {
|
||||
let tree = parse(b"<a x='1'><b><c/><d>text</d><e>mixed <f/> content</e></b><g/><h> </h></a>").unwrap();
|
||||
let pretty = tree.to_xml_pretty(" ");
|
||||
assert_eq!(pretty, "<a x=\"1\">\n <b>\n <c/>\n <d>text</d>\n <e>mixed <f/> content</e>\n </b>\n <g/>\n <h> </h>\n</a>\n");
|
||||
// Character data is unchanged: reading it back without the indentation gives the tree.
|
||||
let back = Options { drop_blank_text: true, ..Options::default() }.parse(pretty.as_bytes()).unwrap();
|
||||
assert_eq!(back, tree);
|
||||
// Printing what was printed changes nothing.
|
||||
assert_eq!(parse(pretty.as_bytes()).unwrap().to_xml_pretty(" "), pretty);
|
||||
assert_eq!(tree.to_xml_pretty("\t").lines().nth(2), Some("\t\t<c/>"));
|
||||
// An element with a comment or an instruction among its children is written on one line.
|
||||
let noted = full().parse(b"<a><!-- n --><b/></a>").unwrap();
|
||||
assert_eq!(noted.to_xml_pretty(" "), "<a><!-- n --><b/></a>\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_and_accessors() {
|
||||
let mut root = El::new("root").with_attr("id", 7).with_child(El::new("p:item").with_attr("n", "1").with_text("one")).with_text("tail");
|
||||
root.push(El::new("q:item").with_node(Node::CData("two".into())));
|
||||
root.push(Node::Comment("c".into()));
|
||||
assert_eq!(root.to_xml(), "<root id=\"7\"><p:item n=\"1\">one</p:item>tail<q:item><![CDATA[two]]></q:item><!--c--></root>");
|
||||
assert_eq!(root.children(Local("item")).count(), 2);
|
||||
assert_eq!(root.children("p:item").count(), 1);
|
||||
assert_eq!(root.child_text(Local("item")).as_deref(), Some("one"));
|
||||
assert_eq!(root.child("q:item").map(|e| e.text().into_owned()).as_deref(), Some("two"), "CDATA is character data");
|
||||
assert_eq!((root.text().as_ref(), root.deep_text().as_str()), ("tail", "onetailtwo"));
|
||||
assert_eq!(root.path(&[Local("item")]).and_then(|e| e.attr("n")), Some("1"));
|
||||
assert_eq!(root.path::<&str>(&[]).map(|e| e.name.as_str()), Some("root"));
|
||||
let name = String::from("p:item");
|
||||
assert!(root.child(&name).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_compact() {
|
||||
let mut w = Writer::new();
|
||||
w.open("a", &[("k", "v\"")]);
|
||||
w.open("b", &[]);
|
||||
w.text("t & u");
|
||||
w.cdata("x]]>y");
|
||||
w.close();
|
||||
w.pi("p", "d");
|
||||
w.tree(&El::new("c").with_text("<"));
|
||||
w.raw("<raw/>");
|
||||
assert_eq!(w.as_str(), "<a k=\"v"\"><b>t & u<![CDATA[x]]]]><![CDATA[>y]]></b><?p d?><c><</c><raw/>");
|
||||
w.close();
|
||||
w.close();
|
||||
w.close();
|
||||
assert_eq!(w.finish(), "<a k=\"v"\"><b>t & u<![CDATA[x]]]]><![CDATA[>y]]></b><?p d?><c><</c><raw/></a>");
|
||||
assert_eq!(Writer::document().into_bytes(), craft_xml::DECL_STANDALONE.as_bytes());
|
||||
let mut plain = Writer::new();
|
||||
plain.decl(false);
|
||||
plain.doctype("not a name");
|
||||
plain.pi("xml", "x");
|
||||
assert_eq!(plain.finish(), craft_xml::DECL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_closes_names_in_order_and_what_is_left_open() {
|
||||
let mut w = Writer::new();
|
||||
for name in ["a", "w:body", "日本", "d"] {
|
||||
w.open(name, &[]);
|
||||
}
|
||||
w.close();
|
||||
w.empty("e", &[]);
|
||||
assert_eq!(w.finish(), "<a><w:body><日本><d></d><e/></日本></w:body></a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_pretty_nests_a_tree_at_the_current_depth() {
|
||||
let mut w = Writer::pretty(" ");
|
||||
w.open("a", &[]);
|
||||
w.tree(&El::new("b").with_child(El::new("c")));
|
||||
assert_eq!(w.finish(), "<a>\n <b>\n <c/>\n </b>\n</a>\n");
|
||||
}
|
||||
Reference in new issue
Block a user