Skip to main content

structured_xades/
xml.rs

1//! Scoped XML access without retaining DOM or callback-local string borrows.
2
3use structured_asn1::memory::{HashWork, MemoryError, Work};
4use structured_ltv::source::{Query, ScopeError};
5
6// The snapshot is part of this trait's vocabulary: implementors name it here.
7pub use structured_ltv::source::Snapshot;
8
9/// An expanded XML name; prefix spelling does not participate in identity.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct Name<'a> {
12    /// Namespace name, empty for an unqualified name.
13    pub namespace: &'a str,
14    /// Local name after the XML owner's namespace processing.
15    pub local: &'a str,
16}
17
18/// One callback-local XML node view, preserving text-node distinctions.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Node<'a> {
21    /// An element's expanded name.
22    Element(Name<'a>),
23    /// XML-normalized character data, not original encoded document bytes.
24    Text(&'a str),
25    /// A comment, processing instruction or other non-element node.
26    Other,
27}
28
29/// The XML owner's stable, snapshot-checked access boundary.
30///
31/// Every method must reject a foreign node and a stale snapshot before access.
32/// The owner admits its complete data-dependent scans and allocations through
33/// `execution` and its own compiled XML policy before doing that work. Common
34/// dispatch admission does not prove accounting inside arbitrary adapter code.
35/// Access must not implicitly reparse, fetch a resource or change the document.
36///
37/// The higher-ranked visitors cannot retain view-local names/text in their
38/// return value. An adapter can therefore resolve nodes inside a temporary
39/// document view, without exposing its DOM types or extending that view's life.
40pub trait Xml {
41    /// Stable node identity scoped to this document, independent of a DOM borrow.
42    /// Its trusted `HashWork` bound covers all hashing/equality bytes so identity
43    /// indexes can admit their complete work before querying attacker input.
44    type Id: Copy + Eq + HashWork;
45    /// Stable document identity.
46    type Document: Copy + Eq;
47    /// Exact document generation.
48    type Generation: Copy + Eq;
49    /// Original XML processing/policy/resource failure.
50    type Error;
51
52    /// Returns identity/generation in constant time, without allocation or I/O.
53    fn snapshot(&self) -> Snapshot<Self::Document, Self::Generation>;
54
55    /// Inspects one original node within the expected snapshot.
56    fn inspect<R, W: Work>(
57        &self,
58        snapshot: Snapshot<Self::Document, Self::Generation>,
59        node: Self::Id,
60        execution: &W,
61        visitor: impl for<'view> FnOnce(Node<'view>) -> R,
62    ) -> Result<R, Self::Error>;
63
64    /// Reads an attribute by expanded name, preserving absence versus empty.
65    ///
66    /// Generic XML attribute normalization and namespace rules belong to the
67    /// owner. Unqualified attributes use the empty namespace, regardless of an
68    /// element's default namespace. Returned text is XML-normalized lexical data.
69    fn attribute<R, W: Work>(
70        &self,
71        snapshot: Snapshot<Self::Document, Self::Generation>,
72        node: Self::Id,
73        name: Name<'_>,
74        execution: &W,
75        visitor: impl for<'view> FnOnce(Option<&'view str>) -> R,
76    ) -> Result<R, Self::Error>;
77
78    /// Returns the first direct child, including text and non-element children.
79    fn first_child<W: Work>(
80        &self,
81        snapshot: Snapshot<Self::Document, Self::Generation>,
82        parent: Self::Id,
83        execution: &W,
84    ) -> Result<Option<Self::Id>, Self::Error>;
85
86    /// Returns the next direct sibling in original document order.
87    ///
88    /// `parent` pins the traversal boundary; the owner rejects a node outside
89    /// that parent's child list rather than continuing in another subtree.
90    fn next_sibling<W: Work>(
91        &self,
92        snapshot: Snapshot<Self::Document, Self::Generation>,
93        parent: Self::Id,
94        node: Self::Id,
95        execution: &W,
96    ) -> Result<Option<Self::Id>, Self::Error>;
97}
98
99/// An XML access refusal located by original node identity, not a fake byte offset.
100#[derive(Debug)]
101pub enum AccessError<E, N> {
102    /// The XML owner's original cause.
103    Xml(E),
104    /// Common admission failed before callback dispatch.
105    Resource {
106        /// Original node associated with the attempted operation.
107        node: N,
108        /// Original execution refusal.
109        cause: MemoryError,
110    },
111    /// Document identity or generation changed; previous evidence is unusable.
112    Changed {
113        /// Node associated with the attempted operation.
114        node: N,
115    },
116}
117
118/// Result retaining the selected XML adapter's cause and original node identity.
119pub type AccessResult<T, X> = Result<T, AccessError<<X as Xml>::Error, <X as Xml>::Id>>;
120
121/// Failure to traverse an element-only property container.
122#[derive(Debug)]
123pub enum ChildError<E, N> {
124    /// Original access, generation or execution refusal.
125    Access(AccessError<E, N>),
126    /// Character data is not XML whitespace in an element-only container.
127    Text {
128        /// Original text-node identity; no document byte position is invented.
129        node: N,
130    },
131}
132
133/// One snapshot and current operation shared by all scoped XML queries.
134///
135/// This guard checks the snapshot before and after every successful callback.
136/// The adapter must also check at actual access, so these checks do not replace
137/// owner synchronization or immutable-view guarantees. No clone, allocation or
138/// reference-count increment is required to construct the guard.
139pub struct Access<'a, X: Xml, W: Work> {
140    xml: &'a X,
141    snapshot: Snapshot<X::Document, X::Generation>,
142    execution: &'a W,
143}
144
145impl<'a, X: Xml, W: Work> Access<'a, X, W> {
146    /// Attaches the expected original snapshot and current operation.
147    #[must_use]
148    pub fn new(
149        xml: &'a X,
150        snapshot: Snapshot<X::Document, X::Generation>,
151        execution: &'a W,
152    ) -> Self {
153        Self {
154            xml,
155            snapshot,
156            execution,
157        }
158    }
159
160    /// The source snapshot associated with node locations and selection evidence.
161    #[must_use]
162    pub fn snapshot(&self) -> Snapshot<X::Document, X::Generation> {
163        self.snapshot
164    }
165
166    /// Borrows the actual current execution used by this access boundary.
167    /// Provider/resolver lexical scans and owned results must be admitted here,
168    /// rather than on an earlier source/storage account. No clone, allocation
169    /// or account/reference-count change occurs. The borrow cannot outlive this
170    /// access handle and does not expose the XML owner's internal DOM.
171    #[must_use]
172    pub fn execution(&self) -> &W {
173        self.execution
174    }
175
176    pub(crate) fn owner(&self) -> &'a X {
177        self.xml
178    }
179
180    /// Traverses direct element children, skipping comments and XML whitespace.
181    ///
182    /// Character data is rejected rather than silently dropped. Each skipped
183    /// node is admitted and checked in the same operation, so an empty result
184    /// cannot bypass a large whitespace/comment fan-out. Construction allocates
185    /// nothing. The first error is returned once, then iteration is fused.
186    #[must_use]
187    pub fn children(&self, parent: X::Id) -> Children<'_, 'a, X, W> {
188        Children {
189            access: self,
190            parent,
191            previous: None,
192            started: false,
193            done: false,
194        }
195    }
196
197    fn unchanged<E>(&self, node: X::Id) -> Result<(), AccessError<E, X::Id>> {
198        if self.xml.snapshot() != self.snapshot {
199            return Err(AccessError::Changed { node });
200        }
201        Ok(())
202    }
203
204    fn call<R, E>(
205        &self,
206        node: X::Id,
207        callback: impl FnOnce() -> Result<R, E>,
208    ) -> Result<R, AccessError<E, X::Id>> {
209        self.unchanged(node)?;
210        self.execution
211            .charge_work(1)
212            .map_err(|cause| AccessError::Resource { node, cause })?;
213        let result = callback().map_err(AccessError::Xml)?;
214        self.unchanged(node)?;
215        Ok(result)
216    }
217
218    pub(crate) fn query<R>(
219        &self,
220        node: X::Id,
221        callback: impl FnOnce(&X, Snapshot<X::Document, X::Generation>, &W) -> Result<R, X::Error>,
222    ) -> AccessResult<R, X> {
223        self.call(node, || callback(self.xml, self.snapshot, self.execution))
224    }
225
226    // Same dispatch/generation/admission core, preserving visitor errors before
227    // the post-success generation check. No side channel or error substitution.
228    pub(crate) fn query_fallible<R, E>(
229        &self,
230        node: X::Id,
231        callback: impl FnOnce(&X, Snapshot<X::Document, X::Generation>, &W) -> Result<R, E>,
232    ) -> Result<R, AccessError<E, X::Id>> {
233        self.call(node, || callback(self.xml, self.snapshot, self.execution))
234    }
235
236    pub(crate) fn equal(&self, left: &str, right: &str) -> Result<bool, MemoryError> {
237        equal_text(left, right, self.execution)
238    }
239
240    pub(crate) fn equal_id(&self, left: X::Id, right: X::Id) -> Result<bool, MemoryError> {
241        equal_id(left, right, self.execution)
242    }
243
244    pub(crate) fn trim_xml_whitespace<'value>(
245        &self,
246        value: &'value str,
247    ) -> Result<&'value str, MemoryError> {
248        self.execution
249            .charge_work(u64::try_from(value.len()).map_err(|_| MemoryError::WorkExceeded)?)?;
250        Ok(value.trim_matches([' ', '\t', '\r', '\n']))
251    }
252
253    /// Inspects one node without leaking callback-local XML borrows.
254    ///
255    /// ```compile_fail
256    /// use structured_xades::xml::{Access, AccessError, Node, Xml};
257    /// use structured_asn1::memory::Work;
258    /// fn escape<'a, X: Xml, W: Work>(access: &Access<'a, X, W>, node: X::Id)
259    ///     -> Result<Option<&'a str>, AccessError<X::Error, X::Id>>
260    /// {
261    ///     access.inspect(node, |value| match value {
262    ///         Node::Text(text) => Some(text),
263    ///         _ => None,
264    ///     })
265    /// }
266    /// ```
267    pub fn inspect<R>(
268        &self,
269        node: X::Id,
270        visitor: impl for<'view> FnOnce(Node<'view>) -> R,
271    ) -> AccessResult<R, X> {
272        self.call(node, || {
273            self.xml
274                .inspect(self.snapshot, node, self.execution, visitor)
275        })
276    }
277
278    /// Compares an element's expanded name with admission before each byte scan.
279    ///
280    /// A different node kind or name length needs no string scan. Namespace
281    /// names are compared exactly, without URI resolution or normalization.
282    pub fn has_name(&self, node: X::Id, expected: Name<'_>) -> AccessResult<bool, X> {
283        let compared = self.inspect(node, |value| {
284            let Node::Element(actual) = value else {
285                return Ok(false);
286            };
287            // Namespaces in XML 1.0 Third Edition §2.3: namespace-name identity
288            // is exact character-string comparison, not URI equivalence.
289            // https://www.w3.org/TR/2009/REC-xml-names-20091208/#ComparingURIReferences
290            if !equal_text(actual.namespace, expected.namespace, self.execution)? {
291                return Ok(false);
292            }
293            equal_text(actual.local, expected.local, self.execution)
294        })?;
295        compared.map_err(|cause| AccessError::Resource { node, cause })
296    }
297
298    /// Reads a normalized attribute, preserving absence versus an empty value.
299    pub fn attribute<R>(
300        &self,
301        node: X::Id,
302        name: Name<'_>,
303        visitor: impl for<'view> FnOnce(Option<&'view str>) -> R,
304    ) -> AccessResult<R, X> {
305        self.call(node, || {
306            self.xml
307                .attribute(self.snapshot, node, name, self.execution, visitor)
308        })
309    }
310
311    /// Begins a direct-child traversal, admitting even the empty-list query.
312    pub fn first_child(&self, parent: X::Id) -> AccessResult<Option<X::Id>, X> {
313        self.call(parent, || {
314            self.xml.first_child(self.snapshot, parent, self.execution)
315        })
316    }
317
318    /// Advances within the same direct-child list in original order.
319    pub fn next_sibling(&self, parent: X::Id, node: X::Id) -> AccessResult<Option<X::Id>, X> {
320        self.call(node, || {
321            self.xml
322                .next_sibling(self.snapshot, parent, node, self.execution)
323        })
324    }
325}
326
327// Long-term validation checks run under the same snapshot guard and current
328// operation as every XML query, so one admission path serves both.
329impl<X: Xml, W: Work> Query for Access<'_, X, W> {
330    type Id = X::Id;
331    type Document = X::Document;
332    type Generation = X::Generation;
333    type Work = W;
334
335    fn snapshot(&self) -> Snapshot<X::Document, X::Generation> {
336        self.snapshot
337    }
338
339    fn execution(&self) -> &W {
340        self.execution
341    }
342
343    fn admit(&self, node: X::Id, units: u64) -> Result<(), ScopeError<X::Id>> {
344        let changed = |_: AccessError<(), X::Id>| ScopeError::Changed { node };
345        self.unchanged(node).map_err(changed)?;
346        self.execution
347            .charge_work(units)
348            .map_err(|cause| ScopeError::Resource { node, cause })?;
349        self.unchanged(node).map_err(changed)
350    }
351}
352
353impl<E, N> From<ScopeError<N>> for AccessError<E, N> {
354    fn from(cause: ScopeError<N>) -> Self {
355        match cause {
356            ScopeError::Changed { node } => Self::Changed { node },
357            ScopeError::Resource { node, cause } => Self::Resource { node, cause },
358        }
359    }
360}
361
362pub(crate) fn equal_id<N: Copy + HashWork, W: Work>(
363    left: N,
364    right: N,
365    execution: &W,
366) -> Result<bool, MemoryError> {
367    let cost = left
368        .hash_work()?
369        .checked_add(right.hash_work()?)
370        .ok_or(MemoryError::WorkExceeded)?;
371    execution.charge_work(cost)?;
372    Ok(left == right)
373}
374
375/// An iterative, snapshot-checked direct-child traversal for property containers.
376pub struct Children<'a, 'xml, X: Xml, W: Work> {
377    access: &'a Access<'xml, X, W>,
378    parent: X::Id,
379    previous: Option<X::Id>,
380    started: bool,
381    done: bool,
382}
383
384impl<X: Xml, W: Work> Iterator for Children<'_, '_, X, W> {
385    type Item = Result<X::Id, ChildError<X::Error, X::Id>>;
386
387    fn next(&mut self) -> Option<Self::Item> {
388        if self.done {
389            return None;
390        }
391        loop {
392            let advanced = if self.started {
393                let Some(previous) = self.previous else {
394                    self.done = true;
395                    return None;
396                };
397                self.access.next_sibling(self.parent, previous)
398            } else {
399                self.started = true;
400                self.access.first_child(self.parent)
401            };
402            let node = match advanced {
403                Ok(Some(node)) => node,
404                Ok(None) => {
405                    self.done = true;
406                    return None;
407                }
408                Err(error) => {
409                    self.done = true;
410                    return Some(Err(ChildError::Access(error)));
411                }
412            };
413            self.previous = Some(node);
414            let inspected = self.access.inspect(node, |value| match value {
415                Node::Element(_) => Ok(Some(true)),
416                Node::Other => Ok(None),
417                Node::Text(text) => {
418                    let length =
419                        u64::try_from(text.len()).map_err(|_| MemoryError::WorkExceeded)?;
420                    self.access.execution.charge_work(length)?;
421                    // XML 1.0 Fifth Edition §2.3, production S: only these four
422                    // characters are XML whitespace, irrespective of Unicode.
423                    // https://www.w3.org/TR/2008/REC-xml-20081126/#NT-S
424                    for byte in text.bytes() {
425                        if !matches!(byte, b' ' | b'\t' | b'\r' | b'\n') {
426                            return Ok(Some(false));
427                        }
428                    }
429                    Ok(None)
430                }
431            });
432            match inspected {
433                Ok(Ok(Some(true))) => return Some(Ok(node)),
434                Ok(Ok(None)) => {}
435                Ok(Ok(Some(false))) => {
436                    self.done = true;
437                    return Some(Err(ChildError::Text { node }));
438                }
439                Ok(Err(cause)) => {
440                    self.done = true;
441                    return Some(Err(ChildError::Access(AccessError::Resource {
442                        node,
443                        cause,
444                    })));
445                }
446                Err(error) => {
447                    self.done = true;
448                    return Some(Err(ChildError::Access(error)));
449                }
450            }
451        }
452    }
453}
454
455impl<X: Xml, W: Work> core::iter::FusedIterator for Children<'_, '_, X, W> {}
456
457fn equal_text<W: Work>(left: &str, right: &str, execution: &W) -> Result<bool, MemoryError> {
458    if left.len() != right.len() {
459        return Ok(false);
460    }
461    let length = u64::try_from(left.len()).map_err(|_| MemoryError::WorkExceeded)?;
462    execution.charge_work(length)?;
463    Ok(left == right)
464}