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