Skip to main content

netget/server/netconf/
xml.rs

1//! Bounded XML represented by a flat, ordered stream of typed nodes.
2//! No DOM recursion, DTD, external entities, raw XML action or configuration store.
3use super::wire::MAX_MESSAGE_BYTES;
4use anyhow::{bail, ensure, Context, Result};
5use quick_xml::{events::Event, Reader};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8
9pub const MAX_DEPTH: usize = 32;
10pub const MAX_NODES: usize = 8192;
11pub const MAX_TEXT_BYTES: usize = 65536;
12pub const MAX_NAME_BYTES: usize = 256;
13pub const MAX_URI_BYTES: usize = 4096;
14pub const MAX_ATTRIBUTE_BYTES: usize = 4096;
15pub const MAX_ATTRIBUTES: usize = 32;
16pub const MAX_NAMESPACES: usize = 16;
17pub const MAX_RETAINED_BYTES: usize = 8 * 1024 * 1024;
18const XML: &str = "http://www.w3.org/XML/1998/namespace";
19const XMLNS: &str = "http://www.w3.org/2000/xmlns/";
20
21#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Document {
24    #[serde(default)]
25    pub context: Vec<Binding>,
26    pub nodes: Vec<Node>,
27}
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct Binding {
31    pub prefix: String,
32    pub uri: String,
33}
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Attribute {
37    pub name: String,
38    pub namespace: String,
39    pub value: String,
40}
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
43pub enum Node {
44    Start {
45        name: String,
46        namespace: String,
47        #[serde(default)]
48        bindings: Vec<Binding>,
49        #[serde(default)]
50        attributes: Vec<Attribute>,
51    },
52    End {
53        name: String,
54        namespace: String,
55    },
56    Text {
57        text: String,
58    },
59}
60
61fn xml_chars(text: &str) -> bool {
62    text.chars().all(
63        |c| matches!(c as u32, 9 | 10 | 13 | 0x20..=0xd7ff | 0xe000..=0xfffd | 0x10000..=0x10ffff),
64    )
65}
66fn name_start(c: char) -> bool {
67    matches!(c as u32, 0x41..=0x5a | 0x5f | 0x61..=0x7a | 0xc0..=0xd6 | 0xd8..=0xf6 | 0xf8..=0x2ff | 0x370..=0x37d | 0x37f..=0x1fff | 0x200c..=0x200d | 0x2070..=0x218f | 0x2c00..=0x2fef | 0x3001..=0xd7ff | 0xf900..=0xfdcf | 0xfdf0..=0xfffd | 0x10000..=0xeffff)
68}
69fn ncname(name: &str) -> bool {
70    let mut chars = name.chars();
71    chars.next().is_some_and(name_start)
72        && chars.all(|c| name_start(c) || matches!(c as u32, 0x2d | 0x2e | 0x30..=0x39 | 0xb7 | 0x300..=0x36f | 0x203f..=0x2040))
73}
74pub fn qualified(name: &str) -> Result<(&str, &str)> {
75    ensure!(
76        !name.is_empty() && name.len() <= MAX_NAME_BYTES,
77        "NETCONF XML name bound"
78    );
79    let (prefix, local) = match name.split_once(':') {
80        Some((prefix, local)) => {
81            ensure!(!prefix.is_empty(), "NETCONF empty XML name prefix");
82            (prefix, local)
83        }
84        None => ("", name),
85    };
86    ensure!(
87        (prefix.is_empty() || ncname(prefix)) && ncname(local),
88        "NETCONF XML qualified name"
89    );
90    ensure!(
91        prefix != "xmlns" && name != "xmlns",
92        "NETCONF reserved namespace name"
93    );
94    Ok((prefix, local))
95}
96fn namespace(name: &str, element: bool, active: &BTreeMap<String, String>) -> Result<String> {
97    let (prefix, _) = qualified(name)?;
98    let value = match prefix {
99        "xml" => XML,
100        "" if !element => "",
101        "" => active.get("").map(String::as_str).unwrap_or(""),
102        _ => active
103            .get(prefix)
104            .map(String::as_str)
105            .context("NETCONF undeclared XML namespace prefix")?,
106    };
107    ensure!(
108        value.len() <= MAX_URI_BYTES && xml_chars(value),
109        "NETCONF namespace URI bound"
110    );
111    Ok(value.to_owned())
112}
113fn bindings(
114    values: &[Binding],
115    inherited: &BTreeMap<String, String>,
116) -> Result<BTreeMap<String, String>> {
117    ensure!(
118        values.len() <= MAX_NAMESPACES,
119        "NETCONF namespace binding count"
120    );
121    let mut active = inherited.clone();
122    let mut seen = BTreeSet::new();
123    for b in values {
124        ensure!(
125            b.prefix.len() <= MAX_NAME_BYTES && (b.prefix.is_empty() || ncname(&b.prefix)),
126            "NETCONF namespace prefix"
127        );
128        ensure!(
129            seen.insert(&b.prefix),
130            "NETCONF duplicate namespace binding"
131        );
132        ensure!(
133            b.uri.len() <= MAX_URI_BYTES && xml_chars(&b.uri),
134            "NETCONF namespace URI bound"
135        );
136        ensure!(
137            b.prefix != "xmlns" && b.uri != XMLNS,
138            "NETCONF reserved namespace binding"
139        );
140        ensure!(
141            (b.prefix == "xml") == (b.uri == XML),
142            "NETCONF reserved xml prefix binding"
143        );
144        ensure!(
145            b.prefix.is_empty() || !b.uri.is_empty(),
146            "NETCONF empty prefixed namespace"
147        );
148        active.insert(b.prefix.clone(), b.uri.clone());
149    }
150    ensure!(
151        active.len() <= MAX_NAMESPACES,
152        "NETCONF active namespace count"
153    );
154    Ok(active)
155}
156fn retained(node: &Node) -> usize {
157    match node {
158        Node::Start {
159            name,
160            namespace,
161            bindings,
162            attributes,
163        } => {
164            256 + name.len()
165                + namespace.len()
166                + bindings
167                    .iter()
168                    .map(|b| 64 + b.prefix.len() + b.uri.len())
169                    .sum::<usize>()
170                + attributes
171                    .iter()
172                    .map(|a| 128 + a.name.len() + a.namespace.len() + a.value.len())
173                    .sum::<usize>()
174        }
175        Node::End { name, namespace } => 128 + name.len() + namespace.len(),
176        Node::Text { text } => 64 + text.len(),
177    }
178}
179fn append(doc: &mut Document, node: Node, bytes: &mut usize) -> Result<()> {
180    *bytes = bytes.saturating_add(retained(&node));
181    ensure!(
182        *bytes <= MAX_RETAINED_BYTES,
183        "NETCONF XML retained-content bound"
184    );
185    if let Node::Text { text } = &node {
186        ensure!(
187            text.len() <= MAX_TEXT_BYTES && xml_chars(text),
188            "NETCONF XML text bound/characters"
189        );
190        if let Some(Node::Text { text: previous }) = doc.nodes.last_mut() {
191            ensure!(
192                text.len() <= MAX_TEXT_BYTES.saturating_sub(previous.len()),
193                "NETCONF contiguous XML text bound"
194            );
195            previous.push_str(text);
196            return Ok(());
197        }
198    }
199    ensure!(doc.nodes.len() < MAX_NODES, "NETCONF XML node count");
200    doc.nodes.push(node);
201    Ok(())
202}
203
204pub fn parse(message: &[u8]) -> Result<Document> {
205    ensure!(
206        !message.is_empty() && message.len() <= MAX_MESSAGE_BYTES,
207        "NETCONF XML message byte bound"
208    );
209    let text = std::str::from_utf8(message)?;
210    let mut reader = Reader::from_str(text);
211    reader.config_mut().expand_empty_elements = true;
212    reader.config_mut().check_comments = true;
213    let mut doc = Document::default();
214    let mut stack: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
215    let mut bytes = 0usize;
216    let mut roots = 0usize;
217    let mut seen = false;
218    loop {
219        match reader.read_event()? {
220            Event::Start(element) => {
221                ensure!(stack.len() < MAX_DEPTH, "NETCONF XML depth bound");
222                if stack.is_empty() {
223                    roots += 1;
224                    ensure!(roots == 1, "NETCONF multiple XML roots");
225                }
226                let name = std::str::from_utf8(element.name().as_ref())?.to_owned();
227                qualified(&name)?;
228                let mut local_bindings = Vec::new();
229                let mut raw_attrs = Vec::new();
230                for attribute in element.attributes() {
231                    let attribute = attribute?;
232                    ensure!(
233                        attribute.value.len() <= MAX_ATTRIBUTE_BYTES,
234                        "NETCONF raw XML attribute bound"
235                    );
236                    let key = std::str::from_utf8(attribute.key.as_ref())?;
237                    // XML1.0 normalizes literal attribute whitespace before entity expansion.
238                    let raw = std::str::from_utf8(&attribute.value)?
239                        .replace("\r\n", " ")
240                        .replace(['\r', '\n', '\t'], " ");
241                    let value = quick_xml::escape::unescape(&raw)?.into_owned();
242                    ensure!(
243                        value.len() <= MAX_ATTRIBUTE_BYTES && xml_chars(&value),
244                        "NETCONF XML attribute bound/characters"
245                    );
246                    if key == "xmlns" || key.starts_with("xmlns:") {
247                        ensure!(
248                            local_bindings.len() < MAX_NAMESPACES,
249                            "NETCONF namespace binding count"
250                        );
251                        ensure!(
252                            key == "xmlns" || !key.strip_prefix("xmlns:").unwrap_or("").is_empty(),
253                            "NETCONF empty declared namespace prefix"
254                        );
255                        local_bindings.push(Binding {
256                            prefix: key.strip_prefix("xmlns:").unwrap_or("").into(),
257                            uri: value,
258                        });
259                    } else {
260                        ensure!(
261                            raw_attrs.len() < MAX_ATTRIBUTES,
262                            "NETCONF XML attribute count"
263                        );
264                        qualified(key)?;
265                        raw_attrs.push((key.to_owned(), value));
266                    }
267                }
268                let active = bindings(
269                    &local_bindings,
270                    stack.last().map(|v| &v.2).unwrap_or(&BTreeMap::new()),
271                )?;
272                let ns = namespace(&name, true, &active)?;
273                let mut attrs = Vec::new();
274                let mut expanded = BTreeSet::new();
275                for (name, value) in raw_attrs {
276                    let attribute_ns = namespace(&name, false, &active)?;
277                    ensure!(
278                        expanded.insert((attribute_ns.clone(), qualified(&name)?.1.to_owned())),
279                        "NETCONF duplicate expanded attribute name"
280                    );
281                    attrs.push(Attribute {
282                        name,
283                        namespace: attribute_ns,
284                        value,
285                    });
286                }
287                append(
288                    &mut doc,
289                    Node::Start {
290                        name: name.clone(),
291                        namespace: ns.clone(),
292                        bindings: local_bindings,
293                        attributes: attrs,
294                    },
295                    &mut bytes,
296                )?;
297                stack.push((name, ns, active));
298                seen = true;
299            }
300            Event::End(element) => {
301                let name = std::str::from_utf8(element.name().as_ref())?.to_owned();
302                let ns = namespace(
303                    &name,
304                    true,
305                    &stack.last().context("NETCONF unmatched XML end")?.2,
306                )?;
307                let (start, start_ns, _) = stack.pop().context("NETCONF unmatched XML end")?;
308                ensure!(
309                    name == start && ns == start_ns,
310                    "NETCONF mismatched XML end"
311                );
312                append(
313                    &mut doc,
314                    Node::End {
315                        name,
316                        namespace: ns,
317                    },
318                    &mut bytes,
319                )?;
320            }
321            Event::Text(value) => {
322                ensure!(value.len() <= MAX_TEXT_BYTES, "NETCONF raw XML text bound");
323                let raw = std::str::from_utf8(value.as_ref())?
324                    .replace("\r\n", "\n")
325                    .replace('\r', "\n");
326                let value = quick_xml::escape::unescape(&raw)?.into_owned();
327                if stack.is_empty() {
328                    ensure!(value.trim().is_empty(), "NETCONF text outside XML root");
329                } else {
330                    append(&mut doc, Node::Text { text: value }, &mut bytes)?;
331                }
332                seen = true;
333            }
334            Event::CData(value) => {
335                ensure!(
336                    !stack.is_empty() && value.len() <= MAX_TEXT_BYTES,
337                    "NETCONF XML CDATA bound/position"
338                );
339                let value = std::str::from_utf8(value.as_ref())?
340                    .replace("\r\n", "\n")
341                    .replace('\r', "\n");
342                append(&mut doc, Node::Text { text: value }, &mut bytes)?;
343            }
344            Event::Decl(decl) => {
345                ensure!(
346                    !seen && doc.nodes.is_empty(),
347                    "NETCONF XML declaration position"
348                );
349                ensure!(
350                    decl.version()?.as_ref() == b"1.0",
351                    "NETCONF selected XML1.0 version"
352                );
353                if let Some(encoding) = decl.encoding() {
354                    ensure!(
355                        encoding?.as_ref().eq_ignore_ascii_case(b"utf-8"),
356                        "NETCONF selected UTF8 encoding"
357                    );
358                }
359                seen = true;
360            }
361            Event::DocType(_) => bail!("NETCONF XML DTD is refused"),
362            Event::Comment(_) => seen = true,
363            Event::PI(value) => {
364                ensure!(
365                    !value.target().eq_ignore_ascii_case(b"xml"),
366                    "NETCONF reserved XML processing instruction"
367                );
368                seen = true;
369            }
370            Event::Empty(_) => unreachable!("empty XML elements expand into Start/End"),
371            Event::Eof => break,
372        }
373    }
374    ensure!(
375        roots == 1 && stack.is_empty(),
376        "NETCONF incomplete XML document"
377    );
378    Ok(doc)
379}
380
381fn escape(output: &mut String, text: &str, attribute: bool) -> Result<()> {
382    ensure!(xml_chars(text), "NETCONF outgoing XML characters");
383    for c in text.chars() {
384        let encoded = match c {
385            '&' => Some("&amp;"),
386            '<' => Some("&lt;"),
387            '>' => Some("&gt;"),
388            '"' if attribute => Some("&quot;"),
389            '\r' => Some("&#13;"),
390            '\n' if attribute => Some("&#10;"),
391            '\t' if attribute => Some("&#9;"),
392            _ => None,
393        };
394        if let Some(encoded) = encoded {
395            output.push_str(encoded);
396        } else {
397            output.push(c);
398        }
399        ensure!(
400            output.len() <= MAX_MESSAGE_BYTES,
401            "NETCONF outgoing XML byte bound"
402        );
403    }
404    Ok(())
405}
406fn binding_text(output: &mut String, prefix: &str, uri: &str) -> Result<()> {
407    output.push_str(" xmlns");
408    if !prefix.is_empty() {
409        output.push(':');
410        output.push_str(prefix);
411    }
412    output.push_str("=\"");
413    escape(output, uri, true)?;
414    output.push('"');
415    Ok(())
416}
417
418/// Render a typed fragment inside a namespaced wrapper. Prefix selection cannot
419/// be redirected by configuration bindings or QName-valued attribute content.
420pub fn embedded(doc: &Document, local: &str, uri: &str) -> Result<String> {
421    ensure!(
422        ncname(local) && uri.len() <= MAX_URI_BYTES,
423        "NETCONF XML wrapper name/URI"
424    );
425    ensure!(
426        doc.nodes.len() <= MAX_NODES,
427        "NETCONF outgoing XML node count"
428    );
429    let context = bindings(&doc.context, &BTreeMap::new())?;
430    let mut prefixes: BTreeSet<&str> = doc.context.iter().map(|b| b.prefix.as_str()).collect();
431    for node in &doc.nodes {
432        if let Node::Start { bindings, .. } = node {
433            prefixes.extend(bindings.iter().map(|b| b.prefix.as_str()));
434        }
435    }
436    let prefix = (0..=prefixes.len())
437        .map(|n| format!("netget{n}"))
438        .find(|p| !prefixes.contains(p.as_str()))
439        .context("NETCONF XML wrapper prefix")?;
440    let mut output = format!("<{prefix}:{local}");
441    binding_text(&mut output, &prefix, uri)?;
442    for b in &doc.context {
443        binding_text(&mut output, &b.prefix, &b.uri)?;
444    }
445    output.push('>');
446    let mut stack: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
447    let mut bytes = 0usize;
448    for node in &doc.nodes {
449        bytes = bytes.saturating_add(retained(node));
450        ensure!(
451            bytes <= MAX_RETAINED_BYTES,
452            "NETCONF outgoing XML retained-content bound"
453        );
454        match node {
455            Node::Start {
456                name,
457                namespace,
458                bindings: local_bindings,
459                attributes,
460            } => {
461                ensure!(
462                    stack.len() < MAX_DEPTH && attributes.len() <= MAX_ATTRIBUTES,
463                    "NETCONF outgoing XML depth/attribute bound"
464                );
465                let (p, _) = qualified(name)?;
466                ensure!(
467                    namespace.len() <= MAX_URI_BYTES
468                        && xml_chars(namespace)
469                        && (p.is_empty() || !namespace.is_empty()),
470                    "NETCONF outgoing element namespace"
471                );
472                let inherited = stack.last().map(|v| &v.2).unwrap_or(&context);
473                let mut active = bindings(local_bindings, inherited)?;
474                let mut declarations: BTreeMap<String, String> = local_bindings
475                    .iter()
476                    .map(|b| (b.prefix.clone(), b.uri.clone()))
477                    .collect();
478                let mut declare = |p: &str, ns: &str| -> Result<()> {
479                    ensure!(
480                        p != "xmlns" && ns != XMLNS && ((p == "xml") == (ns == XML)),
481                        "NETCONF outgoing reserved namespace"
482                    );
483                    if let Some(explicit) = declarations.get(p) {
484                        ensure!(explicit == ns, "NETCONF conflicting namespace declaration");
485                    }
486                    if active.get(p).map(String::as_str).unwrap_or("") != ns {
487                        active.insert(p.to_owned(), ns.to_owned());
488                        declarations.insert(p.to_owned(), ns.to_owned());
489                    }
490                    Ok(())
491                };
492                declare(p, namespace)?;
493                let mut expanded = BTreeSet::new();
494                for a in attributes {
495                    let (p, local) = qualified(&a.name)?;
496                    ensure!(
497                        a.namespace.len() <= MAX_URI_BYTES
498                            && a.value.len() <= MAX_ATTRIBUTE_BYTES
499                            && xml_chars(&a.value),
500                        "NETCONF outgoing attribute bound"
501                    );
502                    ensure!(
503                        !p.is_empty() || a.namespace.is_empty(),
504                        "NETCONF unprefixed attribute namespace"
505                    );
506                    if !p.is_empty() {
507                        ensure!(
508                            !a.namespace.is_empty(),
509                            "NETCONF prefixed attribute namespace"
510                        );
511                        declare(p, &a.namespace)?;
512                    }
513                    ensure!(
514                        expanded.insert((&a.namespace, local)),
515                        "NETCONF duplicate outgoing expanded attribute"
516                    );
517                }
518                ensure!(
519                    active.len() <= MAX_NAMESPACES,
520                    "NETCONF outgoing active namespace count"
521                );
522                output.push('<');
523                output.push_str(name);
524                for (p, ns) in declarations {
525                    binding_text(&mut output, &p, &ns)?;
526                }
527                for a in attributes {
528                    output.push(' ');
529                    output.push_str(&a.name);
530                    output.push_str("=\"");
531                    escape(&mut output, &a.value, true)?;
532                    output.push('"');
533                }
534                output.push('>');
535                stack.push((name.clone(), namespace.clone(), active));
536            }
537            Node::End { name, namespace } => {
538                let (start, ns, _) = stack.pop().context("NETCONF outgoing unmatched XML end")?;
539                ensure!(
540                    *name == start && *namespace == ns,
541                    "NETCONF outgoing mismatched XML end"
542                );
543                output.push_str("</");
544                output.push_str(name);
545                output.push('>');
546            }
547            Node::Text { text } => {
548                ensure!(
549                    text.len() <= MAX_TEXT_BYTES,
550                    "NETCONF outgoing XML text bound"
551                );
552                escape(&mut output, text, false)?;
553            }
554        }
555        ensure!(
556            output.len() <= MAX_MESSAGE_BYTES,
557            "NETCONF outgoing XML byte bound"
558        );
559    }
560    ensure!(stack.is_empty(), "NETCONF outgoing incomplete XML fragment");
561    output.push_str(&format!("</{prefix}:{local}>"));
562    ensure!(
563        output.len() <= MAX_MESSAGE_BYTES,
564        "NETCONF outgoing XML byte bound"
565    );
566    // The wrapper contributes one XML level and two nodes to the wire budget.
567    parse(output.as_bytes())?;
568    Ok(output)
569}
570
571pub fn element(doc: &Document, index: usize) -> Result<(&str, &str, &[Attribute])> {
572    match doc.nodes.get(index) {
573        Some(Node::Start {
574            name,
575            namespace,
576            attributes,
577            ..
578        }) => Ok((qualified(name)?.1, namespace, attributes)),
579        _ => bail!("NETCONF expected XML element"),
580    }
581}
582pub fn is_element(doc: &Document, index: usize, local: &str, uri: &str) -> bool {
583    element(doc, index).is_ok_and(|(name, namespace, _)| name == local && namespace == uri)
584}
585pub fn end_index(doc: &Document, start: usize) -> Result<usize> {
586    element(doc, start)?;
587    let mut depth = 0usize;
588    for (index, node) in doc.nodes.iter().enumerate().skip(start) {
589        match node {
590            Node::Start { .. } => {
591                ensure!(depth < MAX_DEPTH, "NETCONF XML subtree depth");
592                depth += 1;
593            }
594            Node::End { .. } => {
595                depth = depth.checked_sub(1).context("NETCONF XML subtree end")?;
596                if depth == 0 {
597                    return Ok(index);
598                }
599            }
600            Node::Text { .. } => {}
601        }
602    }
603    bail!("NETCONF incomplete XML subtree")
604}
605/// Immediate child elements of a protocol envelope; non-whitespace mixed text
606/// is refused here, while configuration fragments preserve their mixed content.
607pub fn children(doc: &Document, start: usize) -> Result<Vec<usize>> {
608    let end = end_index(doc, start)?;
609    let mut index = start + 1;
610    let mut children = Vec::new();
611    while index < end {
612        match &doc.nodes[index] {
613            Node::Start { .. } => {
614                children.push(index);
615                index = end_index(doc, index)? + 1;
616            }
617            Node::Text { text } => {
618                ensure!(
619                    text.trim().is_empty(),
620                    "NETCONF protocol envelope mixed text"
621                );
622                index += 1;
623            }
624            Node::End { .. } => bail!("NETCONF unexpected XML child end"),
625        }
626    }
627    Ok(children)
628}
629pub fn text(doc: &Document, start: usize) -> Result<String> {
630    let end = end_index(doc, start)?;
631    let mut output = String::new();
632    for node in &doc.nodes[start + 1..end] {
633        if let Node::Text { text } = node {
634            ensure!(
635                text.len() <= MAX_TEXT_BYTES.saturating_sub(output.len()),
636                "NETCONF XML scalar text bound"
637            );
638            output.push_str(text);
639        } else {
640            bail!("NETCONF scalar XML field has child elements")
641        }
642    }
643    Ok(output)
644}
645/// Detach configuration content while carrying all active namespace bindings,
646/// including bindings used only by QName-valued text or attributes.
647pub fn inner(doc: &Document, start: usize) -> Result<Document> {
648    let end = end_index(doc, start)?;
649    let mut scopes = vec![bindings(&doc.context, &BTreeMap::new())?];
650    for (index, node) in doc.nodes.iter().enumerate().take(start + 1) {
651        match node {
652            Node::Start {
653                bindings: local, ..
654            } => {
655                ensure!(
656                    scopes.len() <= MAX_DEPTH,
657                    "NETCONF XML namespace scope depth"
658                );
659                scopes.push(bindings(local, scopes.last().expect("context scope"))?);
660            }
661            Node::End { .. } => {
662                ensure!(scopes.len() > 1, "NETCONF XML namespace scope underflow");
663                scopes.pop();
664            }
665            Node::Text { .. } => {}
666        }
667        if index == start {
668            break;
669        }
670    }
671    Ok(Document {
672        context: scopes
673            .last()
674            .expect("context scope")
675            .iter()
676            .map(|(prefix, uri)| Binding {
677                prefix: prefix.clone(),
678                uri: uri.clone(),
679            })
680            .collect(),
681        nodes: doc.nodes[start + 1..end].to_vec(),
682    })
683}
684
685const FRAGMENT_ROOT: &str = "netget-fragment";
686const FRAGMENT_URI: &str = "urn:netget:netconf:fragment";
687
688/// Parse handler-supplied XML content (zero or more sibling elements and text) with the
689/// same bounds as a wire message. The text is markup the handler wrote; it is parsed, never
690/// spliced, so a fragment cannot close the envelope it is placed in or declare a DTD.
691pub fn parse_fragment(text: &str) -> Result<Document> {
692    parse_fragment_in(text, None)
693}
694
695/// [`parse_fragment`] where unprefixed elements without their own `xmlns` belong to
696/// `default_namespace` — for content whose schema is the envelope's own, such as the RFC 6241
697/// `<error-info>` elements (`session-id`, `bad-element`, …).
698pub fn parse_fragment_in(text: &str, default_namespace: Option<&str>) -> Result<Document> {
699    ensure!(
700        text.len() <= MAX_MESSAGE_BYTES - 128,
701        "NETCONF XML fragment byte bound"
702    );
703    ensure!(
704        !text.trim_start().starts_with("<?xml"),
705        "NETCONF XML fragment must not carry a declaration"
706    );
707    let wrapper = default_namespace.unwrap_or(FRAGMENT_URI);
708    let wrapped = format!("<{FRAGMENT_ROOT} xmlns=\"{wrapper}\">{text}</{FRAGMENT_ROOT}>");
709    let doc = parse(wrapped.as_bytes())?;
710    let mut inner = inner(&doc, 0)?;
711    // The wrapper's default namespace is an artefact of wrapping: unprefixed elements in the
712    // fragment that did not declare their own namespace must stay in no namespace.
713    inner
714        .context
715        .retain(|b| !(b.prefix.is_empty() && b.uri == wrapper));
716    for node in &mut inner.nodes {
717        if default_namespace.is_some() {
718            break;
719        }
720        if let Node::Start { namespace, .. } | Node::End { namespace, .. } = node {
721            if namespace == FRAGMENT_URI {
722                namespace.clear();
723            }
724        }
725    }
726    Ok(inner)
727}
728
729/// Render a detached fragment back to XML text. Each top-level element declares the
730/// bindings that were in scope where the fragment was taken, so the text stands alone.
731pub fn render_fragment(doc: &Document) -> Result<String> {
732    let mut output = String::new();
733    let mut depth = 0usize;
734    for node in &doc.nodes {
735        match node {
736            Node::Start {
737                name,
738                namespace,
739                bindings: local,
740                attributes,
741            } => {
742                ensure!(depth < MAX_DEPTH, "NETCONF outgoing XML depth bound");
743                let (element_prefix, _) = qualified(name)?;
744                output.push('<');
745                output.push_str(name);
746                let mut declared: BTreeMap<&str, &str> = BTreeMap::new();
747                if depth == 0 {
748                    for b in &doc.context {
749                        declared.insert(&b.prefix, &b.uri);
750                    }
751                }
752                for b in local {
753                    declared.insert(&b.prefix, &b.uri);
754                }
755                // An unprefixed top-level element states its own namespace, so it cannot
756                // inherit the default namespace of whatever envelope it is placed in.
757                if depth == 0
758                    && element_prefix.is_empty()
759                    && declared.get("") != Some(&namespace.as_str())
760                {
761                    declared.insert("", namespace);
762                }
763                for (prefix, uri) in declared {
764                    if prefix == "xml" {
765                        continue;
766                    }
767                    binding_text(&mut output, prefix, uri)?;
768                }
769                for a in attributes {
770                    qualified(&a.name)?;
771                    output.push(' ');
772                    output.push_str(&a.name);
773                    output.push_str("=\"");
774                    escape(&mut output, &a.value, true)?;
775                    output.push('"');
776                }
777                output.push('>');
778                depth += 1;
779            }
780            Node::End { name, .. } => {
781                depth = depth
782                    .checked_sub(1)
783                    .context("NETCONF outgoing unmatched XML end")?;
784                output.push_str("</");
785                output.push_str(name);
786                output.push('>');
787            }
788            Node::Text { text } => escape(&mut output, text, false)?,
789        }
790        ensure!(
791            output.len() <= MAX_MESSAGE_BYTES,
792            "NETCONF outgoing XML byte bound"
793        );
794    }
795    ensure!(depth == 0, "NETCONF outgoing incomplete XML fragment");
796    Ok(output)
797}