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//! - **`#xpointer(/)`**: the entire document, including comments.
9//! - **`#xpointer(id('id'))` / `#xpointer(id("id"))`**: element by ID (equivalent to bare-name).
10//!
11//! External URIs (http://, file://, etc.) are not supported — only same-document
12//! references are needed for SAML signature verification.
13
14use std::collections::hash_map::Entry;
15use std::collections::{HashMap, HashSet};
16
17use roxmltree::{Document, Node, NodeId};
18
19use super::types::{NodeSet, NodeSetMaterializationBudget, TransformData, TransformError};
20
21/// Default ID attribute names to scan when building the ID index.
22///
23/// These cover the most common conventions:
24/// - `ID` — SAML 2.0 (`<saml:Assertion ID="...">`)
25/// - `Id` — XMLDSig (`<ds:Signature Id="...">`)
26/// - `id` — general XML
27const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"];
28
29/// Resolves same-document URI references against a parsed XML document.
30///
31/// Builds a `HashMap<&str, Node>` index on construction for O(1) fragment
32/// lookups. Supports caller-provided ID attribute names (important for SAML
33/// which uses `ID` rather than the xml:id mechanism).
34///
35/// # Example
36///
37/// ```
38/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
39/// use xml_sec::xmldsig::uri::UriReferenceResolver;
40///
41/// let xml = r#"<root><item ID="abc">content</item></root>"#;
42/// let doc = roxmltree::Document::parse(xml)?;
43/// let resolver = UriReferenceResolver::new(&doc);
44///
45/// assert!(resolver.has_id("abc"));
46/// assert_eq!(resolver.id_count(), 1);
47/// # Ok(())
48/// # }
49/// ```
50pub struct UriReferenceResolver<'a> {
51    doc: &'a Document<'a>,
52    /// ID → element node mapping for O(1) fragment lookups.
53    id_map: HashMap<&'a str, Node<'a, 'a>>,
54}
55
56impl<'a> UriReferenceResolver<'a> {
57    /// Build a resolver with default ID attribute names (`ID`, `Id`, `id`).
58    pub fn new(doc: &'a Document<'a>) -> Self {
59        Self::with_id_attrs(doc, DEFAULT_ID_ATTRS)
60    }
61
62    /// Build a resolver scanning additional ID attribute names beyond the defaults.
63    ///
64    /// The defaults (`ID`, `Id`, `id`) are always included; `extra_attrs`
65    /// adds to them (does not replace). Pass an empty slice to use only defaults.
66    ///
67    /// Attribute names are matched using `roxmltree`'s *local-name* view of
68    /// attributes: any namespace prefix is stripped before comparison. For
69    /// example, an attribute written as `wsu:Id="..."` in the XML is seen as
70    /// simply `Id` by `roxmltree`, so callers **must** pass `"Id"`, not
71    /// `"wsu:Id"` or `"{namespace}Id"`.
72    pub fn with_id_attrs(doc: &'a Document<'a>, extra_attrs: &[&str]) -> Self {
73        let mut id_map = HashMap::new();
74        // Track IDs seen more than once so they are never reinserted
75        // after being removed (handles 3+ occurrences correctly).
76        let mut duplicate_ids: HashSet<&'a str> = HashSet::new();
77
78        // Merge default + extra attribute names, dedup
79        let mut attr_names: Vec<&str> = DEFAULT_ID_ATTRS.to_vec();
80        for name in extra_attrs {
81            if !attr_names.contains(name) {
82                attr_names.push(name);
83            }
84        }
85
86        // Scan all elements for ID attributes
87        for node in doc.descendants() {
88            if node.is_element() {
89                for attr_name in &attr_names {
90                    if let Some(value) = node.attribute(*attr_name) {
91                        // Skip IDs already marked as duplicate
92                        if duplicate_ids.contains(value) {
93                            continue;
94                        }
95
96                        // Duplicate IDs are invalid per XML spec and can enable
97                        // signature-wrapping attacks. Remove the entry so that
98                        // lookups for ambiguous IDs fail with ElementNotFound
99                        // rather than silently picking an arbitrary node.
100                        match id_map.entry(value) {
101                            Entry::Vacant(v) => {
102                                v.insert(node);
103                            }
104                            Entry::Occupied(o) => {
105                                // Only treat as duplicate if a *different* element
106                                // maps the same ID value. The same element can
107                                // expose the same value via multiple scanned attrs
108                                // (e.g., both `ID="x"` and `Id="x"`).
109                                if o.get().id() != node.id() {
110                                    o.remove();
111                                    duplicate_ids.insert(value);
112                                }
113                            }
114                        }
115                    }
116                }
117            }
118        }
119
120        Self { doc, id_map }
121    }
122
123    /// Dereference a URI string to a [`TransformData`].
124    ///
125    /// # URI forms
126    ///
127    /// | URI | Result |
128    /// |-----|--------|
129    /// | `""` (empty) | Entire document, comments excluded |
130    /// | `"#foo"` | Subtree rooted at element with ID `foo` |
131    /// | `"#xpointer(/)"` | Entire document, comments included |
132    /// | `"#xpointer(id('foo'))"` | Subtree rooted at element with ID `foo` |
133    /// | other | `Err(UnsupportedUri)` |
134    pub fn dereference(&self, uri: &str) -> Result<TransformData<'a>, TransformError> {
135        self.dereference_with_optional_budget(uri, None)
136    }
137
138    pub(crate) fn dereference_with_budget(
139        &self,
140        uri: &str,
141        budget: &NodeSetMaterializationBudget,
142    ) -> Result<TransformData<'a>, TransformError> {
143        self.dereference_with_optional_budget(uri, Some(budget))
144    }
145
146    fn dereference_with_optional_budget(
147        &self,
148        uri: &str,
149        budget: Option<&NodeSetMaterializationBudget>,
150    ) -> Result<TransformData<'a>, TransformError> {
151        if uri.is_empty() {
152            // Empty URI = entire document without comments
153            // XMLDSig §4.3.3.2: "the reference is to the document [...],
154            // and the comment nodes are not included"
155            let nodes = match budget {
156                Some(budget) => {
157                    NodeSet::entire_document_without_comments_with_budget(self.doc, budget)?
158                }
159                None => NodeSet::entire_document_without_comments(self.doc)?,
160            };
161            Ok(TransformData::NodeSet(nodes))
162        } else if let Some(fragment) = uri.strip_prefix('#') {
163            // Note: we intentionally do NOT percent-decode the fragment.
164            // XMLDSig ID values are XML Name tokens (no spaces/special chars),
165            // and real-world SAML never uses percent-encoded fragments.
166            // xmlsec1 also passes fragments through without decoding.
167            self.dereference_fragment(fragment, budget)
168        } else {
169            Err(TransformError::UnsupportedUri(uri.to_string()))
170        }
171    }
172
173    /// Resolve a URI fragment (the part after `#`).
174    ///
175    /// Handles:
176    /// - `xpointer(/)` → entire document (with comments, per XPointer spec)
177    /// - `xpointer(id('foo'))` → element by ID (equivalent to bare-name `#foo`)
178    /// - bare name `foo` → element by ID attribute
179    fn dereference_fragment(
180        &self,
181        fragment: &str,
182        budget: Option<&NodeSetMaterializationBudget>,
183    ) -> Result<TransformData<'a>, TransformError> {
184        if fragment.is_empty() {
185            // Bare "#" is not a valid same-document reference
186            return Err(TransformError::UnsupportedUri("#".to_string()));
187        }
188
189        if fragment == "xpointer(/)" {
190            // XPointer root: entire document WITH comments (unlike empty URI).
191            // Per XMLDSig §4.3.3.3: "the XPointer expression [...] includes
192            // comment nodes"
193            let nodes = match budget {
194                Some(budget) => {
195                    NodeSet::entire_document_with_comments_with_budget(self.doc, budget)?
196                }
197                None => NodeSet::entire_document_with_comments(self.doc)?,
198            };
199            Ok(TransformData::NodeSet(nodes))
200        } else if let Some(id) = parse_xpointer_id_fragment(fragment) {
201            // xpointer(id('foo')) → same as bare-name #foo
202            // Reject empty parsed ID (e.g., xpointer(id(''))) — not a valid XML Name
203            if id.is_empty() {
204                return Err(TransformError::UnsupportedUri(format!("#{fragment}")));
205            }
206            self.resolve_id(id, budget)
207        } else if fragment.starts_with("xpointer(") {
208            // Any other XPointer expression is unsupported
209            Err(TransformError::UnsupportedUri(format!("#{fragment}")))
210        } else {
211            // Bare-name fragment: #foo → element by ID
212            self.resolve_id(fragment, budget)
213        }
214    }
215
216    /// Look up an element by its ID attribute value and return a subtree node set.
217    fn resolve_id(
218        &self,
219        id: &str,
220        budget: Option<&NodeSetMaterializationBudget>,
221    ) -> Result<TransformData<'a>, TransformError> {
222        match self.id_map.get(id) {
223            Some(&element) => {
224                let nodes = match budget {
225                    Some(budget) => NodeSet::subtree_with_budget(element, budget)?,
226                    None => NodeSet::subtree(element)?,
227                };
228                Ok(TransformData::NodeSet(nodes))
229            }
230            None => Err(TransformError::ElementNotFound(id.to_string())),
231        }
232    }
233
234    /// Check if an ID is registered in the resolver's index.
235    pub fn has_id(&self, id: &str) -> bool {
236        self.id_map.contains_key(id)
237    }
238
239    /// Resolve a same-document ID token to a stable node identity.
240    ///
241    /// Returns `None` when the ID is absent or ambiguous (duplicate ID collision),
242    /// matching the resolver behavior used by `dereference()`.
243    pub(crate) fn node_id_for_id(&self, id: &str) -> Option<NodeId> {
244        self.id_map.get(id).map(|node| node.id())
245    }
246
247    /// Get the number of registered IDs.
248    pub fn id_count(&self) -> usize {
249        self.id_map.len()
250    }
251}
252
253/// Parse `xpointer(id('value'))` or `xpointer(id("value"))` and return the ID value.
254/// Returns `None` if the fragment doesn't match this pattern.
255pub(crate) fn parse_xpointer_id_fragment(fragment: &str) -> Option<&str> {
256    let inner = fragment.strip_prefix("xpointer(id(")?.strip_suffix("))")?;
257
258    // Strip single or double quotes using safe helpers to avoid panics
259    // on malformed input (e.g., `xpointer(id('))` where inner is `'`)
260    if let Some(stripped) = inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
261        Some(stripped)
262    } else if let Some(stripped) = inner.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
263        Some(stripped)
264    } else {
265        None
266    }
267}
268
269#[cfg(test)]
270#[allow(clippy::unwrap_used)]
271mod tests {
272    use super::super::types::NodeSet;
273    use super::*;
274
275    #[test]
276    fn empty_uri_returns_whole_document() {
277        let xml = "<root><child>text</child></root>";
278        let doc = Document::parse(xml).unwrap();
279        let resolver = UriReferenceResolver::new(&doc);
280
281        let data = resolver.dereference("").unwrap();
282        let node_set = data.into_node_set().unwrap();
283
284        // Whole document: root and child should be in the set
285        let root = doc.root_element();
286        assert!(node_set.contains(root));
287        let child = root.first_child().unwrap();
288        assert!(node_set.contains(child));
289    }
290
291    #[test]
292    fn empty_uri_excludes_comments() {
293        let xml = "<root><!-- comment --><child/></root>";
294        let doc = Document::parse(xml).unwrap();
295        let resolver = UriReferenceResolver::new(&doc);
296
297        let data = resolver.dereference("").unwrap();
298        let node_set = data.into_node_set().unwrap();
299
300        // Comment should be excluded
301        for node in doc.descendants() {
302            if node.is_comment() {
303                assert!(
304                    !node_set.contains(node),
305                    "comment should be excluded for empty URI"
306                );
307            }
308        }
309        // Element should still be included
310        assert!(node_set.contains(doc.root_element()));
311    }
312
313    #[test]
314    fn fragment_uri_resolves_by_id_attr() {
315        let xml = r#"<root><item ID="abc">content</item><item ID="def">other</item></root>"#;
316        let doc = Document::parse(xml).unwrap();
317        let resolver = UriReferenceResolver::new(&doc);
318
319        let data = resolver.dereference("#abc").unwrap();
320        let node_set = data.into_node_set().unwrap();
321
322        // The element with ID="abc" and its children should be in the set
323        let abc_elem = doc
324            .descendants()
325            .find(|n| n.attribute("ID") == Some("abc"))
326            .unwrap();
327        assert!(node_set.contains(abc_elem));
328
329        // The text child "content" should also be in the set
330        let text_child = abc_elem.first_child().unwrap();
331        assert!(node_set.contains(text_child));
332
333        // The root element should NOT be in the set (subtree only)
334        assert!(!node_set.contains(doc.root_element()));
335
336        // The element with ID="def" should NOT be in the set
337        let def_elem = doc
338            .descendants()
339            .find(|n| n.attribute("ID") == Some("def"))
340            .unwrap();
341        assert!(!node_set.contains(def_elem));
342    }
343
344    #[test]
345    fn fragment_uri_resolves_lowercase_id() {
346        let xml = r#"<root><item id="lower">text</item></root>"#;
347        let doc = Document::parse(xml).unwrap();
348        let resolver = UriReferenceResolver::new(&doc);
349
350        let data = resolver.dereference("#lower").unwrap();
351        let node_set = data.into_node_set().unwrap();
352
353        let elem = doc
354            .descendants()
355            .find(|n| n.attribute("id") == Some("lower"))
356            .unwrap();
357        assert!(node_set.contains(elem));
358    }
359
360    #[test]
361    fn fragment_uri_resolves_mixed_case_id() {
362        let xml = r#"<root><ds:Signature Id="sig1" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/></root>"#;
363        let doc = Document::parse(xml).unwrap();
364        let resolver = UriReferenceResolver::new(&doc);
365
366        assert!(resolver.has_id("sig1"));
367        let data = resolver.dereference("#sig1").unwrap();
368        assert!(data.into_node_set().is_ok());
369    }
370
371    #[test]
372    fn fragment_uri_not_found() {
373        let xml = "<root><child>text</child></root>";
374        let doc = Document::parse(xml).unwrap();
375        let resolver = UriReferenceResolver::new(&doc);
376
377        let result = resolver.dereference("#nonexistent");
378        assert!(result.is_err());
379        match result.unwrap_err() {
380            TransformError::ElementNotFound(id) => assert_eq!(id, "nonexistent"),
381            other => panic!("expected ElementNotFound, got: {other:?}"),
382        }
383    }
384
385    #[test]
386    fn unsupported_external_uri() {
387        let xml = "<root/>";
388        let doc = Document::parse(xml).unwrap();
389        let resolver = UriReferenceResolver::new(&doc);
390
391        let result = resolver.dereference("http://example.com/doc.xml");
392        assert!(result.is_err());
393        match result.unwrap_err() {
394            TransformError::UnsupportedUri(uri) => {
395                assert_eq!(uri, "http://example.com/doc.xml")
396            }
397            other => panic!("expected UnsupportedUri, got: {other:?}"),
398        }
399    }
400
401    #[test]
402    fn unsupported_xpointer_expression() {
403        // XPointer expressions other than xpointer(/) and xpointer(id(...))
404        // should return UnsupportedUri, not fall through to ID lookup
405        let xml = "<root/>";
406        let doc = Document::parse(xml).unwrap();
407        let resolver = UriReferenceResolver::new(&doc);
408
409        let result = resolver.dereference("#xpointer(foo())");
410        assert!(result.is_err());
411        match result.unwrap_err() {
412            TransformError::UnsupportedUri(uri) => {
413                assert_eq!(uri, "#xpointer(foo())")
414            }
415            other => panic!("expected UnsupportedUri, got: {other:?}"),
416        }
417
418        // Generic XPointer with XPath should also be unsupported
419        let result = resolver.dereference("#xpointer(//element)");
420        assert!(result.is_err());
421        assert!(matches!(
422            result.unwrap_err(),
423            TransformError::UnsupportedUri(_)
424        ));
425    }
426
427    #[test]
428    fn empty_fragment_rejected() {
429        // Bare "#" (empty fragment) is not a valid same-document reference
430        let xml = "<root/>";
431        let doc = Document::parse(xml).unwrap();
432        let resolver = UriReferenceResolver::new(&doc);
433
434        let result = resolver.dereference("#");
435        assert!(result.is_err());
436        match result.unwrap_err() {
437            TransformError::UnsupportedUri(uri) => assert_eq!(uri, "#"),
438            other => panic!("expected UnsupportedUri, got: {other:?}"),
439        }
440    }
441
442    #[test]
443    fn foreign_document_node_rejected() {
444        // NodeSet.contains() must reject nodes from a different document
445        let xml1 = "<root><child/></root>";
446        let xml2 = "<other><item/></other>";
447        let doc1 = Document::parse(xml1).unwrap();
448        let doc2 = Document::parse(xml2).unwrap();
449
450        let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
451
452        // Node from doc2 should NOT be in doc1's node set
453        let foreign_node = doc2.root_element();
454        assert!(
455            !node_set.contains(foreign_node),
456            "foreign document node should be rejected"
457        );
458
459        // Node from doc1 should be in the set
460        let own_node = doc1.root_element();
461        assert!(node_set.contains(own_node));
462    }
463
464    #[test]
465    fn custom_id_attr_name() {
466        // roxmltree stores `wsu:Id` with local name "Id" — already in DEFAULT_ID_ATTRS.
467        // Test with a truly custom attribute name instead.
468        let xml = r#"<root><elem myid="custom1">data</elem></root>"#;
469        let doc = Document::parse(xml).unwrap();
470
471        // Default resolver doesn't know about "myid"
472        let resolver_default = UriReferenceResolver::new(&doc);
473        assert!(!resolver_default.has_id("custom1"));
474
475        // Custom resolver with "myid" added
476        let resolver_custom = UriReferenceResolver::with_id_attrs(&doc, &["myid"]);
477        assert!(resolver_custom.has_id("custom1"));
478
479        let data = resolver_custom.dereference("#custom1").unwrap();
480        assert!(data.into_node_set().is_ok());
481    }
482
483    #[test]
484    fn namespaced_id_attr_found_by_local_name() {
485        // roxmltree strips prefix: `wsu:Id` → local name "Id", which is in DEFAULT_ID_ATTRS
486        let xml =
487            r#"<root><elem wsu:Id="ts1" xmlns:wsu="http://example.com/wsu">data</elem></root>"#;
488        let doc = Document::parse(xml).unwrap();
489
490        let resolver = UriReferenceResolver::new(&doc);
491        assert!(resolver.has_id("ts1"));
492    }
493
494    #[test]
495    fn id_count_reports_unique_ids() {
496        let xml = r#"<root ID="r1"><a ID="a1"/><b Id="b1"/><c id="c1"/></root>"#;
497        let doc = Document::parse(xml).unwrap();
498        let resolver = UriReferenceResolver::new(&doc);
499
500        // 4 elements with ID-like attributes
501        assert_eq!(resolver.id_count(), 4);
502    }
503
504    #[test]
505    fn duplicate_ids_are_rejected() {
506        // Duplicate IDs are removed from the index to prevent signature-wrapping
507        // attacks — lookups for ambiguous IDs fail instead of picking arbitrarily.
508        let xml = r#"<root><a ID="dup">first</a><b ID="dup">second</b></root>"#;
509        let doc = Document::parse(xml).unwrap();
510        let resolver = UriReferenceResolver::new(&doc);
511
512        // "dup" appears twice → removed from index
513        assert!(!resolver.has_id("dup"));
514        let result = resolver.dereference("#dup");
515        assert!(result.is_err());
516        assert!(matches!(
517            result.unwrap_err(),
518            TransformError::ElementNotFound(_)
519        ));
520    }
521
522    #[test]
523    fn triple_duplicate_ids_stay_rejected() {
524        // Verify that 3+ occurrences don't re-insert (the HashSet tracks
525        // permanently removed IDs so Entry::Vacant after remove doesn't re-add)
526        let xml = r#"<root><a ID="dup">1</a><b ID="dup">2</b><c ID="dup">3</c></root>"#;
527        let doc = Document::parse(xml).unwrap();
528        let resolver = UriReferenceResolver::new(&doc);
529
530        assert!(!resolver.has_id("dup"));
531        assert!(resolver.dereference("#dup").is_err());
532    }
533
534    #[test]
535    fn node_set_exclude_subtree() {
536        let xml = r#"<root><keep>yes</keep><remove><deep>no</deep></remove></root>"#;
537        let doc = Document::parse(xml).unwrap();
538        let resolver = UriReferenceResolver::new(&doc);
539
540        let data = resolver.dereference("").unwrap();
541        let mut node_set = data.into_node_set().unwrap();
542
543        // Find and exclude the <remove> subtree
544        let remove_elem = doc
545            .descendants()
546            .find(|n| n.is_element() && n.has_tag_name("remove"))
547            .unwrap();
548        node_set.exclude_subtree(remove_elem);
549
550        // <keep> should still be in the set
551        let keep_elem = doc
552            .descendants()
553            .find(|n| n.is_element() && n.has_tag_name("keep"))
554            .unwrap();
555        assert!(node_set.contains(keep_elem));
556
557        // <remove> and its children should be excluded
558        assert!(!node_set.contains(remove_elem));
559        let deep_elem = doc
560            .descendants()
561            .find(|n| n.is_element() && n.has_tag_name("deep"))
562            .unwrap();
563        assert!(!node_set.contains(deep_elem));
564    }
565
566    #[test]
567    fn subtree_includes_comments() {
568        // Subtree dereference (via #id) includes comments, unlike empty URI
569        let xml = r#"<root><item ID="x"><!-- comment --><child/></item></root>"#;
570        let doc = Document::parse(xml).unwrap();
571        let resolver = UriReferenceResolver::new(&doc);
572
573        let data = resolver.dereference("#x").unwrap();
574        let node_set = data.into_node_set().unwrap();
575
576        for node in doc.descendants() {
577            if node.is_comment() {
578                assert!(
579                    node_set.contains(node),
580                    "comment should be included in #id subtree"
581                );
582            }
583        }
584    }
585
586    #[test]
587    fn xpointer_root_returns_whole_document_with_comments() {
588        let xml = "<root><!-- comment --><child/></root>";
589        let doc = Document::parse(xml).unwrap();
590        let resolver = UriReferenceResolver::new(&doc);
591
592        let data = resolver.dereference("#xpointer(/)").unwrap();
593        let node_set = data.into_node_set().unwrap();
594
595        // Unlike empty URI, xpointer(/) includes comments
596        for node in doc.descendants() {
597            if node.is_comment() {
598                assert!(
599                    node_set.contains(node),
600                    "comment should be included for #xpointer(/)"
601                );
602            }
603        }
604        assert!(node_set.contains(doc.root_element()));
605    }
606
607    #[test]
608    fn xpointer_id_single_quotes() {
609        let xml = r#"<root><item ID="abc">content</item></root>"#;
610        let doc = Document::parse(xml).unwrap();
611        let resolver = UriReferenceResolver::new(&doc);
612
613        let data = resolver.dereference("#xpointer(id('abc'))").unwrap();
614        let node_set = data.into_node_set().unwrap();
615
616        let elem = doc
617            .descendants()
618            .find(|n| n.attribute("ID") == Some("abc"))
619            .unwrap();
620        assert!(node_set.contains(elem));
621    }
622
623    #[test]
624    fn xpointer_id_double_quotes() {
625        let xml = r#"<root><item ID="xyz">content</item></root>"#;
626        let doc = Document::parse(xml).unwrap();
627        let resolver = UriReferenceResolver::new(&doc);
628
629        let data = resolver.dereference(r#"#xpointer(id("xyz"))"#).unwrap();
630        let node_set = data.into_node_set().unwrap();
631
632        let elem = doc
633            .descendants()
634            .find(|n| n.attribute("ID") == Some("xyz"))
635            .unwrap();
636        assert!(node_set.contains(elem));
637    }
638
639    #[test]
640    fn xpointer_id_not_found() {
641        let xml = "<root/>";
642        let doc = Document::parse(xml).unwrap();
643        let resolver = UriReferenceResolver::new(&doc);
644
645        let result = resolver.dereference("#xpointer(id('missing'))");
646        assert!(result.is_err());
647        match result.unwrap_err() {
648            TransformError::ElementNotFound(id) => assert_eq!(id, "missing"),
649            other => panic!("expected ElementNotFound, got: {other:?}"),
650        }
651    }
652
653    #[test]
654    fn xpointer_id_empty_value_rejected() {
655        // xpointer(id('')) parses to empty string — reject as UnsupportedUri
656        let xml = "<root/>";
657        let doc = Document::parse(xml).unwrap();
658        let resolver = UriReferenceResolver::new(&doc);
659
660        let result = resolver.dereference("#xpointer(id(''))");
661        assert!(result.is_err());
662        assert!(matches!(
663            result.unwrap_err(),
664            TransformError::UnsupportedUri(_)
665        ));
666    }
667
668    #[test]
669    fn parse_xpointer_id_variants() {
670        // Valid forms
671        assert_eq!(
672            super::parse_xpointer_id_fragment("xpointer(id('foo'))"),
673            Some("foo")
674        );
675        assert_eq!(
676            super::parse_xpointer_id_fragment(r#"xpointer(id("bar"))"#),
677            Some("bar")
678        );
679
680        // Invalid forms
681        assert_eq!(super::parse_xpointer_id_fragment("xpointer(/)"), None);
682        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id(foo))"), None); // no quotes
683        assert_eq!(super::parse_xpointer_id_fragment("not-xpointer"), None);
684        assert_eq!(super::parse_xpointer_id_fragment(""), None);
685
686        // Malformed: single quote char — must not panic (was slicing bug)
687        assert_eq!(super::parse_xpointer_id_fragment("xpointer(id('))"), None);
688        assert_eq!(
689            super::parse_xpointer_id_fragment(r#"xpointer(id("))"#),
690            None
691        );
692    }
693
694    #[test]
695    fn same_element_multiple_id_attrs_not_duplicate() {
696        // An element with both ID="x" and Id="x" should NOT be treated as
697        // duplicate — it's the same element exposing the same value via
698        // different scanned attribute names.
699        let xml = r#"<root><item ID="x" Id="x">data</item></root>"#;
700        let doc = Document::parse(xml).unwrap();
701        let resolver = UriReferenceResolver::new(&doc);
702
703        assert!(resolver.has_id("x"));
704        assert!(resolver.dereference("#x").is_ok());
705    }
706
707    #[test]
708    fn saml_style_document() {
709        // Realistic SAML-like structure
710        let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
711                                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
712                                     ID="_resp1">
713            <saml:Assertion ID="_assert1">
714                <saml:Subject>user@example.com</saml:Subject>
715            </saml:Assertion>
716            <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="sig1">
717                <ds:SignedInfo/>
718            </ds:Signature>
719        </samlp:Response>"#;
720
721        let doc = Document::parse(xml).unwrap();
722        let resolver = UriReferenceResolver::new(&doc);
723
724        // Should find all three IDs
725        assert!(resolver.has_id("_resp1"));
726        assert!(resolver.has_id("_assert1"));
727        assert!(resolver.has_id("sig1"));
728        assert_eq!(resolver.id_count(), 3);
729
730        // Dereference the assertion
731        let data = resolver.dereference("#_assert1").unwrap();
732        let node_set = data.into_node_set().unwrap();
733
734        // Assertion element should be in the set
735        let assertion = doc
736            .descendants()
737            .find(|n| n.attribute("ID") == Some("_assert1"))
738            .unwrap();
739        assert!(node_set.contains(assertion));
740
741        // Subject (child of assertion) should be in the set
742        let subject = assertion
743            .children()
744            .find(|n| n.is_element() && n.has_tag_name("Subject"))
745            .unwrap();
746        assert!(node_set.contains(subject));
747
748        // Response (parent) should NOT be in the set
749        assert!(!node_set.contains(doc.root_element()));
750    }
751}