Skip to main content

xml_sec/
xml.rs

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