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