Skip to main content

xml_sec/
document.rs

1//! Owned XML documents with stable semantic identities.
2//!
3//! [`XmlDocument`] owns both the serialized XML and its parsed view. Read-only
4//! operations reuse that view. Structural mutation replaces the parsed
5//! generation atomically, so identities from an older generation cannot be
6//! confused with nodes in the new tree.
7
8#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
9use std::cell::Cell;
10use std::collections::{HashMap, HashSet, hash_map::Entry};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use crate::xml::dom::{Document, Node, NodeId, ParseError, ParsingOptions, XmlBackend};
14use quick_xml::{
15    Reader as QuickXmlReader,
16    events::{BytesStart as QuickXmlBytesStart, Event as QuickXmlEvent},
17};
18use self_cell::self_cell;
19
20use crate::IdAttributeRegistration;
21use crate::xml::dom::{SemanticDocument, SemanticNodeId};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24struct DocumentMetrics {
25    node_count: usize,
26    max_depth: usize,
27}
28
29static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1);
30const VALIDATION_WRAPPER_NS: &str = "urn:xml-sec:owned-document-validation";
31const VALIDATION_WRAPPER_OPEN: &str = "<xmlsec_owned_document:wrapper xmlns:xmlsec_owned_document=\"urn:xml-sec:owned-document-validation\">";
32const VALIDATION_WRAPPER_CLOSE: &str = "</xmlsec_owned_document:wrapper>";
33// Validation adds one wrapper element and can prevent text-node merging at
34// both replacement boundaries. The committed candidate uses the real ceiling.
35const VALIDATION_WRAPPER_NODE_OVERHEAD: u32 = 3;
36
37#[cfg(test)]
38pub(crate) fn selected_parser_passes() -> usize {
39    3
40}
41
42/// Process-local provenance of one owned XML document.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
44pub struct DocumentIdentity(u64);
45
46/// Identity of one tree node in one immutable document generation.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
48pub struct NodeIdentity {
49    document: DocumentIdentity,
50    generation: u64,
51    semantic_id: SemanticNodeId,
52}
53
54/// Identity of one XPath attribute node and its owning element.
55#[derive(Clone, Debug, PartialEq, Eq, Hash)]
56pub struct AttributeIdentity {
57    owner: NodeIdentity,
58    namespace: Option<String>,
59    local_name: String,
60}
61
62/// Identity of one XPath namespace node and its owning element.
63#[derive(Clone, Debug, PartialEq, Eq, Hash)]
64pub struct NamespaceIdentity {
65    owner: NodeIdentity,
66    prefix: String,
67    uri: String,
68}
69
70/// Deterministic total order for tree, namespace, and attribute identities.
71#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
72pub struct SemanticOrder {
73    node: usize,
74    phase: u8,
75    position: usize,
76}
77
78impl AttributeIdentity {
79    /// Return the element that owns this XPath attribute node.
80    #[must_use]
81    pub const fn owner(&self) -> NodeIdentity {
82        self.owner
83    }
84
85    /// Return the attribute namespace URI.
86    #[must_use]
87    pub fn namespace(&self) -> Option<&str> {
88        self.namespace.as_deref()
89    }
90
91    /// Return the attribute local name.
92    #[must_use]
93    pub fn local_name(&self) -> &str {
94        &self.local_name
95    }
96}
97
98impl NamespaceIdentity {
99    /// Return the element that owns this XPath namespace node.
100    #[must_use]
101    pub const fn owner(&self) -> NodeIdentity {
102        self.owner
103    }
104
105    /// Return the namespace prefix, or an empty string for the default namespace.
106    #[must_use]
107    pub fn prefix(&self) -> &str {
108        &self.prefix
109    }
110
111    /// Return the namespace URI.
112    #[must_use]
113    pub fn uri(&self) -> &str {
114        &self.uri
115    }
116}
117
118#[derive(Clone, Copy)]
119pub(crate) struct DocumentParseSettings {
120    pub(crate) backend: XmlBackend,
121    pub(crate) allow_dtd: bool,
122    pub(crate) nodes_limit: u32,
123    pub(crate) depth_limit: usize,
124    pub(crate) max_bytes: usize,
125}
126
127impl Default for DocumentParseSettings {
128    fn default() -> Self {
129        Self {
130            backend: XmlBackend::build_default(),
131            allow_dtd: false,
132            nodes_limit: crate::hard_limits::XML_DOCUMENT_NODE_CEILING,
133            depth_limit: crate::hard_limits::XML_DOCUMENT_DEPTH_CEILING,
134            max_bytes: crate::hard_limits::XML_DOCUMENT_BYTE_CEILING,
135        }
136    }
137}
138
139impl DocumentParseSettings {
140    pub(crate) const fn with_backend(mut self, backend: XmlBackend) -> Self {
141        self.backend = backend;
142        self
143    }
144}
145
146#[cfg(any(feature = "xmldsig", feature = "xmlenc", test))]
147impl DocumentParseSettings {
148    #[cfg(test)]
149    pub(crate) const fn new(allow_dtd: bool, nodes_limit: u32, max_bytes: usize) -> Self {
150        Self::new_with_depth(
151            allow_dtd,
152            nodes_limit,
153            crate::hard_limits::XML_DOCUMENT_DEPTH_CEILING,
154            max_bytes,
155        )
156    }
157
158    pub(crate) const fn new_with_depth(
159        allow_dtd: bool,
160        nodes_limit: u32,
161        depth_limit: usize,
162        max_bytes: usize,
163    ) -> Self {
164        Self {
165            backend: XmlBackend::build_default(),
166            allow_dtd,
167            nodes_limit,
168            depth_limit,
169            max_bytes,
170        }
171    }
172
173    pub(crate) fn from_policy(
174        xml: &crate::policy::XmlInputPolicy,
175        resources: &crate::policy::ResourcePolicy,
176    ) -> Self {
177        Self::new_with_depth(
178            xml.allow_internal_dtd,
179            resources.effective_xml_nodes(),
180            resources.max_xml_depth,
181            resources.max_xml_document_bytes,
182        )
183    }
184}
185
186/// Monotonic parser-work allowance shared by one XML Security operation.
187///
188/// Every byte handed to the XML parser is charged before parsing, including
189/// structural-validation candidates, staged copies, retries, and committed
190/// document generations. Failed work remains charged so nested helpers cannot
191/// reset or reuse the allowance.
192pub(crate) struct XmlParseWorkBudget {
193    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
194    remaining: Cell<usize>,
195    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
196    maximum: usize,
197}
198
199impl XmlParseWorkBudget {
200    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
201    pub(crate) fn from_resources(resources: &crate::policy::ResourcePolicy) -> Self {
202        Self {
203            remaining: Cell::new(resources.max_xml_parse_work_bytes),
204            maximum: resources.max_xml_parse_work_bytes,
205        }
206    }
207
208    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
209    pub(crate) fn charge_policy(&self, bytes: usize) -> Result<(), crate::policy::PolicyViolation> {
210        let consumed = self.maximum.saturating_sub(self.remaining.get());
211        let Some(remaining) = self.remaining.get().checked_sub(bytes) else {
212            self.remaining.set(0);
213            return Err(crate::policy::PolicyViolation::ResourceLimit {
214                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
215                maximum: self.maximum,
216                actual: consumed.saturating_add(bytes),
217            });
218        };
219        self.remaining.set(remaining);
220        Ok(())
221    }
222
223    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
224    pub(crate) fn charge(&self, bytes: usize) -> Result<(), XmlDocumentError> {
225        self.charge_policy(bytes).map_err(Into::into)
226    }
227
228    #[cfg(all(test, any(feature = "xmldsig", feature = "xmlenc")))]
229    fn with_limit(maximum: usize) -> Self {
230        Self {
231            remaining: Cell::new(maximum),
232            maximum,
233        }
234    }
235
236    #[cfg(all(test, any(feature = "xmldsig", feature = "xmlenc")))]
237    pub(crate) fn consumed(&self) -> usize {
238        self.maximum.saturating_sub(self.remaining.get())
239    }
240}
241
242fn charge_parse_work(
243    budget: Option<&XmlParseWorkBudget>,
244    bytes: usize,
245) -> Result<(), XmlDocumentError> {
246    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
247    if let Some(budget) = budget {
248        budget.charge(bytes)?;
249    }
250    #[cfg(not(any(feature = "xmldsig", feature = "xmlenc")))]
251    let _ = (budget, bytes);
252    Ok(())
253}
254
255fn charge_semantic_parser_work(
256    budget: Option<&XmlParseWorkBudget>,
257    bytes: usize,
258) -> Result<(), XmlDocumentError> {
259    // The lexical sidecar and one semantic parser allowance are caller work.
260    // Differential mode checks each implementation against that same allowance
261    // instead of charging its diagnostic duplicate to the caller twice.
262    for _ in 0..2 {
263        charge_parse_work(budget, bytes)?;
264    }
265    Ok(())
266}
267
268struct ContentReplacementEdit<'a> {
269    range: std::ops::Range<usize>,
270    replacement: &'a str,
271    self_closing: Option<SelfClosingContentEdit>,
272}
273
274struct SelfClosingContentEdit {
275    prefix_end: usize,
276    qualified_name: String,
277}
278
279#[cfg(feature = "xmldsig")]
280fn is_unescaped_base64_text(value: &str) -> bool {
281    value
282        .bytes()
283        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'='))
284}
285
286#[cfg(feature = "xmldsig")]
287fn validate_base64_replacements(
288    replacements: &[(NodeIdentity, String)],
289) -> Result<(), XmlDocumentError> {
290    if replacements
291        .iter()
292        .any(|(_, value)| !is_unescaped_base64_text(value))
293    {
294        return Err(XmlDocumentError::InvalidReplacement(
295            "generated base64 replacement contains XML markup".into(),
296        ));
297    }
298    Ok(())
299}
300
301impl ContentReplacementEdit<'_> {
302    fn output_len(&self) -> Option<usize> {
303        let Some(expansion) = &self.self_closing else {
304            return Some(self.replacement.len());
305        };
306        expansion
307            .prefix_end
308            .checked_add(1)
309            .and_then(|length| length.checked_add(self.replacement.len()))
310            .and_then(|length| length.checked_add(2))
311            .and_then(|length| length.checked_add(expansion.qualified_name.len()))
312            .and_then(|length| length.checked_add(1))
313    }
314
315    fn validation_output_len(&self) -> Option<usize> {
316        self.output_len()?
317            .checked_add(VALIDATION_WRAPPER_OPEN.len())?
318            .checked_add(VALIDATION_WRAPPER_CLOSE.len())
319    }
320
321    fn write_output(&self, source: &str, output: &mut String) {
322        if let Some(expansion) = &self.self_closing {
323            output.push_str(&source[self.range.start..self.range.start + expansion.prefix_end]);
324            output.push('>');
325        }
326        output.push_str(self.replacement);
327        if let Some(expansion) = &self.self_closing {
328            output.push_str("</");
329            output.push_str(&expansion.qualified_name);
330            output.push('>');
331        }
332    }
333
334    fn write_validation_output(&self, source: &str, output: &mut String) -> std::ops::Range<usize> {
335        if let Some(expansion) = &self.self_closing {
336            output.push_str(&source[self.range.start..self.range.start + expansion.prefix_end]);
337            output.push('>');
338        }
339        let wrapper_start = output.len();
340        output.push_str(VALIDATION_WRAPPER_OPEN);
341        output.push_str(self.replacement);
342        output.push_str(VALIDATION_WRAPPER_CLOSE);
343        let wrapper_range = wrapper_start..output.len();
344        if let Some(expansion) = &self.self_closing {
345            output.push_str("</");
346            output.push_str(&expansion.qualified_name);
347            output.push('>');
348        }
349        wrapper_range
350    }
351}
352
353#[derive(Debug)]
354struct DocumentIndexes {
355    order: HashMap<NodeId, usize>,
356    default_ids: HashMap<String, Option<NodeId>>,
357    attributes_by_value: HashMap<(String, String), Vec<NodeId>>,
358}
359
360#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
361mod policy_sealed {
362    pub trait Sealed {}
363}
364
365/// Operation policy snapshots that can configure owned-document parsing.
366///
367/// This sealed trait keeps XML parser policy derived from the same immutable
368/// signing, verification, encryption, or decryption snapshot used by the
369/// subsequent operation. Applications cannot create a second parser-only
370/// configuration path that drifts from operation enforcement.
371#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
372pub trait XmlDocumentPolicy: policy_sealed::Sealed {
373    #[doc(hidden)]
374    fn xml_input_policy(&self) -> &crate::policy::XmlInputPolicy;
375
376    #[doc(hidden)]
377    fn resource_policy(&self) -> &crate::policy::ResourcePolicy;
378}
379
380#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
381macro_rules! impl_document_policy {
382    ($policy:ty) => {
383        impl policy_sealed::Sealed for $policy {}
384
385        impl XmlDocumentPolicy for $policy {
386            fn xml_input_policy(&self) -> &crate::policy::XmlInputPolicy {
387                &self.xml
388            }
389
390            fn resource_policy(&self) -> &crate::policy::ResourcePolicy {
391                &self.resources
392            }
393        }
394    };
395}
396
397#[cfg(feature = "xmldsig")]
398impl_document_policy!(crate::policy::SigningPolicy);
399#[cfg(feature = "xmldsig")]
400impl_document_policy!(crate::policy::VerificationPolicy);
401#[cfg(feature = "xmlenc")]
402impl_document_policy!(crate::policy::EncryptionPolicy);
403#[cfg(feature = "xmlenc")]
404impl_document_policy!(crate::policy::DecryptionPolicy);
405
406struct ParsedDocument<'input> {
407    document: Document<'input>,
408    indexes: DocumentIndexes,
409    node_count: usize,
410    max_depth: usize,
411}
412
413self_cell!(
414    struct DocumentCell {
415        owner: String,
416
417        #[covariant]
418        dependent: ParsedDocument,
419    }
420);
421
422/// Errors from owned document parsing, identity validation, and mutation.
423#[derive(Debug, thiserror::Error)]
424pub enum XmlDocumentError {
425    /// The immutable operation policy contains invalid resource limits.
426    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
427    #[error("XML document policy violation: {0}")]
428    Policy(#[from] crate::policy::PolicyViolation),
429
430    /// The input exceeds the active XML document byte limit.
431    #[error("XML document exceeds the maximum size of {maximum} bytes: {actual} bytes")]
432    DocumentTooLarge {
433        /// Maximum accepted byte length.
434        maximum: usize,
435        /// Actual byte length.
436        actual: usize,
437    },
438    /// The input exceeds the active XML element nesting limit.
439    #[error("XML document exceeds the maximum element depth of {maximum}: {actual}")]
440    DocumentTooDeep {
441        /// Maximum accepted element nesting depth.
442        maximum: usize,
443        /// First observed element nesting depth beyond the limit.
444        actual: usize,
445    },
446    /// The XML parser rejected the document.
447    #[error("XML parsing error: {0}")]
448    Parse(#[from] ParseError),
449    /// An identity belongs to another document.
450    #[error("XML identity belongs to a different document")]
451    ForeignIdentity,
452    /// An identity belongs to an earlier document generation.
453    #[error("XML identity belongs to stale generation {identity}; current generation is {current}")]
454    StaleIdentity {
455        /// Generation captured by the identity.
456        identity: u64,
457        /// Current document generation.
458        current: u64,
459    },
460    /// The node no longer exists in the current parsed tree.
461    #[error("XML identity does not resolve to a node")]
462    MissingNode,
463    /// A mutation target is not an element.
464    #[error("XML mutation target must be an element")]
465    TargetNotElement,
466    /// A semantic node was synthesized from a shared entity-reference token.
467    #[error(
468        "XML mutation target originates from an entity expansion and has no unique source range"
469    )]
470    EntityExpandedMutationTarget,
471    /// Replacement content does not satisfy the requested structural shape.
472    #[error("invalid XML replacement: {0}")]
473    InvalidReplacement(String),
474    /// Process-local document provenance space is exhausted.
475    #[error("XML document identity space is exhausted")]
476    IdentityExhausted,
477    /// A projected mutation exceeds the caller's active node ceiling.
478    #[error("projected XML document exceeds the maximum node count of {maximum}")]
479    ProjectedNodeLimit {
480        /// Maximum accepted parser node count.
481        maximum: usize,
482    },
483}
484
485#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
486impl XmlDocumentError {
487    pub(crate) fn into_policy_violation(
488        self,
489        settings: DocumentParseSettings,
490    ) -> Result<crate::policy::PolicyViolation, Self> {
491        let violation = match self {
492            Self::Policy(error) => error,
493            Self::DocumentTooLarge { maximum, actual } => {
494                crate::policy::PolicyViolation::ResourceLimit {
495                    resource: crate::policy::resource_name::XML_DOCUMENT,
496                    maximum,
497                    actual,
498                }
499            }
500            Self::DocumentTooDeep { maximum, actual } => {
501                crate::policy::PolicyViolation::ResourceLimit {
502                    resource: crate::policy::resource_name::XML_DEPTH,
503                    maximum,
504                    actual,
505                }
506            }
507            Self::Parse(ParseError::NodesLimitReached) => {
508                crate::policy::PolicyViolation::ResourceLimit {
509                    resource: crate::policy::resource_name::XML_NODES,
510                    maximum: settings.nodes_limit as usize,
511                    actual: settings.nodes_limit as usize + 1,
512                }
513            }
514            error => return Err(error),
515        };
516        Ok(violation)
517    }
518}
519
520/// Reusable owned XML document.
521///
522/// The parsed view is retained across read-only operations. Every successful
523/// mutation increments [`Self::generation`] and invalidates all identities
524/// captured from previous views.
525pub struct XmlDocument {
526    identity: DocumentIdentity,
527    generation: u64,
528    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
529    requires_internal_dtd: bool,
530    settings: DocumentParseSettings,
531    cell: DocumentCell,
532}
533
534#[cfg(feature = "xmlenc")]
535pub(crate) struct PreparedDocumentMutation {
536    identity: DocumentIdentity,
537    generation: u64,
538    cell: DocumentCell,
539}
540
541/// Borrowed semantic view of one immutable [`XmlDocument`] generation.
542#[derive(Clone, Copy)]
543pub struct DocumentView<'a> {
544    identity: DocumentIdentity,
545    generation: u64,
546    parsed: &'a ParsedDocument<'a>,
547}
548
549impl XmlDocument {
550    /// Return the parser backend retained for reparsing and mutation.
551    #[must_use]
552    pub fn xml_backend(&self) -> XmlBackend {
553        self.settings.backend
554    }
555
556    /// Parse and own an XML document using conservative XML input defaults.
557    /// Borrowed input is size-checked before it is copied into owned storage.
558    pub fn parse(xml: impl AsRef<str> + Into<String>) -> Result<Self, XmlDocumentError> {
559        let settings = DocumentParseSettings::default();
560        let xml = own_bounded_xml(xml, settings.max_bytes)?;
561        Self::parse_with_settings(xml, settings)
562    }
563
564    /// Parse and own XML with an explicitly selected compiled parser backend.
565    pub fn parse_with_backend(
566        xml: impl AsRef<str> + Into<String>,
567        backend: XmlBackend,
568    ) -> Result<Self, XmlDocumentError> {
569        let settings = DocumentParseSettings::default().with_backend(backend);
570        let xml = own_bounded_xml(xml, settings.max_bytes)?;
571        Self::parse_with_settings(xml, settings)
572    }
573
574    /// Parse and own XML under the same immutable policy used by an operation.
575    /// The policy's byte ceiling is checked before borrowed input is copied.
576    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
577    pub fn parse_with_policy(
578        xml: impl AsRef<str> + Into<String>,
579        policy: &impl XmlDocumentPolicy,
580    ) -> Result<Self, XmlDocumentError> {
581        let resources = policy.resource_policy();
582        resources.validate()?;
583        let budget = XmlParseWorkBudget::from_resources(resources);
584        let xml = own_bounded_xml(xml, resources.max_xml_document_bytes)?;
585        Self::parse_with_settings_and_optional_budget(
586            xml,
587            DocumentParseSettings::from_policy(policy.xml_input_policy(), resources),
588            Some(&budget),
589        )
590    }
591
592    /// Parse and own XML under an operation policy and explicit parser backend.
593    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
594    pub fn parse_with_policy_and_backend(
595        xml: impl AsRef<str> + Into<String>,
596        policy: &impl XmlDocumentPolicy,
597        backend: XmlBackend,
598    ) -> Result<Self, XmlDocumentError> {
599        let resources = policy.resource_policy();
600        resources.validate()?;
601        let budget = XmlParseWorkBudget::from_resources(resources);
602        let xml = own_bounded_xml(xml, resources.max_xml_document_bytes)?;
603        Self::parse_with_settings_and_optional_budget(
604            xml,
605            DocumentParseSettings::from_policy(policy.xml_input_policy(), resources)
606                .with_backend(backend),
607            Some(&budget),
608        )
609    }
610
611    pub(crate) fn parse_with_settings(
612        xml: String,
613        settings: DocumentParseSettings,
614    ) -> Result<Self, XmlDocumentError> {
615        Self::parse_with_settings_and_optional_budget(xml, settings, None)
616    }
617
618    fn parse_with_settings_and_optional_budget(
619        xml: String,
620        settings: DocumentParseSettings,
621        budget: Option<&XmlParseWorkBudget>,
622    ) -> Result<Self, XmlDocumentError> {
623        if xml.len() > settings.max_bytes {
624            return Err(XmlDocumentError::DocumentTooLarge {
625                maximum: settings.max_bytes,
626                actual: xml.len(),
627            });
628        }
629        preflight_document_limits(&xml, settings, budget)?;
630        #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
631        let requires_internal_dtd = document_requires_internal_dtd(&xml, settings, budget)?;
632        let cell = build_cell_after_preflight(xml, settings, budget)?;
633        let identity = allocate_document_identity(&NEXT_DOCUMENT_ID)?;
634        Ok(Self {
635            identity,
636            generation: 0,
637            #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
638            requires_internal_dtd,
639            settings,
640            cell,
641        })
642    }
643
644    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
645    pub(crate) fn parse_with_settings_and_budget(
646        xml: String,
647        settings: DocumentParseSettings,
648        budget: &XmlParseWorkBudget,
649    ) -> Result<Self, XmlDocumentError> {
650        Self::parse_with_settings_and_optional_budget(xml, settings, Some(budget))
651    }
652
653    /// Return this document's stable provenance identity.
654    #[must_use]
655    pub const fn identity(&self) -> DocumentIdentity {
656        self.identity
657    }
658
659    /// Return the current mutation generation.
660    #[must_use]
661    pub const fn generation(&self) -> u64 {
662        self.generation
663    }
664
665    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
666    pub(crate) fn validate_xml_input_policy(
667        &self,
668        allow_internal_dtd: bool,
669    ) -> Result<(), crate::policy::PolicyViolation> {
670        if self.requires_internal_dtd && !allow_internal_dtd {
671            return Err(crate::policy::PolicyViolation::XmlInput {
672                reason: "owned document requires internal DTD support",
673            });
674        }
675        Ok(())
676    }
677
678    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
679    pub(crate) fn validate_operation_policy(
680        &self,
681        xml: &crate::policy::XmlInputPolicy,
682        resources: &crate::policy::ResourcePolicy,
683    ) -> Result<(), crate::policy::PolicyViolation> {
684        resources.validate()?;
685        self.validate_xml_input_policy(xml.allow_internal_dtd)?;
686        resources.validate_xml_document_len(self.as_xml().len())?;
687        self.with_view(|view| {
688            let node_count = view.node_count();
689            if node_count > resources.effective_xml_nodes() as usize {
690                return Err(crate::policy::PolicyViolation::ResourceLimit {
691                    resource: crate::policy::resource_name::XML_NODES,
692                    maximum: resources.effective_xml_nodes() as usize,
693                    actual: node_count,
694                });
695            }
696            let max_depth = view.max_depth();
697            if max_depth > resources.max_xml_depth {
698                return Err(crate::policy::PolicyViolation::ResourceLimit {
699                    resource: crate::policy::resource_name::XML_DEPTH,
700                    maximum: resources.max_xml_depth,
701                    actual: max_depth,
702                });
703            }
704            Ok(())
705        })
706    }
707
708    /// Return the deterministic serialized representation of this generation.
709    #[must_use]
710    pub fn as_xml(&self) -> &str {
711        self.cell.borrow_owner()
712    }
713
714    /// Consume the document and return its deterministic serialization.
715    #[must_use]
716    pub fn into_xml(self) -> String {
717        self.cell.into_owner()
718    }
719
720    #[cfg(feature = "xmldsig")]
721    pub(crate) fn staged_copy_with_budget(
722        &self,
723        settings: DocumentParseSettings,
724        budget: &XmlParseWorkBudget,
725    ) -> Result<Self, XmlDocumentError> {
726        Self::parse_with_settings_and_budget(self.as_xml().to_owned(), settings, budget)
727    }
728
729    #[cfg(feature = "xmldsig")]
730    pub(crate) fn commit_staged(&mut self, staged: Self) -> Result<(), XmlDocumentError> {
731        // Signing mutates and validates every staged generation under the active
732        // policy. Moving that retained cell preserves atomicity without parsing
733        // the complete signed document once more merely to commit it.
734        self.commit_cell(staged.cell)
735    }
736
737    /// Borrow the retained parsed view without reparsing.
738    pub fn with_view<R>(&self, operation: impl for<'a> FnOnce(DocumentView<'a>) -> R) -> R {
739        self.cell.with_dependent(|_, parsed| {
740            operation(DocumentView {
741                identity: self.identity,
742                generation: self.generation,
743                parsed,
744            })
745        })
746    }
747
748    /// Replace one complete element with a well-formed element fragment.
749    pub fn replace_element(
750        &mut self,
751        target: NodeIdentity,
752        replacement: &str,
753    ) -> Result<(), XmlDocumentError> {
754        let range = self.with_view(|view| {
755            let node = view.resolve_mutation_node(target)?;
756            if !node.is_element() {
757                return Err(XmlDocumentError::TargetNotElement);
758            }
759            Ok(node.range())
760        })?;
761        self.ensure_replacement_fits(&range, replacement.len(), self.settings.max_bytes)?;
762        self.validate_single_element_in_parent_context(
763            target,
764            replacement,
765            None,
766            self.settings,
767            None,
768        )?;
769        self.replace_range(range, replacement, None)
770    }
771
772    #[cfg(feature = "xmlenc")]
773    pub(crate) fn replace_element_with_budget(
774        &mut self,
775        target: NodeIdentity,
776        replacement: &str,
777        settings: DocumentParseSettings,
778        budget: &XmlParseWorkBudget,
779    ) -> Result<(), XmlDocumentError> {
780        let range = self.with_view(|view| {
781            let node = view.resolve_mutation_node(target)?;
782            if !node.is_element() {
783                return Err(XmlDocumentError::TargetNotElement);
784            }
785            Ok(node.range())
786        })?;
787        self.ensure_replacement_fits(&range, replacement.len(), settings.max_bytes)?;
788        self.validate_single_element_in_parent_context(
789            target,
790            replacement,
791            Some(settings.nodes_limit as usize),
792            settings,
793            Some(budget),
794        )?;
795        self.replace_range_with_settings(range, replacement, settings, Some(budget))
796    }
797
798    #[cfg(feature = "xmlenc")]
799    pub(crate) fn prepare_element_replacement_with_budget(
800        &self,
801        target: NodeIdentity,
802        replacement: &str,
803        settings: DocumentParseSettings,
804        budget: &XmlParseWorkBudget,
805    ) -> Result<PreparedDocumentMutation, XmlDocumentError> {
806        let range = self.with_view(|view| {
807            let node = view.resolve_mutation_node(target)?;
808            if !node.is_element() {
809                return Err(XmlDocumentError::TargetNotElement);
810            }
811            Ok(node.range())
812        })?;
813        self.ensure_replacement_fits(&range, replacement.len(), settings.max_bytes)?;
814        self.validate_single_element_in_parent_context(
815            target,
816            replacement,
817            Some(settings.nodes_limit as usize),
818            settings,
819            Some(budget),
820        )?;
821        self.prepare_range_replacement(range, replacement, settings, budget)
822    }
823
824    /// Replace one complete node with an XML fragment valid in its parent context.
825    ///
826    /// This operation is intended for XMLEnc Content replacement, where one
827    /// `EncryptedData` element can expand into multiple sibling nodes.
828    pub fn replace_node_with_fragment(
829        &mut self,
830        target: NodeIdentity,
831        replacement: &str,
832    ) -> Result<(), XmlDocumentError> {
833        let range = self.with_view(|view| {
834            Ok::<_, XmlDocumentError>(view.resolve_mutation_node(target)?.range())
835        })?;
836        self.ensure_replacement_fits(&range, replacement.len(), self.settings.max_bytes)?;
837        self.validate_fragment_in_parent_context(target, replacement, None, self.settings, None)?;
838        self.replace_range(range, replacement, None)
839    }
840
841    #[cfg(feature = "xmlenc")]
842    pub(crate) fn prepare_node_fragment_replacement_with_budget(
843        &self,
844        target: NodeIdentity,
845        replacement: &str,
846        settings: DocumentParseSettings,
847        budget: &XmlParseWorkBudget,
848    ) -> Result<PreparedDocumentMutation, XmlDocumentError> {
849        let range = self.with_view(|view| {
850            Ok::<_, XmlDocumentError>(view.resolve_mutation_node(target)?.range())
851        })?;
852        self.ensure_replacement_fits(&range, replacement.len(), settings.max_bytes)?;
853        self.validate_fragment_in_parent_context(
854            target,
855            replacement,
856            Some(settings.nodes_limit as usize),
857            settings,
858            Some(budget),
859        )?;
860        self.prepare_range_replacement(range, replacement, settings, budget)
861    }
862
863    /// Replace all children of one element with a well-formed XML fragment.
864    pub fn replace_content(
865        &mut self,
866        target: NodeIdentity,
867        replacement: &str,
868    ) -> Result<(), XmlDocumentError> {
869        self.replace_content_inner(target, replacement, None, self.settings, None)
870    }
871
872    #[cfg(feature = "xmlenc")]
873    pub(crate) fn replace_content_with_budget(
874        &mut self,
875        target: NodeIdentity,
876        replacement: &str,
877        settings: DocumentParseSettings,
878        budget: &XmlParseWorkBudget,
879    ) -> Result<(), XmlDocumentError> {
880        self.replace_content_inner(
881            target,
882            replacement,
883            Some(settings.nodes_limit as usize),
884            settings,
885            Some(budget),
886        )
887    }
888
889    fn replace_content_inner(
890        &mut self,
891        target: NodeIdentity,
892        replacement: &str,
893        maximum: Option<usize>,
894        settings: DocumentParseSettings,
895        budget: Option<&XmlParseWorkBudget>,
896    ) -> Result<(), XmlDocumentError> {
897        let (range, self_closing_expansion, serialized_len) = self.with_view(|view| {
898            let node = view.resolve_mutation_node(target)?;
899            if !node.is_element() {
900                return Err(XmlDocumentError::TargetNotElement);
901            }
902            let range = node.range();
903            let source = &view.xml()[range.clone()];
904            let (content, qualified_name, self_closing) = element_content_range(source)?;
905            if self_closing {
906                let slash = source.rfind("/>").ok_or_else(|| {
907                    XmlDocumentError::InvalidReplacement(
908                        "self-closing element has no terminator".into(),
909                    )
910                })?;
911                let serialized_len = slash
912                    .checked_add(replacement.len())
913                    .and_then(|length| length.checked_add(qualified_name.len()))
914                    .and_then(|length| length.checked_add(4))
915                    .ok_or(XmlDocumentError::DocumentTooLarge {
916                        maximum: settings.max_bytes,
917                        actual: usize::MAX,
918                    })?;
919                return Ok((range, Some((slash, qualified_name)), serialized_len));
920            }
921            let range = (range.start + content.start)..(range.start + content.end);
922            Ok((range, None, replacement.len()))
923        })?;
924        self.ensure_replacement_fits(&range, serialized_len, settings.max_bytes)?;
925        let serialized_replacement = if let Some((slash, qualified_name)) = self_closing_expansion {
926            let source = &self.as_xml()[range.clone()];
927            format!("{}>{replacement}</{qualified_name}>", &source[..slash])
928        } else {
929            replacement.to_owned()
930        };
931        self.validate_content_in_element_context(target, replacement, maximum, settings, budget)?;
932        if let Some(maximum) = maximum {
933            debug_assert_eq!(maximum, settings.nodes_limit as usize);
934            self.replace_range_with_settings(range, &serialized_replacement, settings, budget)
935        } else {
936            self.replace_range(range, &serialized_replacement, budget)
937        }
938    }
939
940    /// Replace the children of several non-overlapping elements atomically.
941    ///
942    /// All identities must belong to the current generation. Every boundary is
943    /// checked in one validation candidate, then the final candidate is parsed
944    /// and either commits as one new generation or leaves the document unchanged.
945    pub fn replace_contents(
946        &mut self,
947        replacements: &[(NodeIdentity, String)],
948    ) -> Result<(), XmlDocumentError> {
949        self.replace_contents_inner(replacements, self.settings, false, true, None)
950    }
951
952    #[cfg(all(feature = "xmldsig", test))]
953    pub(crate) fn replace_contents_with_budget(
954        &mut self,
955        replacements: &[(NodeIdentity, String)],
956        settings: DocumentParseSettings,
957        budget: &XmlParseWorkBudget,
958    ) -> Result<(), XmlDocumentError> {
959        self.replace_contents_inner(replacements, settings, true, true, Some(budget))
960    }
961
962    #[cfg(feature = "xmldsig")]
963    pub(crate) fn replace_base64_contents_with_budget(
964        &mut self,
965        replacements: &[(NodeIdentity, String)],
966        settings: DocumentParseSettings,
967        budget: &XmlParseWorkBudget,
968    ) -> Result<(), XmlDocumentError> {
969        validate_base64_replacements(replacements)?;
970        // Base64 cannot alter XML structure, so the committed candidate parse
971        // is also the boundary validation. Generic caller fragments retain the
972        // separate contextual validation path above.
973        self.replace_contents_inner(replacements, settings, true, false, Some(budget))
974    }
975
976    #[cfg(feature = "xmldsig")]
977    pub(crate) fn project_base64_contents(
978        &self,
979        replacements: &[(NodeIdentity, String)],
980        maximum_bytes: usize,
981    ) -> Result<String, XmlDocumentError> {
982        validate_base64_replacements(replacements)?;
983        let (edits, projected) =
984            self.prepare_content_replacement_edits(replacements, maximum_bytes)?;
985        Ok(self.render_content_replacement_edits(&edits, projected))
986    }
987
988    fn replace_contents_inner(
989        &mut self,
990        replacements: &[(NodeIdentity, String)],
991        settings: DocumentParseSettings,
992        policy_bounded: bool,
993        validate_fragments: bool,
994        budget: Option<&XmlParseWorkBudget>,
995    ) -> Result<(), XmlDocumentError> {
996        if replacements.is_empty() {
997            return Ok(());
998        }
999        let (edits, projected) =
1000            self.prepare_content_replacement_edits(replacements, settings.max_bytes)?;
1001
1002        if validate_fragments {
1003            self.validate_content_edits(&edits, settings, policy_bounded, budget)?;
1004        }
1005
1006        let output = self.render_content_replacement_edits(&edits, projected);
1007        if policy_bounded {
1008            self.replace_serialized_with_settings(output, settings, budget)
1009        } else {
1010            self.replace_serialized(output, budget)
1011        }
1012    }
1013
1014    fn prepare_content_replacement_edits<'a>(
1015        &self,
1016        replacements: &'a [(NodeIdentity, String)],
1017        maximum_bytes: usize,
1018    ) -> Result<(Vec<ContentReplacementEdit<'a>>, usize), XmlDocumentError> {
1019        let mut targets = HashSet::with_capacity(replacements.len());
1020        if replacements
1021            .iter()
1022            .any(|(target, _)| !targets.insert(*target))
1023        {
1024            return Err(XmlDocumentError::InvalidReplacement(
1025                "replacement targets must be unique".into(),
1026            ));
1027        }
1028        let mut edits = self.with_view(|view| {
1029            replacements
1030                .iter()
1031                .map(|(target, replacement)| {
1032                    let node = view.resolve_mutation_node(*target)?;
1033                    if !node.is_element() {
1034                        return Err(XmlDocumentError::TargetNotElement);
1035                    }
1036                    let range = node.range();
1037                    let source = &view.xml()[range.clone()];
1038                    let (content, qualified_name, self_closing) = element_content_range(source)?;
1039                    if self_closing {
1040                        let slash = source.rfind("/>").ok_or_else(|| {
1041                            XmlDocumentError::InvalidReplacement(
1042                                "self-closing element has no terminator".into(),
1043                            )
1044                        })?;
1045                        Ok(ContentReplacementEdit {
1046                            range,
1047                            replacement,
1048                            self_closing: Some(SelfClosingContentEdit {
1049                                prefix_end: slash,
1050                                qualified_name,
1051                            }),
1052                        })
1053                    } else {
1054                        Ok(ContentReplacementEdit {
1055                            range: (range.start + content.start)..(range.start + content.end),
1056                            replacement,
1057                            self_closing: None,
1058                        })
1059                    }
1060                })
1061                .collect::<Result<Vec<_>, XmlDocumentError>>()
1062        })?;
1063        edits.sort_by_key(|edit| edit.range.start);
1064        if edits
1065            .windows(2)
1066            .any(|pair| pair[0].range.end > pair[1].range.start)
1067        {
1068            return Err(XmlDocumentError::InvalidReplacement(
1069                "replacement targets overlap".into(),
1070            ));
1071        }
1072        let projected = edits.iter().try_fold(self.as_xml().len(), |length, edit| {
1073            length
1074                .checked_sub(edit.range.len())
1075                .and_then(|length| length.checked_add(edit.output_len()?))
1076                .ok_or(XmlDocumentError::DocumentTooLarge {
1077                    maximum: maximum_bytes,
1078                    actual: usize::MAX,
1079                })
1080        })?;
1081        if projected > maximum_bytes {
1082            return Err(XmlDocumentError::DocumentTooLarge {
1083                maximum: maximum_bytes,
1084                actual: projected,
1085            });
1086        }
1087        Ok((edits, projected))
1088    }
1089
1090    fn render_content_replacement_edits(
1091        &self,
1092        edits: &[ContentReplacementEdit<'_>],
1093        projected: usize,
1094    ) -> String {
1095        let mut output = String::with_capacity(projected);
1096        let mut cursor = 0;
1097        for edit in edits {
1098            output.push_str(&self.as_xml()[cursor..edit.range.start]);
1099            edit.write_output(self.as_xml(), &mut output);
1100            cursor = edit.range.end;
1101        }
1102        output.push_str(&self.as_xml()[cursor..]);
1103        debug_assert_eq!(output.len(), projected);
1104        output
1105    }
1106
1107    fn validate_content_edits(
1108        &self,
1109        edits: &[ContentReplacementEdit<'_>],
1110        settings: DocumentParseSettings,
1111        policy_bounded: bool,
1112        budget: Option<&XmlParseWorkBudget>,
1113    ) -> Result<(), XmlDocumentError> {
1114        let projected = edits.iter().try_fold(self.as_xml().len(), |length, edit| {
1115            length
1116                .checked_sub(edit.range.len())
1117                .and_then(|length| length.checked_add(edit.validation_output_len()?))
1118                .ok_or_else(|| {
1119                    XmlDocumentError::InvalidReplacement("validation length overflow".into())
1120                })
1121        })?;
1122        let mut candidate = String::with_capacity(projected);
1123        let mut wrapper_ranges = Vec::with_capacity(edits.len());
1124        let mut cursor = 0;
1125        for edit in edits {
1126            candidate.push_str(&self.as_xml()[cursor..edit.range.start]);
1127            wrapper_ranges.push(edit.write_validation_output(self.as_xml(), &mut candidate));
1128            cursor = edit.range.end;
1129        }
1130        candidate.push_str(&self.as_xml()[cursor..]);
1131        debug_assert_eq!(candidate.len(), projected);
1132
1133        let active_nodes_limit = settings.nodes_limit as usize;
1134        let wrapper_allowance = edits.len();
1135        let validation_nodes_limit = active_nodes_limit
1136            .checked_add(wrapper_allowance)
1137            .and_then(|maximum| u32::try_from(maximum).ok())
1138            .unwrap_or(u32::MAX);
1139        let parsed = build_cell(
1140            candidate,
1141            DocumentParseSettings {
1142                nodes_limit: validation_nodes_limit,
1143                depth_limit: settings.depth_limit.saturating_add(1),
1144                max_bytes: projected,
1145                ..settings
1146            },
1147            budget,
1148        )
1149        .map_err(|error| match (policy_bounded, error) {
1150            (true, XmlDocumentError::Parse(ParseError::NodesLimitReached)) => {
1151                XmlDocumentError::ProjectedNodeLimit {
1152                    maximum: active_nodes_limit,
1153                }
1154            }
1155            (_, XmlDocumentError::DocumentTooDeep { actual, .. }) => {
1156                XmlDocumentError::DocumentTooDeep {
1157                    maximum: settings.depth_limit,
1158                    actual: actual.saturating_sub(1),
1159                }
1160            }
1161            (_, error) => error,
1162        })?;
1163        parsed.with_dependent(|_, document| validate_wrappers(document, &wrapper_ranges))
1164    }
1165
1166    /// Append a well-formed XML fragment as the last child of an element.
1167    pub fn append_child(
1168        &mut self,
1169        target: NodeIdentity,
1170        child: &str,
1171    ) -> Result<(), XmlDocumentError> {
1172        self.append_child_inner(target, child, None, self.settings, true, None)
1173    }
1174
1175    #[cfg(all(feature = "xmldsig", test))]
1176    pub(crate) fn append_child_with_budget(
1177        &mut self,
1178        target: NodeIdentity,
1179        child: &str,
1180        settings: DocumentParseSettings,
1181        budget: &XmlParseWorkBudget,
1182    ) -> Result<(), XmlDocumentError> {
1183        self.append_child_inner(
1184            target,
1185            child,
1186            Some(settings.nodes_limit as usize),
1187            settings,
1188            true,
1189            Some(budget),
1190        )
1191    }
1192
1193    #[cfg(feature = "xmldsig")]
1194    pub(crate) fn append_generated_child_with_budget(
1195        &mut self,
1196        target: NodeIdentity,
1197        child: &str,
1198        settings: DocumentParseSettings,
1199        budget: &XmlParseWorkBudget,
1200    ) -> Result<(), XmlDocumentError> {
1201        // SignatureBuilder serializes this fragment with quick-xml. Parsing the
1202        // final candidate once validates both the generated child and its
1203        // document context without a redundant wrapper-document pass.
1204        self.append_child_inner(
1205            target,
1206            child,
1207            Some(settings.nodes_limit as usize),
1208            settings,
1209            false,
1210            Some(budget),
1211        )
1212    }
1213
1214    fn append_child_inner(
1215        &mut self,
1216        target: NodeIdentity,
1217        child: &str,
1218        maximum: Option<usize>,
1219        settings: DocumentParseSettings,
1220        validate_context: bool,
1221        budget: Option<&XmlParseWorkBudget>,
1222    ) -> Result<(), XmlDocumentError> {
1223        let projected = self.projected_child_append_len(target, child.len())?;
1224        if projected > settings.max_bytes {
1225            return Err(XmlDocumentError::DocumentTooLarge {
1226                maximum: settings.max_bytes,
1227                actual: projected,
1228            });
1229        }
1230        let (range, replacement) = self.with_view(|view| {
1231            let node = view.resolve_mutation_node(target)?;
1232            if !node.is_element() {
1233                return Err(XmlDocumentError::TargetNotElement);
1234            }
1235            let range = node.range();
1236            let source = &view.xml()[range.clone()];
1237            let (content, qualified_name, self_closing) = element_content_range(source)?;
1238            if self_closing {
1239                let slash = source.rfind("/>").ok_or_else(|| {
1240                    XmlDocumentError::InvalidReplacement(
1241                        "self-closing element has no terminator".into(),
1242                    )
1243                })?;
1244                return Ok((
1245                    range,
1246                    format!("{}>{child}</{qualified_name}>", &source[..slash]),
1247                ));
1248            }
1249            Ok((
1250                (range.start + content.end)..(range.start + content.end),
1251                child.to_owned(),
1252            ))
1253        })?;
1254        self.ensure_replacement_fits(&range, replacement.len(), settings.max_bytes)?;
1255        if validate_context {
1256            self.validate_content_in_element_context(target, child, maximum, settings, budget)?;
1257        }
1258        if let Some(maximum) = maximum {
1259            debug_assert_eq!(maximum, settings.nodes_limit as usize);
1260            self.replace_range_with_settings(range, &replacement, settings, budget)
1261        } else {
1262            self.replace_range(range, &replacement, budget)
1263        }
1264    }
1265
1266    pub(crate) fn replace_serialized(
1267        &mut self,
1268        xml: String,
1269        budget: Option<&XmlParseWorkBudget>,
1270    ) -> Result<(), XmlDocumentError> {
1271        let next = build_cell(xml, self.settings, budget)?;
1272        self.commit_cell(next)
1273    }
1274
1275    pub(crate) fn replace_serialized_with_settings(
1276        &mut self,
1277        xml: String,
1278        settings: DocumentParseSettings,
1279        budget: Option<&XmlParseWorkBudget>,
1280    ) -> Result<(), XmlDocumentError> {
1281        let next = build_cell(xml, settings, budget).map_err(|error| match error {
1282            XmlDocumentError::Parse(ParseError::NodesLimitReached) => {
1283                XmlDocumentError::ProjectedNodeLimit {
1284                    maximum: settings.nodes_limit as usize,
1285                }
1286            }
1287            error => error,
1288        })?;
1289        self.commit_cell(next)
1290    }
1291
1292    fn commit_cell(&mut self, next: DocumentCell) -> Result<(), XmlDocumentError> {
1293        let next_generation = self.generation.checked_add(1).ok_or_else(|| {
1294            XmlDocumentError::InvalidReplacement("document generation overflow".into())
1295        })?;
1296        self.cell = next;
1297        self.generation = next_generation;
1298        Ok(())
1299    }
1300
1301    #[cfg(feature = "xmldsig")]
1302    pub(crate) fn projected_content_replacement_len(
1303        &self,
1304        target: NodeIdentity,
1305        replacement_len: usize,
1306    ) -> Result<usize, XmlDocumentError> {
1307        self.with_view(|view| {
1308            let node = view.resolve_mutation_node(target)?;
1309            if !node.is_element() {
1310                return Err(XmlDocumentError::TargetNotElement);
1311            }
1312            let range = node.range();
1313            let source = &view.xml()[range.clone()];
1314            let (content, qualified_name, self_closing) = element_content_range(source)?;
1315            let removed = if self_closing {
1316                range.len()
1317            } else {
1318                content.len()
1319            };
1320            let added = if self_closing {
1321                let slash = source.rfind("/>").ok_or_else(|| {
1322                    XmlDocumentError::InvalidReplacement(
1323                        "self-closing element has no terminator".into(),
1324                    )
1325                })?;
1326                slash
1327                    .checked_add(1)
1328                    .and_then(|length| length.checked_add(replacement_len))
1329                    .and_then(|length| length.checked_add(2))
1330                    .and_then(|length| length.checked_add(qualified_name.len()))
1331                    .and_then(|length| length.checked_add(1))
1332                    .ok_or_else(|| {
1333                        XmlDocumentError::InvalidReplacement(
1334                            "content replacement length overflow".into(),
1335                        )
1336                    })?
1337            } else {
1338                replacement_len
1339            };
1340            view.xml()
1341                .len()
1342                .checked_sub(removed)
1343                .and_then(|length| length.checked_add(added))
1344                .ok_or_else(|| {
1345                    XmlDocumentError::InvalidReplacement(
1346                        "content replacement length overflow".into(),
1347                    )
1348                })
1349        })
1350    }
1351
1352    pub(crate) fn projected_child_append_len(
1353        &self,
1354        target: NodeIdentity,
1355        child_len: usize,
1356    ) -> Result<usize, XmlDocumentError> {
1357        self.with_view(|view| {
1358            let node = view.resolve_mutation_node(target)?;
1359            if !node.is_element() {
1360                return Err(XmlDocumentError::TargetNotElement);
1361            }
1362            let range = node.range();
1363            let source = &view.xml()[range.clone()];
1364            let (_, qualified_name, self_closing) = element_content_range(source)?;
1365            let (removed, added) = if self_closing {
1366                let slash = source.rfind("/>").ok_or_else(|| {
1367                    XmlDocumentError::InvalidReplacement(
1368                        "self-closing element has no terminator".into(),
1369                    )
1370                })?;
1371                let expanded = slash
1372                    .checked_add(1)
1373                    .and_then(|length| length.checked_add(child_len))
1374                    .and_then(|length| length.checked_add(2))
1375                    .and_then(|length| length.checked_add(qualified_name.len()))
1376                    .and_then(|length| length.checked_add(1))
1377                    .ok_or_else(|| {
1378                        XmlDocumentError::InvalidReplacement("child append length overflow".into())
1379                    })?;
1380                (range.len(), expanded)
1381            } else {
1382                (0, child_len)
1383            };
1384            view.xml()
1385                .len()
1386                .checked_sub(removed)
1387                .and_then(|length| length.checked_add(added))
1388                .ok_or_else(|| {
1389                    XmlDocumentError::InvalidReplacement("child append length overflow".into())
1390                })
1391        })
1392    }
1393
1394    fn replace_range(
1395        &mut self,
1396        range: std::ops::Range<usize>,
1397        replacement: &str,
1398        budget: Option<&XmlParseWorkBudget>,
1399    ) -> Result<(), XmlDocumentError> {
1400        let output = self.replaced_range(range, replacement, self.settings.max_bytes)?;
1401        self.replace_serialized(output, budget)
1402    }
1403
1404    fn replace_range_with_settings(
1405        &mut self,
1406        range: std::ops::Range<usize>,
1407        replacement: &str,
1408        settings: DocumentParseSettings,
1409        budget: Option<&XmlParseWorkBudget>,
1410    ) -> Result<(), XmlDocumentError> {
1411        let output = self.replaced_range(range, replacement, settings.max_bytes)?;
1412        self.replace_serialized_with_settings(output, settings, budget)
1413    }
1414
1415    #[cfg(feature = "xmlenc")]
1416    fn prepare_range_replacement(
1417        &self,
1418        range: std::ops::Range<usize>,
1419        replacement: &str,
1420        settings: DocumentParseSettings,
1421        budget: &XmlParseWorkBudget,
1422    ) -> Result<PreparedDocumentMutation, XmlDocumentError> {
1423        let output = self.replaced_range(range, replacement, settings.max_bytes)?;
1424        let cell = build_cell(output, settings, Some(budget)).map_err(|error| match error {
1425            XmlDocumentError::Parse(ParseError::NodesLimitReached) => {
1426                XmlDocumentError::ProjectedNodeLimit {
1427                    maximum: settings.nodes_limit as usize,
1428                }
1429            }
1430            error => error,
1431        })?;
1432        Ok(PreparedDocumentMutation {
1433            identity: self.identity,
1434            generation: self.generation,
1435            cell,
1436        })
1437    }
1438
1439    #[cfg(feature = "xmlenc")]
1440    pub(crate) fn commit_prepared(
1441        &mut self,
1442        prepared: PreparedDocumentMutation,
1443    ) -> Result<(), XmlDocumentError> {
1444        if self.identity != prepared.identity {
1445            return Err(XmlDocumentError::ForeignIdentity);
1446        }
1447        if self.generation != prepared.generation {
1448            return Err(XmlDocumentError::StaleIdentity {
1449                identity: prepared.generation,
1450                current: self.generation,
1451            });
1452        }
1453        self.commit_cell(prepared.cell)
1454    }
1455
1456    fn replaced_range(
1457        &self,
1458        range: std::ops::Range<usize>,
1459        replacement: &str,
1460        maximum_bytes: usize,
1461    ) -> Result<String, XmlDocumentError> {
1462        let projected = self.ensure_replacement_fits(&range, replacement.len(), maximum_bytes)?;
1463        let mut output = String::with_capacity(projected);
1464        output.push_str(&self.as_xml()[..range.start]);
1465        output.push_str(replacement);
1466        output.push_str(&self.as_xml()[range.end..]);
1467        Ok(output)
1468    }
1469
1470    fn ensure_replacement_fits(
1471        &self,
1472        range: &std::ops::Range<usize>,
1473        replacement_len: usize,
1474        maximum_bytes: usize,
1475    ) -> Result<usize, XmlDocumentError> {
1476        let projected = self
1477            .as_xml()
1478            .len()
1479            .checked_sub(range.len())
1480            .and_then(|length| length.checked_add(replacement_len))
1481            .ok_or(XmlDocumentError::DocumentTooLarge {
1482                maximum: maximum_bytes,
1483                actual: usize::MAX,
1484            })?;
1485        if projected > maximum_bytes {
1486            return Err(XmlDocumentError::DocumentTooLarge {
1487                maximum: maximum_bytes,
1488                actual: projected,
1489            });
1490        }
1491        Ok(projected)
1492    }
1493
1494    fn validate_single_element_in_parent_context(
1495        &self,
1496        target: NodeIdentity,
1497        replacement: &str,
1498        maximum: Option<usize>,
1499        settings: DocumentParseSettings,
1500        budget: Option<&XmlParseWorkBudget>,
1501    ) -> Result<(), XmlDocumentError> {
1502        let (parsed, wrapper_range) =
1503            self.parse_fragment_in_parent_context(target, replacement, maximum, settings, budget)?;
1504        parsed.with_dependent(|_, parsed| {
1505            let wrapper = validation_wrapper(parsed, wrapper_range.clone())?;
1506            if wrapper.children().filter(Node::is_element).count() != 1
1507                || wrapper.children().any(|node| {
1508                    !node.is_element()
1509                        && !node.is_comment()
1510                        && !node.is_pi()
1511                        && node.text().is_none_or(|text| !text.trim().is_empty())
1512                })
1513            {
1514                return Err(XmlDocumentError::InvalidReplacement(
1515                    "element replacement must contain exactly one element".into(),
1516                ));
1517            }
1518            Ok(())
1519        })
1520    }
1521
1522    fn validate_fragment_in_parent_context(
1523        &self,
1524        target: NodeIdentity,
1525        replacement: &str,
1526        maximum: Option<usize>,
1527        settings: DocumentParseSettings,
1528        budget: Option<&XmlParseWorkBudget>,
1529    ) -> Result<(), XmlDocumentError> {
1530        self.parse_fragment_in_parent_context(target, replacement, maximum, settings, budget)
1531            .map(|_| ())
1532    }
1533
1534    fn parse_fragment_in_parent_context(
1535        &self,
1536        target: NodeIdentity,
1537        replacement: &str,
1538        maximum: Option<usize>,
1539        settings: DocumentParseSettings,
1540        budget: Option<&XmlParseWorkBudget>,
1541    ) -> Result<(DocumentCell, std::ops::Range<usize>), XmlDocumentError> {
1542        let range =
1543            self.with_view(|view| Ok::<_, XmlDocumentError>(view.resolve_node(target)?.range()))?;
1544        self.parse_wrapped_range(range, replacement, maximum, settings, budget)
1545    }
1546
1547    fn validate_content_in_element_context(
1548        &self,
1549        target: NodeIdentity,
1550        replacement: &str,
1551        maximum: Option<usize>,
1552        settings: DocumentParseSettings,
1553        budget: Option<&XmlParseWorkBudget>,
1554    ) -> Result<(), XmlDocumentError> {
1555        let (element_range, content_range, qualified_name, self_closing) =
1556            self.with_view(|view| {
1557                let target = view.resolve_node(target)?;
1558                if !target.is_element() {
1559                    return Err(XmlDocumentError::TargetNotElement);
1560                }
1561                let element_range = target.range();
1562                let source = &view.xml()[element_range.clone()];
1563                let (content, qualified_name, self_closing) = element_content_range(source)?;
1564                Ok::<_, XmlDocumentError>((
1565                    element_range.clone(),
1566                    (element_range.start + content.start)..(element_range.start + content.end),
1567                    qualified_name,
1568                    self_closing,
1569                ))
1570            })?;
1571        if !self_closing {
1572            return self
1573                .parse_wrapped_range(content_range, replacement, maximum, settings, budget)
1574                .map(|_| ());
1575        }
1576
1577        let source = &self.as_xml()[element_range.clone()];
1578        let slash = source.rfind("/>").ok_or_else(|| {
1579            XmlDocumentError::InvalidReplacement("self-closing element has no terminator".into())
1580        })?;
1581        let wrapped = wrapped_fragment(replacement);
1582        let wrapper_start = element_range.start + slash + 1;
1583        let expanded = format!("{}>{wrapped}</{qualified_name}>", &source[..slash]);
1584        self.parse_wrapped_edit(
1585            element_range,
1586            &expanded,
1587            wrapper_start..(wrapper_start + wrapped.len()),
1588            maximum,
1589            settings,
1590            budget,
1591        )
1592        .map(|_| ())
1593    }
1594
1595    fn parse_wrapped_range(
1596        &self,
1597        range: std::ops::Range<usize>,
1598        replacement: &str,
1599        maximum: Option<usize>,
1600        settings: DocumentParseSettings,
1601        budget: Option<&XmlParseWorkBudget>,
1602    ) -> Result<(DocumentCell, std::ops::Range<usize>), XmlDocumentError> {
1603        let wrapped = wrapped_fragment(replacement);
1604        let wrapper_range = range.start..(range.start + wrapped.len());
1605        self.parse_wrapped_edit(range, &wrapped, wrapper_range, maximum, settings, budget)
1606    }
1607
1608    fn parse_wrapped_edit(
1609        &self,
1610        range: std::ops::Range<usize>,
1611        replacement: &str,
1612        wrapper_range: std::ops::Range<usize>,
1613        maximum: Option<usize>,
1614        settings: DocumentParseSettings,
1615        budget: Option<&XmlParseWorkBudget>,
1616    ) -> Result<(DocumentCell, std::ops::Range<usize>), XmlDocumentError> {
1617        let projected = self
1618            .as_xml()
1619            .len()
1620            .checked_sub(range.len())
1621            .and_then(|length| length.checked_add(replacement.len()))
1622            .ok_or_else(|| {
1623                XmlDocumentError::InvalidReplacement("validation length overflow".into())
1624            })?;
1625        let mut candidate = String::with_capacity(projected);
1626        candidate.push_str(&self.as_xml()[..range.start]);
1627        candidate.push_str(replacement);
1628        candidate.push_str(&self.as_xml()[range.end..]);
1629        let active_nodes_limit = maximum
1630            .map(|maximum| maximum.min(settings.nodes_limit as usize))
1631            .map(|maximum| u32::try_from(maximum).unwrap_or(u32::MAX))
1632            .unwrap_or(settings.nodes_limit);
1633        let parsed = build_cell(
1634            candidate,
1635            DocumentParseSettings {
1636                nodes_limit: active_nodes_limit.saturating_add(VALIDATION_WRAPPER_NODE_OVERHEAD),
1637                // Wrapper markup is validation scaffolding, not document input.
1638                // The committed candidate is checked against the real ceiling.
1639                depth_limit: settings.depth_limit.saturating_add(1),
1640                max_bytes: projected,
1641                ..settings
1642            },
1643            budget,
1644        )
1645        .map_err(|error| match (maximum, error) {
1646            (Some(_), XmlDocumentError::Parse(ParseError::NodesLimitReached)) => {
1647                XmlDocumentError::ProjectedNodeLimit {
1648                    maximum: active_nodes_limit as usize,
1649                }
1650            }
1651            (_, XmlDocumentError::DocumentTooDeep { actual, .. }) => {
1652                XmlDocumentError::DocumentTooDeep {
1653                    maximum: settings.depth_limit,
1654                    actual: actual.saturating_sub(1),
1655                }
1656            }
1657            (_, error) => error,
1658        })?;
1659        parsed.with_dependent(|_, document| {
1660            validation_wrapper(document, wrapper_range.clone()).map(|_| ())
1661        })?;
1662        Ok((parsed, wrapper_range))
1663    }
1664}
1665
1666impl<'a> DocumentView<'a> {
1667    /// Return the owning document identity.
1668    #[must_use]
1669    pub const fn identity(self) -> DocumentIdentity {
1670        self.identity
1671    }
1672
1673    /// Return the immutable generation represented by this view.
1674    #[must_use]
1675    pub const fn generation(self) -> u64 {
1676        self.generation
1677    }
1678
1679    /// Return the serialized XML backing this parsed view.
1680    #[must_use]
1681    pub fn xml(self) -> &'a str {
1682        self.parsed.document.input_text()
1683    }
1684
1685    /// Return the document root identity.
1686    #[must_use]
1687    pub fn root(self) -> NodeIdentity {
1688        self.node_identity(self.parsed.document.root())
1689    }
1690
1691    /// Return the root element identity.
1692    #[must_use]
1693    pub fn root_element(self) -> NodeIdentity {
1694        self.node_identity(self.parsed.document.root_element())
1695    }
1696
1697    /// Return the number of parser nodes in this generation.
1698    #[must_use]
1699    pub fn node_count(self) -> usize {
1700        self.parsed.node_count
1701    }
1702
1703    pub(crate) fn max_depth(self) -> usize {
1704        self.parsed.max_depth
1705    }
1706
1707    /// Resolve an ID using standard spellings plus caller registrations.
1708    #[must_use]
1709    pub fn node_for_id(
1710        self,
1711        value: &str,
1712        registrations: &[IdAttributeRegistration],
1713    ) -> Option<NodeIdentity> {
1714        let mut matches = HashSet::new();
1715        match self.parsed.indexes.default_ids.get(value) {
1716            Some(Some(node)) => {
1717                matches.insert(*node);
1718            }
1719            Some(None) => return None,
1720            None => {}
1721        }
1722        for registration in registrations {
1723            let key = (
1724                registration.attribute_local_name().to_owned(),
1725                value.to_owned(),
1726            );
1727            if let Some(nodes) = self.parsed.indexes.attributes_by_value.get(&key) {
1728                matches.extend(nodes.iter().copied().filter(|node_id| {
1729                    self.parsed
1730                        .document
1731                        .get_node(*node_id)
1732                        .is_some_and(|node| registration.matches_node(node))
1733                }));
1734            }
1735        }
1736        if matches.len() == 1 {
1737            matches
1738                .iter()
1739                .next()
1740                .map(|node| self.node_identity_by_id(*node))
1741        } else {
1742            None
1743        }
1744    }
1745
1746    #[cfg(feature = "xmldsig")]
1747    pub(crate) fn id_index(
1748        self,
1749        registrations: &[IdAttributeRegistration],
1750    ) -> HashMap<String, NodeId> {
1751        let mut candidates: HashMap<String, HashSet<NodeId>> = HashMap::new();
1752        let mut ambiguous = HashSet::new();
1753        for (value, node) in &self.parsed.indexes.default_ids {
1754            if let Some(node) = node {
1755                candidates.entry(value.clone()).or_default().insert(*node);
1756            } else {
1757                ambiguous.insert(value.clone());
1758            }
1759        }
1760        for ((attribute_name, value), nodes) in &self.parsed.indexes.attributes_by_value {
1761            for registration in registrations
1762                .iter()
1763                .filter(|registration| registration.attribute_local_name() == attribute_name)
1764            {
1765                candidates
1766                    .entry(value.clone())
1767                    .or_default()
1768                    .extend(nodes.iter().copied().filter(|node_id| {
1769                        self.parsed
1770                            .document
1771                            .get_node(*node_id)
1772                            .is_some_and(|node| registration.matches_node(node))
1773                    }));
1774            }
1775        }
1776        candidates
1777            .into_iter()
1778            .filter_map(|(value, nodes)| {
1779                if nodes.len() == 1 && !ambiguous.contains(&value) {
1780                    nodes.into_iter().next().map(|node| (value, node))
1781                } else {
1782                    None
1783                }
1784            })
1785            .collect()
1786    }
1787
1788    /// Return deterministic document order for a current tree-node identity.
1789    pub fn document_order(self, identity: NodeIdentity) -> Result<usize, XmlDocumentError> {
1790        self.validate_identity(identity)?;
1791        self.parsed
1792            .indexes
1793            .order
1794            .get(&self.resolve_node(identity)?.id())
1795            .copied()
1796            .ok_or(XmlDocumentError::MissingNode)
1797    }
1798
1799    /// Return a total order key for a current tree node.
1800    pub fn node_order(self, identity: NodeIdentity) -> Result<SemanticOrder, XmlDocumentError> {
1801        Ok(SemanticOrder {
1802            node: self.document_order(identity)?,
1803            phase: 0,
1804            position: 0,
1805        })
1806    }
1807
1808    /// Return a total order key for a current attribute node.
1809    pub fn attribute_order(
1810        self,
1811        identity: &AttributeIdentity,
1812    ) -> Result<SemanticOrder, XmlDocumentError> {
1813        let owner = self.resolve_node(identity.owner)?;
1814        let position = owner
1815            .attributes()
1816            .position(|attribute| {
1817                attribute.namespace() == identity.namespace.as_deref()
1818                    && attribute.name() == identity.local_name
1819            })
1820            .ok_or(XmlDocumentError::MissingNode)?;
1821        Ok(SemanticOrder {
1822            node: self.document_order(identity.owner)?,
1823            phase: 2,
1824            position,
1825        })
1826    }
1827
1828    /// Return a total order key for a current namespace node.
1829    pub fn namespace_order(
1830        self,
1831        identity: &NamespaceIdentity,
1832    ) -> Result<SemanticOrder, XmlDocumentError> {
1833        let owner = self.resolve_node(identity.owner)?;
1834        let target = (identity.prefix.as_str(), identity.uri.as_str());
1835        let mut found = false;
1836        let mut position = 0;
1837        // This is the same complete in-scope axis consumed by
1838        // namespace_identities(), not only declarations written on `owner`.
1839        for namespace in owner.namespaces() {
1840            let candidate = (namespace.name().unwrap_or_default(), namespace.uri());
1841            match candidate.cmp(&target) {
1842                std::cmp::Ordering::Less => position += 1,
1843                std::cmp::Ordering::Equal => found = true,
1844                std::cmp::Ordering::Greater => {}
1845            }
1846        }
1847        if !found {
1848            return Err(XmlDocumentError::MissingNode);
1849        }
1850        Ok(SemanticOrder {
1851            node: self.document_order(identity.owner)?,
1852            phase: 1,
1853            position,
1854        })
1855    }
1856
1857    pub(crate) fn document(self) -> &'a Document<'a> {
1858        &self.parsed.document
1859    }
1860
1861    pub(crate) fn node_identity(self, node: Node<'_, '_>) -> NodeIdentity {
1862        NodeIdentity {
1863            document: self.identity,
1864            generation: self.generation,
1865            semantic_id: SemanticDocument::node_id(&self.parsed.document, node),
1866        }
1867    }
1868
1869    pub(crate) fn node_identity_by_id(self, backend: NodeId) -> NodeIdentity {
1870        let node = self
1871            .parsed
1872            .document
1873            .get_node(backend)
1874            .expect("indexed backend node must remain in the retained document");
1875        self.node_identity(node)
1876    }
1877
1878    pub(crate) fn resolve_node(
1879        self,
1880        identity: NodeIdentity,
1881    ) -> Result<Node<'a, 'a>, XmlDocumentError> {
1882        self.validate_identity(identity)?;
1883        SemanticDocument::node(&self.parsed.document, identity.semantic_id)
1884            .ok_or(XmlDocumentError::MissingNode)
1885    }
1886
1887    fn resolve_mutation_node(
1888        self,
1889        identity: NodeIdentity,
1890    ) -> Result<Node<'a, 'a>, XmlDocumentError> {
1891        let node = self.resolve_node(identity)?;
1892        if !node.has_actionable_range() {
1893            return Err(XmlDocumentError::EntityExpandedMutationTarget);
1894        }
1895        Ok(node)
1896    }
1897
1898    /// Identify an attribute by expanded name on a current owner element.
1899    pub fn attribute_identity(
1900        self,
1901        owner: NodeIdentity,
1902        namespace: Option<&str>,
1903        local_name: &str,
1904    ) -> Result<AttributeIdentity, XmlDocumentError> {
1905        let node = self.resolve_node(owner)?;
1906        if !node.is_element()
1907            || !node.attributes().any(|attribute| {
1908                attribute.namespace() == namespace && attribute.name() == local_name
1909            })
1910        {
1911            return Err(XmlDocumentError::MissingNode);
1912        }
1913        Ok(AttributeIdentity {
1914            owner,
1915            namespace: namespace.map(str::to_owned),
1916            local_name: local_name.to_owned(),
1917        })
1918    }
1919
1920    /// Return the in-scope XPath namespace nodes owned by one current element.
1921    pub fn namespace_identities(
1922        self,
1923        owner: NodeIdentity,
1924    ) -> Result<Vec<NamespaceIdentity>, XmlDocumentError> {
1925        let node = self.resolve_node(owner)?;
1926        if !node.is_element() {
1927            return Err(XmlDocumentError::MissingNode);
1928        }
1929        // roxmltree stores each element's complete in-scope namespace axis as
1930        // a compact range of shared namespace indices. This includes inherited
1931        // bindings with prefix shadowing already applied; walking ancestors
1932        // here would duplicate backend resolution and could diverge from it.
1933        let mut namespaces = node
1934            .namespaces()
1935            .map(|namespace| NamespaceIdentity {
1936                owner,
1937                prefix: namespace.name().unwrap_or_default().to_owned(),
1938                uri: namespace.uri().to_owned(),
1939            })
1940            .collect::<Vec<_>>();
1941        namespaces
1942            .sort_by(|left, right| (&left.prefix, &left.uri).cmp(&(&right.prefix, &right.uri)));
1943        Ok(namespaces)
1944    }
1945
1946    fn validate_identity(self, identity: NodeIdentity) -> Result<(), XmlDocumentError> {
1947        if identity.document != self.identity {
1948            return Err(XmlDocumentError::ForeignIdentity);
1949        }
1950        if identity.generation != self.generation {
1951            return Err(XmlDocumentError::StaleIdentity {
1952                identity: identity.generation,
1953                current: self.generation,
1954            });
1955        }
1956        Ok(())
1957    }
1958}
1959
1960fn build_cell(
1961    xml: String,
1962    settings: DocumentParseSettings,
1963    budget: Option<&XmlParseWorkBudget>,
1964) -> Result<DocumentCell, XmlDocumentError> {
1965    preflight_document_limits(&xml, settings, budget)?;
1966    build_cell_after_preflight(xml, settings, budget)
1967}
1968
1969fn build_cell_after_preflight(
1970    xml: String,
1971    settings: DocumentParseSettings,
1972    budget: Option<&XmlParseWorkBudget>,
1973) -> Result<DocumentCell, XmlDocumentError> {
1974    charge_semantic_parser_work(budget, xml.len())?;
1975    build_semantic_cell(xml, settings)
1976}
1977
1978fn build_semantic_cell(
1979    xml: String,
1980    settings: DocumentParseSettings,
1981) -> Result<DocumentCell, XmlDocumentError> {
1982    DocumentCell::try_new(xml, |source| {
1983        let (document, metrics) = parse_semantic_document(source, settings)?;
1984        let indexes = DocumentIndexes::build(&document);
1985        Ok::<_, XmlDocumentError>(ParsedDocument {
1986            document,
1987            indexes,
1988            node_count: metrics.node_count,
1989            max_depth: metrics.max_depth,
1990        })
1991    })
1992}
1993
1994pub(crate) fn parse_borrowed_with_settings_and_budget<'a>(
1995    xml: &'a str,
1996    settings: DocumentParseSettings,
1997    budget: Option<&XmlParseWorkBudget>,
1998) -> Result<Document<'a>, XmlDocumentError> {
1999    preflight_document_limits(xml, settings, budget)?;
2000    charge_semantic_parser_work(budget, xml.len())?;
2001    let (document, _) = parse_semantic_document(xml, settings)?;
2002    Ok(document)
2003}
2004
2005fn preflight_document_limits(
2006    xml: &str,
2007    settings: DocumentParseSettings,
2008    budget: Option<&XmlParseWorkBudget>,
2009) -> Result<(), XmlDocumentError> {
2010    // This bounded lexical pass is the first parser stage for every entry
2011    // point, before either backend is allowed to construct a DOM.
2012    if xml.len() > settings.max_bytes {
2013        return Err(XmlDocumentError::DocumentTooLarge {
2014            maximum: settings.max_bytes,
2015            actual: xml.len(),
2016        });
2017    }
2018    charge_parse_work(budget, xml.len())?;
2019    let mut dtd = InternalDtd::default();
2020    let mut expansion_stack = HashSet::new();
2021    let mut state = DocumentPreflightState::default();
2022    preflight_xml_fragment(
2023        xml,
2024        settings,
2025        &mut dtd,
2026        &mut expansion_stack,
2027        &mut state,
2028        budget,
2029        true,
2030    )
2031}
2032
2033#[derive(Default)]
2034struct DocumentPreflightState {
2035    depth: usize,
2036    nodes: u32,
2037    in_character_data: bool,
2038    entity_expansions: u32,
2039    entity_expansion_work: usize,
2040}
2041
2042#[derive(Default)]
2043struct InternalDtd {
2044    entities: HashMap<String, String>,
2045    attribute_defaults: HashMap<String, Vec<InternalAttributeDefault>>,
2046}
2047
2048struct InternalAttributeDefault {
2049    attribute_name: String,
2050    value: String,
2051}
2052
2053fn preflight_xml_fragment(
2054    xml: &str,
2055    settings: DocumentParseSettings,
2056    dtd: &mut InternalDtd,
2057    expansion_stack: &mut HashSet<String>,
2058    state: &mut DocumentPreflightState,
2059    budget: Option<&XmlParseWorkBudget>,
2060    collect_doctype: bool,
2061) -> Result<(), XmlDocumentError> {
2062    enum FragmentSource {
2063        Document,
2064        Entity(String),
2065    }
2066
2067    #[derive(Clone, Copy, PartialEq, Eq)]
2068    enum FragmentContext {
2069        Content,
2070        Attribute,
2071    }
2072
2073    struct FragmentFrame {
2074        source: FragmentSource,
2075        offset: usize,
2076        collect_doctype: bool,
2077        context: FragmentContext,
2078        pending_attribute_source: Vec<u8>,
2079        pending_attribute_offset: usize,
2080    }
2081
2082    enum PreflightEvent {
2083        DocType(Option<String>),
2084        GeneralRef {
2085            name: Option<String>,
2086            is_character_reference: bool,
2087        },
2088        CharacterData {
2089            xml_whitespace: bool,
2090        },
2091        Start(Vec<u8>),
2092        Empty(Vec<u8>),
2093        End,
2094        Node,
2095        Other,
2096        Done,
2097    }
2098
2099    if state.nodes == 0 {
2100        // roxmltree exposes its document root as the first semantic node.
2101        state.nodes = 1;
2102    }
2103    let mut fragments = vec![FragmentFrame {
2104        source: FragmentSource::Document,
2105        offset: 0,
2106        collect_doctype,
2107        context: FragmentContext::Content,
2108        pending_attribute_source: Vec::new(),
2109        pending_attribute_offset: 0,
2110    }];
2111    // Syntax diagnostics and exact parser precedence belong to the selected
2112    // DOM pipeline; this pass stops at malformed syntax after bounding its prefix.
2113    while !fragments.is_empty() {
2114        let (event, collect_doctype, context) = {
2115            let frame = fragments.last_mut().expect("fragment stack is not empty");
2116            let mut pending = general_references(
2117                &frame.pending_attribute_source[frame.pending_attribute_offset..],
2118            );
2119            if let Some(name) = pending.next() {
2120                frame.pending_attribute_offset = frame
2121                    .pending_attribute_offset
2122                    .saturating_add(pending.consumed());
2123                (
2124                    PreflightEvent::GeneralRef {
2125                        name: Some(name.to_owned()),
2126                        is_character_reference: false,
2127                    },
2128                    false,
2129                    FragmentContext::Attribute,
2130                )
2131            } else {
2132                frame.pending_attribute_source.clear();
2133                frame.pending_attribute_offset = 0;
2134                let source = match &frame.source {
2135                    FragmentSource::Document => xml,
2136                    FragmentSource::Entity(name) => dtd
2137                        .entities
2138                        .get(name)
2139                        .expect("active entity replacement remains registered"),
2140                };
2141                let remaining = &source[frame.offset..];
2142                let mut reader = QuickXmlReader::from_str(remaining);
2143                // This pass observes lexical events one at a time and deliberately
2144                // leaves structural diagnostics to the selected DOM parser.
2145                reader.config_mut().check_end_names = false;
2146                // A fresh reader has no opening-tag state for an End event at
2147                // this slice boundary. Emit it so our manual depth state and all
2148                // later events remain visible; the DOM still rejects bad pairs.
2149                reader.config_mut().allow_unmatched_ends = true;
2150                let event = match reader.read_event() {
2151                    Ok(QuickXmlEvent::DocType(doctype)) => PreflightEvent::DocType(
2152                        doctype.decode().ok().map(|value| value.into_owned()),
2153                    ),
2154                    Ok(QuickXmlEvent::GeneralRef(reference)) => {
2155                        let name = reference.decode().ok().map(|value| value.into_owned());
2156                        let is_character_reference =
2157                            reference.resolve_char_ref().ok().flatten().is_some()
2158                                || name.as_deref().is_some_and(|name| {
2159                                    matches!(name, "amp" | "apos" | "gt" | "lt" | "quot")
2160                                });
2161                        PreflightEvent::GeneralRef {
2162                            name,
2163                            is_character_reference,
2164                        }
2165                    }
2166                    Ok(QuickXmlEvent::Text(text)) => PreflightEvent::CharacterData {
2167                        xml_whitespace: text
2168                            .as_ref()
2169                            .iter()
2170                            .all(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n')),
2171                    },
2172                    Ok(QuickXmlEvent::CData(_)) => PreflightEvent::CharacterData {
2173                        xml_whitespace: false,
2174                    },
2175                    Ok(QuickXmlEvent::Start(element)) => {
2176                        let source = if frame.context == FragmentContext::Content {
2177                            element_attribute_reference_source(&element, dtd)
2178                        } else {
2179                            Vec::new()
2180                        };
2181                        PreflightEvent::Start(source)
2182                    }
2183                    Ok(QuickXmlEvent::Empty(element)) => {
2184                        let source = if frame.context == FragmentContext::Content {
2185                            element_attribute_reference_source(&element, dtd)
2186                        } else {
2187                            Vec::new()
2188                        };
2189                        PreflightEvent::Empty(source)
2190                    }
2191                    Ok(QuickXmlEvent::End(_)) => PreflightEvent::End,
2192                    Ok(QuickXmlEvent::Comment(_) | QuickXmlEvent::PI(_)) => PreflightEvent::Node,
2193                    Ok(QuickXmlEvent::Eof) | Err(_) => PreflightEvent::Done,
2194                    Ok(_) => PreflightEvent::Other,
2195                };
2196                frame.offset = frame.offset.saturating_add(
2197                    usize::try_from(reader.buffer_position()).unwrap_or(remaining.len()),
2198                );
2199                (event, frame.collect_doctype, frame.context)
2200            }
2201        };
2202
2203        match event {
2204            PreflightEvent::DocType(doctype) => {
2205                if collect_doctype
2206                    && settings.allow_dtd
2207                    && let Some(doctype) = doctype
2208                {
2209                    collect_internal_dtd(&doctype, dtd);
2210                }
2211                state.in_character_data = false;
2212                continue;
2213            }
2214            PreflightEvent::GeneralRef {
2215                name,
2216                is_character_reference,
2217            } => {
2218                let Some(name) = name else {
2219                    continue;
2220                };
2221                if is_character_reference {
2222                    if context == FragmentContext::Content {
2223                        observe_preflight_node(state, settings, true)?;
2224                    }
2225                    continue;
2226                }
2227                if expansion_stack.insert(name.clone()) {
2228                    if let Some(replacement) = dtd.entities.get(&name) {
2229                        state.entity_expansions = state.entity_expansions.saturating_add(1);
2230                        let maximum = crate::hard_limits::XML_ENTITY_EXPANSION_CEILING;
2231                        if state.entity_expansions > maximum {
2232                            return Err(XmlDocumentError::Parse(
2233                                ParseError::EntityExpansionLimitReached {
2234                                    maximum,
2235                                    actual: state.entity_expansions,
2236                                },
2237                            ));
2238                        }
2239                        charge_entity_expansion_work(state, budget, replacement.len())?;
2240                        fragments.push(FragmentFrame {
2241                            source: FragmentSource::Entity(name),
2242                            offset: 0,
2243                            collect_doctype: false,
2244                            context,
2245                            pending_attribute_source: Vec::new(),
2246                            pending_attribute_offset: 0,
2247                        });
2248                    } else {
2249                        expansion_stack.remove(&name);
2250                    }
2251                }
2252                continue;
2253            }
2254            PreflightEvent::Done => {
2255                let frame = fragments.pop().expect("fragment stack is not empty");
2256                if let FragmentSource::Entity(name) = frame.source {
2257                    expansion_stack.remove(&name);
2258                }
2259                continue;
2260            }
2261            PreflightEvent::CharacterData { xml_whitespace } => {
2262                if context == FragmentContext::Attribute {
2263                    continue;
2264                }
2265                if state.depth == 0 && xml_whitespace {
2266                    state.in_character_data = false;
2267                    continue;
2268                }
2269                let starts_semantic_node = !state.in_character_data;
2270                state.in_character_data = true;
2271                if starts_semantic_node {
2272                    observe_preflight_node(state, settings, false)?;
2273                }
2274                continue;
2275            }
2276            PreflightEvent::Node => {
2277                state.in_character_data = false;
2278                observe_preflight_node(state, settings, false)?;
2279                continue;
2280            }
2281            PreflightEvent::Other => {
2282                state.in_character_data = false;
2283                continue;
2284            }
2285            PreflightEvent::Start(_) | PreflightEvent::Empty(_) | PreflightEvent::End => {}
2286        }
2287
2288        if context == FragmentContext::Attribute {
2289            continue;
2290        }
2291        state.in_character_data = false;
2292        match event {
2293            PreflightEvent::Start(attribute_source) => {
2294                let frame = fragments
2295                    .last_mut()
2296                    .expect("active element frame remains registered");
2297                frame.pending_attribute_source = attribute_source;
2298                frame.pending_attribute_offset = 0;
2299                observe_preflight_node(state, settings, false)?;
2300                state.depth = state.depth.saturating_add(1);
2301                if state.depth > settings.depth_limit {
2302                    return Err(XmlDocumentError::DocumentTooDeep {
2303                        maximum: settings.depth_limit,
2304                        actual: state.depth,
2305                    });
2306                }
2307            }
2308            PreflightEvent::Empty(attribute_source) => {
2309                let frame = fragments
2310                    .last_mut()
2311                    .expect("active element frame remains registered");
2312                frame.pending_attribute_source = attribute_source;
2313                frame.pending_attribute_offset = 0;
2314                observe_preflight_node(state, settings, false)?;
2315                let actual = state.depth.saturating_add(1);
2316                if actual > settings.depth_limit {
2317                    return Err(XmlDocumentError::DocumentTooDeep {
2318                        maximum: settings.depth_limit,
2319                        actual,
2320                    });
2321                }
2322            }
2323            PreflightEvent::End => state.depth = state.depth.saturating_sub(1),
2324            _ => unreachable!("non-structural events continue above"),
2325        }
2326    }
2327    Ok(())
2328}
2329
2330fn element_attribute_reference_source(
2331    element: &QuickXmlBytesStart<'_>,
2332    dtd: &InternalDtd,
2333) -> Vec<u8> {
2334    let lexical = element.as_ref();
2335    let mut source = if lexical.contains(&b'&') {
2336        lexical.to_vec()
2337    } else {
2338        Vec::new()
2339    };
2340    if dtd.attribute_defaults.is_empty() {
2341        return source;
2342    }
2343
2344    let element_name = element.name();
2345    let Ok(element_name) = std::str::from_utf8(element_name.as_ref()) else {
2346        return source;
2347    };
2348    if let Some(defaults) = dtd.attribute_defaults.get(element_name) {
2349        let mut present_attributes: HashSet<_> = element
2350            .attributes()
2351            .flatten()
2352            .filter_map(|attribute| {
2353                std::str::from_utf8(attribute.key.as_ref())
2354                    .ok()
2355                    .map(ToOwned::to_owned)
2356            })
2357            .collect();
2358        for default in defaults {
2359            if present_attributes.insert(default.attribute_name.clone())
2360                && default.value.contains('&')
2361            {
2362                source.push(b' ');
2363                source.extend_from_slice(default.value.as_bytes());
2364            }
2365        }
2366    }
2367    source
2368}
2369
2370struct GeneralReferences<'a> {
2371    value: &'a [u8],
2372    offset: usize,
2373}
2374
2375impl GeneralReferences<'_> {
2376    fn consumed(&self) -> usize {
2377        self.offset
2378    }
2379}
2380
2381impl<'a> Iterator for GeneralReferences<'a> {
2382    type Item = &'a str;
2383
2384    fn next(&mut self) -> Option<Self::Item> {
2385        loop {
2386            let relative_start = self.value[self.offset..]
2387                .iter()
2388                .position(|byte| *byte == b'&')?;
2389            let start = self.offset + relative_start + 1;
2390            let Some(relative_end) = self.value[start..].iter().position(|byte| *byte == b';')
2391            else {
2392                self.offset = self.value.len();
2393                return None;
2394            };
2395            let end = start + relative_end;
2396            self.offset = end + 1;
2397            let name = &self.value[start..end];
2398            if !name.starts_with(b"#")
2399                && !matches!(name, b"amp" | b"apos" | b"gt" | b"lt" | b"quot")
2400                && let Ok(name) = std::str::from_utf8(name)
2401            {
2402                return Some(name);
2403            }
2404        }
2405    }
2406}
2407
2408fn general_references(value: &[u8]) -> GeneralReferences<'_> {
2409    GeneralReferences { value, offset: 0 }
2410}
2411
2412fn charge_entity_expansion_work(
2413    state: &mut DocumentPreflightState,
2414    budget: Option<&XmlParseWorkBudget>,
2415    bytes: usize,
2416) -> Result<(), XmlDocumentError> {
2417    charge_parse_work(budget, bytes)?;
2418    if budget.is_none() {
2419        let actual = state.entity_expansion_work.saturating_add(bytes);
2420        let maximum = crate::hard_limits::XML_ENTITY_EXPANSION_WORK_BYTE_CEILING;
2421        if actual > maximum {
2422            return Err(XmlDocumentError::Parse(
2423                ParseError::EntityExpansionWorkLimitReached { maximum, actual },
2424            ));
2425        }
2426        state.entity_expansion_work = actual;
2427    }
2428    Ok(())
2429}
2430
2431pub(crate) fn preflight_dom_limits(
2432    xml: &str,
2433    options: ParsingOptions,
2434) -> Result<ParsingOptions, ParseError> {
2435    let effective = ParsingOptions {
2436        allow_dtd: options.allow_dtd,
2437        nodes_limit: options
2438            .nodes_limit
2439            .min(crate::hard_limits::XML_DOCUMENT_NODE_CEILING),
2440    };
2441    let settings = DocumentParseSettings {
2442        backend: XmlBackend::default(),
2443        allow_dtd: effective.allow_dtd,
2444        nodes_limit: effective.nodes_limit,
2445        depth_limit: crate::hard_limits::XML_DOCUMENT_DEPTH_CEILING,
2446        max_bytes: crate::hard_limits::XML_DOCUMENT_BYTE_CEILING,
2447    };
2448    match preflight_document_limits(xml, settings, None) {
2449        Ok(()) => Ok(effective),
2450        Err(XmlDocumentError::Parse(error)) => Err(error),
2451        Err(XmlDocumentError::DocumentTooDeep { maximum, actual }) => {
2452            Err(ParseError::DepthLimitReached { maximum, actual })
2453        }
2454        Err(XmlDocumentError::DocumentTooLarge { maximum, actual }) => {
2455            Err(ParseError::ByteLimitReached { maximum, actual })
2456        }
2457        Err(error) => Err(ParseError::Backend {
2458            backend: "xml-limit-preflight",
2459            message: error.to_string(),
2460        }),
2461    }
2462}
2463
2464fn observe_preflight_node(
2465    state: &mut DocumentPreflightState,
2466    settings: DocumentParseSettings,
2467    character_data: bool,
2468) -> Result<(), XmlDocumentError> {
2469    if character_data {
2470        if state.in_character_data {
2471            return Ok(());
2472        }
2473        state.in_character_data = true;
2474    }
2475    state.nodes = state.nodes.saturating_add(1);
2476    if state.nodes > settings.nodes_limit {
2477        return Err(XmlDocumentError::Parse(ParseError::NodesLimitReached));
2478    }
2479    Ok(())
2480}
2481
2482fn collect_internal_dtd(doctype: &str, dtd: &mut InternalDtd) {
2483    let Some(subset_start) = find_unquoted_byte(doctype.as_bytes(), b'[', 0) else {
2484        return;
2485    };
2486    let Some(subset_end) = doctype.rfind(']') else {
2487        return;
2488    };
2489    let subset = &doctype[subset_start + 1..subset_end];
2490    let mut offset = 0;
2491    let bytes = subset.as_bytes();
2492    while offset < bytes.len() {
2493        if bytes[offset..].starts_with(b"<!--") {
2494            let Some(end) = find_bytes(&bytes[offset + 4..], b"-->") else {
2495                break;
2496            };
2497            offset += 4 + end + 3;
2498        } else if bytes[offset..].starts_with(b"<?") {
2499            let Some(end) = find_bytes(&bytes[offset + 2..], b"?>") else {
2500                break;
2501            };
2502            offset += 2 + end + 2;
2503        } else if bytes[offset..].starts_with(b"<!ENTITY") {
2504            let declaration_start = offset + "<!ENTITY".len();
2505            let Some(declaration_end) = find_unquoted_byte(bytes, b'>', declaration_start) else {
2506                break;
2507            };
2508            let declaration = &subset[declaration_start..declaration_end];
2509            if let Some((name, value)) = parse_internal_general_entity(declaration) {
2510                // roxmltree resolves the first declaration with a matching name.
2511                dtd.entities
2512                    .entry(name.to_owned())
2513                    .or_insert_with(|| normalize_internal_entity_value(value));
2514            }
2515            offset = declaration_end + 1;
2516        } else if bytes[offset..].starts_with(b"<!ATTLIST") {
2517            let declaration_start = offset + "<!ATTLIST".len();
2518            let Some(declaration_end) = find_unquoted_byte(bytes, b'>', declaration_start) else {
2519                break;
2520            };
2521            // Parser-created default values bypass lexical start-tag attributes,
2522            // so retain their references for the same iterative work accounting.
2523            collect_internal_attribute_defaults(
2524                &subset[declaration_start..declaration_end],
2525                &mut dtd.attribute_defaults,
2526            );
2527            offset = declaration_end + 1;
2528        } else if bytes[offset..].starts_with(b"<!") {
2529            let Some(declaration_end) = find_unquoted_byte(bytes, b'>', offset + 2) else {
2530                break;
2531            };
2532            offset = declaration_end + 1;
2533        } else {
2534            offset += 1;
2535        }
2536    }
2537}
2538
2539fn collect_internal_attribute_defaults(
2540    declaration: &str,
2541    defaults: &mut HashMap<String, Vec<InternalAttributeDefault>>,
2542) {
2543    let bytes = declaration.as_bytes();
2544    let mut offset = 0;
2545    skip_dtd_whitespace(bytes, &mut offset);
2546    let Some(element_name) = consume_dtd_token(declaration, &mut offset) else {
2547        return;
2548    };
2549    let mut declarations = Vec::new();
2550    loop {
2551        skip_dtd_whitespace(bytes, &mut offset);
2552        if offset == bytes.len() {
2553            break;
2554        }
2555        let Some(attribute_name) = consume_dtd_token(declaration, &mut offset) else {
2556            break;
2557        };
2558        skip_dtd_whitespace(bytes, &mut offset);
2559        if !consume_attribute_type(declaration, &mut offset) {
2560            break;
2561        }
2562        skip_dtd_whitespace(bytes, &mut offset);
2563
2564        if consume_dtd_keyword(declaration, &mut offset, "#REQUIRED")
2565            || consume_dtd_keyword(declaration, &mut offset, "#IMPLIED")
2566        {
2567            continue;
2568        }
2569        if consume_dtd_keyword(declaration, &mut offset, "#FIXED") {
2570            skip_dtd_whitespace(bytes, &mut offset);
2571        }
2572        let Some(value) = consume_dtd_quoted_value(declaration, &mut offset) else {
2573            break;
2574        };
2575        declarations.push(InternalAttributeDefault {
2576            attribute_name: attribute_name.to_owned(),
2577            value: value.to_owned(),
2578        });
2579    }
2580
2581    if !declarations.is_empty() {
2582        defaults
2583            .entry(element_name.to_owned())
2584            .or_default()
2585            .extend(declarations);
2586    }
2587}
2588
2589fn skip_dtd_whitespace(bytes: &[u8], offset: &mut usize) {
2590    while bytes
2591        .get(*offset)
2592        .is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
2593    {
2594        *offset += 1;
2595    }
2596}
2597
2598fn consume_dtd_token<'a>(input: &'a str, offset: &mut usize) -> Option<&'a str> {
2599    let bytes = input.as_bytes();
2600    let start = *offset;
2601    while bytes.get(*offset).is_some_and(|byte| {
2602        !matches!(
2603            byte,
2604            b' ' | b'\t' | b'\r' | b'\n' | b'(' | b')' | b'\'' | b'"'
2605        )
2606    }) {
2607        *offset += 1;
2608    }
2609    (start != *offset).then(|| &input[start..*offset])
2610}
2611
2612fn consume_attribute_type(input: &str, offset: &mut usize) -> bool {
2613    let bytes = input.as_bytes();
2614    if consume_dtd_keyword(input, offset, "NOTATION") {
2615        skip_dtd_whitespace(bytes, offset);
2616        return consume_parenthesized_dtd_group(bytes, offset);
2617    }
2618    if bytes.get(*offset) == Some(&b'(') {
2619        return consume_parenthesized_dtd_group(bytes, offset);
2620    }
2621    consume_dtd_token(input, offset).is_some()
2622}
2623
2624fn consume_parenthesized_dtd_group(bytes: &[u8], offset: &mut usize) -> bool {
2625    if bytes.get(*offset) != Some(&b'(') {
2626        return false;
2627    }
2628    let mut depth = 0usize;
2629    while let Some(byte) = bytes.get(*offset).copied() {
2630        *offset += 1;
2631        match byte {
2632            b'(' => depth += 1,
2633            b')' => {
2634                depth -= 1;
2635                if depth == 0 {
2636                    return true;
2637                }
2638            }
2639            _ => {}
2640        }
2641    }
2642    false
2643}
2644
2645fn consume_dtd_keyword(input: &str, offset: &mut usize, keyword: &str) -> bool {
2646    let remaining = &input[*offset..];
2647    if !remaining.starts_with(keyword) {
2648        return false;
2649    }
2650    let end = *offset + keyword.len();
2651    if input
2652        .as_bytes()
2653        .get(end)
2654        .is_some_and(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n' | b'(' | b'\'' | b'"'))
2655    {
2656        return false;
2657    }
2658    *offset = end;
2659    true
2660}
2661
2662fn consume_dtd_quoted_value<'a>(input: &'a str, offset: &mut usize) -> Option<&'a str> {
2663    let bytes = input.as_bytes();
2664    let quote = *bytes.get(*offset)?;
2665    if !matches!(quote, b'\'' | b'"') {
2666        return None;
2667    }
2668    let start = *offset + 1;
2669    let relative_end = bytes[start..].iter().position(|byte| *byte == quote)?;
2670    let end = start + relative_end;
2671    *offset = end + 1;
2672    Some(&input[start..end])
2673}
2674
2675fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2676    haystack
2677        .windows(needle.len())
2678        .position(|window| window == needle)
2679}
2680
2681fn parse_internal_general_entity(declaration: &str) -> Option<(&str, &str)> {
2682    let declaration = declaration.trim_start();
2683    if declaration.starts_with('%') {
2684        return None;
2685    }
2686    let name_end = declaration.find(char::is_whitespace)?;
2687    let name = &declaration[..name_end];
2688    let definition = declaration[name_end..].trim_start();
2689    let quote = definition.as_bytes().first().copied()?;
2690    if !matches!(quote, b'\'' | b'"') {
2691        // External entities have no replacement text without an explicit
2692        // resolver, which this crate never installs.
2693        return None;
2694    }
2695    let value_end = definition.as_bytes()[1..]
2696        .iter()
2697        .position(|byte| *byte == quote)?
2698        + 1;
2699    Some((name, &definition[1..value_end]))
2700}
2701
2702fn normalize_internal_entity_value(value: &str) -> String {
2703    // XML 1.0 §4.4 expands numeric character references while reading the
2704    // entity declaration. General and predefined entity references remain
2705    // lexical input for the later replacement-text pass.
2706    let mut normalized = String::with_capacity(value.len());
2707    let mut offset = 0;
2708    while let Some(relative_start) = value[offset..].find("&#") {
2709        let start = offset + relative_start;
2710        normalized.push_str(&value[offset..start]);
2711        let Some(relative_end) = value[start + 2..].find(';') else {
2712            normalized.push_str(&value[start..]);
2713            return normalized;
2714        };
2715        let end = start + 2 + relative_end;
2716        let digits = &value[start + 2..end];
2717        let (radix, digits) = digits
2718            .strip_prefix('x')
2719            .map_or((10, digits), |digits| (16, digits));
2720        let replacement = u32::from_str_radix(digits, radix)
2721            .ok()
2722            .filter(|codepoint| is_xml_character(*codepoint))
2723            .and_then(char::from_u32);
2724        if let Some(replacement) = replacement {
2725            normalized.push(replacement);
2726        } else {
2727            normalized.push_str(&value[start..=end]);
2728        }
2729        offset = end + 1;
2730    }
2731    normalized.push_str(&value[offset..]);
2732    normalized
2733}
2734
2735const fn is_xml_character(codepoint: u32) -> bool {
2736    matches!(
2737        codepoint,
2738        0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF
2739    )
2740}
2741
2742fn find_unquoted_byte(bytes: &[u8], needle: u8, start: usize) -> Option<usize> {
2743    let mut quote = None;
2744    for (index, byte) in bytes.iter().copied().enumerate().skip(start) {
2745        match byte {
2746            b'\'' | b'"' => match quote {
2747                Some(active) if active == byte => quote = None,
2748                None => quote = Some(byte),
2749                Some(_) => {}
2750            },
2751            _ if quote.is_none() && byte == needle => return Some(index),
2752            _ => {}
2753        }
2754    }
2755    None
2756}
2757
2758fn parse_semantic_document<'a>(
2759    source: &'a str,
2760    settings: DocumentParseSettings,
2761) -> Result<(Document<'a>, DocumentMetrics), XmlDocumentError> {
2762    let document = Document::parse_after_limit_preflight_with_backend(
2763        source,
2764        ParsingOptions {
2765            allow_dtd: settings.allow_dtd,
2766            nodes_limit: settings.nodes_limit,
2767        },
2768        settings.backend,
2769    )
2770    .map_err(XmlDocumentError::Parse)?;
2771    let metrics = validate_document_metrics(&document, settings.depth_limit)?;
2772    Ok((document, metrics))
2773}
2774
2775fn validate_document_metrics(
2776    document: &Document<'_>,
2777    maximum: usize,
2778) -> Result<DocumentMetrics, XmlDocumentError> {
2779    // Node IDs follow document order, so each parent's depth has already been
2780    // recorded. This keeps the compatibility path linear instead of walking
2781    // every ancestor chain independently.
2782    let mut depths = Vec::new();
2783    let mut node_count = 0;
2784    let mut max_depth = 0;
2785    for node in document.descendants() {
2786        node_count += 1;
2787        let parent_depth = node
2788            .parent()
2789            .and_then(|parent| depths.get(parent.id().get_usize()))
2790            .copied()
2791            .unwrap_or(0);
2792        let depth = parent_depth + usize::from(node.is_element());
2793        let node_index = node.id().get_usize();
2794        if depths.len() <= node_index {
2795            depths.resize(node_index + 1, 0);
2796        }
2797        depths[node_index] = depth;
2798        max_depth = max_depth.max(depth);
2799        if depth > maximum {
2800            return Err(XmlDocumentError::DocumentTooDeep {
2801                maximum,
2802                actual: depth,
2803            });
2804        }
2805    }
2806    Ok(DocumentMetrics {
2807        node_count,
2808        max_depth,
2809    })
2810}
2811
2812fn own_bounded_xml(
2813    xml: impl AsRef<str> + Into<String>,
2814    maximum: usize,
2815) -> Result<String, XmlDocumentError> {
2816    let actual = xml.as_ref().len();
2817    if actual > maximum {
2818        return Err(XmlDocumentError::DocumentTooLarge { maximum, actual });
2819    }
2820    Ok(xml.into())
2821}
2822
2823fn allocate_document_identity(counter: &AtomicU64) -> Result<DocumentIdentity, XmlDocumentError> {
2824    let mut current = counter.load(Ordering::Relaxed);
2825    loop {
2826        let next = current
2827            .checked_add(1)
2828            .ok_or(XmlDocumentError::IdentityExhausted)?;
2829        match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
2830            Ok(_) => return Ok(DocumentIdentity(current)),
2831            Err(observed) => current = observed,
2832        }
2833    }
2834}
2835
2836#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
2837fn document_requires_internal_dtd(
2838    xml: &str,
2839    settings: DocumentParseSettings,
2840    budget: Option<&XmlParseWorkBudget>,
2841) -> Result<bool, XmlDocumentError> {
2842    // Parse provenance records what the source actually requires, not merely
2843    // whether its creator used a permissive policy. This lets a later strict
2844    // operation accept ordinary XML while rejecting DTD-dependent documents.
2845    if !settings.allow_dtd {
2846        return Ok(false);
2847    }
2848    charge_semantic_parser_work(budget, xml.len())?;
2849    Ok(Document::parse_after_limit_preflight_with_backend(
2850        xml,
2851        ParsingOptions {
2852            allow_dtd: false,
2853            nodes_limit: settings.nodes_limit,
2854        },
2855        settings.backend,
2856    )
2857    .is_err())
2858}
2859
2860fn wrapped_fragment(replacement: &str) -> String {
2861    format!("{VALIDATION_WRAPPER_OPEN}{replacement}{VALIDATION_WRAPPER_CLOSE}")
2862}
2863
2864fn validation_wrapper<'a, 'input>(
2865    parsed: &'a ParsedDocument<'input>,
2866    expected_range: std::ops::Range<usize>,
2867) -> Result<Node<'a, 'input>, XmlDocumentError> {
2868    parsed
2869        .document
2870        .descendants()
2871        .find(|node| {
2872            node.has_tag_name((VALIDATION_WRAPPER_NS, "wrapper")) && node.range() == expected_range
2873        })
2874        .ok_or_else(|| {
2875            XmlDocumentError::InvalidReplacement(
2876                "replacement escaped its structural validation boundary".into(),
2877            )
2878        })
2879}
2880
2881fn validate_wrappers(
2882    parsed: &ParsedDocument<'_>,
2883    expected_ranges: &[std::ops::Range<usize>],
2884) -> Result<(), XmlDocumentError> {
2885    let mut remaining = expected_ranges
2886        .iter()
2887        .map(|range| (range.start, range.end))
2888        .collect::<HashSet<_>>();
2889    for node in parsed
2890        .document
2891        .descendants()
2892        .filter(|node| node.has_tag_name((VALIDATION_WRAPPER_NS, "wrapper")))
2893    {
2894        let range = node.range();
2895        remaining.remove(&(range.start, range.end));
2896    }
2897    if remaining.is_empty() {
2898        Ok(())
2899    } else {
2900        Err(XmlDocumentError::InvalidReplacement(
2901            "replacement escaped its structural validation boundary".into(),
2902        ))
2903    }
2904}
2905
2906impl DocumentIndexes {
2907    fn build(document: &Document<'_>) -> Self {
2908        let mut order = HashMap::new();
2909        let mut default_ids = HashMap::new();
2910        let mut duplicate_ids = HashSet::new();
2911        let mut attributes_by_value: HashMap<(String, String), Vec<NodeId>> = HashMap::new();
2912
2913        for (position, node) in document.descendants().enumerate() {
2914            order.insert(node.id(), position);
2915            if !node.is_element() {
2916                continue;
2917            }
2918            for attribute in node.attributes() {
2919                attributes_by_value
2920                    .entry((attribute.name().to_owned(), attribute.value().to_owned()))
2921                    .or_default()
2922                    .push(node.id());
2923                if !matches!(attribute.name(), "ID" | "Id" | "id")
2924                    || duplicate_ids.contains(attribute.value())
2925                {
2926                    continue;
2927                }
2928                match default_ids.entry(attribute.value().to_owned()) {
2929                    Entry::Vacant(entry) => {
2930                        entry.insert(Some(node.id()));
2931                    }
2932                    Entry::Occupied(mut entry) if entry.get() != &Some(node.id()) => {
2933                        entry.insert(None);
2934                        duplicate_ids.insert(attribute.value().to_owned());
2935                    }
2936                    Entry::Occupied(_) => {}
2937                }
2938            }
2939        }
2940        Self {
2941            order,
2942            default_ids,
2943            attributes_by_value,
2944        }
2945    }
2946}
2947
2948fn element_content_range(
2949    element: &str,
2950) -> Result<(std::ops::Range<usize>, String, bool), XmlDocumentError> {
2951    let start_end = element_opening_end(element)?;
2952    let start_tag = &element[..=start_end];
2953    let qualified_name = start_tag[1..]
2954        .split(|character: char| character.is_ascii_whitespace() || matches!(character, '/' | '>'))
2955        .next()
2956        .filter(|name| !name.is_empty())
2957        .ok_or_else(|| XmlDocumentError::InvalidReplacement("element name is missing".into()))?
2958        .to_owned();
2959    if start_tag[..start_tag.len() - 1].trim_end().ends_with('/') {
2960        return Ok((start_end..start_end, qualified_name, true));
2961    }
2962    let close = element
2963        .rfind("</")
2964        .ok_or_else(|| XmlDocumentError::InvalidReplacement("element end tag is missing".into()))?;
2965    Ok(((start_end + 1)..close, qualified_name, false))
2966}
2967
2968fn element_opening_end(element: &str) -> Result<usize, XmlDocumentError> {
2969    let mut quote = None;
2970    for (index, byte) in element.bytes().enumerate() {
2971        match byte {
2972            b'\'' | b'"' => match quote {
2973                Some(active) if active == byte => quote = None,
2974                None => quote = Some(byte),
2975                Some(_) => {}
2976            },
2977            b'>' if quote.is_none() => return Ok(index),
2978            _ => {}
2979        }
2980    }
2981    Err(XmlDocumentError::InvalidReplacement(
2982        "element start tag is incomplete".into(),
2983    ))
2984}
2985
2986#[cfg(test)]
2987mod tests {
2988    use super::*;
2989
2990    #[test]
2991    fn selected_backend_builds_the_retained_semantic_projection() {
2992        let settings = DocumentParseSettings::default();
2993        let xml =
2994            r#"<root xmlns:p="urn:test"><p:item ID="target"><![CDATA[value]]></p:item></root>"#;
2995        let retained = build_semantic_cell(xml.to_owned(), settings)
2996            .expect("selected backend semantic projection must parse");
2997        retained.with_dependent(|_, parsed| {
2998            assert!(parsed.indexes.default_ids.contains_key("target"));
2999            assert_eq!(
3000                parsed
3001                    .document
3002                    .root_element()
3003                    .first_element_child()
3004                    .expect("fixture must contain the indexed child element")
3005                    .text(),
3006                Some("value")
3007            );
3008        });
3009    }
3010
3011    #[cfg(feature = "xml-backend-xmloxide")]
3012    #[test]
3013    fn xmloxide_attribute_capacity_is_independent_of_retained_node_limit() {
3014        // Retained nodes and per-element attributes are different resources.
3015        // A tight node limit must not make the selected backend reject an
3016        // otherwise bounded element that the semantic projection accepts.
3017        let xml = r#"<root a="1" b="2" c="3"/>"#;
3018        let settings = DocumentParseSettings::new(false, 2, xml.len());
3019        build_cell(xml.to_owned(), settings, None)
3020            .expect("attributes must not consume the retained node allowance");
3021    }
3022
3023    #[test]
3024    fn node_preflight_rejects_wide_input_before_any_dom_pass() {
3025        // The allowance covers only the streaming preflight. Reaching either
3026        // DOM would exhaust parser work before it could report the node bound.
3027        let xml = "<root><first/><second/></root>";
3028        let resources = crate::policy::ResourcePolicy {
3029            max_xml_parse_work_bytes: xml.len(),
3030            ..crate::policy::ResourcePolicy::default()
3031        };
3032        let budget = XmlParseWorkBudget::from_resources(&resources);
3033        let settings = DocumentParseSettings::new(false, 3, xml.len());
3034
3035        assert!(matches!(
3036            build_cell(xml.to_owned(), settings, Some(&budget)),
3037            Err(XmlDocumentError::Parse(ParseError::NodesLimitReached))
3038        ));
3039    }
3040
3041    #[test]
3042    fn node_preflight_counts_contiguous_character_data_once() {
3043        // Entity references and CDATA split the lexical event stream, but
3044        // roxmltree retains the contiguous character data as one text node.
3045        let xml = "<root>a&amp;<![CDATA[b]]>&#99;</root>";
3046        let exact_settings = DocumentParseSettings::new(false, 3, xml.len());
3047
3048        build_cell(xml.to_owned(), exact_settings, None)
3049            .expect("the exact retained semantic node count must be accepted");
3050        assert!(matches!(
3051            build_cell(
3052                xml.to_owned(),
3053                DocumentParseSettings::new(false, 2, xml.len()),
3054                None,
3055            ),
3056            Err(XmlDocumentError::Parse(ParseError::NodesLimitReached))
3057        ));
3058    }
3059
3060    #[test]
3061    fn node_preflight_excludes_document_boundary_whitespace() {
3062        // XML Misc whitespace outside the document element is not retained as
3063        // a roxmltree text node and must not consume semantic-node policy.
3064        let xml = " \n<root/>\n ";
3065        let settings = DocumentParseSettings::new(false, 2, xml.len());
3066
3067        preflight_document_limits(xml, settings, None)
3068            .expect("document plus root element must fit the exact node ceiling");
3069    }
3070
3071    #[test]
3072    fn node_preflight_continues_after_explicit_end_tags() {
3073        // The lexical reader is recreated at each event boundary. A closing tag
3074        // must still be observed so later siblings cannot bypass node policy.
3075        let xml = "<root><first></first><second/></root>";
3076        let settings = DocumentParseSettings::new(false, 3, xml.len());
3077
3078        assert!(matches!(
3079            preflight_document_limits(xml, settings, None),
3080            Err(XmlDocumentError::Parse(ParseError::NodesLimitReached))
3081        ));
3082    }
3083
3084    #[test]
3085    fn depth_preflight_expands_nested_internal_entity_markup() {
3086        // Entity replacement markup contributes real retained element depth.
3087        // The streaming guard must reject it before either DOM parser allocates
3088        // the expanded tree, including references nested inside replacements.
3089        let xml = r#"<!DOCTYPE root [
3090            <!ENTITY inner "<b><c/></b>">
3091            <!ENTITY deep "<a>&inner;</a>">
3092        ]><root>&deep;</root>"#;
3093        let settings = DocumentParseSettings::new_with_depth(true, 32, 3, xml.len());
3094
3095        assert!(matches!(
3096            preflight_document_limits(xml, settings, None),
3097            Err(XmlDocumentError::DocumentTooDeep {
3098                maximum: 3,
3099                actual: 4,
3100            })
3101        ));
3102
3103        let exact_settings = DocumentParseSettings::new_with_depth(true, 32, 4, xml.len());
3104        preflight_document_limits(xml, exact_settings, None)
3105            .expect("expanded markup at the exact depth boundary must be accepted");
3106    }
3107
3108    #[test]
3109    fn depth_preflight_observes_markup_generated_by_a_character_reference() {
3110        // Numeric references are normalized while the entity declaration is
3111        // read, so `&#60;` becomes markup when the replacement is later parsed.
3112        let xml = r#"<!DOCTYPE root [
3113            <!ENTITY generated "&#60;a><b/></a>">
3114        ]><root>&generated;</root>"#;
3115        let settings = DocumentParseSettings::new_with_depth(true, 16, 2, xml.len());
3116
3117        assert!(matches!(
3118            preflight_document_limits(xml, settings, None),
3119            Err(XmlDocumentError::DocumentTooDeep {
3120                maximum: 2,
3121                actual: 3,
3122            })
3123        ));
3124    }
3125
3126    #[test]
3127    fn entity_preflight_ignores_declarations_inside_dtd_comments() {
3128        // Comment text is not a declaration. Treating it as one would let a
3129        // harmless reference acquire attacker-controlled phantom markup.
3130        let xml = r#"<!DOCTYPE root [
3131            <!-- <!ENTITY value "<a><b><c/></b></a>"> -->
3132            <!ENTITY value "ok">
3133        ]><root>&value;</root>"#;
3134        let settings = DocumentParseSettings::new_with_depth(true, 8, 1, xml.len());
3135
3136        preflight_document_limits(xml, settings, None)
3137            .expect("comment contents must not participate in entity expansion");
3138        build_cell(xml.to_owned(), settings, None)
3139            .expect("the real declaration contains only character data");
3140    }
3141
3142    #[test]
3143    fn entity_preflight_charges_nested_replacement_work() {
3144        // Repeated nested references must exhaust parser work during the
3145        // streaming pass, before either DOM parser receives the document.
3146        let xml = r#"<!DOCTYPE root [
3147            <!ENTITY a "0123456789">
3148            <!ENTITY b "&a;&a;&a;&a;">
3149        ]><root>&b;</root>"#;
3150        let first_replacement = "&a;&a;&a;&a;".len();
3151        let nested_replacement = "0123456789".len();
3152        let maximum = xml.len() + first_replacement + nested_replacement - 1;
3153        let budget = XmlParseWorkBudget::with_limit(maximum);
3154        let settings = DocumentParseSettings::new_with_depth(true, 32, 4, xml.len());
3155
3156        assert!(matches!(
3157            build_cell(xml.to_owned(), settings, Some(&budget)),
3158            Err(XmlDocumentError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3159                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
3160                maximum: observed_maximum,
3161                actual,
3162            })) if observed_maximum == maximum
3163                && actual == xml.len() + first_replacement + nested_replacement
3164        ));
3165    }
3166
3167    #[test]
3168    fn entity_preflight_charges_a_reference_generated_by_a_character_reference() {
3169        // Declaration-time `&#38;` normalization exposes a general reference
3170        // which must recurse through the same aggregate parse-work budget.
3171        let xml = r#"<!DOCTYPE root [
3172            <!ENTITY nested "0123456789">
3173            <!ENTITY generated "&#38;nested;">
3174        ]><root>&generated;</root>"#;
3175        let first_replacement = "&nested;".len();
3176        let nested_replacement = "0123456789".len();
3177        let maximum = xml.len() + first_replacement + nested_replacement - 1;
3178        let budget = XmlParseWorkBudget::with_limit(maximum);
3179        let settings = DocumentParseSettings::new_with_depth(true, 16, 2, xml.len());
3180
3181        assert!(matches!(
3182            preflight_document_limits(xml, settings, Some(&budget)),
3183            Err(XmlDocumentError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3184                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
3185                maximum: observed_maximum,
3186                actual,
3187            })) if observed_maximum == maximum
3188                && actual == xml.len() + first_replacement + nested_replacement
3189        ));
3190    }
3191
3192    #[test]
3193    fn entity_preflight_charges_nested_attribute_replacements() {
3194        // Attribute values are part of parser expansion work even though their
3195        // entity references are contained inside one lexical start-tag event.
3196        let xml = r#"<!DOCTYPE root [
3197            <!ENTITY a "0123456789">
3198            <!ENTITY b "&a;&a;&a;&a;">
3199        ]><root value="&b;"/>"#;
3200        let first_replacement = "&a;&a;&a;&a;".len();
3201        let nested_replacement = "0123456789".len();
3202        let expected_work = xml.len() + first_replacement + 4 * nested_replacement;
3203        let budget = XmlParseWorkBudget::with_limit(expected_work);
3204        let settings = DocumentParseSettings::new_with_depth(true, 8, 1, xml.len());
3205
3206        preflight_document_limits(xml, settings, Some(&budget))
3207            .expect("the exact repeated attribute expansion budget must be accepted");
3208        assert_eq!(budget.consumed(), expected_work);
3209
3210        let maximum = expected_work - 1;
3211        let budget = XmlParseWorkBudget::with_limit(maximum);
3212
3213        assert!(matches!(
3214            preflight_document_limits(xml, settings, Some(&budget)),
3215            Err(XmlDocumentError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3216                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
3217                maximum: observed_maximum,
3218                actual,
3219            })) if observed_maximum == maximum
3220                && actual == expected_work
3221        ));
3222    }
3223
3224    #[test]
3225    fn entity_preflight_streams_dense_attribute_references_in_source_order() {
3226        // A dense attribute must fail on the first expansion that exhausts the
3227        // budget. Buffering every reference and popping in reverse both delays
3228        // enforcement and exposes the transient allocation amplification.
3229        let repeated = "&first;".repeat(4_096);
3230        let xml = format!(
3231            "<!DOCTYPE root [<!ENTITY first \"0123456789\"><!ENTITY last \"01234567890123456789\">]><root value=\"{repeated}&last;\"/>"
3232        );
3233        let maximum = xml.len() + 9;
3234        let budget = XmlParseWorkBudget::with_limit(maximum);
3235        let settings = DocumentParseSettings::new_with_depth(true, 8, 1, xml.len());
3236
3237        let mut scanner = general_references(b"&first;&last;");
3238        assert_eq!(scanner.next(), Some("first"));
3239        assert_eq!(scanner.next(), Some("last"));
3240        assert_eq!(scanner.next(), None);
3241
3242        assert!(matches!(
3243            preflight_document_limits(&xml, settings, Some(&budget)),
3244            Err(XmlDocumentError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3245                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
3246                maximum: observed_maximum,
3247                actual,
3248            })) if observed_maximum == maximum && actual == xml.len() + 10
3249        ));
3250    }
3251
3252    #[test]
3253    fn entity_preflight_charges_applicable_dtd_attribute_defaults() {
3254        // Parser-created DTD defaults bypass lexical start-tag attributes when
3255        // the source element omits that name. Their references must use the same
3256        // budget as literal values without charging an overridden default.
3257        let omitted = r#"<!DOCTYPE root [
3258            <!ENTITY a "0123456789">
3259            <!ENTITY b "&a;&a;&a;&a;">
3260            <!ATTLIST root value CDATA "&b;">
3261        ]><root/>"#;
3262        let first_replacement = "&a;&a;&a;&a;".len();
3263        let nested_replacement = "0123456789".len();
3264        let expected_work = omitted.len() + first_replacement + 4 * nested_replacement;
3265        let settings = DocumentParseSettings::new_with_depth(true, 8, 1, omitted.len());
3266        let exact_budget = XmlParseWorkBudget::with_limit(expected_work);
3267
3268        preflight_document_limits(omitted, settings, Some(&exact_budget))
3269            .expect("the exact default-attribute expansion budget must be accepted");
3270        assert_eq!(exact_budget.consumed(), expected_work);
3271
3272        let maximum = expected_work - 1;
3273        let short_budget = XmlParseWorkBudget::with_limit(maximum);
3274        assert!(matches!(
3275            preflight_document_limits(omitted, settings, Some(&short_budget)),
3276            Err(XmlDocumentError::Policy(crate::policy::PolicyViolation::ResourceLimit {
3277                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
3278                maximum: observed_maximum,
3279                actual,
3280            })) if observed_maximum == maximum && actual == expected_work
3281        ));
3282
3283        let overridden = omitted.replace("<root/>", "<root value=\"literal\"/>");
3284        let source_only_budget = XmlParseWorkBudget::with_limit(overridden.len());
3285        let overridden_settings =
3286            DocumentParseSettings::new_with_depth(true, 8, 1, overridden.len());
3287        preflight_document_limits(&overridden, overridden_settings, Some(&source_only_budget))
3288            .expect("an explicit source attribute must suppress its DTD default");
3289        assert_eq!(source_only_budget.consumed(), overridden.len());
3290    }
3291
3292    #[test]
3293    fn entity_preflight_uses_bounded_heap_for_long_acyclic_chains() {
3294        // Entity indirection is independent of element depth. A legal acyclic
3295        // chain must not consume one native stack frame per replacement.
3296        const ENTITY_COUNT: usize = 4_096;
3297        let mut xml = String::from("<!DOCTYPE root [\n<!ENTITY e0 \"value\">\n");
3298        for index in 1..ENTITY_COUNT {
3299            use std::fmt::Write as _;
3300            writeln!(xml, "<!ENTITY e{index} \"&e{};\">", index - 1)
3301                .expect("write entity declaration");
3302        }
3303        use std::fmt::Write as _;
3304        write!(xml, "]><root>&e{};</root>", ENTITY_COUNT - 1).expect("write root reference");
3305        let settings = DocumentParseSettings::new_with_depth(true, 16, 1, xml.len());
3306
3307        preflight_document_limits(&xml, settings, None)
3308            .expect("long acyclic entity traversal must use bounded native stack");
3309    }
3310
3311    #[test]
3312    fn owned_parse_preflights_depth_before_dtd_provenance() {
3313        // Enabling internal DTD syntax must not move the provenance probe ahead
3314        // of the allocation-free depth boundary for an ordinary document.
3315        let xml = "<root><child><leaf/></child></root>";
3316        let settings = DocumentParseSettings::new_with_depth(true, 16, 2, xml.len());
3317        let budget = XmlParseWorkBudget::with_limit(xml.len());
3318
3319        assert!(matches!(
3320            XmlDocument::parse_with_settings_and_optional_budget(
3321                xml.to_owned(),
3322                settings,
3323                Some(&budget),
3324            ),
3325            Err(XmlDocumentError::DocumentTooDeep {
3326                maximum: 2,
3327                actual: 3,
3328            })
3329        ));
3330    }
3331
3332    fn nested_document(depth: usize) -> String {
3333        let mut xml = "<node>".repeat(depth);
3334        xml.push_str(&"</node>".repeat(depth));
3335        xml
3336    }
3337
3338    #[test]
3339    fn selected_backend_enforces_exact_depth_boundary() {
3340        let settings = DocumentParseSettings::new_with_depth(false, 128, 2, 4_096);
3341        let accepted = nested_document(2);
3342        build_semantic_cell(accepted, settings).expect("exact depth must parse");
3343
3344        let rejected = nested_document(3);
3345        assert!(matches!(
3346            build_semantic_cell(rejected, settings),
3347            Err(XmlDocumentError::DocumentTooDeep {
3348                maximum: 2,
3349                actual: 3,
3350            })
3351        ));
3352    }
3353
3354    #[cfg(feature = "xml-backend-xmloxide")]
3355    #[test]
3356    fn xmloxide_backend_enforces_exact_depth_boundary() {
3357        // The default backend rejects before constructing an oversized tree,
3358        // while exposing the same backend-neutral document error.
3359        let settings = DocumentParseSettings::new_with_depth(false, 128, 2, 4_096);
3360        let accepted = nested_document(2);
3361        build_cell(accepted, settings, None).expect("exact depth must parse");
3362
3363        let rejected = nested_document(3);
3364        assert!(matches!(
3365            build_cell(rejected, settings, None),
3366            Err(XmlDocumentError::DocumentTooDeep {
3367                maximum: 2,
3368                actual: 3,
3369            })
3370        ));
3371    }
3372
3373    struct OversizedBorrowedInput<'a>(&'a str);
3374
3375    impl AsRef<str> for OversizedBorrowedInput<'_> {
3376        fn as_ref(&self) -> &str {
3377            self.0
3378        }
3379    }
3380
3381    impl From<OversizedBorrowedInput<'_>> for String {
3382        fn from(_: OversizedBorrowedInput<'_>) -> Self {
3383            panic!("oversized borrowed XML must be rejected before ownership conversion")
3384        }
3385    }
3386
3387    #[test]
3388    fn oversized_borrowed_input_is_rejected_before_ownership_conversion() {
3389        // Borrowed request bodies can be arbitrarily large. Both constructors
3390        // must inspect their size before cloning them into the owned document.
3391        let oversized = "x".repeat(crate::hard_limits::XML_DOCUMENT_BYTE_CEILING + 1);
3392        assert!(matches!(
3393            XmlDocument::parse(OversizedBorrowedInput(&oversized)),
3394            Err(XmlDocumentError::DocumentTooLarge { .. })
3395        ));
3396
3397        #[cfg(feature = "xmldsig")]
3398        {
3399            let mut policy = crate::policy::SigningPolicy::default();
3400            policy.resources.max_xml_document_bytes = 8;
3401            assert!(matches!(
3402                XmlDocument::parse_with_policy(OversizedBorrowedInput("<root/>xx"), &policy),
3403                Err(XmlDocumentError::DocumentTooLarge {
3404                    maximum: 8,
3405                    actual: 9,
3406                })
3407            ));
3408        }
3409    }
3410
3411    #[test]
3412    fn views_reuse_identity_until_mutation_invalidates_generation() {
3413        let mut document =
3414            XmlDocument::parse("<root><child ID=\"target\"/></root>").expect("fixture must parse");
3415        let target = document.with_view(|view| {
3416            view.node_for_id("target", &[])
3417                .expect("ID target must resolve")
3418        });
3419        assert_eq!(document.generation(), 0);
3420        document
3421            .replace_element(target, "<replacement ID=\"target\"/>")
3422            .expect("replacement must succeed");
3423
3424        let error = document
3425            .with_view(|view| view.resolve_node(target).map(|_| ()))
3426            .expect_err("old identity must be stale");
3427        assert!(matches!(error, XmlDocumentError::StaleIdentity { .. }));
3428        assert_eq!(document.generation(), 1);
3429    }
3430
3431    #[test]
3432    fn identities_cannot_cross_documents() {
3433        let first = XmlDocument::parse("<root/>").expect("fixture must parse");
3434        let second = XmlDocument::parse("<root/>").expect("fixture must parse");
3435        let root = first.with_view(|view| view.root_element());
3436
3437        let error = second
3438            .with_view(|view| view.resolve_node(root).map(|_| ()))
3439            .expect_err("foreign identity must fail closed");
3440        assert!(matches!(error, XmlDocumentError::ForeignIdentity));
3441    }
3442
3443    #[test]
3444    fn child_append_projection_matches_both_element_forms() {
3445        // Signing must be able to enforce a tighter operation policy before
3446        // append_child allocates either form of the replacement document.
3447        for xml in ["<root></root>", "<root/>"] {
3448            let document = XmlDocument::parse(xml).expect("fixture must parse");
3449            let root = document.with_view(|view| view.root_element());
3450            let projected = document
3451                .projected_child_append_len(root, "<child/>".len())
3452                .expect("append length must project");
3453
3454            assert_eq!(projected, "<root><child/></root>".len());
3455        }
3456    }
3457
3458    #[test]
3459    fn document_identity_allocation_reports_exhaustion_without_wrapping() {
3460        let counter = AtomicU64::new(u64::MAX - 1);
3461
3462        assert_eq!(
3463            allocate_document_identity(&counter).expect("last identity must be allocated"),
3464            DocumentIdentity(u64::MAX - 1)
3465        );
3466        assert!(matches!(
3467            allocate_document_identity(&counter),
3468            Err(XmlDocumentError::IdentityExhausted)
3469        ));
3470        assert_eq!(counter.load(Ordering::Relaxed), u64::MAX);
3471    }
3472
3473    #[test]
3474    fn duplicate_and_caller_registered_ids_share_one_index() {
3475        let document = XmlDocument::parse(
3476            "<root><a ID=\"duplicate\" custom=\"selected\"/><b Id=\"duplicate\"/></root>",
3477        )
3478        .expect("fixture must parse");
3479        let registrations = [IdAttributeRegistration::global("custom")];
3480
3481        document.with_view(|view| {
3482            assert!(view.node_for_id("duplicate", &[]).is_none());
3483            assert!(view.node_for_id("selected", &registrations).is_some());
3484        });
3485    }
3486
3487    #[test]
3488    fn duplicate_default_id_stays_ambiguous_with_caller_registration() {
3489        // A caller registration must not turn an already ambiguous standard ID
3490        // into a unique match by selecting only one of the duplicate elements.
3491        let document = XmlDocument::parse(
3492            "<root><a ID=\"duplicate\" custom=\"duplicate\"/><b Id=\"duplicate\"/></root>",
3493        )
3494        .expect("fixture must parse");
3495        let registrations = [IdAttributeRegistration::global("custom")];
3496
3497        document.with_view(|view| {
3498            assert!(view.node_for_id("duplicate", &registrations).is_none());
3499        });
3500    }
3501
3502    #[test]
3503    fn content_replacement_uses_parent_namespace_context() {
3504        let mut document = XmlDocument::parse("<p:root xmlns:p=\"urn:test\"><p:old/></p:root>")
3505            .expect("fixture must parse");
3506        let root = document.with_view(|view| view.root_element());
3507        document
3508            .replace_content(root, "<p:new/>")
3509            .expect("in-scope prefix must be accepted");
3510        assert_eq!(
3511            document.as_xml(),
3512            "<p:root xmlns:p=\"urn:test\"><p:new/></p:root>"
3513        );
3514    }
3515
3516    #[cfg(feature = "xml-backend-xmloxide")]
3517    #[test]
3518    fn folded_character_data_replacement_splices_its_complete_source_range() {
3519        // Adjacent text and CDATA tokens form one semantic text node. Mutation
3520        // must replace every lexical token represented by that node.
3521        let mut document = XmlDocument::parse("<r>one<![CDATA[+]]>two</r>")
3522            .expect("mixed character data fixture must parse");
3523        let target = document.with_view(|view| {
3524            let text = view
3525                .document()
3526                .root_element()
3527                .first_child()
3528                .expect("fixture must contain a text node");
3529            assert_eq!(text.text(), Some("one+two"));
3530            view.node_identity(text)
3531        });
3532
3533        document
3534            .replace_node_with_fragment(target, "replacement")
3535            .expect("the complete folded text node must be replaceable");
3536
3537        assert_eq!(document.as_xml(), "<r>replacement</r>");
3538    }
3539
3540    #[test]
3541    fn validation_wrapper_does_not_consume_document_depth() {
3542        // The synthetic wrapper exists only while validating the replacement;
3543        // a result at the real document depth ceiling must remain accepted.
3544        let mut document = XmlDocument::parse_with_settings(
3545            "<root><target/></root>".into(),
3546            DocumentParseSettings::new_with_depth(false, 128, 2, 4_096),
3547        )
3548        .expect("fixture must fit the exact depth ceiling");
3549        let target = document.with_view(|view| {
3550            view.node_identity(
3551                view.document()
3552                    .descendants()
3553                    .find(|node| node.has_tag_name("target"))
3554                    .expect("target must exist"),
3555            )
3556        });
3557
3558        document
3559            .replace_content(target, "text")
3560            .expect("validation-only wrapper must not consume caller depth");
3561        assert_eq!(document.as_xml(), "<root><target>text</target></root>");
3562    }
3563
3564    #[test]
3565    fn validation_wrapper_preserves_real_depth_rejection() {
3566        // Exempting validation scaffolding must not exempt an inserted element
3567        // that makes the committed document exceed the configured depth.
3568        let mut document = XmlDocument::parse_with_settings(
3569            "<root><target/></root>".into(),
3570            DocumentParseSettings::new_with_depth(false, 128, 2, 4_096),
3571        )
3572        .expect("fixture must fit the exact depth ceiling");
3573        let target = document.with_view(|view| {
3574            view.node_identity(
3575                view.document()
3576                    .descendants()
3577                    .find(|node| node.has_tag_name("target"))
3578                    .expect("target must exist"),
3579            )
3580        });
3581        let before = document.as_xml().to_owned();
3582
3583        let error = document
3584            .replace_content(target, "<child/>")
3585            .expect_err("real replacement depth must remain bounded");
3586        assert!(
3587            matches!(
3588                error,
3589                XmlDocumentError::DocumentTooDeep {
3590                    maximum: 2,
3591                    actual: 3,
3592                }
3593            ),
3594            "unexpected error: {error:?}"
3595        );
3596        assert_eq!(document.as_xml(), before);
3597        assert_eq!(document.generation(), 0);
3598    }
3599
3600    #[test]
3601    fn batched_validation_wrappers_do_not_consume_document_depth() {
3602        // The combined validator inserts one wrapper per target, but sibling
3603        // scaffolding must not reduce the active depth available to either edit.
3604        let mut document = XmlDocument::parse_with_settings(
3605            "<root><first/><second/></root>".into(),
3606            DocumentParseSettings::new_with_depth(false, 128, 2, 4_096),
3607        )
3608        .expect("fixture must fit the exact depth ceiling");
3609        let (first, second) = document.with_view(|view| {
3610            let mut targets = view
3611                .document()
3612                .descendants()
3613                .filter(|node| matches!(node.tag_name().name(), "first" | "second"));
3614            (
3615                view.node_identity(targets.next().expect("first target")),
3616                view.node_identity(targets.next().expect("second target")),
3617            )
3618        });
3619
3620        document
3621            .replace_contents(&[(first, "a".into()), (second, "b".into())])
3622            .expect("validation-only wrappers must not consume caller depth");
3623        assert_eq!(
3624            document.as_xml(),
3625            "<root><first>a</first><second>b</second></root>"
3626        );
3627    }
3628
3629    #[test]
3630    fn mutations_ignore_greater_than_inside_quoted_attributes() {
3631        // The opening-tag boundary is the unquoted '>', not a character in an
3632        // attribute value; both content replacement and append use that boundary.
3633        let mut document =
3634            XmlDocument::parse("<root marker=\">\"><old/></root>").expect("fixture must parse");
3635        let root = document.with_view(|view| view.root_element());
3636
3637        document
3638            .replace_content(root, "<first/>")
3639            .expect("quoted greater-than must not corrupt content replacement");
3640        let root = document.with_view(|view| view.root_element());
3641        document
3642            .append_child(root, "<second/>")
3643            .expect("quoted greater-than must not corrupt child append");
3644
3645        assert_eq!(
3646            document.as_xml(),
3647            "<root marker=\">\"><first/><second/></root>"
3648        );
3649    }
3650
3651    #[test]
3652    fn fragment_validation_reuses_internal_dtd_entities() {
3653        // Validation must run in the original document context so an entity
3654        // declared by its internal subset remains available during mutation.
3655        let mut document = XmlDocument::parse_with_settings(
3656            "<!DOCTYPE root [<!ENTITY custom \"replacement\">]><root><old/></root>".into(),
3657            DocumentParseSettings::new(
3658                true,
3659                crate::hard_limits::XML_DOCUMENT_NODE_CEILING,
3660                crate::hard_limits::XML_DOCUMENT_BYTE_CEILING,
3661            ),
3662        )
3663        .expect("DTD fixture must parse when explicitly enabled");
3664        let root = document.with_view(|view| view.root_element());
3665
3666        document
3667            .replace_content(root, "&custom;")
3668            .expect("declared entity must remain valid in replacement context");
3669        assert_eq!(
3670            document.as_xml(),
3671            "<!DOCTYPE root [<!ENTITY custom \"replacement\">]><root>&custom;</root>"
3672        );
3673    }
3674
3675    #[test]
3676    fn serialization_is_stable_for_unchanged_views() {
3677        let document = XmlDocument::parse("<?pi value?><root a=\"1\"><!--c--></root>")
3678            .expect("fixture must parse");
3679        let first = document.as_xml().to_owned();
3680        document.with_view(|view| assert_eq!(view.xml(), first));
3681        document.with_view(|view| assert_eq!(view.xml(), first));
3682        assert_eq!(document.as_xml(), first);
3683    }
3684
3685    #[test]
3686    fn rejected_mutation_keeps_source_and_generation_unchanged() {
3687        // Failed fragment parsing must be transactional: callers can safely
3688        // retry without observing a partially changed document or stale IDs.
3689        let mut document = XmlDocument::parse("<root><child/></root>").expect("fixture must parse");
3690        let root = document.with_view(|view| view.root_element());
3691        let before = document.as_xml().to_owned();
3692
3693        assert!(document.replace_content(root, "<unclosed>").is_err());
3694        assert_eq!(document.as_xml(), before);
3695        assert_eq!(document.generation(), 0);
3696    }
3697
3698    #[test]
3699    fn oversized_invalid_fragment_fails_at_the_document_byte_boundary() {
3700        // Resource rejection must happen before wrapper parsing, so malformed
3701        // attacker input cannot force allocations beyond the document ceiling.
3702        // Cover both direct content replacement and self-closing expansion.
3703        for xml in ["<root><child/></root>", "<root/>"] {
3704            let mut document = XmlDocument::parse_with_settings(
3705                xml.into(),
3706                DocumentParseSettings::new(false, 64, 64),
3707            )
3708            .expect("bounded fixture must parse");
3709            let root = document.with_view(|view| view.root_element());
3710            let replacement = "<".repeat(1_024);
3711
3712            assert!(matches!(
3713                document.replace_content(root, &replacement),
3714                Err(XmlDocumentError::DocumentTooLarge {
3715                    maximum: 64,
3716                    actual,
3717                }) if actual > 64
3718            ));
3719            assert_eq!(document.as_xml(), xml);
3720            assert_eq!(document.generation(), 0);
3721        }
3722    }
3723
3724    #[cfg(feature = "xmlenc")]
3725    #[test]
3726    fn bounded_fragment_validation_uses_the_active_node_ceiling() {
3727        // The validation wrapper must not parse attacker-controlled plaintext
3728        // under a broader document-creation ceiling before the operation limit.
3729        let document = XmlDocument::parse_with_settings(
3730            "<root><target/></root>".into(),
3731            DocumentParseSettings::new(false, 128, 4_096),
3732        )
3733        .expect("fixture must parse");
3734        let target = document.with_view(|view| {
3735            let target = view
3736                .document()
3737                .descendants()
3738                .find(|node| node.has_tag_name("target"))
3739                .expect("target must exist");
3740            view.node_identity(target)
3741        });
3742        let maximum = document.with_view(|view| view.node_count());
3743        let before = document.as_xml().to_owned();
3744        let budget = XmlParseWorkBudget::from_resources(&crate::policy::ResourcePolicy::default());
3745
3746        let replacement = document.prepare_node_fragment_replacement_with_budget(
3747            target,
3748            "<replacement><child/><child/><malformed>",
3749            DocumentParseSettings::new(false, maximum as u32, 4_096),
3750            &budget,
3751        );
3752        assert!(matches!(
3753            replacement,
3754            Err(XmlDocumentError::ProjectedNodeLimit { maximum: rejected })
3755                if rejected == maximum
3756        ));
3757        assert_eq!(document.as_xml(), before);
3758        assert_eq!(document.generation(), 0);
3759    }
3760
3761    #[test]
3762    fn duplicate_empty_content_targets_are_rejected_atomically() {
3763        let mut document =
3764            XmlDocument::parse("<root><target></target></root>").expect("fixture must parse");
3765        let target = document.with_view(|view| {
3766            let target = view
3767                .document()
3768                .descendants()
3769                .find(|node| node.has_tag_name("target"))
3770                .expect("target must exist");
3771            view.node_identity(target)
3772        });
3773
3774        let error = document
3775            .replace_contents(&[(target, "first".into()), (target, "second".into())])
3776            .expect_err("one identity cannot be replaced twice");
3777
3778        assert!(matches!(error, XmlDocumentError::InvalidReplacement(_)));
3779        assert_eq!(document.as_xml(), "<root><target></target></root>");
3780        assert_eq!(document.generation(), 0);
3781    }
3782
3783    #[cfg(feature = "xmldsig")]
3784    #[test]
3785    fn batched_content_replacements_obey_the_active_byte_ceiling() {
3786        // A retained document can have broader parse settings than the current
3787        // signing operation; the complete batch must use the active ceiling.
3788        let mut document = XmlDocument::parse_with_settings(
3789            "<root><first/><second/></root>".into(),
3790            DocumentParseSettings::new(false, 128, 4_096),
3791        )
3792        .expect("fixture must parse");
3793        let (first, second) = document.with_view(|view| {
3794            let mut targets = view
3795                .document()
3796                .descendants()
3797                .filter(|node| matches!(node.tag_name().name(), "first" | "second"));
3798            (
3799                view.node_identity(targets.next().expect("first target")),
3800                view.node_identity(targets.next().expect("second target")),
3801            )
3802        });
3803        let before = document.as_xml().to_owned();
3804        let replacements = [(first, "alpha".into()), (second, "beta".into())];
3805        let expected = "<root><first>alpha</first><second>beta</second></root>";
3806        let maximum = expected.len() - 1;
3807        let budget = XmlParseWorkBudget::from_resources(&crate::policy::ResourcePolicy::default());
3808
3809        let error = document
3810            .replace_contents_with_budget(
3811                &replacements,
3812                DocumentParseSettings::new(false, 128, maximum),
3813                &budget,
3814            )
3815            .expect_err("the active byte ceiling must reject the complete batch");
3816
3817        assert!(matches!(
3818            error,
3819            XmlDocumentError::DocumentTooLarge {
3820                maximum: observed_maximum,
3821                actual,
3822            } if observed_maximum == maximum && actual == expected.len()
3823        ));
3824        assert_eq!(document.as_xml(), before);
3825        assert_eq!(document.generation(), 0);
3826    }
3827
3828    #[test]
3829    fn batched_content_replacements_validate_one_namespaced_candidate() {
3830        // The combined validator must preserve each target's parent namespace
3831        // context across both self-closing expansion and ordinary replacement.
3832        let mut document = XmlDocument::parse(
3833            r#"<root xmlns:p="urn:test"><first/><second><old/></second></root>"#,
3834        )
3835        .expect("fixture must parse");
3836        let (first, second) = document.with_view(|view| {
3837            let mut targets = view
3838                .document()
3839                .descendants()
3840                .filter(|node| matches!(node.tag_name().name(), "first" | "second"));
3841            (
3842                view.node_identity(targets.next().expect("first target")),
3843                view.node_identity(targets.next().expect("second target")),
3844            )
3845        });
3846
3847        document
3848            .replace_contents(&[
3849                (first, "<p:alpha/>".into()),
3850                (second, "text<p:beta/>".into()),
3851            ])
3852            .expect("all fragments must validate in one candidate");
3853
3854        assert_eq!(
3855            document.as_xml(),
3856            r#"<root xmlns:p="urn:test"><first><p:alpha/></first><second>text<p:beta/></second></root>"#
3857        );
3858        assert_eq!(document.generation(), 1);
3859    }
3860
3861    #[cfg(feature = "xmldsig")]
3862    #[test]
3863    fn batched_content_replacements_charge_two_combined_parses() {
3864        // A full 64-reference signing batch must pay for one combined
3865        // structural-validation candidate and one committed generation, not
3866        // one full-document parse per independent DigestValue replacement.
3867        const TARGETS: usize = 64;
3868        let source = format!("<root>{}</root>", "<item>old</item>".repeat(TARGETS));
3869        let mut document = XmlDocument::parse(&source).expect("fixture must parse");
3870        let replacements = document.with_view(|view| {
3871            view.document()
3872                .descendants()
3873                .filter(|node| node.has_tag_name("item"))
3874                .map(|node| (view.node_identity(node), "x".to_owned()))
3875                .collect::<Vec<_>>()
3876        });
3877        let committed_len = source.len() - TARGETS * "old".len() + TARGETS;
3878        let validation_len =
3879            source.len() - TARGETS * "old".len() + TARGETS * wrapped_fragment("x").len();
3880        let parser_passes = selected_parser_passes();
3881        let maximum = (validation_len + committed_len) * parser_passes;
3882        let budget = XmlParseWorkBudget::with_limit(maximum);
3883
3884        document
3885            .replace_contents_with_budget(&replacements, DocumentParseSettings::default(), &budget)
3886            .expect("the batch must fit its two combined parser passes exactly");
3887
3888        assert_eq!(budget.consumed(), maximum);
3889        assert_eq!(document.as_xml().matches("<item>x</item>").count(), TARGETS);
3890        assert_eq!(document.generation(), 1);
3891    }
3892
3893    #[test]
3894    fn parser_work_accounting_follows_the_runtime_backend() {
3895        // Differential mode validates both implementations against the same
3896        // per-backend allowance; its diagnostic duplicate is not caller work.
3897        let xml = "<root><value/></root>";
3898        for backend in [
3899            XmlBackend::Xmloxide,
3900            XmlBackend::Roxmltree,
3901            XmlBackend::Differential,
3902        ]
3903        .into_iter()
3904        .filter(|backend| backend.is_available())
3905        {
3906            let expected = xml.len() * 3;
3907            let budget = XmlParseWorkBudget::with_limit(expected);
3908            parse_borrowed_with_settings_and_budget(
3909                xml,
3910                DocumentParseSettings::default().with_backend(backend),
3911                Some(&budget),
3912            )
3913            .expect("the exact runtime parser-work allowance must fit");
3914            assert_eq!(budget.consumed(), expected, "{backend:?}");
3915        }
3916    }
3917
3918    #[test]
3919    fn batched_content_replacements_reject_one_escaped_boundary_atomically() {
3920        // One fragment escaping its target must reject the whole batch even
3921        // when every other replacement is structurally valid.
3922        let mut document = XmlDocument::parse("<root><first></first><second></second></root>")
3923            .expect("fixture must parse");
3924        let (first, second) = document.with_view(|view| {
3925            let mut targets = view
3926                .document()
3927                .descendants()
3928                .filter(|node| matches!(node.tag_name().name(), "first" | "second"));
3929            (
3930                view.node_identity(targets.next().expect("first target")),
3931                view.node_identity(targets.next().expect("second target")),
3932            )
3933        });
3934        let before = document.as_xml().to_owned();
3935
3936        document
3937            .replace_contents(&[
3938                (first, "</first><attacker/><first>".into()),
3939                (second, "safe".into()),
3940            ])
3941            .expect_err("a fragment must not escape its target boundary");
3942
3943        assert_eq!(document.as_xml(), before);
3944        assert_eq!(document.generation(), 0);
3945    }
3946
3947    #[cfg(feature = "xmldsig")]
3948    #[test]
3949    fn retained_mutations_share_one_sticky_parse_work_budget() {
3950        // Each mutation performs one wrapped validation parse and one committed
3951        // document parse. A later mutation must inherit the consumed allowance,
3952        // and a rejected charge must exhaust it rather than permit retries.
3953        let mut document =
3954            XmlDocument::parse("<root><first/><second/></root>").expect("fixture must parse");
3955        let first = document.with_view(|view| {
3956            view.node_identity(
3957                view.document()
3958                    .descendants()
3959                    .find(|node| node.has_tag_name("first"))
3960                    .expect("first target"),
3961            )
3962        });
3963        let first_output = "<root><first>a</first><second/></root>";
3964        let first_validation_len = first_output
3965            .len()
3966            .checked_add(VALIDATION_WRAPPER_OPEN.len())
3967            .and_then(|length| length.checked_add(VALIDATION_WRAPPER_CLOSE.len()))
3968            .expect("fixture length must fit");
3969        let parser_passes = selected_parser_passes();
3970        let maximum = (first_validation_len + first_output.len()) * parser_passes;
3971        let budget = XmlParseWorkBudget::with_limit(maximum);
3972
3973        document
3974            .replace_contents_with_budget(
3975                &[(first, "a".into())],
3976                DocumentParseSettings::default(),
3977                &budget,
3978            )
3979            .expect("the first mutation must consume the exact allowance");
3980        assert_eq!(document.as_xml(), first_output);
3981
3982        let second = document.with_view(|view| {
3983            view.node_identity(
3984                view.document()
3985                    .descendants()
3986                    .find(|node| node.has_tag_name("second"))
3987                    .expect("second target"),
3988            )
3989        });
3990        let before = document.as_xml().to_owned();
3991        let error = document
3992            .replace_contents_with_budget(
3993                &[(second, "b".into())],
3994                DocumentParseSettings::default(),
3995                &budget,
3996            )
3997            .expect_err("the second mutation must not receive a fresh allowance");
3998
3999        assert!(matches!(
4000            error,
4001            XmlDocumentError::Policy(crate::policy::PolicyViolation::ResourceLimit {
4002                resource: crate::policy::resource_name::XML_PARSE_WORK_BYTES,
4003                maximum: observed_maximum,
4004                actual,
4005            }) if observed_maximum == maximum && actual > maximum
4006        ));
4007        assert_eq!(document.as_xml(), before);
4008        assert_eq!(document.generation(), 1);
4009    }
4010
4011    #[cfg(feature = "xmlenc")]
4012    #[test]
4013    fn bounded_content_replacement_rejects_new_text_node_atomically() {
4014        let mut document =
4015            XmlDocument::parse("<root><target/></root>").expect("fixture must parse");
4016        let target = document.with_view(|view| {
4017            let root = view
4018                .resolve_node(view.root_element())
4019                .expect("root must resolve");
4020            view.node_identity(
4021                root.children()
4022                    .find(Node::is_element)
4023                    .expect("target must exist"),
4024            )
4025        });
4026        let maximum = document.with_view(|view| view.node_count());
4027        let before = document.as_xml().to_owned();
4028        let budget = XmlParseWorkBudget::from_resources(&crate::policy::ResourcePolicy::default());
4029
4030        assert!(matches!(
4031            document.replace_content_with_budget(
4032                target,
4033                "value",
4034                DocumentParseSettings::new(false, maximum as u32, 1_024),
4035                &budget,
4036            ),
4037            Err(XmlDocumentError::ProjectedNodeLimit { maximum: rejected })
4038                if rejected == maximum
4039        ));
4040        assert_eq!(document.as_xml(), before);
4041        assert_eq!(document.generation(), 0);
4042    }
4043
4044    #[test]
4045    fn entity_expanded_elements_are_not_mutation_targets() {
4046        // One entity token may expand to several semantic siblings. Mutating
4047        // one projected identity must not splice the shared lexical token.
4048        let source = "<!DOCTYPE root [<!ENTITY pair '<a ID=\"target\"/><b/>'>]><root>&pair;</root>";
4049        let settings = DocumentParseSettings::new(true, 64, 4_096);
4050        let mut document = XmlDocument::parse_with_settings(source.into(), settings)
4051            .expect("entity fixture must parse");
4052        let target = document
4053            .with_view(|view| view.node_for_id("target", &[]))
4054            .expect("expanded element ID must resolve");
4055        let before = document.as_xml().to_owned();
4056
4057        let error = document
4058            .replace_element(target, "<replacement/>")
4059            .expect_err("entity-expanded identity must not be mutable");
4060
4061        assert!(error.to_string().contains("entity expansion"), "{error}");
4062        assert_eq!(document.as_xml(), before);
4063        assert_eq!(document.generation(), 0);
4064    }
4065
4066    #[cfg(feature = "xmldsig")]
4067    #[test]
4068    fn bounded_append_accepts_text_merged_with_existing_content() {
4069        // Wrapper validation temporarily separates adjacent text, but the
4070        // committed document merges it and therefore stays at the node ceiling.
4071        let mut document = XmlDocument::parse_with_settings(
4072            "<root>text</root>".into(),
4073            DocumentParseSettings::new(false, 3, 1_024),
4074        )
4075        .expect("fixture must fit the exact node ceiling");
4076        let root = document.with_view(|view| view.root_element());
4077        let budget = XmlParseWorkBudget::from_resources(&crate::policy::ResourcePolicy::default());
4078
4079        document
4080            .append_child_with_budget(
4081                root,
4082                "more",
4083                DocumentParseSettings::new(false, 3, 1_024),
4084                &budget,
4085            )
4086            .expect("merged text must not consume another committed node");
4087
4088        assert_eq!(document.as_xml(), "<root>textmore</root>");
4089        assert_eq!(document.with_view(|view| view.node_count()), 3);
4090    }
4091
4092    #[cfg(feature = "xmlenc")]
4093    #[test]
4094    fn bounded_fragment_accepts_two_boundary_text_merges() {
4095        // A wrapper can prevent text merging on both sides of a replacement.
4096        // The final candidate still contains one text node and fits exactly.
4097        let mut document =
4098            XmlDocument::parse("<root>left<target/>right</root>").expect("fixture must parse");
4099        let target = document.with_view(|view| {
4100            let target = view
4101                .document()
4102                .descendants()
4103                .find(|node| node.has_tag_name("target"))
4104                .expect("target must exist");
4105            view.node_identity(target)
4106        });
4107        let budget = XmlParseWorkBudget::from_resources(&crate::policy::ResourcePolicy::default());
4108
4109        let prepared = document
4110            .prepare_node_fragment_replacement_with_budget(
4111                target,
4112                "middle",
4113                DocumentParseSettings::new(false, 3, 1_024),
4114                &budget,
4115            )
4116            .expect("both boundary text pairs must fit in the prepared document");
4117        document
4118            .commit_prepared(prepared)
4119            .expect("prepared replacement must commit atomically");
4120
4121        assert_eq!(document.as_xml(), "<root>leftmiddleright</root>");
4122        assert_eq!(document.with_view(|view| view.node_count()), 3);
4123    }
4124
4125    #[test]
4126    fn semantic_order_covers_namespace_attribute_and_tree_nodes() {
4127        let document =
4128            XmlDocument::parse("<root xmlns:p=\"urn:test\" p:value=\"1\"><child/></root>")
4129                .expect("fixture must parse");
4130
4131        document.with_view(|view| {
4132            let root = view.root_element();
4133            let root_node = view.resolve_node(root).expect("root must resolve");
4134            let child = view.node_identity(
4135                root_node
4136                    .children()
4137                    .find(Node::is_element)
4138                    .expect("child must exist"),
4139            );
4140            let attribute = view
4141                .attribute_identity(root, Some("urn:test"), "value")
4142                .expect("attribute must exist");
4143            let namespace = view
4144                .namespace_identities(root)
4145                .expect("namespace axis must exist")
4146                .into_iter()
4147                .find(|namespace| namespace.prefix() == "p")
4148                .expect("p namespace must exist");
4149
4150            let root_order = view.node_order(root).expect("root order must exist");
4151            let namespace_order = view
4152                .namespace_order(&namespace)
4153                .expect("namespace order must exist");
4154            let attribute_order = view
4155                .attribute_order(&attribute)
4156                .expect("attribute order must exist");
4157            let child_order = view.node_order(child).expect("child order must exist");
4158            assert!(root_order < namespace_order);
4159            assert!(namespace_order < attribute_order);
4160            assert!(attribute_order < child_order);
4161        });
4162    }
4163
4164    #[test]
4165    fn inherited_namespace_axes_are_materialized_only_for_the_requested_owner() {
4166        // A wide namespace scope inherited by many descendants must remain in
4167        // roxmltree's structural representation instead of being cloned into an
4168        // O(elements * namespaces) eager document index.
4169        let declarations = (0..128)
4170            .map(|index| format!(r#" xmlns:p{index}="urn:namespace:{index}""#))
4171            .collect::<String>();
4172        let children = (0..1_024)
4173            .map(|index| format!("<child index=\"{index}\"/>"))
4174            .collect::<String>();
4175        let document = XmlDocument::parse(format!("<root{declarations}>{children}</root>"))
4176            .expect("wide namespace fixture must parse");
4177
4178        document.with_view(|view| {
4179            let last_child = view
4180                .document()
4181                .descendants()
4182                .rfind(|node| node.has_tag_name("child"))
4183                .expect("last child must exist");
4184            let owner = view.node_identity(last_child);
4185            let namespaces = view
4186                .namespace_identities(owner)
4187                .expect("inherited namespace axis must materialize on demand");
4188            assert_eq!(namespaces.len(), 128);
4189            assert!(namespaces.windows(2).all(|pair| {
4190                view.namespace_order(&pair[0]).expect("left order")
4191                    < view.namespace_order(&pair[1]).expect("right order")
4192            }));
4193        });
4194    }
4195
4196    #[test]
4197    fn mutation_invalidates_attribute_and_namespace_identities() {
4198        let mut document =
4199            XmlDocument::parse("<root xmlns:p=\"urn:test\" p:value=\"1\"><child/></root>")
4200                .expect("fixture must parse");
4201        let (root, attribute, namespace) = document.with_view(|view| {
4202            let root = view.root_element();
4203            let attribute = view
4204                .attribute_identity(root, Some("urn:test"), "value")
4205                .expect("attribute must exist");
4206            let namespace = view
4207                .namespace_identities(root)
4208                .expect("namespace axis must exist")
4209                .into_iter()
4210                .find(|namespace| namespace.prefix() == "p")
4211                .expect("p namespace must exist");
4212            (root, attribute, namespace)
4213        });
4214
4215        document
4216            .append_child(root, "<p:next/>")
4217            .expect("mutation must succeed");
4218        document.with_view(|view| {
4219            assert!(matches!(
4220                view.attribute_order(&attribute),
4221                Err(XmlDocumentError::StaleIdentity { .. })
4222            ));
4223            assert!(matches!(
4224                view.namespace_order(&namespace),
4225                Err(XmlDocumentError::StaleIdentity { .. })
4226            ));
4227        });
4228    }
4229
4230    #[test]
4231    fn content_replacement_cannot_escape_its_target_element() {
4232        let mut document = XmlDocument::parse("<outer><target><old/></target></outer>")
4233            .expect("fixture must parse");
4234        let target = document.with_view(|view| {
4235            view.node_identity(
4236                view.document()
4237                    .descendants()
4238                    .find(|node| node.has_tag_name("target"))
4239                    .expect("target must exist"),
4240            )
4241        });
4242        let before = document.as_xml().to_owned();
4243
4244        assert!(
4245            document
4246                .replace_content(target, "</target><attacker/><target>")
4247                .is_err()
4248        );
4249        assert_eq!(document.as_xml(), before);
4250        assert_eq!(document.generation(), 0);
4251    }
4252}