Skip to main content

xml_sec/
xml.rs

1//! Shared XML lexical invariants used before serialization.
2
3#[cfg(any(feature = "xmldsig", test))]
4use std::collections::{HashMap, HashSet, hash_map::Entry};
5
6#[cfg(any(feature = "xmldsig", test))]
7use roxmltree::Document;
8use roxmltree::Node;
9
10#[cfg(feature = "xmldsig")]
11use roxmltree::NodeId;
12
13/// Default ID attribute names shared by XMLDSig and XMLEnc selection.
14#[cfg(any(feature = "xmldsig", test))]
15const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"];
16
17/// Caller-declared XML ID attribute registration.
18///
19/// Registrations are request context rather than security policy. A global
20/// registration applies an attribute local name to every element; a scoped
21/// registration applies to one element local name in either any namespace or
22/// one exact namespace.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct IdAttributeRegistration {
25    attribute_local_name: String,
26    element_scope: IdAttributeElementScope,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30enum IdAttributeElementScope {
31    AnyElement,
32    AnyNamespace {
33        local_name: String,
34    },
35    ExpandedName {
36        local_name: String,
37        namespace: Option<String>,
38    },
39}
40
41impl IdAttributeRegistration {
42    /// Register an attribute local name as an ID on every element.
43    #[must_use]
44    pub fn global(attribute_local_name: impl Into<String>) -> Self {
45        Self {
46            attribute_local_name: attribute_local_name.into(),
47            element_scope: IdAttributeElementScope::AnyElement,
48        }
49    }
50
51    /// Register an attribute as an ID on a local element name in any namespace.
52    ///
53    /// This models libxmlsec1's unqualified `--id-attr` element-name contract.
54    #[must_use]
55    pub fn scoped_any_namespace(
56        attribute_local_name: impl Into<String>,
57        element_local_name: impl Into<String>,
58    ) -> Self {
59        Self {
60            attribute_local_name: attribute_local_name.into(),
61            element_scope: IdAttributeElementScope::AnyNamespace {
62                local_name: element_local_name.into(),
63            },
64        }
65    }
66
67    /// Register an attribute as an ID only on matching elements.
68    ///
69    /// `element_namespace` is the namespace URI, not an XML prefix. `None`
70    /// matches only elements without a namespace.
71    #[must_use]
72    pub fn scoped(
73        attribute_local_name: impl Into<String>,
74        element_local_name: impl Into<String>,
75        element_namespace: Option<&str>,
76    ) -> Self {
77        Self {
78            attribute_local_name: attribute_local_name.into(),
79            element_scope: IdAttributeElementScope::ExpandedName {
80                local_name: element_local_name.into(),
81                namespace: element_namespace.map(str::to_owned),
82            },
83        }
84    }
85
86    #[cfg(any(feature = "xmldsig", test))]
87    fn matches(&self, node: Node<'_, '_>, attribute_name: &str) -> bool {
88        self.attribute_local_name == attribute_name && self.matches_node(node)
89    }
90
91    pub(crate) fn attribute_local_name(&self) -> &str {
92        &self.attribute_local_name
93    }
94
95    pub(crate) fn matches_node(&self, node: Node<'_, '_>) -> bool {
96        match &self.element_scope {
97            IdAttributeElementScope::AnyElement => true,
98            IdAttributeElementScope::AnyNamespace { local_name } => {
99                node.tag_name().name() == local_name
100            }
101            IdAttributeElementScope::ExpandedName {
102                local_name,
103                namespace,
104            } => {
105                node.tag_name().name() == local_name
106                    && node.tag_name().namespace() == namespace.as_deref()
107            }
108        }
109    }
110}
111
112/// Duplicate-safe index of XML ID attributes in one parsed document.
113#[cfg(any(feature = "xmldsig", test))]
114pub(crate) struct XmlIdIndex<'a> {
115    nodes: HashMap<&'a str, Node<'a, 'a>>,
116}
117
118#[cfg(any(feature = "xmldsig", test))]
119impl<'a> XmlIdIndex<'a> {
120    /// Index standard ID spellings plus caller-declared local attribute names.
121    #[cfg(feature = "xmldsig")]
122    pub(crate) fn with_extra_attrs(document: &'a Document<'a>, extra_attrs: &[&str]) -> Self {
123        let registrations = extra_attrs
124            .iter()
125            .map(|name| IdAttributeRegistration::global(*name))
126            .collect::<Vec<_>>();
127        Self::with_registrations(document, &registrations)
128    }
129
130    /// Index standard ID spellings plus caller-declared registrations.
131    pub(crate) fn with_registrations(
132        document: &'a Document<'a>,
133        registrations: &[IdAttributeRegistration],
134    ) -> Self {
135        let mut nodes = HashMap::new();
136        let mut duplicates = HashSet::new();
137        for node in document.descendants().filter(Node::is_element) {
138            // ID registration is local-name based: qualified profile attributes
139            // such as wsu:Id and xml:id participate alongside unqualified Id.
140            for value in node
141                .attributes()
142                .filter(|attribute| {
143                    DEFAULT_ID_ATTRS.contains(&attribute.name())
144                        || registrations
145                            .iter()
146                            .any(|registration| registration.matches(node, attribute.name()))
147                })
148                .map(|attribute| attribute.value())
149            {
150                if duplicates.contains(value) {
151                    continue;
152                }
153                match nodes.entry(value) {
154                    Entry::Vacant(entry) => {
155                        entry.insert(node);
156                    }
157                    Entry::Occupied(entry) if entry.get().id() != node.id() => {
158                        entry.remove();
159                        duplicates.insert(value);
160                    }
161                    Entry::Occupied(_) => {}
162                }
163            }
164        }
165        Self { nodes }
166    }
167
168    #[cfg(feature = "xmldsig")]
169    pub(crate) fn contains(&self, id: &str) -> bool {
170        self.nodes.contains_key(id)
171    }
172
173    #[cfg(feature = "xmldsig")]
174    pub(crate) fn node_id(&self, id: &str) -> Option<NodeId> {
175        self.nodes.get(id).map(Node::id)
176    }
177
178    pub(crate) fn node(&self, id: &str) -> Option<Node<'a, 'a>> {
179        self.nodes.get(id).copied()
180    }
181
182    #[cfg(feature = "xmldsig")]
183    pub(crate) fn len(&self) -> usize {
184        self.nodes.len()
185    }
186}
187
188/// Return whether a Unicode scalar is permitted by XML 1.0 Fifth Edition [2].
189#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
190pub(crate) fn is_xml_1_0_character(character: char) -> bool {
191    // Rust `char` cannot represent the surrogate range between D7FF and E000,
192    // but the split keeps that exclusion explicit alongside XML's upper bound.
193    matches!(
194        character,
195        '\u{9}'
196            | '\u{A}'
197            | '\u{D}'
198            | '\u{20}'..='\u{D7FF}'
199            | '\u{E000}'..='\u{FFFD}'
200            | '\u{10000}'..='\u{10FFFF}'
201    )
202}
203
204/// Return whether a string is an XML 1.0 NCName.
205#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
206pub(crate) fn is_xml_ncname(value: &str) -> bool {
207    if value.is_empty() || value.contains(':') {
208        return false;
209    }
210
211    // Delegate the complete Unicode Name grammar to the parser used by the
212    // rest of the crate instead of maintaining a partial ASCII approximation.
213    roxmltree::Document::parse(&format!("<{value}/>"))
214        .is_ok_and(|document| document.root_element().tag_name().name() == value)
215}
216
217#[cfg(test)]
218mod tests {
219    use roxmltree::{Document, ParsingOptions};
220
221    use super::{IdAttributeRegistration, XmlIdIndex};
222    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
223    use super::{is_xml_1_0_character, is_xml_ncname};
224
225    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
226    #[test]
227    fn xml_1_0_character_boundaries_match_production_two() {
228        // Exercise each explicit singleton/range boundary in XML 1.0 [2].
229        for character in [
230            '\u{9}',
231            '\u{A}',
232            '\u{D}',
233            '\u{20}',
234            '\u{D7FF}',
235            '\u{E000}',
236            '\u{FFFD}',
237            '\u{10000}',
238            '\u{10FFFF}',
239        ] {
240            assert!(is_xml_1_0_character(character), "{character:?}");
241        }
242        for character in [
243            '\0', '\u{1}', '\u{B}', '\u{C}', '\u{E}', '\u{1F}', '\u{FFFE}', '\u{FFFF}',
244        ] {
245            assert!(!is_xml_1_0_character(character), "{character:?}");
246        }
247    }
248
249    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
250    #[test]
251    fn ncname_validation_uses_the_xml_unicode_grammar() {
252        for valid in ["id", "_private", "Δοκιμή"] {
253            assert!(is_xml_ncname(valid), "{valid:?}");
254        }
255        for invalid in ["", "1leading", "bad id", "qualified:name"] {
256            assert!(!is_xml_ncname(invalid), "{invalid:?}");
257        }
258    }
259
260    #[test]
261    fn id_index_rejects_duplicate_values_but_not_duplicate_attributes_on_one_node() {
262        // Ambiguous IDs must fail closed across every consumer, while one node
263        // carrying equivalent ID spellings still denotes one stable target.
264        let document = Document::parse(
265            r#"<root><one ID="same" Id="same"/><two id="duplicate"/><three ID="duplicate"/></root>"#,
266        )
267        .expect("ID index fixture must be valid XML");
268        let index = XmlIdIndex::with_registrations(&document, &[]);
269
270        assert_eq!(
271            index.node("same").map(|node| node.tag_name().name()),
272            Some("one")
273        );
274        assert!(index.node("duplicate").is_none());
275    }
276
277    #[test]
278    fn id_index_matches_supported_local_names_in_any_namespace() {
279        // ID registration is defined by local attribute name. Common security
280        // profiles qualify Id with wsu or xml, but the target remains the same.
281        let document = Document::parse(
282            r#"<root xmlns:wsu="urn:wsu"><one wsu:Id="wsu-target"/><two xml:id="xml-target"/></root>"#,
283        )
284        .expect("namespaced ID fixture must parse");
285        let index = XmlIdIndex::with_registrations(&document, &[]);
286
287        assert_eq!(
288            index.node("wsu-target").map(|node| node.tag_name().name()),
289            Some("one")
290        );
291        assert_eq!(
292            index.node("xml-target").map(|node| node.tag_name().name()),
293            Some("two")
294        );
295    }
296
297    #[test]
298    fn id_registration_distinguishes_any_and_exact_element_namespaces() {
299        // Donor --id-attr without a namespace matches the local element name
300        // everywhere, while the public scoped API retains exact-name matching.
301        let document = Document::parse(
302            r#"<root xmlns:n="urn:item"><item Token="plain"/><n:item Token="namespaced"/></root>"#,
303        )
304        .expect("scope fixture must parse");
305
306        let any_namespace = XmlIdIndex::with_registrations(
307            &document,
308            &[IdAttributeRegistration::scoped_any_namespace(
309                "Token", "item",
310            )],
311        );
312        assert!(any_namespace.node("plain").is_some());
313        assert!(any_namespace.node("namespaced").is_some());
314
315        let no_namespace = XmlIdIndex::with_registrations(
316            &document,
317            &[IdAttributeRegistration::scoped("Token", "item", None)],
318        );
319        assert!(no_namespace.node("plain").is_some());
320        assert!(no_namespace.node("namespaced").is_none());
321
322        let exact_namespace = XmlIdIndex::with_registrations(
323            &document,
324            &[IdAttributeRegistration::scoped(
325                "Token",
326                "item",
327                Some("urn:item"),
328            )],
329        );
330        assert!(exact_namespace.node("plain").is_none());
331        assert!(exact_namespace.node("namespaced").is_some());
332    }
333
334    #[test]
335    fn dtd_id_declarations_do_not_replace_request_registration() {
336        // Internal-DTD policy controls parsing only. The owned Rust tree does
337        // not expose DTD attribute types, so custom IDs remain request context.
338        let document = Document::parse_with_options(
339            "<!DOCTYPE root [<!ATTLIST item Token ID #REQUIRED>]><root><item Token=\"target\"/></root>",
340            ParsingOptions {
341                allow_dtd: true,
342                ..ParsingOptions::default()
343            },
344        )
345        .expect("bounded internal DTD fixture must parse");
346
347        let implicit = XmlIdIndex::with_registrations(&document, &[]);
348        assert!(implicit.node("target").is_none());
349
350        let registered =
351            XmlIdIndex::with_registrations(&document, &[IdAttributeRegistration::global("Token")]);
352        assert_eq!(
353            registered.node("target").map(|node| node.tag_name().name()),
354            Some("item")
355        );
356    }
357}