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
564                .same_document_id_fragment(fragment)
565                .ok_or_else(|| TransformError::UnsupportedUri(format!("#{fragment}")))?;
566            self.resolve_id(id, budget, with_comments)
567        }
568    }
569
570    fn same_document_id_fragment<'uri>(&self, fragment: &'uri str) -> Option<(&'uri str, bool)> {
571        if let Some(id) = parse_xpointer_id_fragment(fragment) {
572            // Explicit XPointer dereference retains comments, unlike every
573            // barename mode, including libxmlsec1's internal wrapper.
574            if id.is_empty() {
575                return None;
576            }
577            return Some((id, true));
578        }
579        if fragment.starts_with("xpointer(") {
580            return None;
581        }
582        match self.same_document_id_semantics {
583            SameDocumentIdSemantics::Specification if !is_xml_ncname(fragment) => {
584                return None;
585            }
586            SameDocumentIdSemantics::XmlSecBarename if fragment.contains('\'') => {
587                return None;
588            }
589            SameDocumentIdSemantics::Specification
590            | SameDocumentIdSemantics::XmlSecBarename
591            | SameDocumentIdSemantics::XmlSecVisa3d => {}
592        }
593        Some((fragment, false))
594    }
595
596    /// Look up an element by its ID attribute value and return a subtree node set.
597    fn resolve_id(
598        &self,
599        id: &str,
600        budget: Option<&NodeSetMaterializationBudget>,
601        with_comments: bool,
602    ) -> Result<TransformData<'a>, TransformError> {
603        match self.id_index.node(self.doc, id) {
604            Some(element) => {
605                let nodes = if let Some(view) = self.view {
606                    NodeSet::subtree_from_view(view, element, with_comments, budget)?
607                } else if with_comments {
608                    match budget {
609                        Some(budget) => NodeSet::subtree_with_budget(element, budget)?,
610                        None => NodeSet::subtree(element)?,
611                    }
612                } else {
613                    NodeSet::subtree_without_comments_with_budget(element, budget)?
614                };
615                Ok(TransformData::NodeSet(nodes))
616            }
617            None => Err(TransformError::ElementNotFound(id.to_string())),
618        }
619    }
620
621    /// Check if an ID is registered in the resolver's index.
622    pub fn has_id(&self, id: &str) -> bool {
623        self.id_index.contains(id)
624    }
625
626    /// Resolve an unambiguous XML ID to its element node.
627    ///
628    /// Returns `None` when the ID is absent or duplicated, matching fragment
629    /// dereferencing and operation start-node selection.
630    pub fn node_for_id(&self, id: &str) -> Option<Node<'a, 'a>> {
631        self.id_index.node(self.doc, id)
632    }
633
634    /// Resolve a same-document URI to an element under the configured grammar.
635    /// An empty URI selects the document element for element-valued consumers.
636    pub fn node_for_same_document_reference(
637        &self,
638        uri: &str,
639    ) -> Result<Option<Node<'a, 'a>>, TransformError> {
640        if uri.is_empty() {
641            return Ok(Some(self.doc.root_element()));
642        }
643        Ok(self
644            .node_id_for_same_document_reference(uri)?
645            .and_then(|id| self.doc.get_node(id)))
646    }
647
648    /// Borrowed association prefilter, sharing the normal URI grammar and ID
649    /// index without constructing errors for unrelated or malformed references.
650    #[cfg(feature = "xmlenc")]
651    pub(crate) fn same_document_reference_targets(&self, uri: &str, target: NodeId) -> bool {
652        if uri.is_empty() {
653            return self.doc.root_element().id() == target;
654        }
655        let Some(fragment) = uri.strip_prefix('#') else {
656            return false;
657        };
658        if fragment.is_empty() || fragment == "xpointer(/)" {
659            return false;
660        }
661        let Some((id, _)) = self.same_document_id_fragment(fragment) else {
662            return false;
663        };
664        self.id_index.node_id(id) == Some(target)
665    }
666
667    /// Resolve a same-document URI to a stable node identity under the
668    /// configured grammar, for secondary consumers such as Manifest trust.
669    pub(crate) fn node_id_for_same_document_reference(
670        &self,
671        uri: &str,
672    ) -> Result<Option<NodeId>, TransformError> {
673        let fragment = uri
674            .strip_prefix('#')
675            .ok_or_else(|| TransformError::UnsupportedUri(uri.to_owned()))?;
676        if fragment.is_empty() || fragment == "xpointer(/)" {
677            return Err(TransformError::UnsupportedUri(uri.to_owned()));
678        }
679        let (id, _) = self
680            .same_document_id_fragment(fragment)
681            .ok_or_else(|| TransformError::UnsupportedUri(uri.to_owned()))?;
682        Ok(self.id_index.node_id(id))
683    }
684
685    pub(crate) fn node_for_node_id(&self, id: NodeId) -> Option<Node<'a, 'a>> {
686        self.doc.get_node(id)
687    }
688
689    /// Get the number of registered IDs.
690    pub fn id_count(&self) -> usize {
691        self.id_index.len()
692    }
693}
694
695fn map_xml_base_resolution_error(error: XmlBaseResolutionError) -> TransformError {
696    match error {
697        XmlBaseResolutionError::Components { maximum, actual } => transform_resource_limit(
698            crate::policy::resource_name::XML_BASE_COMPONENTS,
699            maximum,
700            actual,
701        ),
702        XmlBaseResolutionError::Bytes { maximum, actual } => transform_resource_limit(
703            crate::policy::resource_name::XML_BASE_RESOLUTION_BYTES,
704            maximum,
705            actual,
706        ),
707    }
708}
709
710/// Parse `xpointer(id('value'))` or `xpointer(id("value"))` and return the ID value.
711/// Returns `None` if the fragment doesn't match this pattern.
712pub(crate) fn parse_xpointer_id_fragment(fragment: &str) -> Option<&str> {
713    let inner = fragment.strip_prefix("xpointer(id(")?.strip_suffix("))")?;
714
715    // Strip single or double quotes using safe helpers to avoid panics
716    // on malformed input (e.g., `xpointer(id('))` where inner is `'`)
717    if let Some(stripped) = inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
718        Some(stripped)
719    } else if let Some(stripped) = inner.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
720        Some(stripped)
721    } else {
722        None
723    }
724}
725
726#[cfg(test)]
727#[allow(clippy::unwrap_used)]
728mod tests {
729    use super::super::types::NodeSet;
730    use super::*;
731
732    #[test]
733    fn empty_same_document_node_reference_selects_the_document_element() {
734        // Element-valued consumers of the Reference URI contract interpret an
735        // empty URI as the root element of the current XML document.
736        let document = Document::parse("<root><child/></root>").unwrap();
737        let resolver = UriReferenceResolver::new(&document);
738
739        assert_eq!(
740            resolver
741                .node_for_same_document_reference("")
742                .unwrap()
743                .unwrap(),
744            document.root_element()
745        );
746    }
747
748    #[test]
749    fn empty_uri_returns_whole_document() {
750        let xml = "<root><child>text</child></root>";
751        let doc = Document::parse(xml).unwrap();
752        let resolver = UriReferenceResolver::new(&doc);
753
754        let data = resolver.dereference("").unwrap();
755        let node_set = data.into_node_set().unwrap();
756
757        // Whole document: root and child should be in the set
758        let root = doc.root_element();
759        assert!(node_set.contains(root));
760        let child = root.first_child().unwrap();
761        assert!(node_set.contains(child));
762    }
763
764    #[test]
765    fn empty_uri_excludes_comments() {
766        let xml = "<root><!-- comment --><child/></root>";
767        let doc = Document::parse(xml).unwrap();
768        let resolver = UriReferenceResolver::new(&doc);
769
770        let data = resolver.dereference("").unwrap();
771        let node_set = data.into_node_set().unwrap();
772
773        // Comment should be excluded
774        for node in doc.descendants() {
775            if node.is_comment() {
776                assert!(
777                    !node_set.contains(node),
778                    "comment should be excluded for empty URI"
779                );
780            }
781        }
782        // Element should still be included
783        assert!(node_set.contains(doc.root_element()));
784    }
785
786    #[test]
787    fn fragment_uri_resolves_by_id_attr() {
788        let xml = r#"<root><item ID="abc">content</item><item ID="def">other</item></root>"#;
789        let doc = Document::parse(xml).unwrap();
790        let resolver = UriReferenceResolver::new(&doc);
791
792        let data = resolver.dereference("#abc").unwrap();
793        let node_set = data.into_node_set().unwrap();
794
795        // The element with ID="abc" and its children should be in the set
796        let abc_elem = doc
797            .descendants()
798            .find(|n| n.attribute("ID") == Some("abc"))
799            .unwrap();
800        assert!(node_set.contains(abc_elem));
801
802        // The text child "content" should also be in the set
803        let text_child = abc_elem.first_child().unwrap();
804        assert!(node_set.contains(text_child));
805
806        // The root element should NOT be in the set (subtree only)
807        assert!(!node_set.contains(doc.root_element()));
808
809        // The element with ID="def" should NOT be in the set
810        let def_elem = doc
811            .descendants()
812            .find(|n| n.attribute("ID") == Some("def"))
813            .unwrap();
814        assert!(!node_set.contains(def_elem));
815    }
816
817    #[test]
818    fn fragment_uri_resolves_lowercase_id() {
819        let xml = r#"<root><item id="lower">text</item></root>"#;
820        let doc = Document::parse(xml).unwrap();
821        let resolver = UriReferenceResolver::new(&doc);
822
823        let data = resolver.dereference("#lower").unwrap();
824        let node_set = data.into_node_set().unwrap();
825
826        let elem = doc
827            .descendants()
828            .find(|n| n.attribute("id") == Some("lower"))
829            .unwrap();
830        assert!(node_set.contains(elem));
831    }
832
833    #[test]
834    fn fragment_uri_resolves_mixed_case_id() {
835        let xml = r#"<root><ds:Signature Id="sig1" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/></root>"#;
836        let doc = Document::parse(xml).unwrap();
837        let resolver = UriReferenceResolver::new(&doc);
838
839        assert!(resolver.has_id("sig1"));
840        let data = resolver.dereference("#sig1").unwrap();
841        assert!(data.into_node_set().is_ok());
842    }
843
844    #[test]
845    fn fragment_uri_not_found() {
846        let xml = "<root><child>text</child></root>";
847        let doc = Document::parse(xml).unwrap();
848        let resolver = UriReferenceResolver::new(&doc);
849
850        let result = resolver.dereference("#nonexistent");
851        assert!(result.is_err());
852        match result.unwrap_err() {
853            TransformError::ElementNotFound(id) => assert_eq!(id, "nonexistent"),
854            other => panic!("expected ElementNotFound, got: {other:?}"),
855        }
856    }
857
858    #[test]
859    fn unsupported_external_uri() {
860        let xml = "<root/>";
861        let doc = Document::parse(xml).unwrap();
862        let resolver = UriReferenceResolver::new(&doc);
863
864        let result = resolver.dereference("http://example.com/doc.xml");
865        assert!(result.is_err());
866        match result.unwrap_err() {
867            TransformError::UnsupportedUri(uri) => {
868                assert_eq!(uri, "http://example.com/doc.xml")
869            }
870            other => panic!("expected UnsupportedUri, got: {other:?}"),
871        }
872    }
873
874    #[test]
875    fn unsupported_xpointer_expression() {
876        // XPointer expressions other than xpointer(/) and xpointer(id(...))
877        // should return UnsupportedUri, not fall through to ID lookup
878        let xml = "<root/>";
879        let doc = Document::parse(xml).unwrap();
880        let resolver = UriReferenceResolver::new(&doc);
881
882        let result = resolver.dereference("#xpointer(foo())");
883        assert!(result.is_err());
884        match result.unwrap_err() {
885            TransformError::UnsupportedUri(uri) => {
886                assert_eq!(uri, "#xpointer(foo())")
887            }
888            other => panic!("expected UnsupportedUri, got: {other:?}"),
889        }
890
891        // Generic XPointer with XPath should also be unsupported
892        let result = resolver.dereference("#xpointer(//element)");
893        assert!(result.is_err());
894        assert!(matches!(
895            result.unwrap_err(),
896            TransformError::UnsupportedUri(_)
897        ));
898    }
899
900    #[test]
901    fn empty_fragment_rejected() {
902        // Bare "#" (empty fragment) is not a valid same-document reference
903        let xml = "<root/>";
904        let doc = Document::parse(xml).unwrap();
905        let resolver = UriReferenceResolver::new(&doc);
906
907        let result = resolver.dereference("#");
908        assert!(result.is_err());
909        match result.unwrap_err() {
910            TransformError::UnsupportedUri(uri) => assert_eq!(uri, "#"),
911            other => panic!("expected UnsupportedUri, got: {other:?}"),
912        }
913    }
914
915    #[test]
916    fn foreign_document_node_rejected() {
917        // NodeSet.contains() must reject nodes from a different document
918        let xml1 = "<root><child/></root>";
919        let xml2 = "<other><item/></other>";
920        let doc1 = Document::parse(xml1).unwrap();
921        let doc2 = Document::parse(xml2).unwrap();
922
923        let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
924
925        // Node from doc2 should NOT be in doc1's node set
926        let foreign_node = doc2.root_element();
927        assert!(
928            !node_set.contains(foreign_node),
929            "foreign document node should be rejected"
930        );
931
932        // Node from doc1 should be in the set
933        let own_node = doc1.root_element();
934        assert!(node_set.contains(own_node));
935    }
936
937    #[test]
938    fn custom_id_attr_name() {
939        // roxmltree stores `wsu:Id` with local name "Id" — already in DEFAULT_ID_ATTRS.
940        // Test with a truly custom attribute name instead.
941        let xml = r#"<root><elem myid="custom1">data</elem></root>"#;
942        let doc = Document::parse(xml).unwrap();
943
944        // Default resolver doesn't know about "myid"
945        let resolver_default = UriReferenceResolver::new(&doc);
946        assert!(!resolver_default.has_id("custom1"));
947
948        // Custom resolver with "myid" added
949        let resolver_custom = UriReferenceResolver::with_id_attrs(&doc, &["myid"]);
950        assert!(resolver_custom.has_id("custom1"));
951
952        let data = resolver_custom.dereference("#custom1").unwrap();
953        assert!(data.into_node_set().is_ok());
954    }
955
956    #[test]
957    fn absolute_external_uri_uses_normalized_resource_identity() {
958        // Caller maps are keyed by the resolved RFC 3986 identity, not by an
959        // unnormalized spelling embedded in an untrusted Signature document.
960        let xml = r#"<root xml:base="https://base.example/ignored/">
961            <reference URI="https://example.test/a/../data.bin"/>
962        </root>"#;
963        let doc = Document::parse(xml).unwrap();
964        let reference = doc
965            .descendants()
966            .find(|node| node.has_tag_name("reference"))
967            .unwrap();
968        let resources = HashMap::from([(
969            "https://example.test/data.bin".to_owned(),
970            b"payload".to_vec(),
971        )]);
972        let budget = NodeSetMaterializationBudget::default();
973        let xml_base_budget = XmlBaseResolutionBudget::default();
974        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
975
976        let data = resolver
977            .dereference_from_with_budget(
978                reference.attribute("URI").unwrap(),
979                reference,
980                &budget,
981                &xml_base_budget,
982            )
983            .unwrap();
984
985        assert_eq!(data.into_binary().unwrap(), b"payload");
986    }
987
988    #[test]
989    fn absolute_external_uri_does_not_consume_xml_base_components() {
990        // A scheme-bearing reference supplies its own base and must remain
991        // resolvable even when inherited XML Base components are disallowed.
992        let xml = r#"<root xml:base="ignored/"><reference URI="https://example.test/a/../data.bin"/></root>"#;
993        let doc = Document::parse(xml).unwrap();
994        let reference = doc
995            .descendants()
996            .find(|node| node.has_tag_name("reference"))
997            .unwrap();
998        let resources = HashMap::from([(
999            "https://example.test/data.bin".to_owned(),
1000            b"payload".to_vec(),
1001        )]);
1002        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1003
1004        let data = resolver
1005            .dereference_from_with_budget(
1006                reference.attribute("URI").unwrap(),
1007                reference,
1008                &NodeSetMaterializationBudget::default(),
1009                &XmlBaseResolutionBudget::with_limits(0, 1_024),
1010            )
1011            .expect("absolute references must bypass inherited XML Base traversal");
1012
1013        assert_eq!(data.into_binary().unwrap(), b"payload");
1014    }
1015
1016    #[test]
1017    fn external_uri_without_xml_base_uses_normalized_resource_identity() {
1018        // RFC 3986 normalization defines the caller map key even when the
1019        // document does not provide an explicit XML Base ancestor.
1020        let xml = r#"<root><reference URI="https://example.test/a/../data.bin"/></root>"#;
1021        let doc = Document::parse(xml).unwrap();
1022        let reference = doc
1023            .descendants()
1024            .find(|node| node.has_tag_name("reference"))
1025            .unwrap();
1026        let resources = HashMap::from([(
1027            "https://example.test/data.bin".to_owned(),
1028            b"payload".to_vec(),
1029        )]);
1030        let budget = NodeSetMaterializationBudget::default();
1031        let xml_base_budget = XmlBaseResolutionBudget::default();
1032        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1033
1034        let data = resolver
1035            .dereference_from_with_budget(
1036                reference.attribute("URI").unwrap(),
1037                reference,
1038                &budget,
1039                &xml_base_budget,
1040            )
1041            .expect("the normalized resource key must resolve without xml:base");
1042
1043        assert_eq!(data.into_binary().unwrap(), b"payload");
1044    }
1045
1046    #[test]
1047    fn pathless_relative_xml_base_preserves_relative_resource_identity() {
1048        // Query-only xml:base values do not turn a relative URI into an
1049        // absolute-path reference when resolving caller-owned resources.
1050        let xml = r#"<root xml:base="?old">
1051            <reference URI="data.bin"/>
1052        </root>"#;
1053        let doc = Document::parse(xml).unwrap();
1054        let reference = doc
1055            .descendants()
1056            .find(|node| node.has_tag_name("reference"))
1057            .unwrap();
1058        let resources = HashMap::from([("data.bin".to_owned(), b"payload".to_vec())]);
1059        let budget = NodeSetMaterializationBudget::default();
1060        let xml_base_budget = XmlBaseResolutionBudget::default();
1061        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1062
1063        let data = resolver
1064            .dereference_from_with_budget(
1065                reference.attribute("URI").unwrap(),
1066                reference,
1067                &budget,
1068                &xml_base_budget,
1069            )
1070            .unwrap();
1071
1072        assert_eq!(data.into_binary().unwrap(), b"payload");
1073    }
1074
1075    #[test]
1076    fn relative_xml_base_normalizes_absolute_external_path() {
1077        // An absolute-path reference replaces a relative base path, but RFC
1078        // 3986 dot-segment removal still defines the caller resource identity.
1079        let xml = r#"<root xml:base="a/b">
1080            <reference URI="/x/../data.bin"/>
1081        </root>"#;
1082        let doc = Document::parse(xml).unwrap();
1083        let reference = doc
1084            .descendants()
1085            .find(|node| node.has_tag_name("reference"))
1086            .unwrap();
1087        let resources = HashMap::from([("/data.bin".to_owned(), b"payload".to_vec())]);
1088        let budget = NodeSetMaterializationBudget::default();
1089        let xml_base_budget = XmlBaseResolutionBudget::default();
1090        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1091
1092        let data = resolver
1093            .dereference_from_with_budget(
1094                reference.attribute("URI").unwrap(),
1095                reference,
1096                &budget,
1097                &xml_base_budget,
1098            )
1099            .unwrap();
1100
1101        assert_eq!(data.into_binary().unwrap(), b"payload");
1102    }
1103
1104    #[test]
1105    fn network_path_xml_base_preserves_external_resource_authority() {
1106        // A schemeless authority remains part of the resolved caller-owned
1107        // resource identity when an absolute-path URI replaces the base path.
1108        let xml = r#"<root xml:base="//cdn.example/a/b/">
1109            <reference URI="/x/../data.bin"/>
1110        </root>"#;
1111        let doc = Document::parse(xml).unwrap();
1112        let reference = doc
1113            .descendants()
1114            .find(|node| node.has_tag_name("reference"))
1115            .unwrap();
1116        let resources = HashMap::from([("//cdn.example/data.bin".to_owned(), b"payload".to_vec())]);
1117        let budget = NodeSetMaterializationBudget::default();
1118        let xml_base_budget = XmlBaseResolutionBudget::default();
1119        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1120
1121        let data = resolver
1122            .dereference_from_with_budget(
1123                reference.attribute("URI").unwrap(),
1124                reference,
1125                &budget,
1126                &xml_base_budget,
1127            )
1128            .unwrap();
1129
1130        assert_eq!(data.into_binary().unwrap(), b"payload");
1131    }
1132
1133    #[test]
1134    fn unicode_external_uri_resolves_without_panicking() {
1135        // Untrusted XML may start a relative URI with a multibyte scalar; the
1136        // resolver must produce its UTF-8 resource identity without panicking.
1137        let xml = r#"<root xml:base="https://example.test/base/">
1138            <reference URI="é?x"/>
1139        </root>"#;
1140        let doc = Document::parse(xml).unwrap();
1141        let reference = doc
1142            .descendants()
1143            .find(|node| node.has_tag_name("reference"))
1144            .unwrap();
1145        let resources = HashMap::from([(
1146            "https://example.test/base/é?x".to_owned(),
1147            b"payload".to_vec(),
1148        )]);
1149        let budget = NodeSetMaterializationBudget::default();
1150        let xml_base_budget = XmlBaseResolutionBudget::default();
1151        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1152
1153        let data = resolver
1154            .dereference_from_with_budget(
1155                reference.attribute("URI").unwrap(),
1156                reference,
1157                &budget,
1158                &xml_base_budget,
1159            )
1160            .unwrap();
1161
1162        assert_eq!(data.into_binary().unwrap(), b"payload");
1163    }
1164
1165    #[test]
1166    fn absolute_rootless_external_uri_discards_leading_parent_segment() {
1167        let xml = r#"<root xml:base="https://example.test/base/">
1168            <reference URI="urn:../payload"/>
1169        </root>"#;
1170        let doc = Document::parse(xml).unwrap();
1171        let reference = doc
1172            .descendants()
1173            .find(|node| node.has_tag_name("reference"))
1174            .unwrap();
1175        let resources = HashMap::from([("urn:payload".to_owned(), b"payload".to_vec())]);
1176        let budget = NodeSetMaterializationBudget::default();
1177        let xml_base_budget = XmlBaseResolutionBudget::default();
1178        let resolver = UriReferenceResolver::new(&doc).with_external_resources(&resources);
1179
1180        let data = resolver
1181            .dereference_from_with_budget(
1182                reference.attribute("URI").unwrap(),
1183                reference,
1184                &budget,
1185                &xml_base_budget,
1186            )
1187            .unwrap();
1188
1189        assert_eq!(data.into_binary().unwrap(), b"payload");
1190    }
1191
1192    #[test]
1193    fn namespaced_id_attr_found_by_local_name() {
1194        // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS
1195        let xml =
1196            r#"<root><elem wsu:Id="ts1" xmlns:wsu="http://example.com/wsu">data</elem></root>"#;
1197        let doc = Document::parse(xml).unwrap();
1198
1199        let resolver = UriReferenceResolver::new(&doc);
1200        assert!(resolver.has_id("ts1"));
1201    }
1202
1203    #[test]
1204    fn id_count_reports_unique_ids() {
1205        let xml = r#"<root ID="r1"><a ID="a1"/><b Id="b1"/><c id="c1"/></root>"#;
1206        let doc = Document::parse(xml).unwrap();
1207        let resolver = UriReferenceResolver::new(&doc);
1208
1209        // 4 elements with ID-like attributes
1210        assert_eq!(resolver.id_count(), 4);
1211    }
1212
1213    #[test]
1214    fn duplicate_ids_are_rejected() {
1215        // Duplicate IDs are removed from the index to prevent signature-wrapping
1216        // attacks — lookups for ambiguous IDs fail instead of picking arbitrarily.
1217        let xml = r#"<root><a ID="dup">first</a><b ID="dup">second</b></root>"#;
1218        let doc = Document::parse(xml).unwrap();
1219        let resolver = UriReferenceResolver::new(&doc);
1220
1221        // "dup" appears twice → removed from index
1222        assert!(!resolver.has_id("dup"));
1223        let result = resolver.dereference("#dup");
1224        assert!(result.is_err());
1225        assert!(matches!(
1226            result.unwrap_err(),
1227            TransformError::ElementNotFound(_)
1228        ));
1229    }
1230
1231    #[test]
1232    fn triple_duplicate_ids_stay_rejected() {
1233        // Verify that 3+ occurrences don't re-insert (the HashSet tracks
1234        // permanently removed IDs so Entry::Vacant after remove doesn't re-add)
1235        let xml = r#"<root><a ID="dup">1</a><b ID="dup">2</b><c ID="dup">3</c></root>"#;
1236        let doc = Document::parse(xml).unwrap();
1237        let resolver = UriReferenceResolver::new(&doc);
1238
1239        assert!(!resolver.has_id("dup"));
1240        assert!(resolver.dereference("#dup").is_err());
1241    }
1242
1243    #[test]
1244    fn node_set_exclude_subtree() {
1245        let xml = r#"<root><keep>yes</keep><remove><deep>no</deep></remove></root>"#;
1246        let doc = Document::parse(xml).unwrap();
1247        let resolver = UriReferenceResolver::new(&doc);
1248
1249        let data = resolver.dereference("").unwrap();
1250        let mut node_set = data.into_node_set().unwrap();
1251
1252        // Find and exclude the <remove> subtree
1253        let remove_elem = doc
1254            .descendants()
1255            .find(|n| n.is_element() && n.has_tag_name("remove"))
1256            .unwrap();
1257        node_set.exclude_subtree(remove_elem);
1258
1259        // <keep> should still be in the set
1260        let keep_elem = doc
1261            .descendants()
1262            .find(|n| n.is_element() && n.has_tag_name("keep"))
1263            .unwrap();
1264        assert!(node_set.contains(keep_elem));
1265
1266        // <remove> and its children should be excluded
1267        assert!(!node_set.contains(remove_elem));
1268        let deep_elem = doc
1269            .descendants()
1270            .find(|n| n.is_element() && n.has_tag_name("deep"))
1271            .unwrap();
1272        assert!(!node_set.contains(deep_elem));
1273    }
1274
1275    #[test]
1276    fn bare_name_subtree_excludes_comments() {
1277        // XMLDSig's bare-name same-document shortcut removes comment nodes.
1278        let xml = r#"<root><item ID="x"><!-- comment --><child/></item></root>"#;
1279        let doc = Document::parse(xml).unwrap();
1280        let resolver = UriReferenceResolver::new(&doc);
1281
1282        let data = resolver.dereference("#x").unwrap();
1283        let node_set = data.into_node_set().unwrap();
1284
1285        for node in doc.descendants() {
1286            if node.is_comment() {
1287                assert!(
1288                    !node_set.contains(node),
1289                    "comment must be excluded from #id"
1290                );
1291            }
1292        }
1293    }
1294
1295    #[test]
1296    fn xpointer_root_returns_whole_document_with_comments() {
1297        let xml = "<root><!-- comment --><child/></root>";
1298        let doc = Document::parse(xml).unwrap();
1299        let resolver = UriReferenceResolver::new(&doc);
1300
1301        let data = resolver.dereference("#xpointer(/)").unwrap();
1302        let node_set = data.into_node_set().unwrap();
1303
1304        // Unlike empty URI, xpointer(/) includes comments
1305        for node in doc.descendants() {
1306            if node.is_comment() {
1307                assert!(
1308                    node_set.contains(node),
1309                    "comment should be included for #xpointer(/)"
1310                );
1311            }
1312        }
1313        assert!(node_set.contains(doc.root_element()));
1314    }
1315
1316    #[test]
1317    fn xpointer_id_single_quotes() {
1318        // XPointer ID dereference retains comments, unlike bare-name fragments.
1319        let xml = r#"<root><item ID="abc"><!-- retained -->content</item></root>"#;
1320        let doc = Document::parse(xml).unwrap();
1321        let resolver = UriReferenceResolver::new(&doc);
1322
1323        let data = resolver.dereference("#xpointer(id('abc'))").unwrap();
1324        let node_set = data.into_node_set().unwrap();
1325
1326        let elem = doc
1327            .descendants()
1328            .find(|n| n.attribute("ID") == Some("abc"))
1329            .unwrap();
1330        assert!(node_set.contains(elem));
1331        assert!(
1332            elem.children()
1333                .any(|node| node.is_comment() && node_set.contains(node))
1334        );
1335    }
1336
1337    #[test]
1338    fn xpointer_id_double_quotes() {
1339        let xml = r#"<root><item ID="xyz">content</item></root>"#;
1340        let doc = Document::parse(xml).unwrap();
1341        let resolver = UriReferenceResolver::new(&doc);
1342
1343        let data = resolver.dereference(r#"#xpointer(id("xyz"))"#).unwrap();
1344        let node_set = data.into_node_set().unwrap();
1345
1346        let elem = doc
1347            .descendants()
1348            .find(|n| n.attribute("ID") == Some("xyz"))
1349            .unwrap();
1350        assert!(node_set.contains(elem));
1351    }
1352
1353    #[test]
1354    fn xpointer_id_not_found() {
1355        let xml = "<root/>";
1356        let doc = Document::parse(xml).unwrap();
1357        let resolver = UriReferenceResolver::new(&doc);
1358
1359        let result = resolver.dereference("#xpointer(id('missing'))");
1360        assert!(result.is_err());
1361        match result.unwrap_err() {
1362            TransformError::ElementNotFound(id) => assert_eq!(id, "missing"),
1363            other => panic!("expected ElementNotFound, got: {other:?}"),
1364        }
1365    }
1366
1367    #[test]
1368    fn xpointer_id_empty_value_rejected() {
1369        // xpointer(id('')) parses to empty string — reject as UnsupportedUri
1370        let xml = "<root/>";
1371        let doc = Document::parse(xml).unwrap();
1372        let resolver = UriReferenceResolver::new(&doc);
1373
1374        let result = resolver.dereference("#xpointer(id(''))");
1375        assert!(result.is_err());
1376        assert!(matches!(
1377            result.unwrap_err(),
1378            TransformError::UnsupportedUri(_)
1379        ));
1380    }
1381
1382    #[test]
1383    fn bare_fragment_rejects_non_ncname_without_visa3d_compatibility() {
1384        // XMLDSig bare-name fragments use an XML Name. Numeric identifiers are
1385        // accepted by libxmlsec1 only through its explicit Visa3D compatibility
1386        // mode and must not leak into the standards-default resolver.
1387        let xml = r#"<root><item ID="12345">content</item></root>"#;
1388        let doc = Document::parse(xml).unwrap();
1389        let resolver = UriReferenceResolver::new(&doc);
1390
1391        assert!(matches!(
1392            resolver.dereference("#12345"),
1393            Err(TransformError::UnsupportedUri(uri)) if uri == "#12345"
1394        ));
1395    }
1396
1397    #[test]
1398    fn visa3d_compatibility_resolves_non_ncname_id_directly() {
1399        // The compatibility mode is deliberately narrow: it changes only the
1400        // bare-fragment lookup grammar and retains duplicate-safe ID indexing.
1401        let xml = r#"<root><item ID="12345">content</item></root>"#;
1402        let doc = Document::parse(xml).unwrap();
1403        let resolver = UriReferenceResolver::new(&doc)
1404            .with_same_document_id_semantics(SameDocumentIdSemantics::XmlSecVisa3d);
1405
1406        assert!(resolver.dereference("#12345").is_ok());
1407    }
1408
1409    #[test]
1410    fn xmlsec_barename_compatibility_matches_donor_literal_and_comment_semantics() {
1411        // The donor internally wraps a barename in XPointer to accept numeric
1412        // IDs, but still selects TreeWithoutComments; an apostrophe cannot be
1413        // represented in the wrapper's single-quoted expression.
1414        let xml = r#"<root><item ID="12345"><!-- excluded -->numeric</item><item ID="visa'3d">quoted</item></root>"#;
1415        let doc = Document::parse(xml).unwrap();
1416        let resolver = UriReferenceResolver::new(&doc)
1417            .with_same_document_id_semantics(SameDocumentIdSemantics::XmlSecBarename);
1418
1419        let nodes = resolver
1420            .dereference("#12345")
1421            .unwrap()
1422            .into_node_set()
1423            .unwrap();
1424        assert!(
1425            doc.descendants()
1426                .filter(|node| node.is_comment())
1427                .all(|node| !nodes.contains(node))
1428        );
1429        assert!(matches!(
1430            resolver.dereference("#visa'3d"),
1431            Err(TransformError::UnsupportedUri(uri)) if uri == "#visa'3d"
1432        ));
1433    }
1434
1435    #[test]
1436    fn parse_xpointer_id_variants() {
1437        // Valid forms
1438        assert_eq!(
1439            super::parse_xpointer_id_fragment("xpointer(id('foo'))"),
1440            Some("foo")
1441        );
1442        assert_eq!(
1443            super::parse_xpointer_id_fragment(r#"xpointer(id("bar"))"#),
1444            Some("bar")
1445        );
1446
1447        // Invalid forms
1448        assert_eq!(super::parse_xpointer_id_fragment("xpointer(/)"), None);
1449        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id(foo))"), None); // no quotes
1450        assert_eq!(super::parse_xpointer_id_fragment("not-xpointer"), None);
1451        assert_eq!(super::parse_xpointer_id_fragment(""), None);
1452
1453        // Malformed: single quote char — must not panic (was slicing bug)
1454        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id('))"), None);
1455        assert_eq!(
1456            super::parse_xpointer_id_fragment(r#"xpointer(id("))"#),
1457            None
1458        );
1459    }
1460
1461    #[test]
1462    fn same_document_reference_nodes_reject_non_id_fragments() {
1463        let document = Document::parse(r#"<root><item ID="target"/></root>"#).unwrap();
1464        let resolver = UriReferenceResolver::new(&document);
1465        for uri in [
1466            "#target",
1467            "#xpointer(id('target'))",
1468            r#"#xpointer(id("target"))"#,
1469        ] {
1470            assert!(
1471                resolver
1472                    .node_id_for_same_document_reference(uri)
1473                    .unwrap()
1474                    .is_some()
1475            );
1476        }
1477        for uri in [
1478            "",
1479            "target",
1480            "#",
1481            "#xpointer(/)",
1482            "#xpointer(id(''))",
1483            "#xpointer(id(target))",
1484        ] {
1485            assert!(
1486                resolver.node_id_for_same_document_reference(uri).is_err(),
1487                "{uri}"
1488            );
1489        }
1490    }
1491
1492    #[test]
1493    fn same_element_multiple_id_attrs_not_duplicate() {
1494        // An element with both ID="x" and Id="x" should NOT be treated as
1495        // duplicate — it's the same element exposing the same value via
1496        // different scanned attribute names.
1497        let xml = r#"<root><item ID="x" Id="x">data</item></root>"#;
1498        let doc = Document::parse(xml).unwrap();
1499        let resolver = UriReferenceResolver::new(&doc);
1500
1501        assert!(resolver.has_id("x"));
1502        assert!(resolver.dereference("#x").is_ok());
1503    }
1504
1505    #[test]
1506    fn saml_style_document() {
1507        // Realistic SAML-like structure
1508        let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
1509                                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
1510                                     ID="_resp1">
1511            <saml:Assertion ID="_assert1">
1512                <saml:Subject>user@example.com</saml:Subject>
1513            </saml:Assertion>
1514            <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="sig1">
1515                <ds:SignedInfo/>
1516            </ds:Signature>
1517        </samlp:Response>"#;
1518
1519        let doc = Document::parse(xml).unwrap();
1520        let resolver = UriReferenceResolver::new(&doc);
1521
1522        // Should find all three IDs
1523        assert!(resolver.has_id("_resp1"));
1524        assert!(resolver.has_id("_assert1"));
1525        assert!(resolver.has_id("sig1"));
1526        assert_eq!(resolver.id_count(), 3);
1527
1528        // Dereference the assertion
1529        let data = resolver.dereference("#_assert1").unwrap();
1530        let node_set = data.into_node_set().unwrap();
1531
1532        // Assertion element should be in the set
1533        let assertion = doc
1534            .descendants()
1535            .find(|n| n.attribute("ID") == Some("_assert1"))
1536            .unwrap();
1537        assert!(node_set.contains(assertion));
1538
1539        // Subject (child of assertion) should be in the set
1540        let subject = assertion
1541            .children()
1542            .find(|n| n.is_element() && n.has_tag_name("Subject"))
1543            .unwrap();
1544        assert!(node_set.contains(subject));
1545
1546        // Response (parent) should NOT be in the set
1547        assert!(!node_set.contains(doc.root_element()));
1548    }
1549}