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