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    /// Build a resolver with default ID attribute names (`ID`, `Id`, `id`).
266    pub fn new(doc: &'a Document<'a>) -> Self {
267        Self::with_id_attrs(doc, &[])
268    }
269
270    /// Build a resolver scanning additional ID attribute names beyond the defaults.
271    ///
272    /// The defaults (`ID`, `Id`, `id`) are always included; `extra_attrs`
273    /// adds to them (does not replace). Pass an empty slice to use only defaults.
274    ///
275    /// Attribute names use the semantic DOM's local-name view, independent of
276    /// the selected XML parser backend. For example, `wsu:Id="..."` is
277    /// registered as `"Id"`, not `"wsu:Id"` or `"{namespace}Id"`.
278    pub fn with_id_attrs(doc: &'a Document<'a>, extra_attrs: &[&str]) -> Self {
279        let id_registrations = extra_attrs
280            .iter()
281            .map(|name| crate::IdAttributeRegistration::global(*name))
282            .collect::<Vec<_>>();
283        Self {
284            doc,
285            view: None,
286            resource_identity: None,
287            id_index: ResolverIdIndex::Borrowed(XmlIdIndex::with_registrations(
288                doc,
289                &id_registrations,
290            )),
291            id_registrations,
292            external_resources: None,
293            external_resource_budget: Rc::new(ExternalResourceBudget::default()),
294            same_document_id_semantics: SameDocumentIdSemantics::Specification,
295        }
296    }
297
298    /// Build a resolver with typed global and element-scoped ID registrations.
299    pub fn with_id_registrations(
300        doc: &'a Document<'a>,
301        registrations: &[crate::IdAttributeRegistration],
302    ) -> Self {
303        let id_registrations = registrations.to_vec();
304        Self {
305            doc,
306            view: None,
307            resource_identity: None,
308            id_index: ResolverIdIndex::Borrowed(XmlIdIndex::with_registrations(
309                doc,
310                &id_registrations,
311            )),
312            id_registrations,
313            external_resources: None,
314            external_resource_budget: Rc::new(ExternalResourceBudget::default()),
315            same_document_id_semantics: SameDocumentIdSemantics::Specification,
316        }
317    }
318
319    pub(crate) fn with_document_view(
320        view: crate::DocumentView<'a>,
321        registrations: &[crate::IdAttributeRegistration],
322    ) -> Self {
323        let id_registrations = registrations.to_vec();
324        Self {
325            doc: view.document(),
326            view: Some(view),
327            resource_identity: None,
328            id_index: ResolverIdIndex::Retained(view.id_index(&id_registrations)),
329            id_registrations,
330            external_resources: None,
331            external_resource_budget: Rc::new(ExternalResourceBudget::default()),
332            same_document_id_semantics: SameDocumentIdSemantics::Specification,
333        }
334    }
335
336    pub(crate) fn with_same_document_id_semantics(
337        mut self,
338        semantics: SameDocumentIdSemantics,
339    ) -> Self {
340        self.same_document_id_semantics = semantics;
341        self
342    }
343
344    /// Rebind document-local URI resolution while retaining caller-owned
345    /// external resources and their aggregate byte budget.
346    pub(crate) fn for_document_view<'b>(
347        &self,
348        view: crate::DocumentView<'b>,
349    ) -> UriReferenceResolver<'b>
350    where
351        'a: 'b,
352    {
353        UriReferenceResolver {
354            doc: view.document(),
355            view: Some(view),
356            resource_identity: None,
357            id_index: ResolverIdIndex::Retained(view.id_index(&self.id_registrations)),
358            id_registrations: self.id_registrations.clone(),
359            external_resources: self.external_resources,
360            external_resource_budget: Rc::clone(&self.external_resource_budget),
361            same_document_id_semantics: self.same_document_id_semantics,
362        }
363    }
364
365    /// Rebind document-local URI resolution to a stable external resource.
366    ///
367    /// The identity survives reparsing, so recursive reference traversal can
368    /// distinguish equal fragments in different resources without overlooking
369    /// a cycle that returns to the same resource.
370    pub(crate) fn for_external_document_view<'b>(
371        &self,
372        view: crate::DocumentView<'b>,
373        resource_identity: &str,
374    ) -> UriReferenceResolver<'b>
375    where
376        'a: 'b,
377    {
378        let mut resolver = self.for_document_view(view);
379        resolver.resource_identity = Some(resource_identity.to_owned());
380        resolver
381    }
382
383    pub(crate) fn traversal_document_identity(&self) -> TraversalDocumentIdentity {
384        if let Some(identity) = &self.resource_identity {
385            TraversalDocumentIdentity::External(identity.clone())
386        } else if let Some(view) = self.view {
387            TraversalDocumentIdentity::Owned(view.identity())
388        } else {
389            TraversalDocumentIdentity::Borrowed(self.doc as *const Document<'_> as usize)
390        }
391    }
392
393    /// Attach an explicit caller-owned external-resource map.
394    ///
395    /// No network or filesystem access is performed by this resolver. Keys are
396    /// RFC 3986 resolved URI identities: paths have dot segments removed while
397    /// query and fragment suffixes are retained.
398    pub fn with_external_resources(mut self, resources: &'a HashMap<String, Vec<u8>>) -> Self {
399        self.external_resources = Some(resources);
400        self
401    }
402
403    pub(crate) fn with_external_resource_limits(
404        mut self,
405        max_resource_bytes: usize,
406        max_total_bytes: usize,
407    ) -> Self {
408        self.external_resource_budget = Rc::new(ExternalResourceBudget::with_limits(
409            max_resource_bytes,
410            max_total_bytes,
411        ));
412        self
413    }
414
415    pub(crate) fn external_resource(&self, uri: &str) -> Result<Option<&'a [u8]>, TransformError> {
416        let Some(bytes) = self
417            .external_resources
418            .and_then(|resources| resources.get(uri))
419        else {
420            return Ok(None);
421        };
422        self.external_resource_budget.charge(bytes.len())?;
423        Ok(Some(bytes))
424    }
425
426    pub(crate) fn external_resource_identity(
427        &self,
428        uri: &str,
429    ) -> Option<crate::operation::OperationResourceIdentity> {
430        self.external_resources
431            .and_then(|resources| resources.get(uri))
432            .map(|bytes| crate::operation::OperationResourceIdentity::external(uri, bytes))
433    }
434
435    pub(crate) fn external_resource_set_identity(
436        &self,
437    ) -> crate::operation::OperationResourceIdentity {
438        use sha2::Digest;
439
440        let mut entries = self
441            .external_resources
442            .into_iter()
443            .flat_map(HashMap::iter)
444            .collect::<Vec<_>>();
445        entries.sort_unstable_by_key(|(uri, _)| *uri);
446        let mut hasher = sha2::Sha256::new();
447        for (uri, bytes) in entries {
448            hasher.update(uri.len().to_be_bytes());
449            hasher.update(uri.as_bytes());
450            hasher.update(bytes.len().to_be_bytes());
451            hasher.update(bytes);
452        }
453        crate::operation::OperationResourceIdentity::External {
454            uri: "caller-owned-external-resource-set".to_owned(),
455            fingerprint: hasher.finalize().into(),
456        }
457    }
458
459    /// Dereference a URI string to a [`TransformData`].
460    ///
461    /// # URI forms
462    ///
463    /// | URI | Result |
464    /// |-----|--------|
465    /// | `""` (empty) | Entire document, comments excluded |
466    /// | `"#foo"` | Subtree rooted at element with ID `foo`, comments excluded |
467    /// | `"#xpointer(/)"` | Entire document, comments included |
468    /// | `"#xpointer(id('foo'))"` | Subtree rooted at element with ID `foo`, comments included |
469    /// | external URI in caller map | A copy of the mapped bytes |
470    /// | other | `Err(UnsupportedUri)` |
471    pub fn dereference(&self, uri: &str) -> Result<TransformData<'a>, TransformError> {
472        self.dereference_with_optional_budget(uri, None)
473    }
474
475    pub(crate) fn dereference_with_budget(
476        &self,
477        uri: &str,
478        budget: &NodeSetMaterializationBudget,
479    ) -> Result<TransformData<'a>, TransformError> {
480        self.dereference_with_optional_budget(uri, Some(budget))
481    }
482
483    pub(crate) fn dereference_from_with_budget(
484        &self,
485        uri: &str,
486        origin: Node<'_, '_>,
487        budget: &NodeSetMaterializationBudget,
488        xml_base_budget: &XmlBaseResolutionBudget,
489    ) -> Result<TransformData<'a>, TransformError> {
490        // XMLDSig assigns special dereference semantics to lexical empty and
491        // fragment-only references. Only external references use XML Base.
492        if uri.is_empty() || uri.starts_with('#') {
493            return self.dereference_with_budget(uri, budget);
494        }
495        let resolved = resolve_uri_from_node_with_budget(origin, uri, xml_base_budget)
496            .map_err(map_xml_base_resolution_error)?;
497        self.dereference_with_budget(&resolved, budget)
498    }
499
500    fn dereference_with_optional_budget(
501        &self,
502        uri: &str,
503        budget: Option<&NodeSetMaterializationBudget>,
504    ) -> Result<TransformData<'a>, TransformError> {
505        if uri.is_empty() {
506            // Empty URI = entire document without comments
507            // XMLDSig §4.3.3.2: "the reference is to the document [...],
508            // and the comment nodes are not included"
509            let nodes = match self.view {
510                Some(view) => NodeSet::entire_document_without_comments_from_view(view, budget)?,
511                None => match budget {
512                    Some(budget) => {
513                        NodeSet::entire_document_without_comments_with_budget(self.doc, budget)?
514                    }
515                    None => NodeSet::entire_document_without_comments(self.doc)?,
516                },
517            };
518            Ok(TransformData::NodeSet(nodes))
519        } else if let Some(fragment) = uri.strip_prefix('#') {
520            // Note: we intentionally do NOT percent-decode the fragment.
521            // XMLDSig ID values are XML Name tokens (no spaces/special chars),
522            // and real-world SAML never uses percent-encoded fragments.
523            // xmlsec1 also passes fragments through without decoding.
524            self.dereference_fragment(fragment, budget)
525        } else {
526            self.external_resource(uri)?
527                .map(|bytes| TransformData::Binary(bytes.to_vec()))
528                .ok_or_else(|| TransformError::UnsupportedUri(uri.to_string()))
529        }
530    }
531
532    /// Resolve a URI fragment (the part after `#`).
533    ///
534    /// Handles:
535    /// - `xpointer(/)` → entire document (with comments, per XPointer spec)
536    /// - `xpointer(id('foo'))` → element by ID, retaining comments
537    /// - bare name `foo` → element by ID attribute
538    fn dereference_fragment(
539        &self,
540        fragment: &str,
541        budget: Option<&NodeSetMaterializationBudget>,
542    ) -> Result<TransformData<'a>, TransformError> {
543        if fragment.is_empty() {
544            // Bare "#" is not a valid same-document reference
545            return Err(TransformError::UnsupportedUri("#".to_string()));
546        }
547
548        if fragment == "xpointer(/)" {
549            // XPointer root: entire document WITH comments (unlike empty URI).
550            // Per XMLDSig §4.3.3.3: "the XPointer expression [...] includes
551            // comment nodes"
552            let nodes = match self.view {
553                Some(view) => NodeSet::entire_document_with_comments_from_view(view, budget)?,
554                None => match budget {
555                    Some(budget) => {
556                        NodeSet::entire_document_with_comments_with_budget(self.doc, budget)?
557                    }
558                    None => NodeSet::entire_document_with_comments(self.doc)?,
559                },
560            };
561            Ok(TransformData::NodeSet(nodes))
562        } else {
563            let (id, with_comments) = self.same_document_id_fragment(fragment)?;
564            self.resolve_id(id, budget, with_comments)
565        }
566    }
567
568    fn same_document_id_fragment<'uri>(
569        &self,
570        fragment: &'uri str,
571    ) -> Result<(&'uri str, bool), TransformError> {
572        if let Some(id) = parse_xpointer_id_fragment(fragment) {
573            // Explicit XPointer dereference retains comments, unlike every
574            // barename mode, including libxmlsec1's internal wrapper.
575            if id.is_empty() {
576                return Err(TransformError::UnsupportedUri(format!("#{fragment}")));
577            }
578            return Ok((id, true));
579        }
580        if fragment.starts_with("xpointer(") {
581            return Err(TransformError::UnsupportedUri(format!("#{fragment}")));
582        }
583        match self.same_document_id_semantics {
584            SameDocumentIdSemantics::Specification if !is_xml_ncname(fragment) => {
585                return Err(TransformError::UnsupportedUri(format!("#{fragment}")));
586            }
587            SameDocumentIdSemantics::XmlSecBarename if fragment.contains('\'') => {
588                return Err(TransformError::UnsupportedUri(format!("#{fragment}")));
589            }
590            SameDocumentIdSemantics::Specification
591            | SameDocumentIdSemantics::XmlSecBarename
592            | SameDocumentIdSemantics::XmlSecVisa3d => {}
593        }
594        Ok((fragment, false))
595    }
596
597    /// Look up an element by its ID attribute value and return a subtree node set.
598    fn resolve_id(
599        &self,
600        id: &str,
601        budget: Option<&NodeSetMaterializationBudget>,
602        with_comments: bool,
603    ) -> Result<TransformData<'a>, TransformError> {
604        match self.id_index.node(self.doc, id) {
605            Some(element) => {
606                let nodes = if let Some(view) = self.view {
607                    NodeSet::subtree_from_view(view, element, with_comments, budget)?
608                } else if with_comments {
609                    match budget {
610                        Some(budget) => NodeSet::subtree_with_budget(element, budget)?,
611                        None => NodeSet::subtree(element)?,
612                    }
613                } else {
614                    NodeSet::subtree_without_comments_with_budget(element, budget)?
615                };
616                Ok(TransformData::NodeSet(nodes))
617            }
618            None => Err(TransformError::ElementNotFound(id.to_string())),
619        }
620    }
621
622    /// Check if an ID is registered in the resolver's index.
623    pub fn has_id(&self, id: &str) -> bool {
624        self.id_index.contains(id)
625    }
626
627    /// Resolve an unambiguous XML ID to its element node.
628    ///
629    /// Returns `None` when the ID is absent or duplicated, matching fragment
630    /// dereferencing and operation start-node selection.
631    pub fn node_for_id(&self, id: &str) -> Option<Node<'a, 'a>> {
632        self.id_index.node(self.doc, id)
633    }
634
635    /// Resolve a same-document URI to an element under the configured grammar.
636    /// An empty URI selects the document element for element-valued consumers.
637    pub fn node_for_same_document_reference(
638        &self,
639        uri: &str,
640    ) -> Result<Option<Node<'a, 'a>>, TransformError> {
641        if uri.is_empty() {
642            return Ok(Some(self.doc.root_element()));
643        }
644        Ok(self
645            .node_id_for_same_document_reference(uri)?
646            .and_then(|id| self.doc.get_node(id)))
647    }
648
649    /// Resolve a same-document URI to a stable node identity under the
650    /// configured grammar, for secondary consumers such as Manifest trust.
651    pub(crate) fn node_id_for_same_document_reference(
652        &self,
653        uri: &str,
654    ) -> Result<Option<NodeId>, TransformError> {
655        let fragment = uri
656            .strip_prefix('#')
657            .ok_or_else(|| TransformError::UnsupportedUri(uri.to_owned()))?;
658        if fragment.is_empty() || fragment == "xpointer(/)" {
659            return Err(TransformError::UnsupportedUri(uri.to_owned()));
660        }
661        let (id, _) = self.same_document_id_fragment(fragment)?;
662        Ok(self.id_index.node_id(id))
663    }
664
665    pub(crate) fn node_for_node_id(&self, id: NodeId) -> Option<Node<'a, 'a>> {
666        self.doc.get_node(id)
667    }
668
669    /// Get the number of registered IDs.
670    pub fn id_count(&self) -> usize {
671        self.id_index.len()
672    }
673}
674
675fn map_xml_base_resolution_error(error: XmlBaseResolutionError) -> TransformError {
676    match error {
677        XmlBaseResolutionError::Components { maximum, actual } => transform_resource_limit(
678            crate::policy::resource_name::XML_BASE_COMPONENTS,
679            maximum,
680            actual,
681        ),
682        XmlBaseResolutionError::Bytes { maximum, actual } => transform_resource_limit(
683            crate::policy::resource_name::XML_BASE_RESOLUTION_BYTES,
684            maximum,
685            actual,
686        ),
687    }
688}
689
690/// Parse `xpointer(id('value'))` or `xpointer(id("value"))` and return the ID value.
691/// Returns `None` if the fragment doesn't match this pattern.
692pub(crate) fn parse_xpointer_id_fragment(fragment: &str) -> Option<&str> {
693    let inner = fragment.strip_prefix("xpointer(id(")?.strip_suffix("))")?;
694
695    // Strip single or double quotes using safe helpers to avoid panics
696    // on malformed input (e.g., `xpointer(id('))` where inner is `'`)
697    if let Some(stripped) = inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
698        Some(stripped)
699    } else if let Some(stripped) = inner.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
700        Some(stripped)
701    } else {
702        None
703    }
704}
705
706#[cfg(test)]
707#[allow(clippy::unwrap_used)]
708mod tests {
709    use super::super::types::NodeSet;
710    use super::*;
711
712    #[test]
713    fn empty_same_document_node_reference_selects_the_document_element() {
714        // Element-valued consumers of the Reference URI contract interpret an
715        // empty URI as the root element of the current XML document.
716        let document = Document::parse("<root><child/></root>").unwrap();
717        let resolver = UriReferenceResolver::new(&document);
718
719        assert_eq!(
720            resolver
721                .node_for_same_document_reference("")
722                .unwrap()
723                .unwrap(),
724            document.root_element()
725        );
726    }
727
728    #[test]
729    fn empty_uri_returns_whole_document() {
730        let xml = "<root><child>text</child></root>";
731        let doc = Document::parse(xml).unwrap();
732        let resolver = UriReferenceResolver::new(&doc);
733
734        let data = resolver.dereference("").unwrap();
735        let node_set = data.into_node_set().unwrap();
736
737        // Whole document: root and child should be in the set
738        let root = doc.root_element();
739        assert!(node_set.contains(root));
740        let child = root.first_child().unwrap();
741        assert!(node_set.contains(child));
742    }
743
744    #[test]
745    fn empty_uri_excludes_comments() {
746        let xml = "<root><!-- comment --><child/></root>";
747        let doc = Document::parse(xml).unwrap();
748        let resolver = UriReferenceResolver::new(&doc);
749
750        let data = resolver.dereference("").unwrap();
751        let node_set = data.into_node_set().unwrap();
752
753        // Comment should be excluded
754        for node in doc.descendants() {
755            if node.is_comment() {
756                assert!(
757                    !node_set.contains(node),
758                    "comment should be excluded for empty URI"
759                );
760            }
761        }
762        // Element should still be included
763        assert!(node_set.contains(doc.root_element()));
764    }
765
766    #[test]
767    fn fragment_uri_resolves_by_id_attr() {
768        let xml = r#"<root><item ID="abc">content</item><item ID="def">other</item></root>"#;
769        let doc = Document::parse(xml).unwrap();
770        let resolver = UriReferenceResolver::new(&doc);
771
772        let data = resolver.dereference("#abc").unwrap();
773        let node_set = data.into_node_set().unwrap();
774
775        // The element with ID="abc" and its children should be in the set
776        let abc_elem = doc
777            .descendants()
778            .find(|n| n.attribute("ID") == Some("abc"))
779            .unwrap();
780        assert!(node_set.contains(abc_elem));
781
782        // The text child "content" should also be in the set
783        let text_child = abc_elem.first_child().unwrap();
784        assert!(node_set.contains(text_child));
785
786        // The root element should NOT be in the set (subtree only)
787        assert!(!node_set.contains(doc.root_element()));
788
789        // The element with ID="def" should NOT be in the set
790        let def_elem = doc
791            .descendants()
792            .find(|n| n.attribute("ID") == Some("def"))
793            .unwrap();
794        assert!(!node_set.contains(def_elem));
795    }
796
797    #[test]
798    fn fragment_uri_resolves_lowercase_id() {
799        let xml = r#"<root><item id="lower">text</item></root>"#;
800        let doc = Document::parse(xml).unwrap();
801        let resolver = UriReferenceResolver::new(&doc);
802
803        let data = resolver.dereference("#lower").unwrap();
804        let node_set = data.into_node_set().unwrap();
805
806        let elem = doc
807            .descendants()
808            .find(|n| n.attribute("id") == Some("lower"))
809            .unwrap();
810        assert!(node_set.contains(elem));
811    }
812
813    #[test]
814    fn fragment_uri_resolves_mixed_case_id() {
815        let xml = r#"<root><ds:Signature Id="sig1" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/></root>"#;
816        let doc = Document::parse(xml).unwrap();
817        let resolver = UriReferenceResolver::new(&doc);
818
819        assert!(resolver.has_id("sig1"));
820        let data = resolver.dereference("#sig1").unwrap();
821        assert!(data.into_node_set().is_ok());
822    }
823
824    #[test]
825    fn fragment_uri_not_found() {
826        let xml = "<root><child>text</child></root>";
827        let doc = Document::parse(xml).unwrap();
828        let resolver = UriReferenceResolver::new(&doc);
829
830        let result = resolver.dereference("#nonexistent");
831        assert!(result.is_err());
832        match result.unwrap_err() {
833            TransformError::ElementNotFound(id) => assert_eq!(id, "nonexistent"),
834            other => panic!("expected ElementNotFound, got: {other:?}"),
835        }
836    }
837
838    #[test]
839    fn unsupported_external_uri() {
840        let xml = "<root/>";
841        let doc = Document::parse(xml).unwrap();
842        let resolver = UriReferenceResolver::new(&doc);
843
844        let result = resolver.dereference("http://example.com/doc.xml");
845        assert!(result.is_err());
846        match result.unwrap_err() {
847            TransformError::UnsupportedUri(uri) => {
848                assert_eq!(uri, "http://example.com/doc.xml")
849            }
850            other => panic!("expected UnsupportedUri, got: {other:?}"),
851        }
852    }
853
854    #[test]
855    fn unsupported_xpointer_expression() {
856        // XPointer expressions other than xpointer(/) and xpointer(id(...))
857        // should return UnsupportedUri, not fall through to ID lookup
858        let xml = "<root/>";
859        let doc = Document::parse(xml).unwrap();
860        let resolver = UriReferenceResolver::new(&doc);
861
862        let result = resolver.dereference("#xpointer(foo())");
863        assert!(result.is_err());
864        match result.unwrap_err() {
865            TransformError::UnsupportedUri(uri) => {
866                assert_eq!(uri, "#xpointer(foo())")
867            }
868            other => panic!("expected UnsupportedUri, got: {other:?}"),
869        }
870
871        // Generic XPointer with XPath should also be unsupported
872        let result = resolver.dereference("#xpointer(//element)");
873        assert!(result.is_err());
874        assert!(matches!(
875            result.unwrap_err(),
876            TransformError::UnsupportedUri(_)
877        ));
878    }
879
880    #[test]
881    fn empty_fragment_rejected() {
882        // Bare "#" (empty fragment) is not a valid same-document reference
883        let xml = "<root/>";
884        let doc = Document::parse(xml).unwrap();
885        let resolver = UriReferenceResolver::new(&doc);
886
887        let result = resolver.dereference("#");
888        assert!(result.is_err());
889        match result.unwrap_err() {
890            TransformError::UnsupportedUri(uri) => assert_eq!(uri, "#"),
891            other => panic!("expected UnsupportedUri, got: {other:?}"),
892        }
893    }
894
895    #[test]
896    fn foreign_document_node_rejected() {
897        // NodeSet.contains() must reject nodes from a different document
898        let xml1 = "<root><child/></root>";
899        let xml2 = "<other><item/></other>";
900        let doc1 = Document::parse(xml1).unwrap();
901        let doc2 = Document::parse(xml2).unwrap();
902
903        let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
904
905        // Node from doc2 should NOT be in doc1's node set
906        let foreign_node = doc2.root_element();
907        assert!(
908            !node_set.contains(foreign_node),
909            "foreign document node should be rejected"
910        );
911
912        // Node from doc1 should be in the set
913        let own_node = doc1.root_element();
914        assert!(node_set.contains(own_node));
915    }
916
917    #[test]
918    fn custom_id_attr_name() {
919        // roxmltree stores `wsu:Id` with local name "Id" — already in DEFAULT_ID_ATTRS.
920        // Test with a truly custom attribute name instead.
921        let xml = r#"<root><elem myid="custom1">data</elem></root>"#;
922        let doc = Document::parse(xml).unwrap();
923
924        // Default resolver doesn't know about "myid"
925        let resolver_default = UriReferenceResolver::new(&doc);
926        assert!(!resolver_default.has_id("custom1"));
927
928        // Custom resolver with "myid" added
929        let resolver_custom = UriReferenceResolver::with_id_attrs(&doc, &["myid"]);
930        assert!(resolver_custom.has_id("custom1"));
931
932        let data = resolver_custom.dereference("#custom1").unwrap();
933        assert!(data.into_node_set().is_ok());
934    }
935
936    #[test]
937    fn absolute_external_uri_uses_normalized_resource_identity() {
938        // Caller maps are keyed by the resolved RFC 3986 identity, not by an
939        // unnormalized spelling embedded in an untrusted Signature document.
940        let xml = r#"<root xml:base="https://base.example/ignored/">
941            <reference URI="https://example.test/a/../data.bin"/>
942        </root>"#;
943        let doc = Document::parse(xml).unwrap();
944        let reference = doc
945            .descendants()
946            .find(|node| node.has_tag_name("reference"))
947            .unwrap();
948        let resources = HashMap::from([(
949            "https://example.test/data.bin".to_owned(),
950            b"payload".to_vec(),
951        )]);
952        let budget = NodeSetMaterializationBudget::default();
953        let xml_base_budget = XmlBaseResolutionBudget::default();
954        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
955
956        let data = resolver
957            .dereference_from_with_budget(
958                reference.attribute("URI").unwrap(),
959                reference,
960                &budget,
961                &xml_base_budget,
962            )
963            .unwrap();
964
965        assert_eq!(data.into_binary().unwrap(), b"payload");
966    }
967
968    #[test]
969    fn absolute_external_uri_does_not_consume_xml_base_components() {
970        // A scheme-bearing reference supplies its own base and must remain
971        // resolvable even when inherited XML Base components are disallowed.
972        let xml = r#"<root xml:base="ignored/"><reference URI="https://example.test/a/../data.bin"/></root>"#;
973        let doc = Document::parse(xml).unwrap();
974        let reference = doc
975            .descendants()
976            .find(|node| node.has_tag_name("reference"))
977            .unwrap();
978        let resources = HashMap::from([(
979            "https://example.test/data.bin".to_owned(),
980            b"payload".to_vec(),
981        )]);
982        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
983
984        let data = resolver
985            .dereference_from_with_budget(
986                reference.attribute("URI").unwrap(),
987                reference,
988                &NodeSetMaterializationBudget::default(),
989                &XmlBaseResolutionBudget::with_limits(0, 1_024),
990            )
991            .expect("absolute references must bypass inherited XML Base traversal");
992
993        assert_eq!(data.into_binary().unwrap(), b"payload");
994    }
995
996    #[test]
997    fn external_uri_without_xml_base_uses_normalized_resource_identity() {
998        // RFC 3986 normalization defines the caller map key even when the
999        // document does not provide an explicit XML Base ancestor.
1000        let xml = r#"<root><reference URI="https://example.test/a/../data.bin"/></root>"#;
1001        let doc = Document::parse(xml).unwrap();
1002        let reference = doc
1003            .descendants()
1004            .find(|node| node.has_tag_name("reference"))
1005            .unwrap();
1006        let resources = HashMap::from([(
1007            "https://example.test/data.bin".to_owned(),
1008            b"payload".to_vec(),
1009        )]);
1010        let budget = NodeSetMaterializationBudget::default();
1011        let xml_base_budget = XmlBaseResolutionBudget::default();
1012        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1013
1014        let data = resolver
1015            .dereference_from_with_budget(
1016                reference.attribute("URI").unwrap(),
1017                reference,
1018                &budget,
1019                &xml_base_budget,
1020            )
1021            .expect("the normalized resource key must resolve without xml:base");
1022
1023        assert_eq!(data.into_binary().unwrap(), b"payload");
1024    }
1025
1026    #[test]
1027    fn pathless_relative_xml_base_preserves_relative_resource_identity() {
1028        // Query-only xml:base values do not turn a relative URI into an
1029        // absolute-path reference when resolving caller-owned resources.
1030        let xml = r#"<root xml:base="?old">
1031            <reference URI="data.bin"/>
1032        </root>"#;
1033        let doc = Document::parse(xml).unwrap();
1034        let reference = doc
1035            .descendants()
1036            .find(|node| node.has_tag_name("reference"))
1037            .unwrap();
1038        let resources = HashMap::from([("data.bin".to_owned(), b"payload".to_vec())]);
1039        let budget = NodeSetMaterializationBudget::default();
1040        let xml_base_budget = XmlBaseResolutionBudget::default();
1041        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1042
1043        let data = resolver
1044            .dereference_from_with_budget(
1045                reference.attribute("URI").unwrap(),
1046                reference,
1047                &budget,
1048                &xml_base_budget,
1049            )
1050            .unwrap();
1051
1052        assert_eq!(data.into_binary().unwrap(), b"payload");
1053    }
1054
1055    #[test]
1056    fn relative_xml_base_normalizes_absolute_external_path() {
1057        // An absolute-path reference replaces a relative base path, but RFC
1058        // 3986 dot-segment removal still defines the caller resource identity.
1059        let xml = r#"<root xml:base="a/b">
1060            <reference URI="/x/../data.bin"/>
1061        </root>"#;
1062        let doc = Document::parse(xml).unwrap();
1063        let reference = doc
1064            .descendants()
1065            .find(|node| node.has_tag_name("reference"))
1066            .unwrap();
1067        let resources = HashMap::from([("/data.bin".to_owned(), b"payload".to_vec())]);
1068        let budget = NodeSetMaterializationBudget::default();
1069        let xml_base_budget = XmlBaseResolutionBudget::default();
1070        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1071
1072        let data = resolver
1073            .dereference_from_with_budget(
1074                reference.attribute("URI").unwrap(),
1075                reference,
1076                &budget,
1077                &xml_base_budget,
1078            )
1079            .unwrap();
1080
1081        assert_eq!(data.into_binary().unwrap(), b"payload");
1082    }
1083
1084    #[test]
1085    fn network_path_xml_base_preserves_external_resource_authority() {
1086        // A schemeless authority remains part of the resolved caller-owned
1087        // resource identity when an absolute-path URI replaces the base path.
1088        let xml = r#"<root xml:base="//cdn.example/a/b/">
1089            <reference URI="/x/../data.bin"/>
1090        </root>"#;
1091        let doc = Document::parse(xml).unwrap();
1092        let reference = doc
1093            .descendants()
1094            .find(|node| node.has_tag_name("reference"))
1095            .unwrap();
1096        let resources = HashMap::from([("//cdn.example/data.bin".to_owned(), b"payload".to_vec())]);
1097        let budget = NodeSetMaterializationBudget::default();
1098        let xml_base_budget = XmlBaseResolutionBudget::default();
1099        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1100
1101        let data = resolver
1102            .dereference_from_with_budget(
1103                reference.attribute("URI").unwrap(),
1104                reference,
1105                &budget,
1106                &xml_base_budget,
1107            )
1108            .unwrap();
1109
1110        assert_eq!(data.into_binary().unwrap(), b"payload");
1111    }
1112
1113    #[test]
1114    fn unicode_external_uri_resolves_without_panicking() {
1115        // Untrusted XML may start a relative URI with a multibyte scalar; the
1116        // resolver must produce its UTF-8 resource identity without panicking.
1117        let xml = r#"<root xml:base="https://example.test/base/">
1118            <reference URI="é?x"/>
1119        </root>"#;
1120        let doc = Document::parse(xml).unwrap();
1121        let reference = doc
1122            .descendants()
1123            .find(|node| node.has_tag_name("reference"))
1124            .unwrap();
1125        let resources = HashMap::from([(
1126            "https://example.test/base/é?x".to_owned(),
1127            b"payload".to_vec(),
1128        )]);
1129        let budget = NodeSetMaterializationBudget::default();
1130        let xml_base_budget = XmlBaseResolutionBudget::default();
1131        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1132
1133        let data = resolver
1134            .dereference_from_with_budget(
1135                reference.attribute("URI").unwrap(),
1136                reference,
1137                &budget,
1138                &xml_base_budget,
1139            )
1140            .unwrap();
1141
1142        assert_eq!(data.into_binary().unwrap(), b"payload");
1143    }
1144
1145    #[test]
1146    fn absolute_rootless_external_uri_discards_leading_parent_segment() {
1147        let xml = r#"<root xml:base="https://example.test/base/">
1148            <reference URI="urn:../payload"/>
1149        </root>"#;
1150        let doc = Document::parse(xml).unwrap();
1151        let reference = doc
1152            .descendants()
1153            .find(|node| node.has_tag_name("reference"))
1154            .unwrap();
1155        let resources = HashMap::from([("urn:payload".to_owned(), b"payload".to_vec())]);
1156        let budget = NodeSetMaterializationBudget::default();
1157        let xml_base_budget = XmlBaseResolutionBudget::default();
1158        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1159
1160        let data = resolver
1161            .dereference_from_with_budget(
1162                reference.attribute("URI").unwrap(),
1163                reference,
1164                &budget,
1165                &xml_base_budget,
1166            )
1167            .unwrap();
1168
1169        assert_eq!(data.into_binary().unwrap(), b"payload");
1170    }
1171
1172    #[test]
1173    fn namespaced_id_attr_found_by_local_name() {
1174        // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS
1175        let xml =
1176            r#"<root><elem wsu:Id="ts1" xmlns:wsu="http://example.com/wsu">data</elem></root>"#;
1177        let doc = Document::parse(xml).unwrap();
1178
1179        let resolver = UriReferenceResolver::new(&doc);
1180        assert!(resolver.has_id("ts1"));
1181    }
1182
1183    #[test]
1184    fn id_count_reports_unique_ids() {
1185        let xml = r#"<root ID="r1"><a ID="a1"/><b Id="b1"/><c id="c1"/></root>"#;
1186        let doc = Document::parse(xml).unwrap();
1187        let resolver = UriReferenceResolver::new(&doc);
1188
1189        // 4 elements with ID-like attributes
1190        assert_eq!(resolver.id_count(), 4);
1191    }
1192
1193    #[test]
1194    fn duplicate_ids_are_rejected() {
1195        // Duplicate IDs are removed from the index to prevent signature-wrapping
1196        // attacks — lookups for ambiguous IDs fail instead of picking arbitrarily.
1197        let xml = r#"<root><a ID="dup">first</a><b ID="dup">second</b></root>"#;
1198        let doc = Document::parse(xml).unwrap();
1199        let resolver = UriReferenceResolver::new(&doc);
1200
1201        // "dup" appears twice → removed from index
1202        assert!(!resolver.has_id("dup"));
1203        let result = resolver.dereference("#dup");
1204        assert!(result.is_err());
1205        assert!(matches!(
1206            result.unwrap_err(),
1207            TransformError::ElementNotFound(_)
1208        ));
1209    }
1210
1211    #[test]
1212    fn triple_duplicate_ids_stay_rejected() {
1213        // Verify that 3+ occurrences don't re-insert (the HashSet tracks
1214        // permanently removed IDs so Entry::Vacant after remove doesn't re-add)
1215        let xml = r#"<root><a ID="dup">1</a><b ID="dup">2</b><c ID="dup">3</c></root>"#;
1216        let doc = Document::parse(xml).unwrap();
1217        let resolver = UriReferenceResolver::new(&doc);
1218
1219        assert!(!resolver.has_id("dup"));
1220        assert!(resolver.dereference("#dup").is_err());
1221    }
1222
1223    #[test]
1224    fn node_set_exclude_subtree() {
1225        let xml = r#"<root><keep>yes</keep><remove><deep>no</deep></remove></root>"#;
1226        let doc = Document::parse(xml).unwrap();
1227        let resolver = UriReferenceResolver::new(&doc);
1228
1229        let data = resolver.dereference("").unwrap();
1230        let mut node_set = data.into_node_set().unwrap();
1231
1232        // Find and exclude the <remove> subtree
1233        let remove_elem = doc
1234            .descendants()
1235            .find(|n| n.is_element() && n.has_tag_name("remove"))
1236            .unwrap();
1237        node_set.exclude_subtree(remove_elem);
1238
1239        // <keep> should still be in the set
1240        let keep_elem = doc
1241            .descendants()
1242            .find(|n| n.is_element() && n.has_tag_name("keep"))
1243            .unwrap();
1244        assert!(node_set.contains(keep_elem));
1245
1246        // <remove> and its children should be excluded
1247        assert!(!node_set.contains(remove_elem));
1248        let deep_elem = doc
1249            .descendants()
1250            .find(|n| n.is_element() && n.has_tag_name("deep"))
1251            .unwrap();
1252        assert!(!node_set.contains(deep_elem));
1253    }
1254
1255    #[test]
1256    fn bare_name_subtree_excludes_comments() {
1257        // XMLDSig's bare-name same-document shortcut removes comment nodes.
1258        let xml = r#"<root><item ID="x"><!-- comment --><child/></item></root>"#;
1259        let doc = Document::parse(xml).unwrap();
1260        let resolver = UriReferenceResolver::new(&doc);
1261
1262        let data = resolver.dereference("#x").unwrap();
1263        let node_set = data.into_node_set().unwrap();
1264
1265        for node in doc.descendants() {
1266            if node.is_comment() {
1267                assert!(
1268                    !node_set.contains(node),
1269                    "comment must be excluded from #id"
1270                );
1271            }
1272        }
1273    }
1274
1275    #[test]
1276    fn xpointer_root_returns_whole_document_with_comments() {
1277        let xml = "<root><!-- comment --><child/></root>";
1278        let doc = Document::parse(xml).unwrap();
1279        let resolver = UriReferenceResolver::new(&doc);
1280
1281        let data = resolver.dereference("#xpointer(/)").unwrap();
1282        let node_set = data.into_node_set().unwrap();
1283
1284        // Unlike empty URI, xpointer(/) includes comments
1285        for node in doc.descendants() {
1286            if node.is_comment() {
1287                assert!(
1288                    node_set.contains(node),
1289                    "comment should be included for #xpointer(/)"
1290                );
1291            }
1292        }
1293        assert!(node_set.contains(doc.root_element()));
1294    }
1295
1296    #[test]
1297    fn xpointer_id_single_quotes() {
1298        // XPointer ID dereference retains comments, unlike bare-name fragments.
1299        let xml = r#"<root><item ID="abc"><!-- retained -->content</item></root>"#;
1300        let doc = Document::parse(xml).unwrap();
1301        let resolver = UriReferenceResolver::new(&doc);
1302
1303        let data = resolver.dereference("#xpointer(id('abc'))").unwrap();
1304        let node_set = data.into_node_set().unwrap();
1305
1306        let elem = doc
1307            .descendants()
1308            .find(|n| n.attribute("ID") == Some("abc"))
1309            .unwrap();
1310        assert!(node_set.contains(elem));
1311        assert!(
1312            elem.children()
1313                .any(|node| node.is_comment() && node_set.contains(node))
1314        );
1315    }
1316
1317    #[test]
1318    fn xpointer_id_double_quotes() {
1319        let xml = r#"<root><item ID="xyz">content</item></root>"#;
1320        let doc = Document::parse(xml).unwrap();
1321        let resolver = UriReferenceResolver::new(&doc);
1322
1323        let data = resolver.dereference(r#"#xpointer(id("xyz"))"#).unwrap();
1324        let node_set = data.into_node_set().unwrap();
1325
1326        let elem = doc
1327            .descendants()
1328            .find(|n| n.attribute("ID") == Some("xyz"))
1329            .unwrap();
1330        assert!(node_set.contains(elem));
1331    }
1332
1333    #[test]
1334    fn xpointer_id_not_found() {
1335        let xml = "<root/>";
1336        let doc = Document::parse(xml).unwrap();
1337        let resolver = UriReferenceResolver::new(&doc);
1338
1339        let result = resolver.dereference("#xpointer(id('missing'))");
1340        assert!(result.is_err());
1341        match result.unwrap_err() {
1342            TransformError::ElementNotFound(id) => assert_eq!(id, "missing"),
1343            other => panic!("expected ElementNotFound, got: {other:?}"),
1344        }
1345    }
1346
1347    #[test]
1348    fn xpointer_id_empty_value_rejected() {
1349        // xpointer(id('')) parses to empty string — reject as UnsupportedUri
1350        let xml = "<root/>";
1351        let doc = Document::parse(xml).unwrap();
1352        let resolver = UriReferenceResolver::new(&doc);
1353
1354        let result = resolver.dereference("#xpointer(id(''))");
1355        assert!(result.is_err());
1356        assert!(matches!(
1357            result.unwrap_err(),
1358            TransformError::UnsupportedUri(_)
1359        ));
1360    }
1361
1362    #[test]
1363    fn bare_fragment_rejects_non_ncname_without_visa3d_compatibility() {
1364        // XMLDSig bare-name fragments use an XML Name. Numeric identifiers are
1365        // accepted by libxmlsec1 only through its explicit Visa3D compatibility
1366        // mode and must not leak into the standards-default resolver.
1367        let xml = r#"<root><item ID="12345">content</item></root>"#;
1368        let doc = Document::parse(xml).unwrap();
1369        let resolver = UriReferenceResolver::new(&doc);
1370
1371        assert!(matches!(
1372            resolver.dereference("#12345"),
1373            Err(TransformError::UnsupportedUri(uri)) if uri == "#12345"
1374        ));
1375    }
1376
1377    #[test]
1378    fn visa3d_compatibility_resolves_non_ncname_id_directly() {
1379        // The compatibility mode is deliberately narrow: it changes only the
1380        // bare-fragment lookup grammar and retains duplicate-safe ID indexing.
1381        let xml = r#"<root><item ID="12345">content</item></root>"#;
1382        let doc = Document::parse(xml).unwrap();
1383        let resolver = UriReferenceResolver::new(&doc)
1384            .with_same_document_id_semantics(SameDocumentIdSemantics::XmlSecVisa3d);
1385
1386        assert!(resolver.dereference("#12345").is_ok());
1387    }
1388
1389    #[test]
1390    fn xmlsec_barename_compatibility_matches_donor_literal_and_comment_semantics() {
1391        // The donor internally wraps a barename in XPointer to accept numeric
1392        // IDs, but still selects TreeWithoutComments; an apostrophe cannot be
1393        // represented in the wrapper's single-quoted expression.
1394        let xml = r#"<root><item ID="12345"><!-- excluded -->numeric</item><item ID="visa'3d">quoted</item></root>"#;
1395        let doc = Document::parse(xml).unwrap();
1396        let resolver = UriReferenceResolver::new(&doc)
1397            .with_same_document_id_semantics(SameDocumentIdSemantics::XmlSecBarename);
1398
1399        let nodes = resolver
1400            .dereference("#12345")
1401            .unwrap()
1402            .into_node_set()
1403            .unwrap();
1404        assert!(
1405            doc.descendants()
1406                .filter(|node| node.is_comment())
1407                .all(|node| !nodes.contains(node))
1408        );
1409        assert!(matches!(
1410            resolver.dereference("#visa'3d"),
1411            Err(TransformError::UnsupportedUri(uri)) if uri == "#visa'3d"
1412        ));
1413    }
1414
1415    #[test]
1416    fn parse_xpointer_id_variants() {
1417        // Valid forms
1418        assert_eq!(
1419            super::parse_xpointer_id_fragment("xpointer(id('foo'))"),
1420            Some("foo")
1421        );
1422        assert_eq!(
1423            super::parse_xpointer_id_fragment(r#"xpointer(id("bar"))"#),
1424            Some("bar")
1425        );
1426
1427        // Invalid forms
1428        assert_eq!(super::parse_xpointer_id_fragment("xpointer(/)"), None);
1429        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id(foo))"), None); // no quotes
1430        assert_eq!(super::parse_xpointer_id_fragment("not-xpointer"), None);
1431        assert_eq!(super::parse_xpointer_id_fragment(""), None);
1432
1433        // Malformed: single quote char — must not panic (was slicing bug)
1434        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id('))"), None);
1435        assert_eq!(
1436            super::parse_xpointer_id_fragment(r#"xpointer(id("))"#),
1437            None
1438        );
1439    }
1440
1441    #[test]
1442    fn same_document_reference_nodes_reject_non_id_fragments() {
1443        let document = Document::parse(r#"<root><item ID="target"/></root>"#).unwrap();
1444        let resolver = UriReferenceResolver::new(&document);
1445        for uri in [
1446            "#target",
1447            "#xpointer(id('target'))",
1448            r#"#xpointer(id("target"))"#,
1449        ] {
1450            assert!(
1451                resolver
1452                    .node_id_for_same_document_reference(uri)
1453                    .unwrap()
1454                    .is_some()
1455            );
1456        }
1457        for uri in [
1458            "",
1459            "target",
1460            "#",
1461            "#xpointer(/)",
1462            "#xpointer(id(''))",
1463            "#xpointer(id(target))",
1464        ] {
1465            assert!(
1466                resolver.node_id_for_same_document_reference(uri).is_err(),
1467                "{uri}"
1468            );
1469        }
1470    }
1471
1472    #[test]
1473    fn same_element_multiple_id_attrs_not_duplicate() {
1474        // An element with both ID="x" and Id="x" should NOT be treated as
1475        // duplicate — it's the same element exposing the same value via
1476        // different scanned attribute names.
1477        let xml = r#"<root><item ID="x" Id="x">data</item></root>"#;
1478        let doc = Document::parse(xml).unwrap();
1479        let resolver = UriReferenceResolver::new(&doc);
1480
1481        assert!(resolver.has_id("x"));
1482        assert!(resolver.dereference("#x").is_ok());
1483    }
1484
1485    #[test]
1486    fn saml_style_document() {
1487        // Realistic SAML-like structure
1488        let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
1489                                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
1490                                     ID="_resp1">
1491            <saml:Assertion ID="_assert1">
1492                <saml:Subject>user@example.com</saml:Subject>
1493            </saml:Assertion>
1494            <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="sig1">
1495                <ds:SignedInfo/>
1496            </ds:Signature>
1497        </samlp:Response>"#;
1498
1499        let doc = Document::parse(xml).unwrap();
1500        let resolver = UriReferenceResolver::new(&doc);
1501
1502        // Should find all three IDs
1503        assert!(resolver.has_id("_resp1"));
1504        assert!(resolver.has_id("_assert1"));
1505        assert!(resolver.has_id("sig1"));
1506        assert_eq!(resolver.id_count(), 3);
1507
1508        // Dereference the assertion
1509        let data = resolver.dereference("#_assert1").unwrap();
1510        let node_set = data.into_node_set().unwrap();
1511
1512        // Assertion element should be in the set
1513        let assertion = doc
1514            .descendants()
1515            .find(|n| n.attribute("ID") == Some("_assert1"))
1516            .unwrap();
1517        assert!(node_set.contains(assertion));
1518
1519        // Subject (child of assertion) should be in the set
1520        let subject = assertion
1521            .children()
1522            .find(|n| n.is_element() && n.has_tag_name("Subject"))
1523            .unwrap();
1524        assert!(node_set.contains(subject));
1525
1526        // Response (parent) should NOT be in the set
1527        assert!(!node_set.contains(doc.root_element()));
1528    }
1529}