Skip to main content

bpmnkit_xml/
lib.rs

1//! XML reading and writing with the fidelity `@bpmnkit/core` (`packages/core/src/xml/xml-parser.ts`)
2//! has: qualified names keep their prefixes, attributes keep their source order, and nothing is
3//! namespace-resolved, so unknown content can be written back as it was read.
4//!
5//! Tokenising is done by `quick-xml` (borrowed events, builds for `wasm32-unknown-unknown`, and
6//! already in reebe's dependency tree). What the TS scanner decides itself is decided here too:
7//! entity decoding (the five predefined entities and character references; anything else is kept
8//! as written), CDATA kept raw, comments and processing instructions dropped, text concatenated
9//! per element, and everything after the root element ignored.
10//!
11//! ```
12//! use bpmnkit_xml::{parse_xml, serialize_xml};
13//!
14//! let root = parse_xml(r#"<a:root xmlns:a="urn:x" b="1"><a:child>t</a:child></a:root>"#).unwrap();
15//! assert_eq!(root.name, "a:root"); // prefixes are kept, not resolved
16//! assert_eq!(root.local_name(), "root");
17//! assert_eq!(root.attr("b"), Some("1"));
18//! assert!(serialize_xml(&root).contains("<a:child>t</a:child>"));
19//! ```
20
21use indexmap::IndexMap;
22use quick_xml::events::{BytesStart, Event};
23use quick_xml::Reader;
24use serde::{Deserialize, Serialize};
25use std::fmt;
26
27/// Attributes in source order. Qualified keys (`xsi:type`) are kept as written.
28pub type Attrs = IndexMap<String, String>;
29
30/// An element with namespace prefixes preserved. The JSON shape matches TS's `XmlElement`.
31#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
32pub struct XmlElement {
33    /// Qualified name, e.g. `zeebe:taskDefinition`.
34    pub name: String,
35    pub attributes: Attrs,
36    pub children: Vec<XmlElement>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub text: Option<String>,
39}
40
41impl XmlElement {
42    pub fn new(name: impl Into<String>) -> Self {
43        Self {
44            name: name.into(),
45            ..Self::default()
46        }
47    }
48
49    /// The name without its prefix.
50    pub fn local_name(&self) -> &str {
51        local_name(&self.name)
52    }
53
54    pub fn attr(&self, name: &str) -> Option<&str> {
55        self.attributes.get(name).map(String::as_str)
56    }
57}
58
59/// `name` without its prefix (`bpmn:task` → `task`).
60pub fn local_name(name: &str) -> &str {
61    match name.find(':') {
62        Some(i) => &name[i + 1..],
63        None => name,
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct XmlError(pub String);
69
70impl fmt::Display for XmlError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(&self.0)
73    }
74}
75
76impl std::error::Error for XmlError {}
77
78/// What a sink wants the scanner to do with an element after its start tag.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Visit {
81    /// Report child elements and character data.
82    All,
83    /// Skip the content without decoding it; `end` is not called for the element.
84    Skip,
85    /// Report child elements, drop character data.
86    ElementsOnly,
87}
88
89/// Receives the events of one document from [`scan_xml`].
90pub trait XmlSink {
91    /// `name` is qualified, `local` has the prefix removed; `end` follows a self-closing
92    /// tag immediately unless the sink returned [`Visit::Skip`].
93    fn start(&mut self, name: &str, local: &str, attributes: Attrs, self_closing: bool) -> Visit;
94    /// Character data or CDATA directly inside the current element, entities decoded.
95    fn text(&mut self, text: &str);
96    fn end(&mut self, name: &str);
97}
98
99fn err(e: impl fmt::Display) -> XmlError {
100    XmlError(e.to_string())
101}
102
103fn utf8(bytes: &[u8]) -> Result<&str, XmlError> {
104    std::str::from_utf8(bytes).map_err(err)
105}
106
107fn attributes(e: &BytesStart<'_>) -> Result<Attrs, XmlError> {
108    let mut attrs = Attrs::new();
109    // Duplicates are not an error in TS: the last value wins, at the first position.
110    for a in e.attributes().with_checks(false) {
111        let a = a.map_err(err)?;
112        let key = utf8(a.key.as_ref())?;
113        let value = decode_entities(utf8(&a.value)?);
114        attrs.insert(key.to_owned(), value);
115    }
116    Ok(attrs)
117}
118
119/// Scans `xml`, reporting the root element and everything inside it to `sink`. Returns false
120/// when the document has no root element. Content after the root element is ignored.
121pub fn scan_xml(xml: &str, sink: &mut impl XmlSink) -> Result<bool, XmlError> {
122    let mut reader = Reader::from_str(xml);
123    let config = reader.config_mut();
124    config.trim_text(false);
125    config.check_end_names = true;
126    config.expand_empty_elements = false;
127    // Whether each open element wants character data.
128    let mut want_text: Vec<bool> = Vec::new();
129    let mut seen_root = false;
130    // quick-xml reports entity references apart from the text around them; joining the run
131    // and decoding it once gives sinks one `text` call per run, as before.
132    let mut run = String::new();
133    loop {
134        let event = reader.read_event().map_err(err)?;
135        match &event {
136            Event::Text(t) => {
137                if want_text.last() == Some(&true) {
138                    run.push_str(utf8(t)?);
139                }
140                continue;
141            }
142            Event::GeneralRef(r) => {
143                if want_text.last() == Some(&true) {
144                    run.push('&');
145                    run.push_str(utf8(r)?);
146                    run.push(';');
147                }
148                continue;
149            }
150            _ if !run.is_empty() => {
151                sink.text(&decode_entities(&run));
152                run.clear();
153            }
154            _ => {}
155        }
156        match event {
157            Event::Start(e) => {
158                let name = utf8(e.name().into_inner())?;
159                let visit = sink.start(name, local_name(name), attributes(&e)?, false);
160                seen_root = true;
161                if visit == Visit::Skip {
162                    reader.read_to_end(e.name()).map_err(err)?;
163                    if want_text.is_empty() {
164                        return Ok(true);
165                    }
166                } else {
167                    want_text.push(visit == Visit::All);
168                }
169            }
170            Event::Empty(e) => {
171                let name = utf8(e.name().into_inner())?;
172                if sink.start(name, local_name(name), attributes(&e)?, true) != Visit::Skip {
173                    sink.end(name);
174                }
175                if want_text.is_empty() {
176                    return Ok(true);
177                }
178            }
179            Event::End(e) => {
180                if want_text.pop().is_none() {
181                    return Err(XmlError(format!(
182                        "Unexpected closing tag </{}>",
183                        String::from_utf8_lossy(e.name().into_inner())
184                    )));
185                }
186                sink.end(utf8(e.name().into_inner())?);
187                if want_text.is_empty() {
188                    return Ok(true);
189                }
190            }
191            Event::CData(t) => {
192                if want_text.last() == Some(&true) {
193                    sink.text(utf8(&t)?);
194                }
195            }
196            Event::Eof => {
197                if !want_text.is_empty() {
198                    return Err(XmlError(format!(
199                        "Expected \"</\" at position {}",
200                        reader.buffer_position()
201                    )));
202                }
203                return Ok(seen_root);
204            }
205            // Comments, processing instructions, the declaration and DOCTYPE carry nothing.
206            _ => {}
207        }
208    }
209}
210
211/// Parses `xml` into an element tree.
212pub fn parse_xml(xml: &str) -> Result<XmlElement, XmlError> {
213    let mut b = TreeBuilder::default();
214    if !scan_xml(xml, &mut b)? || b.root.is_none() {
215        return Err(XmlError(
216            "Failed to parse XML: no root element found".into(),
217        ));
218    }
219    Ok(b.root.unwrap_or_default())
220}
221
222#[derive(Default)]
223struct TreeBuilder {
224    root: Option<XmlElement>,
225    stack: Vec<XmlElement>,
226}
227
228impl XmlSink for TreeBuilder {
229    fn start(&mut self, name: &str, _: &str, attributes: Attrs, _: bool) -> Visit {
230        self.stack.push(XmlElement {
231            name: name.to_owned(),
232            attributes,
233            children: vec![],
234            text: None,
235        });
236        Visit::All
237    }
238
239    fn text(&mut self, text: &str) {
240        if let Some(el) = self.stack.last_mut() {
241            el.text.get_or_insert_with(String::new).push_str(text);
242        }
243    }
244
245    fn end(&mut self, _: &str) {
246        let Some(el) = self.stack.pop() else { return };
247        match self.stack.last_mut() {
248            Some(parent) => parent.children.push(el),
249            None => self.root = Some(el),
250        }
251    }
252}
253
254/// Decodes `&amp; &lt; &gt; &quot; &apos;` and numeric character references. Other `&...;`
255/// sequences are left as written, as in TS.
256pub fn decode_entities(s: &str) -> String {
257    if !s.contains('&') {
258        return s.to_owned();
259    }
260    let mut out = String::with_capacity(s.len());
261    let mut rest = s;
262    while let Some(i) = rest.find('&') {
263        out.push_str(&rest[..i]);
264        rest = &rest[i..];
265        let decoded = rest.find(';').and_then(|end| {
266            let ent = &rest[1..end];
267            let ch = match ent {
268                "amp" => Some('&'),
269                "lt" => Some('<'),
270                "gt" => Some('>'),
271                "quot" => Some('"'),
272                "apos" => Some('\''),
273                _ => {
274                    let code = if let Some(hex) = ent.strip_prefix("#x") {
275                        (!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()))
276                            .then(|| u32::from_str_radix(hex, 16).ok())
277                            .flatten()
278                    } else if let Some(dec) = ent.strip_prefix('#') {
279                        (!dec.is_empty() && dec.bytes().all(|b| b.is_ascii_digit()))
280                            .then(|| dec.parse::<u32>().ok())
281                            .flatten()
282                    } else {
283                        None
284                    };
285                    code.map(|c| char::from_u32(c).unwrap_or('\u{FFFD}'))
286                }
287            };
288            ch.map(|c| (c, end))
289        });
290        match decoded {
291            Some((c, end)) => {
292                out.push(c);
293                rest = &rest[end + 1..];
294            }
295            None => {
296                out.push('&');
297                rest = &rest[1..];
298            }
299        }
300    }
301    out.push_str(rest);
302    out
303}
304
305/// Escapes a value for use between double quotes, including the whitespace an XML parser would
306/// normalise.
307pub fn escape_attr(value: &str, out: &mut String) {
308    for c in value.chars() {
309        match c {
310            '&' => out.push_str("&amp;"),
311            '<' => out.push_str("&lt;"),
312            '"' => out.push_str("&quot;"),
313            '\n' => out.push_str("&#10;"),
314            '\r' => out.push_str("&#13;"),
315            '\t' => out.push_str("&#9;"),
316            c => out.push(c),
317        }
318    }
319}
320
321/// Escapes a value for use as character data.
322pub fn escape_text(value: &str, out: &mut String) {
323    for c in value.chars() {
324        match c {
325            '&' => out.push_str("&amp;"),
326            '<' => out.push_str("&lt;"),
327            '>' => out.push_str("&gt;"),
328            c => out.push(c),
329        }
330    }
331}
332
333/// Writes `element` as a document with an XML declaration, two-space indentation and a
334/// trailing newline, byte-identical to TS's `serializeXml`.
335pub fn serialize_xml(element: &XmlElement) -> String {
336    let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
337    write_element(element, 0, &mut out);
338    out.push('\n');
339    out
340}
341
342/// Writes one element at `depth`, ending with a newline.
343pub fn write_element(el: &XmlElement, depth: usize, out: &mut String) {
344    for _ in 0..depth {
345        out.push_str("  ");
346    }
347    out.push('<');
348    out.push_str(&el.name);
349    for (k, v) in &el.attributes {
350        out.push(' ');
351        out.push_str(k);
352        out.push_str("=\"");
353        escape_attr(v, out);
354        out.push('"');
355    }
356    if el.children.is_empty() && el.text.is_none() {
357        out.push_str("/>\n");
358        return;
359    }
360    out.push('>');
361    if let Some(t) = &el.text {
362        escape_text(t, out);
363    }
364    if !el.children.is_empty() {
365        out.push('\n');
366        for c in &el.children {
367            write_element(c, depth + 1, out);
368        }
369        for _ in 0..depth {
370            out.push_str("  ");
371        }
372    }
373    out.push_str("</");
374    out.push_str(&el.name);
375    out.push_str(">\n");
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn decodes_known_entities_only() {
384        assert_eq!(decode_entities("a &amp; b &lt;&#65;&#x42;"), "a & b <AB");
385        assert_eq!(decode_entities("&nbsp; & &;"), "&nbsp; & &;");
386    }
387
388    #[test]
389    fn keeps_prefixes_order_and_text() {
390        let x = r#"<?xml version="1.0"?><!-- c --><a:root z="1" b:y="&quot;" x='2'>
391  t<![CDATA[<raw>]]><b:child/><c>x &amp; y</c></a:root><after/>"#;
392        let el = parse_xml(x).unwrap();
393        assert_eq!(el.name, "a:root");
394        let keys: Vec<_> = el.attributes.keys().cloned().collect();
395        assert_eq!(keys, ["z", "b:y", "x"]);
396        assert_eq!(el.attr("b:y"), Some("\""));
397        assert_eq!(el.text.as_deref(), Some("\n  t<raw>"));
398        assert_eq!(el.children[1].text.as_deref(), Some("x & y"));
399    }
400
401    #[test]
402    fn writes_like_ts() {
403        let el = parse_xml(r#"<r a="x&#10;"><c>1 &lt; 2</c><e/></r>"#).unwrap();
404        assert_eq!(
405            serialize_xml(&el),
406            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<r a=\"x&#10;\">\n  <c>1 &lt; 2</c>\n  <e/>\n</r>\n\n"
407        );
408    }
409
410    #[test]
411    fn errors() {
412        assert!(parse_xml("<a><b></a>").is_err());
413        assert!(parse_xml("<a>").is_err());
414        assert!(parse_xml("  ").is_err());
415    }
416
417    struct SkipB(Vec<String>);
418    impl XmlSink for SkipB {
419        fn start(&mut self, n: &str, l: &str, _: Attrs, _: bool) -> Visit {
420            self.0.push(format!("<{n}"));
421            if l == "b" {
422                Visit::Skip
423            } else {
424                Visit::ElementsOnly
425            }
426        }
427        fn text(&mut self, t: &str) {
428            self.0.push(t.to_owned());
429        }
430        fn end(&mut self, n: &str) {
431            self.0.push(format!("</{n}"));
432        }
433    }
434
435    #[test]
436    fn skip_and_elements_only() {
437        let mut s = SkipB(vec![]);
438        assert!(scan_xml("<r>t<x:b><c/>u</x:b><d/></r>", &mut s).unwrap());
439        assert_eq!(s.0, ["<r", "<x:b", "<d", "</d", "</r"]);
440    }
441
442    #[test]
443    fn entity_references_join_the_surrounding_text() {
444        let el = parse_xml("<a>x &amp; &nbsp;&#65;<!--c-->y&lt;</a>").unwrap();
445        assert_eq!(el.text.as_deref(), Some("x & &nbsp;Ay<"));
446    }
447
448    // RUSTSEC-2026-0194/0195: duplicate-attribute checks and namespace bookkeeping must not
449    // grow with the square of, or beyond, the declarations in one start tag.
450    #[test]
451    fn many_attributes_and_namespace_declarations_stay_linear() {
452        let n = 50_000;
453        let mut xml = String::from("<r");
454        for i in 0..n {
455            xml.push_str(&format!(" xmlns:p{i}=\"urn:{i}\" a{i}=\"{i}\""));
456        }
457        xml.push_str(" a0=\"last\"/>");
458        let started = std::time::Instant::now();
459        let el = parse_xml(&xml).unwrap();
460        assert!(started.elapsed() < std::time::Duration::from_secs(5));
461        assert_eq!(el.attributes.len(), 2 * n);
462        assert_eq!(el.attr("a0"), Some("last"));
463        assert_eq!(
464            el.attributes.get_index(1).map(|(k, _)| k.as_str()),
465            Some("a0")
466        );
467    }
468}