Skip to main content

xml_sec/xmldsig/
uri.rs

1//! URI dereference for XMLDSig `<Reference>` elements.
2//!
3//! Implements same-document URI resolution per
4//! [XMLDSig §4.3.3.2](https://www.w3.org/TR/xmldsig-core1/#sec-Same-Document):
5//!
6//! - **Empty URI** (`""` or absent): the entire document, excluding comments.
7//! - **Bare-name `#id`**: the element whose ID attribute matches `id`, as a subtree
8//!   with comments removed by the XMLDSig same-document dereference rule.
9//! - **`#xpointer(/)`**: the entire document, including comments.
10//! - **`#xpointer(id('id'))` / `#xpointer(id("id"))`**: element by ID, with comments retained.
11//!
12//! External URI bytes are resolved only from an explicit caller-owned map; this
13//! module never performs network or filesystem I/O.
14
15use std::cell::Cell;
16use std::collections::HashMap;
17use std::rc::Rc;
18
19use crate::xml::dom::{Document, Node, NodeId};
20
21use crate::c14n::xml_base::{
22    XmlBaseResolutionBudget, XmlBaseResolutionError, resolve_uri_from_node_with_budget,
23};
24use crate::policy::SameDocumentIdSemantics;
25use crate::xml::{XmlIdIndex, is_xml_ncname};
26
27use super::types::{
28    NodeSet, NodeSetMaterializationBudget, TransformData, TransformError, transform_resource_limit,
29};
30
31/// Validate signing URI policy independently of request-scoped resource bytes.
32pub(crate) fn validate_signing_reference_uri(
33    uri: &str,
34    policy: &crate::policy::SigningPolicy,
35) -> Result<(), crate::policy::PolicyViolation> {
36    if !policy.uris.references.allows(uri) {
37        return Err(crate::policy::PolicyViolation::Uri {
38            operation: "signing",
39            reason: "signing reference URI class is not permitted",
40        });
41    }
42    Ok(())
43}
44
45/// Require a request resource boundary before processing a detached signing URI.
46pub(crate) fn validate_signing_reference_request(
47    uri: &str,
48    has_external_resources: bool,
49) -> Result<(), crate::policy::PolicyViolation> {
50    if !uri.is_empty() && !uri.starts_with('#') && !has_external_resources {
51        return Err(crate::policy::PolicyViolation::Uri {
52            operation: "signing",
53            reason: "external signing references require request-scoped resource bytes",
54        });
55    }
56    Ok(())
57}
58
59struct ExternalResourceBudget {
60    remaining_total_bytes: Cell<usize>,
61    max_resource_bytes: usize,
62    max_total_bytes: usize,
63}
64
65pub(crate) enum ExternalResourceMapError {
66    Policy(crate::policy::PolicyViolation),
67    TotalLengthOverflow,
68}
69
70pub(crate) fn validate_external_resource_map(
71    resources: &HashMap<String, Vec<u8>>,
72    max_resource_bytes: usize,
73    max_total_bytes: usize,
74) -> Result<(), ExternalResourceMapError> {
75    let mut total = 0usize;
76    for bytes in resources.values() {
77        if bytes.len() > max_resource_bytes {
78            return Err(ExternalResourceMapError::Policy(
79                crate::policy::PolicyViolation::ResourceLimit {
80                    resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES,
81                    maximum: max_resource_bytes,
82                    actual: bytes.len(),
83                },
84            ));
85        }
86        total = total
87            .checked_add(bytes.len())
88            .ok_or(ExternalResourceMapError::TotalLengthOverflow)?;
89    }
90    if total > max_total_bytes {
91        return Err(ExternalResourceMapError::Policy(
92            crate::policy::PolicyViolation::ResourceLimit {
93                resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES,
94                maximum: max_total_bytes,
95                actual: total,
96            },
97        ));
98    }
99    Ok(())
100}
101
102/// Request-scoped external bytes and their shared aggregate work budget.
103///
104/// Signing reparses its staged document while filling dependent digests. This
105/// context keeps the external-resource budget continuous across every resolver
106/// rebound instead of silently resetting it for each parsed generation.
107pub(crate) struct ExternalResourceContext<'a> {
108    resources: Option<&'a HashMap<String, Vec<u8>>>,
109    budget: Rc<ExternalResourceBudget>,
110}
111
112impl<'a> ExternalResourceContext<'a> {
113    pub(crate) fn new(
114        resources: Option<&'a HashMap<String, Vec<u8>>>,
115        max_resource_bytes: usize,
116        max_total_bytes: usize,
117    ) -> Self {
118        Self {
119            resources,
120            budget: Rc::new(ExternalResourceBudget::with_limits(
121                max_resource_bytes,
122                max_total_bytes,
123            )),
124        }
125    }
126
127    pub(crate) fn is_configured(&self) -> bool {
128        self.resources.is_some()
129    }
130
131    pub(crate) fn bind<'doc>(
132        &'doc self,
133        doc: &'doc Document<'doc>,
134        registrations: &[crate::IdAttributeRegistration],
135        same_document_id_semantics: SameDocumentIdSemantics,
136    ) -> UriReferenceResolver<'doc>
137    where
138        'a: 'doc,
139    {
140        let mut resolver = UriReferenceResolver::with_id_registrations(doc, registrations)
141            .with_same_document_id_semantics(same_document_id_semantics);
142        resolver.external_resources = self.resources;
143        resolver.external_resource_budget = Rc::clone(&self.budget);
144        resolver
145    }
146}
147
148impl Default for ExternalResourceBudget {
149    fn default() -> Self {
150        Self::with_limits(
151            crate::hard_limits::EXTERNAL_RESOURCE_BYTE_CEILING,
152            crate::hard_limits::EXTERNAL_RESOURCE_TOTAL_BYTE_CEILING,
153        )
154    }
155}
156
157impl ExternalResourceBudget {
158    fn with_limits(max_resource_bytes: usize, max_total_bytes: usize) -> Self {
159        Self {
160            remaining_total_bytes: Cell::new(max_total_bytes),
161            max_resource_bytes,
162            max_total_bytes,
163        }
164    }
165
166    fn charge(&self, bytes: usize) -> Result<(), TransformError> {
167        if bytes > self.max_resource_bytes {
168            return Err(transform_resource_limit(
169                crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES,
170                self.max_resource_bytes,
171                bytes,
172            ));
173        }
174        let remaining = self.remaining_total_bytes.get();
175        let Some(next) = remaining.checked_sub(bytes) else {
176            self.remaining_total_bytes.set(0);
177            return Err(transform_resource_limit(
178                crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES,
179                self.max_total_bytes,
180                self.max_total_bytes
181                    .saturating_add(bytes.saturating_sub(remaining)),
182            ));
183        };
184        self.remaining_total_bytes.set(next);
185        Ok(())
186    }
187}
188
189/// Resolves same-document URI references against a parsed XML document.
190///
191/// Builds a `HashMap<&str, Node>` index on construction for O(1) fragment
192/// lookups. Supports caller-provided ID attribute names (important for SAML
193/// which uses `ID` rather than the xml:id mechanism).
194///
195/// # Example
196///
197/// ```
198/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
199/// use xml_sec::Document;
200/// use xml_sec::xmldsig::uri::UriReferenceResolver;
201///
202/// let xml = r#"<root><item ID="abc">content</item></root>"#;
203/// let doc = Document::parse(xml)?;
204/// let resolver = UriReferenceResolver::new(&doc);
205///
206/// assert!(resolver.has_id("abc"));
207/// assert_eq!(resolver.id_count(), 1);
208/// # Ok(())
209/// # }
210/// ```
211pub struct UriReferenceResolver<'a> {
212    doc: &'a Document<'a>,
213    view: Option<crate::DocumentView<'a>>,
214    resource_identity: Option<String>,
215    id_registrations: Vec<crate::IdAttributeRegistration>,
216    id_index: ResolverIdIndex<'a>,
217    external_resources: Option<&'a HashMap<String, Vec<u8>>>,
218    external_resource_budget: Rc<ExternalResourceBudget>,
219    same_document_id_semantics: SameDocumentIdSemantics,
220}
221
222enum ResolverIdIndex<'a> {
223    Borrowed(XmlIdIndex<'a>),
224    Retained(HashMap<String, NodeId>),
225}
226
227#[derive(Clone, Debug, PartialEq, Eq, Hash)]
228pub(crate) enum TraversalDocumentIdentity {
229    Owned(crate::DocumentIdentity),
230    External(String),
231    Borrowed(usize),
232}
233
234impl<'a> ResolverIdIndex<'a> {
235    fn node(&self, document: &'a Document<'a>, id: &str) -> Option<Node<'a, 'a>> {
236        match self {
237            Self::Borrowed(index) => index.node(id),
238            Self::Retained(index) => index.get(id).and_then(|node| document.get_node(*node)),
239        }
240    }
241
242    fn contains(&self, id: &str) -> bool {
243        match self {
244            Self::Borrowed(index) => index.contains(id),
245            Self::Retained(index) => index.contains_key(id),
246        }
247    }
248
249    fn node_id(&self, id: &str) -> Option<NodeId> {
250        match self {
251            Self::Borrowed(index) => index.node_id(id),
252            Self::Retained(index) => index.get(id).copied(),
253        }
254    }
255
256    fn len(&self) -> usize {
257        match self {
258            Self::Borrowed(index) => index.len(),
259            Self::Retained(index) => index.len(),
260        }
261    }
262}
263
264impl<'a> UriReferenceResolver<'a> {
265    pub(crate) fn id_registrations(&self) -> &[crate::IdAttributeRegistration] {
266        &self.id_registrations
267    }
268    /// Build a resolver with default ID attribute names (`ID`, `Id`, `id`).
269    pub fn new(doc: &'a Document<'a>) -> Self {
270        Self::with_id_attrs(doc, &[])
271    }
272
273    /// Build a resolver scanning additional ID attribute names beyond the defaults.
274    ///
275    /// The defaults (`ID`, `Id`, `id`) are always included; `extra_attrs`
276    /// adds to them (does not replace). Pass an empty slice to use only defaults.
277    ///
278    /// Attribute names use the semantic DOM's local-name view, independent of
279    /// the selected XML parser backend. For example, `wsu:Id="..."` is
280    /// registered as `"Id"`, not `"wsu:Id"` or `"{namespace}Id"`.
281    pub fn with_id_attrs(doc: &'a Document<'a>, extra_attrs: &[&str]) -> Self {
282        let id_registrations = extra_attrs
283            .iter()
284            .map(|name| crate::IdAttributeRegistration::global(*name))
285            .collect::<Vec<_>>();
286        Self {
287            doc,
288            view: None,
289            resource_identity: None,
290            id_index: ResolverIdIndex::Borrowed(XmlIdIndex::with_registrations(
291                doc,
292                &id_registrations,
293            )),
294            id_registrations,
295            external_resources: None,
296            external_resource_budget: Rc::new(ExternalResourceBudget::default()),
297            same_document_id_semantics: SameDocumentIdSemantics::Specification,
298        }
299    }
300
301    /// Build a resolver with typed global and element-scoped ID registrations.
302    pub fn with_id_registrations(
303        doc: &'a Document<'a>,
304        registrations: &[crate::IdAttributeRegistration],
305    ) -> Self {
306        let id_registrations = registrations.to_vec();
307        Self {
308            doc,
309            view: None,
310            resource_identity: None,
311            id_index: ResolverIdIndex::Borrowed(XmlIdIndex::with_registrations(
312                doc,
313                &id_registrations,
314            )),
315            id_registrations,
316            external_resources: None,
317            external_resource_budget: Rc::new(ExternalResourceBudget::default()),
318            same_document_id_semantics: SameDocumentIdSemantics::Specification,
319        }
320    }
321
322    pub(crate) fn with_document_view(
323        view: crate::DocumentView<'a>,
324        registrations: &[crate::IdAttributeRegistration],
325    ) -> Self {
326        let id_registrations = registrations.to_vec();
327        Self {
328            doc: view.document(),
329            view: Some(view),
330            resource_identity: None,
331            id_index: ResolverIdIndex::Retained(view.id_index(&id_registrations)),
332            id_registrations,
333            external_resources: None,
334            external_resource_budget: Rc::new(ExternalResourceBudget::default()),
335            same_document_id_semantics: SameDocumentIdSemantics::Specification,
336        }
337    }
338
339    pub(crate) fn with_same_document_id_semantics(
340        mut self,
341        semantics: SameDocumentIdSemantics,
342    ) -> Self {
343        self.same_document_id_semantics = semantics;
344        self
345    }
346
347    /// Rebind document-local URI resolution while retaining caller-owned
348    /// external resources and their aggregate byte budget.
349    pub(crate) fn for_document_view<'b>(
350        &self,
351        view: crate::DocumentView<'b>,
352    ) -> UriReferenceResolver<'b>
353    where
354        'a: 'b,
355    {
356        UriReferenceResolver {
357            doc: view.document(),
358            view: Some(view),
359            resource_identity: None,
360            id_index: ResolverIdIndex::Retained(view.id_index(&self.id_registrations)),
361            id_registrations: self.id_registrations.clone(),
362            external_resources: self.external_resources,
363            external_resource_budget: Rc::clone(&self.external_resource_budget),
364            same_document_id_semantics: self.same_document_id_semantics,
365        }
366    }
367
368    /// Rebind document-local URI resolution to a stable external resource.
369    ///
370    /// The identity survives reparsing, so recursive reference traversal can
371    /// distinguish equal fragments in different resources without overlooking
372    /// a cycle that returns to the same resource.
373    pub(crate) fn for_external_document_view<'b>(
374        &self,
375        view: crate::DocumentView<'b>,
376        resource_identity: &str,
377    ) -> UriReferenceResolver<'b>
378    where
379        'a: 'b,
380    {
381        let mut resolver = self.for_document_view(view);
382        resolver.resource_identity = Some(resource_identity.to_owned());
383        resolver
384    }
385
386    pub(crate) fn traversal_document_identity(&self) -> TraversalDocumentIdentity {
387        if let Some(identity) = &self.resource_identity {
388            TraversalDocumentIdentity::External(identity.clone())
389        } else if let Some(view) = self.view {
390            TraversalDocumentIdentity::Owned(view.identity())
391        } else {
392            TraversalDocumentIdentity::Borrowed(self.doc as *const Document<'_> as usize)
393        }
394    }
395
396    /// Attach an explicit caller-owned external-resource map.
397    ///
398    /// No network or filesystem access is performed by this resolver. Keys are
399    /// RFC 3986 resolved URI identities: paths have dot segments removed while
400    /// query and fragment suffixes are retained.
401    pub fn with_external_resources(mut self, resources: &'a HashMap<String, Vec<u8>>) -> Self {
402        self.external_resources = Some(resources);
403        self
404    }
405
406    pub(crate) fn with_external_resource_limits(
407        mut self,
408        max_resource_bytes: usize,
409        max_total_bytes: usize,
410    ) -> Self {
411        self.external_resource_budget = Rc::new(ExternalResourceBudget::with_limits(
412            max_resource_bytes,
413            max_total_bytes,
414        ));
415        self
416    }
417
418    pub(crate) fn external_resource(&self, uri: &str) -> Result<Option<&'a [u8]>, TransformError> {
419        let Some(bytes) = self
420            .external_resources
421            .and_then(|resources| resources.get(uri))
422        else {
423            return Ok(None);
424        };
425        self.external_resource_budget.charge(bytes.len())?;
426        Ok(Some(bytes))
427    }
428
429    pub(crate) fn external_resource_identity(
430        &self,
431        uri: &str,
432    ) -> Option<crate::operation::OperationResourceIdentity> {
433        self.external_resources
434            .and_then(|resources| resources.get(uri))
435            .map(|bytes| crate::operation::OperationResourceIdentity::external(uri, bytes))
436    }
437
438    pub(crate) fn external_resource_set_identity(
439        &self,
440    ) -> crate::operation::OperationResourceIdentity {
441        use sha2::Digest;
442
443        let mut entries = self
444            .external_resources
445            .into_iter()
446            .flat_map(HashMap::iter)
447            .collect::<Vec<_>>();
448        entries.sort_unstable_by_key(|(uri, _)| *uri);
449        let mut hasher = sha2::Sha256::new();
450        for (uri, bytes) in entries {
451            hasher.update(uri.len().to_be_bytes());
452            hasher.update(uri.as_bytes());
453            hasher.update(bytes.len().to_be_bytes());
454            hasher.update(bytes);
455        }
456        crate::operation::OperationResourceIdentity::External {
457            uri: "caller-owned-external-resource-set".to_owned(),
458            fingerprint: hasher.finalize().into(),
459        }
460    }
461
462    /// Dereference a URI string to a [`TransformData`].
463    ///
464    /// # URI forms
465    ///
466    /// | URI | Result |
467    /// |-----|--------|
468    /// | `""` (empty) | Entire document, comments excluded |
469    /// | `"#foo"` | Subtree rooted at element with ID `foo`, comments excluded |
470    /// | `"#xpointer(/)"` | Entire document, comments included |
471    /// | `"#xpointer(id('foo'))"` | Subtree rooted at element with ID `foo`, comments included |
472    /// | external URI in caller map | A copy of the mapped bytes |
473    /// | other | `Err(UnsupportedUri)` |
474    pub fn dereference(&self, uri: &str) -> Result<TransformData<'a>, TransformError> {
475        self.dereference_with_optional_budget(uri, None)
476    }
477
478    pub(crate) fn dereference_with_budget(
479        &self,
480        uri: &str,
481        budget: &NodeSetMaterializationBudget,
482    ) -> Result<TransformData<'a>, TransformError> {
483        self.dereference_with_optional_budget(uri, Some(budget))
484    }
485
486    pub(crate) fn dereference_from_with_budget(
487        &self,
488        uri: &str,
489        origin: Node<'_, '_>,
490        budget: &NodeSetMaterializationBudget,
491        xml_base_budget: &XmlBaseResolutionBudget,
492    ) -> Result<TransformData<'a>, TransformError> {
493        // XMLDSig assigns special dereference semantics to lexical empty and
494        // fragment-only references. Only external references use XML Base.
495        if uri.is_empty() || uri.starts_with('#') {
496            return self.dereference_with_budget(uri, budget);
497        }
498        let resolved = resolve_uri_from_node_with_budget(origin, uri, xml_base_budget)
499            .map_err(map_xml_base_resolution_error)?;
500        self.dereference_with_budget(&resolved, budget)
501    }
502
503    fn dereference_with_optional_budget(
504        &self,
505        uri: &str,
506        budget: Option<&NodeSetMaterializationBudget>,
507    ) -> Result<TransformData<'a>, TransformError> {
508        if uri.is_empty() {
509            // Empty URI = entire document without comments
510            // XMLDSig §4.3.3.2: "the reference is to the document [...],
511            // and the comment nodes are not included"
512            let nodes = match self.view {
513                Some(view) => NodeSet::entire_document_without_comments_from_view(view, budget)?,
514                None => match budget {
515                    Some(budget) => {
516                        NodeSet::entire_document_without_comments_with_budget(self.doc, budget)?
517                    }
518                    None => NodeSet::entire_document_without_comments(self.doc)?,
519                },
520            };
521            Ok(TransformData::NodeSet(nodes))
522        } else if let Some(fragment) = uri.strip_prefix('#') {
523            self.dereference_fragment(fragment, budget)
524        } else {
525            self.external_resource(uri)?
526                .map(|bytes| TransformData::Binary(bytes.to_vec()))
527                .ok_or_else(|| TransformError::UnsupportedUri(uri.to_string()))
528        }
529    }
530
531    /// Resolve a URI fragment (the part after `#`).
532    ///
533    /// Handles:
534    /// - `xpointer(/)` → entire document (with comments, per XPointer spec)
535    /// - `xpointer(id('foo'))` → element by ID, retaining comments
536    /// - bare name `foo` → element by ID attribute
537    fn dereference_fragment(
538        &self,
539        fragment: &str,
540        budget: Option<&NodeSetMaterializationBudget>,
541    ) -> Result<TransformData<'a>, TransformError> {
542        let normalized =
543            Self::normalize_fragment(fragment, budget, self.same_document_id_semantics)?;
544        let fragment = normalized.as_ref();
545        if fragment.is_empty() {
546            // Bare "#" is not a valid same-document reference
547            return Err(TransformError::UnsupportedUri("#".to_string()));
548        }
549
550        if is_xpointer_root(fragment) {
551            // XPointer root: entire document WITH comments (unlike empty URI).
552            // Per XMLDSig §4.3.3.3: "the XPointer expression [...] includes
553            // comment nodes"
554            let nodes = match self.view {
555                Some(view) => NodeSet::entire_document_with_comments_from_view(view, budget)?,
556                None => match budget {
557                    Some(budget) => {
558                        NodeSet::entire_document_with_comments_with_budget(self.doc, budget)?
559                    }
560                    None => NodeSet::entire_document_with_comments(self.doc)?,
561                },
562            };
563            Ok(TransformData::NodeSet(nodes))
564        } else {
565            let (id, with_comments) = self
566                .same_document_id_fragment(fragment)
567                .ok_or_else(|| TransformError::UnsupportedUri(format!("#{fragment}")))?;
568            self.resolve_id(id, budget, with_comments)
569        }
570    }
571
572    fn same_document_id_fragment<'uri>(&self, fragment: &'uri str) -> Option<(&'uri str, bool)> {
573        Self::id_fragment(fragment, self.same_document_id_semantics)
574    }
575
576    pub(crate) fn id_fragment(
577        fragment: &str,
578        semantics: SameDocumentIdSemantics,
579    ) -> Option<(&str, bool)> {
580        if fragment.is_empty() {
581            return None;
582        }
583        if let Some(id) = parse_xpointer_id_fragment(fragment) {
584            // Explicit XPointer dereference retains comments, unlike every
585            // barename mode, including libxmlsec1's internal wrapper.
586            if id.is_empty() {
587                return None;
588            }
589            return Some((id, true));
590        }
591        if fragment.starts_with("xpointer(") {
592            return None;
593        }
594        match semantics {
595            SameDocumentIdSemantics::Specification if !is_xml_ncname(fragment) => {
596                return None;
597            }
598            SameDocumentIdSemantics::XmlSecBarename if fragment.contains('\'') => {
599                return None;
600            }
601            SameDocumentIdSemantics::Specification
602            | SameDocumentIdSemantics::XmlSecBarename
603            | SameDocumentIdSemantics::XmlSecVisa3d => {}
604        }
605        Some((fragment, false))
606    }
607
608    /// Look up an element by its ID attribute value and return a subtree node set.
609    fn resolve_id(
610        &self,
611        id: &str,
612        budget: Option<&NodeSetMaterializationBudget>,
613        with_comments: bool,
614    ) -> Result<TransformData<'a>, TransformError> {
615        match self.id_index.node(self.doc, id) {
616            Some(element) => {
617                let nodes = if let Some(view) = self.view {
618                    NodeSet::subtree_from_view(view, element, with_comments, budget)?
619                } else if with_comments {
620                    match budget {
621                        Some(budget) => NodeSet::subtree_with_budget(element, budget)?,
622                        None => NodeSet::subtree(element)?,
623                    }
624                } else {
625                    NodeSet::subtree_without_comments_with_budget(element, budget)?
626                };
627                Ok(TransformData::NodeSet(nodes))
628            }
629            None => Err(TransformError::ElementNotFound(id.to_string())),
630        }
631    }
632
633    /// Check if an ID is registered in the resolver's index.
634    pub fn has_id(&self, id: &str) -> bool {
635        self.id_index.contains(id)
636    }
637
638    /// Resolve an unambiguous XML ID to its element node.
639    ///
640    /// Returns `None` when the ID is absent or duplicated, matching fragment
641    /// dereferencing and operation start-node selection.
642    pub fn node_for_id(&self, id: &str) -> Option<Node<'a, 'a>> {
643        self.id_index.node(self.doc, id)
644    }
645
646    /// Resolve a same-document URI to an element under the configured grammar.
647    /// An empty URI selects the document element for element-valued consumers.
648    pub fn node_for_same_document_reference(
649        &self,
650        uri: &str,
651    ) -> Result<Option<Node<'a, 'a>>, TransformError> {
652        self.node_for_same_document_reference_with_budget(uri, None)
653    }
654
655    pub(crate) fn node_for_same_document_reference_with_budget(
656        &self,
657        uri: &str,
658        budget: Option<&NodeSetMaterializationBudget>,
659    ) -> Result<Option<Node<'a, 'a>>, TransformError> {
660        if uri.is_empty() {
661            return Ok(Some(self.doc.root_element()));
662        }
663        let target = self.node_id_for_reference_resource(uri, budget)?;
664        if target == Some(self.doc.root().id()) {
665            return Err(TransformError::UnsupportedUri(uri.to_owned()));
666        }
667        Ok(target.and_then(|id| self.doc.get_node(id)))
668    }
669
670    /// Borrowed association prefilter, sharing the normal URI grammar and ID
671    /// index without constructing errors for unrelated or malformed references.
672    #[cfg(feature = "xmlenc")]
673    pub(crate) fn same_document_reference_targets(&self, uri: &str, target: NodeId) -> bool {
674        if uri.is_empty() {
675            return self.doc.root_element().id() == target;
676        }
677        let Some(fragment) = uri.strip_prefix('#') else {
678            return false;
679        };
680        let Ok(normalized) =
681            Self::normalize_fragment(fragment, None, self.same_document_id_semantics)
682        else {
683            return false;
684        };
685        let fragment = normalized.as_ref();
686        if fragment.is_empty() || is_xpointer_root(fragment) {
687            return false;
688        }
689        let Some((id, _)) = self.same_document_id_fragment(fragment) else {
690            return false;
691        };
692        self.id_index.node_id(id) == Some(target)
693    }
694
695    /// Resolve a same-document URI to a stable node identity under the
696    /// configured grammar, for secondary consumers such as Manifest trust.
697    pub(crate) fn node_id_for_same_document_reference(
698        &self,
699        uri: &str,
700    ) -> Result<Option<NodeId>, TransformError> {
701        let target = self.node_id_for_reference_resource(uri, None)?;
702        if uri.is_empty() || target == Some(self.doc.root().id()) {
703            return Err(TransformError::UnsupportedUri(uri.to_owned()));
704        }
705        Ok(target)
706    }
707
708    /// Classify the resource once using the same normalization as dereference;
709    /// unlike element-only consumers, evidence may bind the document root.
710    pub(crate) fn node_id_for_reference_resource(
711        &self,
712        uri: &str,
713        budget: Option<&NodeSetMaterializationBudget>,
714    ) -> Result<Option<NodeId>, TransformError> {
715        if uri.is_empty() {
716            return Ok(Some(self.doc.root().id()));
717        }
718        let fragment = uri
719            .strip_prefix('#')
720            .ok_or_else(|| TransformError::UnsupportedUri(uri.to_owned()))?;
721        let normalized =
722            Self::normalize_fragment(fragment, budget, self.same_document_id_semantics)?;
723        let fragment = normalized.as_ref();
724        if fragment.is_empty() {
725            return Err(TransformError::UnsupportedUri(uri.to_owned()));
726        }
727        if is_xpointer_root(fragment) {
728            return Ok(Some(self.doc.root().id()));
729        }
730        let (id, _) = self
731            .same_document_id_fragment(fragment)
732            .ok_or_else(|| TransformError::UnsupportedUri(uri.to_owned()))?;
733        Ok(self.id_index.node_id(id))
734    }
735
736    pub(crate) fn node_for_node_id(&self, id: NodeId) -> Option<Node<'a, 'a>> {
737        self.doc.get_node(id)
738    }
739
740    /// Get the number of registered IDs.
741    pub fn id_count(&self) -> usize {
742        self.id_index.len()
743    }
744
745    pub(crate) fn normalize_fragment<'uri>(
746        fragment: &'uri str,
747        budget: Option<&NodeSetMaterializationBudget>,
748        semantics: SameDocumentIdSemantics,
749    ) -> Result<std::borrow::Cow<'uri, str>, TransformError> {
750        use std::borrow::Cow;
751        let invalid = || TransformError::UnsupportedUri("invalid same-document fragment".into());
752        if fragment.len() > crate::hard_limits::XML_DOCUMENT_BYTE_CEILING {
753            return Err(invalid());
754        }
755        let mut result = Cow::Borrowed(fragment);
756        // XPointer Framework sections 3 and 4 reverse URI escaping before
757        // evaluating the pointer. libxmlsec1's barename/Visa3D modes instead
758        // use literal bare ID strings. Explicit pointers still follow the
759        // Framework, independently of the selected barename compatibility mode.
760        // https://www.w3.org/TR/2003/REC-xptr-framework-20030325/#escaping
761        if fragment.contains('%')
762            && (semantics == SameDocumentIdSemantics::Specification
763                || has_encoded_xpointer_prefix(fragment))
764        {
765            if let Some(budget) = budget {
766                budget.reserve_workspace(fragment.len())?;
767            }
768            let mut decoded = Vec::with_capacity(fragment.len());
769            let bytes = fragment.as_bytes();
770            let mut position = 0;
771            while position < bytes.len() {
772                if bytes[position] == b'%' {
773                    let high = bytes
774                        .get(position + 1)
775                        .and_then(|byte| hex_digit(*byte))
776                        .ok_or_else(invalid)?;
777                    let low = bytes
778                        .get(position + 2)
779                        .and_then(|byte| hex_digit(*byte))
780                        .ok_or_else(invalid)?;
781                    decoded.push(high * 16 + low);
782                    position += 3;
783                } else {
784                    decoded.push(bytes[position]);
785                    position += 1;
786                }
787            }
788            result = Cow::Owned(String::from_utf8(decoded).map_err(|_| invalid())?);
789        }
790        if result.starts_with("xpointer(") {
791            // Framework [7] balances parentheses even inside XPath literals;
792            // only ^(, ^), and ^^ are escaping pairs.
793            let bytes = result.as_bytes();
794            let mut depth = 0usize;
795            let mut position = "xpointer".len();
796            while position < bytes.len() {
797                match bytes[position] {
798                    b'^' => {
799                        if !matches!(bytes.get(position + 1), Some(b'(' | b')' | b'^')) {
800                            return Err(invalid());
801                        }
802                        position += 2;
803                        continue;
804                    }
805                    b'(' => depth += 1,
806                    b')' => {
807                        if depth == 0 {
808                            return Err(invalid());
809                        }
810                        depth -= 1;
811                        if depth == 0 && position + 1 != bytes.len() {
812                            return Err(invalid());
813                        }
814                    }
815                    _ => {}
816                }
817                position += 1;
818            }
819            if depth != 0 {
820                return Err(invalid());
821            }
822            if result.contains('^') {
823                if matches!(result, Cow::Borrowed(_))
824                    && let Some(budget) = budget
825                {
826                    budget.reserve_workspace(result.len())?;
827                }
828                let mut escaped = false;
829                result.to_mut().retain(|character| {
830                    if escaped {
831                        escaped = false;
832                        true
833                    } else if character == '^' {
834                        escaped = true;
835                        false
836                    } else {
837                        true
838                    }
839                });
840            }
841        }
842        Ok(result)
843    }
844}
845
846fn hex_digit(byte: u8) -> Option<u8> {
847    match byte {
848        b'0'..=b'9' => Some(byte - b'0'),
849        b'a'..=b'f' => Some(byte - b'a' + 10),
850        b'A'..=b'F' => Some(byte - b'A' + 10),
851        _ => None,
852    }
853}
854
855fn has_encoded_xpointer_prefix(fragment: &str) -> bool {
856    // Recognize URI-escaped scheme syntax without materializing a candidate.
857    let mut bytes = fragment.bytes();
858    for expected in b"xpointer(" {
859        let Some(mut actual) = bytes.next() else {
860            return false;
861        };
862        if actual == b'%' {
863            let Some(high) = bytes.next().and_then(hex_digit) else {
864                return false;
865            };
866            let Some(low) = bytes.next().and_then(hex_digit) else {
867                return false;
868            };
869            actual = high * 16 + low;
870        }
871        if actual != *expected {
872            return false;
873        }
874    }
875    true
876}
877
878fn is_xpointer_root(fragment: &str) -> bool {
879    fragment
880        .strip_prefix("xpointer(")
881        .and_then(|value| value.strip_suffix(')'))
882        .is_some_and(|expression| xml_space_trim(expression) == "/")
883}
884
885pub(crate) fn map_xml_base_resolution_error(error: XmlBaseResolutionError) -> TransformError {
886    match error {
887        XmlBaseResolutionError::Components { maximum, actual } => transform_resource_limit(
888            crate::policy::resource_name::XML_BASE_COMPONENTS,
889            maximum,
890            actual,
891        ),
892        XmlBaseResolutionError::Bytes { maximum, actual } => transform_resource_limit(
893            crate::policy::resource_name::XML_BASE_RESOLUTION_BYTES,
894            maximum,
895            actual,
896        ),
897    }
898}
899
900/// Parse `xpointer(id('value'))` or `xpointer(id("value"))` and return the ID value.
901/// Returns `None` if the fragment doesn't match this pattern.
902pub(crate) fn parse_xpointer_id_fragment(fragment: &str) -> Option<&str> {
903    // XPath 1.0 section 3.7 [29]: a Literal cannot contain its delimiter.
904    // Whitespace may separate tokens, but must not split the function name.
905    // https://www.w3.org/TR/1999/REC-xpath-19991116/#exprlex
906    let expression = fragment.strip_prefix("xpointer(")?.strip_suffix(')')?;
907    let argument = xml_space_trim(expression)
908        .strip_prefix("id")?
909        .trim_start_matches(is_xml_space)
910        .strip_prefix('(')?;
911    let literal = xml_space_trim(argument.strip_suffix(')')?);
912    let quote = literal.as_bytes().first().copied()?;
913    if !matches!(quote, b'\'' | b'"')
914        || literal.len() < 2
915        || literal.as_bytes().last() != Some(&quote)
916    {
917        return None;
918    }
919    let value = &literal[1..literal.len() - 1];
920    if value.as_bytes().contains(&quote) {
921        return None;
922    }
923    Some(value)
924}
925
926fn is_xml_space(character: char) -> bool {
927    matches!(character, ' ' | '\t' | '\r' | '\n')
928}
929
930fn xml_space_trim(value: &str) -> &str {
931    value.trim_matches(is_xml_space)
932}
933
934#[cfg(test)]
935#[allow(clippy::unwrap_used)]
936mod tests {
937    use super::super::types::NodeSet;
938    use super::*;
939
940    #[test]
941    fn escaped_fragments_preserve_target_and_comment_semantics() {
942        // Framework URI/caret escaping must identify the same node; only the
943        // explicit XPointer form retains comments (XMLDSig 4.4.3.3).
944        let document =
945            Document::parse(r#"<root><item ID="café"><!--kept--></item><item ID="a(b)"/></root>"#)
946                .unwrap();
947        let resolver = UriReferenceResolver::new(&document);
948        let plain = resolver
949            .dereference("#caf%C3%A9")
950            .unwrap()
951            .into_node_set()
952            .unwrap();
953        let pointer = resolver
954            .dereference("#xpointer(id('caf%C3%A9'))")
955            .unwrap()
956            .into_node_set()
957            .unwrap();
958        let comment = document
959            .descendants()
960            .find(|node| node.is_comment())
961            .unwrap();
962        assert!(!plain.contains(comment));
963        assert!(pointer.contains(comment));
964        assert!(resolver.dereference("#xpointer(id('a^(b^)'))").is_ok());
965        assert!(resolver.dereference("#xpointer( / )").is_ok());
966        for uri in [
967            "#caf%",
968            "#caf%FF",
969            "#xpointer(id('a^x'))",
970            "#xpointer(id('a(b'))",
971            "#xpointer(id('a') or 'b')",
972        ] {
973            assert!(resolver.dereference(uri).is_err(), "{uri}");
974        }
975    }
976
977    #[test]
978    fn fragment_decoding_reserves_workspace_before_allocation() {
979        // A reference cannot bypass the cumulative projection budget through
980        // its URI-decoding workspace, while a borrowed fragment costs nothing.
981        let budget = NodeSetMaterializationBudget::with_limit(0);
982        assert!(
983            UriReferenceResolver::normalize_fragment(
984                "target",
985                Some(&budget),
986                SameDocumentIdSemantics::Specification
987            )
988            .is_ok()
989        );
990        assert!(matches!(
991            UriReferenceResolver::normalize_fragment(
992                "tar%67et",
993                Some(&budget),
994                SameDocumentIdSemantics::Specification
995            ),
996            Err(TransformError::Policy(_))
997        ));
998        assert!(matches!(
999            UriReferenceResolver::normalize_fragment(
1000                "xpointer(id('a^(b^)'))",
1001                Some(&budget),
1002                SameDocumentIdSemantics::Specification
1003            ),
1004            Err(TransformError::Policy(_))
1005        ));
1006    }
1007
1008    #[test]
1009    fn xpointer_id_requires_one_complete_literal() {
1010        // XPath 1.0 Literal excludes its own delimiter; accepting a compound
1011        // expression here incorrectly resolves a different element.
1012        assert_eq!(parse_xpointer_id_fragment("xpointer(id('a' or 'b'))"), None);
1013        assert_eq!(
1014            parse_xpointer_id_fragment("xpointer(id(\"a\" or \"b\"))"),
1015            None
1016        );
1017        assert_eq!(
1018            parse_xpointer_id_fragment("xpointer( id ( 'target' ) )"),
1019            Some("target")
1020        );
1021    }
1022
1023    #[test]
1024    fn empty_same_document_node_reference_selects_the_document_element() {
1025        // Element-valued consumers of the Reference URI contract interpret an
1026        // empty URI as the root element of the current XML document.
1027        let document = Document::parse("<root><child/></root>").unwrap();
1028        let resolver = UriReferenceResolver::new(&document);
1029
1030        assert_eq!(
1031            resolver
1032                .node_for_same_document_reference("")
1033                .unwrap()
1034                .unwrap(),
1035            document.root_element()
1036        );
1037    }
1038
1039    #[test]
1040    fn empty_uri_returns_whole_document() {
1041        let xml = "<root><child>text</child></root>";
1042        let doc = Document::parse(xml).unwrap();
1043        let resolver = UriReferenceResolver::new(&doc);
1044
1045        let data = resolver.dereference("").unwrap();
1046        let node_set = data.into_node_set().unwrap();
1047
1048        // Whole document: root and child should be in the set
1049        let root = doc.root_element();
1050        assert!(node_set.contains(root));
1051        let child = root.first_child().unwrap();
1052        assert!(node_set.contains(child));
1053    }
1054
1055    #[test]
1056    fn empty_uri_excludes_comments() {
1057        let xml = "<root><!-- comment --><child/></root>";
1058        let doc = Document::parse(xml).unwrap();
1059        let resolver = UriReferenceResolver::new(&doc);
1060
1061        let data = resolver.dereference("").unwrap();
1062        let node_set = data.into_node_set().unwrap();
1063
1064        // Comment should be excluded
1065        for node in doc.descendants() {
1066            if node.is_comment() {
1067                assert!(
1068                    !node_set.contains(node),
1069                    "comment should be excluded for empty URI"
1070                );
1071            }
1072        }
1073        // Element should still be included
1074        assert!(node_set.contains(doc.root_element()));
1075    }
1076
1077    #[test]
1078    fn fragment_uri_resolves_by_id_attr() {
1079        let xml = r#"<root><item ID="abc">content</item><item ID="def">other</item></root>"#;
1080        let doc = Document::parse(xml).unwrap();
1081        let resolver = UriReferenceResolver::new(&doc);
1082
1083        let data = resolver.dereference("#abc").unwrap();
1084        let node_set = data.into_node_set().unwrap();
1085
1086        // The element with ID="abc" and its children should be in the set
1087        let abc_elem = doc
1088            .descendants()
1089            .find(|n| n.attribute("ID") == Some("abc"))
1090            .unwrap();
1091        assert!(node_set.contains(abc_elem));
1092
1093        // The text child "content" should also be in the set
1094        let text_child = abc_elem.first_child().unwrap();
1095        assert!(node_set.contains(text_child));
1096
1097        // The root element should NOT be in the set (subtree only)
1098        assert!(!node_set.contains(doc.root_element()));
1099
1100        // The element with ID="def" should NOT be in the set
1101        let def_elem = doc
1102            .descendants()
1103            .find(|n| n.attribute("ID") == Some("def"))
1104            .unwrap();
1105        assert!(!node_set.contains(def_elem));
1106    }
1107
1108    #[test]
1109    fn fragment_uri_resolves_lowercase_id() {
1110        let xml = r#"<root><item id="lower">text</item></root>"#;
1111        let doc = Document::parse(xml).unwrap();
1112        let resolver = UriReferenceResolver::new(&doc);
1113
1114        let data = resolver.dereference("#lower").unwrap();
1115        let node_set = data.into_node_set().unwrap();
1116
1117        let elem = doc
1118            .descendants()
1119            .find(|n| n.attribute("id") == Some("lower"))
1120            .unwrap();
1121        assert!(node_set.contains(elem));
1122    }
1123
1124    #[test]
1125    fn fragment_uri_resolves_mixed_case_id() {
1126        let xml = r#"<root><ds:Signature Id="sig1" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/></root>"#;
1127        let doc = Document::parse(xml).unwrap();
1128        let resolver = UriReferenceResolver::new(&doc);
1129
1130        assert!(resolver.has_id("sig1"));
1131        let data = resolver.dereference("#sig1").unwrap();
1132        assert!(data.into_node_set().is_ok());
1133    }
1134
1135    #[test]
1136    fn fragment_uri_not_found() {
1137        let xml = "<root><child>text</child></root>";
1138        let doc = Document::parse(xml).unwrap();
1139        let resolver = UriReferenceResolver::new(&doc);
1140
1141        let result = resolver.dereference("#nonexistent");
1142        assert!(result.is_err());
1143        match result.unwrap_err() {
1144            TransformError::ElementNotFound(id) => assert_eq!(id, "nonexistent"),
1145            other => panic!("expected ElementNotFound, got: {other:?}"),
1146        }
1147    }
1148
1149    #[test]
1150    fn unsupported_external_uri() {
1151        let xml = "<root/>";
1152        let doc = Document::parse(xml).unwrap();
1153        let resolver = UriReferenceResolver::new(&doc);
1154
1155        let result = resolver.dereference("http://example.com/doc.xml");
1156        assert!(result.is_err());
1157        match result.unwrap_err() {
1158            TransformError::UnsupportedUri(uri) => {
1159                assert_eq!(uri, "http://example.com/doc.xml")
1160            }
1161            other => panic!("expected UnsupportedUri, got: {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn unsupported_xpointer_expression() {
1167        // XPointer expressions other than xpointer(/) and xpointer(id(...))
1168        // should return UnsupportedUri, not fall through to ID lookup
1169        let xml = "<root/>";
1170        let doc = Document::parse(xml).unwrap();
1171        let resolver = UriReferenceResolver::new(&doc);
1172
1173        let result = resolver.dereference("#xpointer(foo())");
1174        assert!(result.is_err());
1175        match result.unwrap_err() {
1176            TransformError::UnsupportedUri(uri) => {
1177                assert_eq!(uri, "#xpointer(foo())")
1178            }
1179            other => panic!("expected UnsupportedUri, got: {other:?}"),
1180        }
1181
1182        // Generic XPointer with XPath should also be unsupported
1183        let result = resolver.dereference("#xpointer(//element)");
1184        assert!(result.is_err());
1185        assert!(matches!(
1186            result.unwrap_err(),
1187            TransformError::UnsupportedUri(_)
1188        ));
1189    }
1190
1191    #[test]
1192    fn empty_fragment_rejected() {
1193        // Bare "#" (empty fragment) is not a valid same-document reference
1194        let xml = "<root/>";
1195        let doc = Document::parse(xml).unwrap();
1196        let resolver = UriReferenceResolver::new(&doc);
1197
1198        let result = resolver.dereference("#");
1199        assert!(result.is_err());
1200        match result.unwrap_err() {
1201            TransformError::UnsupportedUri(uri) => assert_eq!(uri, "#"),
1202            other => panic!("expected UnsupportedUri, got: {other:?}"),
1203        }
1204    }
1205
1206    #[test]
1207    fn foreign_document_node_rejected() {
1208        // NodeSet.contains() must reject nodes from a different document
1209        let xml1 = "<root><child/></root>";
1210        let xml2 = "<other><item/></other>";
1211        let doc1 = Document::parse(xml1).unwrap();
1212        let doc2 = Document::parse(xml2).unwrap();
1213
1214        let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
1215
1216        // Node from doc2 should NOT be in doc1's node set
1217        let foreign_node = doc2.root_element();
1218        assert!(
1219            !node_set.contains(foreign_node),
1220            "foreign document node should be rejected"
1221        );
1222
1223        // Node from doc1 should be in the set
1224        let own_node = doc1.root_element();
1225        assert!(node_set.contains(own_node));
1226    }
1227
1228    #[test]
1229    fn custom_id_attr_name() {
1230        // roxmltree stores `wsu:Id` with local name "Id" — already in DEFAULT_ID_ATTRS.
1231        // Test with a truly custom attribute name instead.
1232        let xml = r#"<root><elem myid="custom1">data</elem></root>"#;
1233        let doc = Document::parse(xml).unwrap();
1234
1235        // Default resolver doesn't know about "myid"
1236        let resolver_default = UriReferenceResolver::new(&doc);
1237        assert!(!resolver_default.has_id("custom1"));
1238
1239        // Custom resolver with "myid" added
1240        let resolver_custom = UriReferenceResolver::with_id_attrs(&doc, &["myid"]);
1241        assert!(resolver_custom.has_id("custom1"));
1242
1243        let data = resolver_custom.dereference("#custom1").unwrap();
1244        assert!(data.into_node_set().is_ok());
1245    }
1246
1247    #[test]
1248    fn absolute_external_uri_uses_normalized_resource_identity() {
1249        // Caller maps are keyed by the resolved RFC 3986 identity, not by an
1250        // unnormalized spelling embedded in an untrusted Signature document.
1251        let xml = r#"<root xml:base="https://base.example/ignored/">
1252            <reference URI="https://example.test/a/../data.bin"/>
1253        </root>"#;
1254        let doc = Document::parse(xml).unwrap();
1255        let reference = doc
1256            .descendants()
1257            .find(|node| node.has_tag_name("reference"))
1258            .unwrap();
1259        let resources = HashMap::from([(
1260            "https://example.test/data.bin".to_owned(),
1261            b"payload".to_vec(),
1262        )]);
1263        let budget = NodeSetMaterializationBudget::default();
1264        let xml_base_budget = XmlBaseResolutionBudget::default();
1265        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1266
1267        let data = resolver
1268            .dereference_from_with_budget(
1269                reference.attribute("URI").unwrap(),
1270                reference,
1271                &budget,
1272                &xml_base_budget,
1273            )
1274            .unwrap();
1275
1276        assert_eq!(data.into_binary().unwrap(), b"payload");
1277    }
1278
1279    #[test]
1280    fn absolute_external_uri_does_not_consume_xml_base_components() {
1281        // A scheme-bearing reference supplies its own base and must remain
1282        // resolvable even when inherited XML Base components are disallowed.
1283        let xml = r#"<root xml:base="ignored/"><reference URI="https://example.test/a/../data.bin"/></root>"#;
1284        let doc = Document::parse(xml).unwrap();
1285        let reference = doc
1286            .descendants()
1287            .find(|node| node.has_tag_name("reference"))
1288            .unwrap();
1289        let resources = HashMap::from([(
1290            "https://example.test/data.bin".to_owned(),
1291            b"payload".to_vec(),
1292        )]);
1293        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1294
1295        let data = resolver
1296            .dereference_from_with_budget(
1297                reference.attribute("URI").unwrap(),
1298                reference,
1299                &NodeSetMaterializationBudget::default(),
1300                &XmlBaseResolutionBudget::with_limits(0, 1_024),
1301            )
1302            .expect("absolute references must bypass inherited XML Base traversal");
1303
1304        assert_eq!(data.into_binary().unwrap(), b"payload");
1305    }
1306
1307    #[test]
1308    fn external_uri_without_xml_base_uses_normalized_resource_identity() {
1309        // RFC 3986 normalization defines the caller map key even when the
1310        // document does not provide an explicit XML Base ancestor.
1311        let xml = r#"<root><reference URI="https://example.test/a/../data.bin"/></root>"#;
1312        let doc = Document::parse(xml).unwrap();
1313        let reference = doc
1314            .descendants()
1315            .find(|node| node.has_tag_name("reference"))
1316            .unwrap();
1317        let resources = HashMap::from([(
1318            "https://example.test/data.bin".to_owned(),
1319            b"payload".to_vec(),
1320        )]);
1321        let budget = NodeSetMaterializationBudget::default();
1322        let xml_base_budget = XmlBaseResolutionBudget::default();
1323        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1324
1325        let data = resolver
1326            .dereference_from_with_budget(
1327                reference.attribute("URI").unwrap(),
1328                reference,
1329                &budget,
1330                &xml_base_budget,
1331            )
1332            .expect("the normalized resource key must resolve without xml:base");
1333
1334        assert_eq!(data.into_binary().unwrap(), b"payload");
1335    }
1336
1337    #[test]
1338    fn pathless_relative_xml_base_preserves_relative_resource_identity() {
1339        // Query-only xml:base values do not turn a relative URI into an
1340        // absolute-path reference when resolving caller-owned resources.
1341        let xml = r#"<root xml:base="?old">
1342            <reference URI="data.bin"/>
1343        </root>"#;
1344        let doc = Document::parse(xml).unwrap();
1345        let reference = doc
1346            .descendants()
1347            .find(|node| node.has_tag_name("reference"))
1348            .unwrap();
1349        let resources = HashMap::from([("data.bin".to_owned(), b"payload".to_vec())]);
1350        let budget = NodeSetMaterializationBudget::default();
1351        let xml_base_budget = XmlBaseResolutionBudget::default();
1352        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1353
1354        let data = resolver
1355            .dereference_from_with_budget(
1356                reference.attribute("URI").unwrap(),
1357                reference,
1358                &budget,
1359                &xml_base_budget,
1360            )
1361            .unwrap();
1362
1363        assert_eq!(data.into_binary().unwrap(), b"payload");
1364    }
1365
1366    #[test]
1367    fn relative_xml_base_normalizes_absolute_external_path() {
1368        // An absolute-path reference replaces a relative base path, but RFC
1369        // 3986 dot-segment removal still defines the caller resource identity.
1370        let xml = r#"<root xml:base="a/b">
1371            <reference URI="/x/../data.bin"/>
1372        </root>"#;
1373        let doc = Document::parse(xml).unwrap();
1374        let reference = doc
1375            .descendants()
1376            .find(|node| node.has_tag_name("reference"))
1377            .unwrap();
1378        let resources = HashMap::from([("/data.bin".to_owned(), b"payload".to_vec())]);
1379        let budget = NodeSetMaterializationBudget::default();
1380        let xml_base_budget = XmlBaseResolutionBudget::default();
1381        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1382
1383        let data = resolver
1384            .dereference_from_with_budget(
1385                reference.attribute("URI").unwrap(),
1386                reference,
1387                &budget,
1388                &xml_base_budget,
1389            )
1390            .unwrap();
1391
1392        assert_eq!(data.into_binary().unwrap(), b"payload");
1393    }
1394
1395    #[test]
1396    fn network_path_xml_base_preserves_external_resource_authority() {
1397        // A schemeless authority remains part of the resolved caller-owned
1398        // resource identity when an absolute-path URI replaces the base path.
1399        let xml = r#"<root xml:base="//cdn.example/a/b/">
1400            <reference URI="/x/../data.bin"/>
1401        </root>"#;
1402        let doc = Document::parse(xml).unwrap();
1403        let reference = doc
1404            .descendants()
1405            .find(|node| node.has_tag_name("reference"))
1406            .unwrap();
1407        let resources = HashMap::from([("//cdn.example/data.bin".to_owned(), b"payload".to_vec())]);
1408        let budget = NodeSetMaterializationBudget::default();
1409        let xml_base_budget = XmlBaseResolutionBudget::default();
1410        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1411
1412        let data = resolver
1413            .dereference_from_with_budget(
1414                reference.attribute("URI").unwrap(),
1415                reference,
1416                &budget,
1417                &xml_base_budget,
1418            )
1419            .unwrap();
1420
1421        assert_eq!(data.into_binary().unwrap(), b"payload");
1422    }
1423
1424    #[test]
1425    fn unicode_external_uri_resolves_without_panicking() {
1426        // Untrusted XML may start a relative URI with a multibyte scalar; the
1427        // resolver must produce its UTF-8 resource identity without panicking.
1428        let xml = r#"<root xml:base="https://example.test/base/">
1429            <reference URI="é?x"/>
1430        </root>"#;
1431        let doc = Document::parse(xml).unwrap();
1432        let reference = doc
1433            .descendants()
1434            .find(|node| node.has_tag_name("reference"))
1435            .unwrap();
1436        let resources = HashMap::from([(
1437            "https://example.test/base/é?x".to_owned(),
1438            b"payload".to_vec(),
1439        )]);
1440        let budget = NodeSetMaterializationBudget::default();
1441        let xml_base_budget = XmlBaseResolutionBudget::default();
1442        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1443
1444        let data = resolver
1445            .dereference_from_with_budget(
1446                reference.attribute("URI").unwrap(),
1447                reference,
1448                &budget,
1449                &xml_base_budget,
1450            )
1451            .unwrap();
1452
1453        assert_eq!(data.into_binary().unwrap(), b"payload");
1454    }
1455
1456    #[test]
1457    fn absolute_rootless_external_uri_discards_leading_parent_segment() {
1458        let xml = r#"<root xml:base="https://example.test/base/">
1459            <reference URI="urn:../payload"/>
1460        </root>"#;
1461        let doc = Document::parse(xml).unwrap();
1462        let reference = doc
1463            .descendants()
1464            .find(|node| node.has_tag_name("reference"))
1465            .unwrap();
1466        let resources = HashMap::from([("urn:payload".to_owned(), b"payload".to_vec())]);
1467        let budget = NodeSetMaterializationBudget::default();
1468        let xml_base_budget = XmlBaseResolutionBudget::default();
1469        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1470
1471        let data = resolver
1472            .dereference_from_with_budget(
1473                reference.attribute("URI").unwrap(),
1474                reference,
1475                &budget,
1476                &xml_base_budget,
1477            )
1478            .unwrap();
1479
1480        assert_eq!(data.into_binary().unwrap(), b"payload");
1481    }
1482
1483    #[test]
1484    fn namespaced_id_attr_found_by_local_name() {
1485        // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS
1486        let xml =
1487            r#"<root><elem wsu:Id="ts1" xmlns:wsu="http://example.com/wsu">data</elem></root>"#;
1488        let doc = Document::parse(xml).unwrap();
1489
1490        let resolver = UriReferenceResolver::new(&doc);
1491        assert!(resolver.has_id("ts1"));
1492    }
1493
1494    #[test]
1495    fn id_count_reports_unique_ids() {
1496        let xml = r#"<root ID="r1"><a ID="a1"/><b Id="b1"/><c id="c1"/></root>"#;
1497        let doc = Document::parse(xml).unwrap();
1498        let resolver = UriReferenceResolver::new(&doc);
1499
1500        // 4 elements with ID-like attributes
1501        assert_eq!(resolver.id_count(), 4);
1502    }
1503
1504    #[test]
1505    fn duplicate_ids_are_rejected() {
1506        // Duplicate IDs are removed from the index to prevent signature-wrapping
1507        // attacks — lookups for ambiguous IDs fail instead of picking arbitrarily.
1508        let xml = r#"<root><a ID="dup">first</a><b ID="dup">second</b></root>"#;
1509        let doc = Document::parse(xml).unwrap();
1510        let resolver = UriReferenceResolver::new(&doc);
1511
1512        // "dup" appears twice → removed from index
1513        assert!(!resolver.has_id("dup"));
1514        let result = resolver.dereference("#dup");
1515        assert!(result.is_err());
1516        assert!(matches!(
1517            result.unwrap_err(),
1518            TransformError::ElementNotFound(_)
1519        ));
1520    }
1521
1522    #[test]
1523    fn triple_duplicate_ids_stay_rejected() {
1524        // Verify that 3+ occurrences don't re-insert (the HashSet tracks
1525        // permanently removed IDs so Entry::Vacant after remove doesn't re-add)
1526        let xml = r#"<root><a ID="dup">1</a><b ID="dup">2</b><c ID="dup">3</c></root>"#;
1527        let doc = Document::parse(xml).unwrap();
1528        let resolver = UriReferenceResolver::new(&doc);
1529
1530        assert!(!resolver.has_id("dup"));
1531        assert!(resolver.dereference("#dup").is_err());
1532    }
1533
1534    #[test]
1535    fn node_set_exclude_subtree() {
1536        let xml = r#"<root><keep>yes</keep><remove><deep>no</deep></remove></root>"#;
1537        let doc = Document::parse(xml).unwrap();
1538        let resolver = UriReferenceResolver::new(&doc);
1539
1540        let data = resolver.dereference("").unwrap();
1541        let mut node_set = data.into_node_set().unwrap();
1542
1543        // Find and exclude the <remove> subtree
1544        let remove_elem = doc
1545            .descendants()
1546            .find(|n| n.is_element() && n.has_tag_name("remove"))
1547            .unwrap();
1548        node_set.exclude_subtree(remove_elem);
1549
1550        // <keep> should still be in the set
1551        let keep_elem = doc
1552            .descendants()
1553            .find(|n| n.is_element() && n.has_tag_name("keep"))
1554            .unwrap();
1555        assert!(node_set.contains(keep_elem));
1556
1557        // <remove> and its children should be excluded
1558        assert!(!node_set.contains(remove_elem));
1559        let deep_elem = doc
1560            .descendants()
1561            .find(|n| n.is_element() && n.has_tag_name("deep"))
1562            .unwrap();
1563        assert!(!node_set.contains(deep_elem));
1564    }
1565
1566    #[test]
1567    fn bare_name_subtree_excludes_comments() {
1568        // XMLDSig's bare-name same-document shortcut removes comment nodes.
1569        let xml = r#"<root><item ID="x"><!-- comment --><child/></item></root>"#;
1570        let doc = Document::parse(xml).unwrap();
1571        let resolver = UriReferenceResolver::new(&doc);
1572
1573        let data = resolver.dereference("#x").unwrap();
1574        let node_set = data.into_node_set().unwrap();
1575
1576        for node in doc.descendants() {
1577            if node.is_comment() {
1578                assert!(
1579                    !node_set.contains(node),
1580                    "comment must be excluded from #id"
1581                );
1582            }
1583        }
1584    }
1585
1586    #[test]
1587    fn xpointer_root_returns_whole_document_with_comments() {
1588        let xml = "<root><!-- comment --><child/></root>";
1589        let doc = Document::parse(xml).unwrap();
1590        let resolver = UriReferenceResolver::new(&doc);
1591
1592        let data = resolver.dereference("#xpointer(/)").unwrap();
1593        let node_set = data.into_node_set().unwrap();
1594
1595        // Unlike empty URI, xpointer(/) includes comments
1596        for node in doc.descendants() {
1597            if node.is_comment() {
1598                assert!(
1599                    node_set.contains(node),
1600                    "comment should be included for #xpointer(/)"
1601                );
1602            }
1603        }
1604        assert!(node_set.contains(doc.root_element()));
1605    }
1606
1607    #[test]
1608    fn xpointer_id_single_quotes() {
1609        // XPointer ID dereference retains comments, unlike bare-name fragments.
1610        let xml = r#"<root><item ID="abc"><!-- retained -->content</item></root>"#;
1611        let doc = Document::parse(xml).unwrap();
1612        let resolver = UriReferenceResolver::new(&doc);
1613
1614        let data = resolver.dereference("#xpointer(id('abc'))").unwrap();
1615        let node_set = data.into_node_set().unwrap();
1616
1617        let elem = doc
1618            .descendants()
1619            .find(|n| n.attribute("ID") == Some("abc"))
1620            .unwrap();
1621        assert!(node_set.contains(elem));
1622        assert!(
1623            elem.children()
1624                .any(|node| node.is_comment() && node_set.contains(node))
1625        );
1626    }
1627
1628    #[test]
1629    fn xpointer_id_double_quotes() {
1630        let xml = r#"<root><item ID="xyz">content</item></root>"#;
1631        let doc = Document::parse(xml).unwrap();
1632        let resolver = UriReferenceResolver::new(&doc);
1633
1634        let data = resolver.dereference(r#"#xpointer(id("xyz"))"#).unwrap();
1635        let node_set = data.into_node_set().unwrap();
1636
1637        let elem = doc
1638            .descendants()
1639            .find(|n| n.attribute("ID") == Some("xyz"))
1640            .unwrap();
1641        assert!(node_set.contains(elem));
1642    }
1643
1644    #[test]
1645    fn xpointer_id_not_found() {
1646        let xml = "<root/>";
1647        let doc = Document::parse(xml).unwrap();
1648        let resolver = UriReferenceResolver::new(&doc);
1649
1650        let result = resolver.dereference("#xpointer(id('missing'))");
1651        assert!(result.is_err());
1652        match result.unwrap_err() {
1653            TransformError::ElementNotFound(id) => assert_eq!(id, "missing"),
1654            other => panic!("expected ElementNotFound, got: {other:?}"),
1655        }
1656    }
1657
1658    #[test]
1659    fn xpointer_id_empty_value_rejected() {
1660        // xpointer(id('')) parses to empty string — reject as UnsupportedUri
1661        let xml = "<root/>";
1662        let doc = Document::parse(xml).unwrap();
1663        let resolver = UriReferenceResolver::new(&doc);
1664
1665        let result = resolver.dereference("#xpointer(id(''))");
1666        assert!(result.is_err());
1667        assert!(matches!(
1668            result.unwrap_err(),
1669            TransformError::UnsupportedUri(_)
1670        ));
1671    }
1672
1673    #[test]
1674    fn bare_fragment_rejects_non_ncname_without_visa3d_compatibility() {
1675        // XMLDSig bare-name fragments use an XML Name. Numeric identifiers are
1676        // accepted by libxmlsec1 only through its explicit Visa3D compatibility
1677        // mode and must not leak into the standards-default resolver.
1678        let xml = r#"<root><item ID="12345">content</item></root>"#;
1679        let doc = Document::parse(xml).unwrap();
1680        let resolver = UriReferenceResolver::new(&doc);
1681
1682        assert!(matches!(
1683            resolver.dereference("#12345"),
1684            Err(TransformError::UnsupportedUri(uri)) if uri == "#12345"
1685        ));
1686    }
1687
1688    #[test]
1689    fn visa3d_compatibility_resolves_non_ncname_id_directly() {
1690        // The compatibility mode is deliberately narrow: it changes only the
1691        // bare-fragment lookup grammar and retains duplicate-safe ID indexing.
1692        let xml = r#"<root><item ID="12345">content</item></root>"#;
1693        let doc = Document::parse(xml).unwrap();
1694        let resolver = UriReferenceResolver::new(&doc)
1695            .with_same_document_id_semantics(SameDocumentIdSemantics::XmlSecVisa3d);
1696
1697        assert!(resolver.dereference("#12345").is_ok());
1698    }
1699
1700    #[test]
1701    fn xmlsec_barename_compatibility_matches_donor_literal_and_comment_semantics() {
1702        // The donor internally wraps a barename in XPointer to accept numeric
1703        // IDs, but still selects TreeWithoutComments; an apostrophe cannot be
1704        // represented in the wrapper's single-quoted expression.
1705        let xml = r#"<root><item ID="12345"><!-- excluded -->numeric</item><item ID="visa'3d">quoted</item></root>"#;
1706        let doc = Document::parse(xml).unwrap();
1707        let resolver = UriReferenceResolver::new(&doc)
1708            .with_same_document_id_semantics(SameDocumentIdSemantics::XmlSecBarename);
1709
1710        let nodes = resolver
1711            .dereference("#12345")
1712            .unwrap()
1713            .into_node_set()
1714            .unwrap();
1715        assert!(
1716            doc.descendants()
1717                .filter(|node| node.is_comment())
1718                .all(|node| !nodes.contains(node))
1719        );
1720        assert!(matches!(
1721            resolver.dereference("#visa'3d"),
1722            Err(TransformError::UnsupportedUri(uri)) if uri == "#visa'3d"
1723        ));
1724    }
1725
1726    #[test]
1727    fn explicit_xpointer_decoding_is_independent_of_barename_mode() {
1728        // Compatibility modes change bare IDs, not explicit XPointer escaping.
1729        let document = Document::parse("<root><item ID='café'/></root>").unwrap();
1730        for semantics in [
1731            SameDocumentIdSemantics::Specification,
1732            SameDocumentIdSemantics::XmlSecBarename,
1733            SameDocumentIdSemantics::XmlSecVisa3d,
1734        ] {
1735            let resolver =
1736                UriReferenceResolver::new(&document).with_same_document_id_semantics(semantics);
1737            for uri in [
1738                "#xpointer(id('caf%C3%A9'))",
1739                "#%78pointer%28id('caf%C3%A9')%29",
1740            ] {
1741                assert!(resolver.dereference(uri).is_ok(), "{semantics:?}: {uri}");
1742            }
1743        }
1744    }
1745
1746    #[test]
1747    fn parse_xpointer_id_variants() {
1748        // Valid forms
1749        assert_eq!(
1750            super::parse_xpointer_id_fragment("xpointer(id('foo'))"),
1751            Some("foo")
1752        );
1753        assert_eq!(
1754            super::parse_xpointer_id_fragment(r#"xpointer(id("bar"))"#),
1755            Some("bar")
1756        );
1757
1758        // Invalid forms
1759        assert_eq!(super::parse_xpointer_id_fragment("xpointer(/)"), None);
1760        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id(foo))"), None); // no quotes
1761        assert_eq!(super::parse_xpointer_id_fragment("not-xpointer"), None);
1762        assert_eq!(super::parse_xpointer_id_fragment(""), None);
1763
1764        // Malformed: single quote char — must not panic (was slicing bug)
1765        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id('))"), None);
1766        assert_eq!(
1767            super::parse_xpointer_id_fragment(r#"xpointer(id("))"#),
1768            None
1769        );
1770    }
1771
1772    #[test]
1773    fn same_document_reference_nodes_reject_non_id_fragments() {
1774        let document = Document::parse(r#"<root><item ID="target"/></root>"#).unwrap();
1775        let resolver = UriReferenceResolver::new(&document);
1776        for uri in [
1777            "#target",
1778            "#xpointer(id('target'))",
1779            r#"#xpointer(id("target"))"#,
1780        ] {
1781            assert!(
1782                resolver
1783                    .node_id_for_same_document_reference(uri)
1784                    .unwrap()
1785                    .is_some()
1786            );
1787        }
1788        for uri in [
1789            "",
1790            "target",
1791            "#",
1792            "#xpointer(/)",
1793            "#xpointer(id(''))",
1794            "#xpointer(id(target))",
1795        ] {
1796            assert!(
1797                resolver.node_id_for_same_document_reference(uri).is_err(),
1798                "{uri}"
1799            );
1800        }
1801    }
1802
1803    #[test]
1804    fn same_element_multiple_id_attrs_not_duplicate() {
1805        // An element with both ID="x" and Id="x" should NOT be treated as
1806        // duplicate — it's the same element exposing the same value via
1807        // different scanned attribute names.
1808        let xml = r#"<root><item ID="x" Id="x">data</item></root>"#;
1809        let doc = Document::parse(xml).unwrap();
1810        let resolver = UriReferenceResolver::new(&doc);
1811
1812        assert!(resolver.has_id("x"));
1813        assert!(resolver.dereference("#x").is_ok());
1814    }
1815
1816    #[test]
1817    fn saml_style_document() {
1818        // Realistic SAML-like structure
1819        let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
1820                                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
1821                                     ID="_resp1">
1822            <saml:Assertion ID="_assert1">
1823                <saml:Subject>user@example.com</saml:Subject>
1824            </saml:Assertion>
1825            <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="sig1">
1826                <ds:SignedInfo/>
1827            </ds:Signature>
1828        </samlp:Response>"#;
1829
1830        let doc = Document::parse(xml).unwrap();
1831        let resolver = UriReferenceResolver::new(&doc);
1832
1833        // Should find all three IDs
1834        assert!(resolver.has_id("_resp1"));
1835        assert!(resolver.has_id("_assert1"));
1836        assert!(resolver.has_id("sig1"));
1837        assert_eq!(resolver.id_count(), 3);
1838
1839        // Dereference the assertion
1840        let data = resolver.dereference("#_assert1").unwrap();
1841        let node_set = data.into_node_set().unwrap();
1842
1843        // Assertion element should be in the set
1844        let assertion = doc
1845            .descendants()
1846            .find(|n| n.attribute("ID") == Some("_assert1"))
1847            .unwrap();
1848        assert!(node_set.contains(assertion));
1849
1850        // Subject (child of assertion) should be in the set
1851        let subject = assertion
1852            .children()
1853            .find(|n| n.is_element() && n.has_tag_name("Subject"))
1854            .unwrap();
1855        assert!(node_set.contains(subject));
1856
1857        // Response (parent) should NOT be in the set
1858        assert!(!node_set.contains(doc.root_element()));
1859    }
1860}