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<S: dom::storage::Storage>(
108        &self,
109        node: Node<'_, '_, S>,
110        attribute: dom::Attribute<'_, S>,
111    ) -> bool {
112        self.attribute_local_name == attribute.name()
113            && match &self.attribute_namespace {
114                AttributeNamespaceScope::Any => true,
115                AttributeNamespaceScope::Exact(namespace) => {
116                    namespace.as_deref() == attribute.namespace()
117                }
118            }
119            && self.matches_node(node)
120    }
121
122    pub(crate) fn attribute_local_name(&self) -> &str {
123        &self.attribute_local_name
124    }
125
126    pub(crate) fn matches_node<S: dom::storage::Storage>(&self, node: Node<'_, '_, S>) -> bool {
127        match &self.element_scope {
128            IdAttributeElementScope::AnyElement => true,
129            IdAttributeElementScope::AnyNamespace { local_name } => {
130                node.tag_name().name() == local_name
131            }
132            IdAttributeElementScope::ExpandedName {
133                local_name,
134                namespace,
135            } => {
136                node.tag_name().name() == local_name
137                    && node.tag_name().namespace() == namespace.as_deref()
138            }
139        }
140    }
141}
142
143/// One ID classification contract for indexes and XPath's projected DOM.
144pub(crate) fn is_id_attribute<S: dom::storage::Storage>(
145    node: Node<'_, '_, S>,
146    attribute: dom::Attribute<'_, S>,
147    registrations: &[IdAttributeRegistration],
148) -> bool {
149    attribute.is_id()
150        || DEFAULT_ID_ATTRS.contains(&attribute.name())
151        || registrations
152            .iter()
153            .any(|registration| registration.matches(node, attribute))
154}
155
156/// Duplicate-safe index of XML ID attributes in one parsed document.
157#[cfg(any(feature = "xmldsig", test))]
158pub(crate) struct XmlIdIndex<'a> {
159    nodes: HashMap<&'a str, Node<'a, 'a>>,
160}
161
162#[cfg(any(feature = "xmldsig", test))]
163impl<'a> XmlIdIndex<'a> {
164    /// Index standard ID spellings plus caller-declared registrations.
165    pub(crate) fn with_registrations(
166        document: &'a Document<'a>,
167        registrations: &[IdAttributeRegistration],
168    ) -> Self {
169        let mut nodes = HashMap::new();
170        let mut duplicates = HashSet::new();
171        for node in document.descendants().filter(Node::is_element) {
172            // ID registration is local-name based: qualified profile attributes
173            // such as wsu:Id and xml:id participate alongside unqualified Id.
174            for value in node
175                .attributes()
176                .filter(|attribute| is_id_attribute(node, *attribute, registrations))
177                .map(|attribute| attribute.value())
178            {
179                if duplicates.contains(value) {
180                    continue;
181                }
182                match nodes.entry(value) {
183                    Entry::Vacant(entry) => {
184                        entry.insert(node);
185                    }
186                    Entry::Occupied(entry) if entry.get().id() != node.id() => {
187                        entry.remove();
188                        duplicates.insert(value);
189                    }
190                    Entry::Occupied(_) => {}
191                }
192            }
193        }
194        Self { nodes }
195    }
196
197    #[cfg(feature = "xmldsig")]
198    pub(crate) fn contains(&self, id: &str) -> bool {
199        self.nodes.contains_key(id)
200    }
201
202    #[cfg(feature = "xmldsig")]
203    pub(crate) fn node_id(&self, id: &str) -> Option<NodeId> {
204        self.nodes.get(id).map(|node| node.id())
205    }
206
207    pub(crate) fn node(&self, id: &str) -> Option<Node<'a, 'a>> {
208        self.nodes.get(id).copied()
209    }
210
211    #[cfg(feature = "xmldsig")]
212    pub(crate) fn len(&self) -> usize {
213        self.nodes.len()
214    }
215
216    #[cfg(feature = "xmldsig")]
217    pub(crate) fn into_node_ids(self) -> HashMap<String, NodeId> {
218        self.nodes
219            .into_iter()
220            .map(|(value, node)| (value.to_owned(), node.id()))
221            .collect()
222    }
223}
224
225/// Return whether a Unicode scalar is permitted by XML 1.0 Fifth Edition [2].
226#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
227pub(crate) fn is_xml_1_0_character(character: char) -> bool {
228    // Rust `char` cannot represent the surrogate range between D7FF and E000,
229    // but the split keeps that exclusion explicit alongside XML's upper bound.
230    matches!(
231        character,
232        '\u{9}'
233            | '\u{A}'
234            | '\u{D}'
235            | '\u{20}'..='\u{D7FF}'
236            | '\u{E000}'..='\u{FFFD}'
237            | '\u{10000}'..='\u{10FFFF}'
238    )
239}
240
241/// Return whether a string is an XML 1.0 NCName.
242pub(crate) fn is_xml_ncname(value: &str) -> bool {
243    // Namespaces in XML 1.0 section 3: NCName is QName without a colon.
244    // Reuse the common lexical grammar without allocating or parsing a DOM.
245    // https://www.w3.org/TR/2009/REC-xml-names-20091208/#ns-qualnames
246    !value.contains(':') && crate::xml_input::lexical::is_qname(value)
247}
248
249#[cfg(test)]
250mod tests {
251    use crate::xml::dom::{Document, ParsingOptions};
252
253    use super::{IdAttributeRegistration, XmlIdIndex};
254    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
255    use super::{is_xml_1_0_character, is_xml_ncname};
256
257    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
258    #[test]
259    fn xml_1_0_character_boundaries_match_production_two() {
260        // Exercise each explicit singleton/range boundary in XML 1.0 [2].
261        for character in [
262            '\u{9}',
263            '\u{A}',
264            '\u{D}',
265            '\u{20}',
266            '\u{D7FF}',
267            '\u{E000}',
268            '\u{FFFD}',
269            '\u{10000}',
270            '\u{10FFFF}',
271        ] {
272            assert!(is_xml_1_0_character(character), "{character:?}");
273        }
274        for character in [
275            '\0', '\u{1}', '\u{B}', '\u{C}', '\u{E}', '\u{1F}', '\u{FFFE}', '\u{FFFF}',
276        ] {
277            assert!(!is_xml_1_0_character(character), "{character:?}");
278        }
279    }
280
281    #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
282    #[test]
283    fn ncname_validation_uses_the_xml_unicode_grammar() {
284        for valid in ["id", "_private", "Δοκιμή"] {
285            assert!(is_xml_ncname(valid), "{valid:?}");
286        }
287        for invalid in ["", "1leading", "bad id", "qualified:name"] {
288            assert!(!is_xml_ncname(invalid), "{invalid:?}");
289        }
290    }
291
292    #[test]
293    fn id_index_rejects_duplicate_values_but_not_duplicate_attributes_on_one_node() {
294        // Ambiguous IDs must fail closed across every consumer, while one node
295        // carrying equivalent ID spellings still denotes one stable target.
296        let document = Document::parse(
297            r#"<root><one ID="same" Id="same"/><two id="duplicate"/><three ID="duplicate"/></root>"#,
298        )
299        .expect("ID index fixture must be valid XML");
300        let index = XmlIdIndex::with_registrations(&document, &[]);
301
302        assert_eq!(
303            index.node("same").map(|node| node.tag_name().name()),
304            Some("one")
305        );
306        assert!(index.node("duplicate").is_none());
307    }
308
309    #[test]
310    fn id_index_matches_supported_local_names_in_any_namespace() {
311        // ID registration is defined by local attribute name. Common security
312        // profiles qualify Id with wsu or xml, but the target remains the same.
313        let document = Document::parse(
314            r#"<root xmlns:wsu="urn:wsu"><one wsu:Id="wsu-target"/><two xml:id="xml-target"/></root>"#,
315        )
316        .expect("namespaced ID fixture must parse");
317        let index = XmlIdIndex::with_registrations(&document, &[]);
318
319        assert_eq!(
320            index.node("wsu-target").map(|node| node.tag_name().name()),
321            Some("one")
322        );
323        assert_eq!(
324            index.node("xml-target").map(|node| node.tag_name().name()),
325            Some("two")
326        );
327    }
328
329    #[test]
330    fn id_registration_distinguishes_any_and_exact_element_namespaces() {
331        // Donor --id-attr without a namespace matches the local element name
332        // everywhere, while the public scoped API retains exact-name matching.
333        let document = Document::parse(
334            r#"<root xmlns:n="urn:item"><item Token="plain"/><n:item Token="namespaced"/></root>"#,
335        )
336        .expect("scope fixture must parse");
337
338        let any_namespace = XmlIdIndex::with_registrations(
339            &document,
340            &[IdAttributeRegistration::scoped_any_namespace(
341                "Token", "item",
342            )],
343        );
344        assert!(any_namespace.node("plain").is_some());
345        assert!(any_namespace.node("namespaced").is_some());
346
347        let no_namespace = XmlIdIndex::with_registrations(
348            &document,
349            &[IdAttributeRegistration::scoped("Token", "item", None)],
350        );
351        assert!(no_namespace.node("plain").is_some());
352        assert!(no_namespace.node("namespaced").is_none());
353
354        let exact_namespace = XmlIdIndex::with_registrations(
355            &document,
356            &[IdAttributeRegistration::scoped(
357                "Token",
358                "item",
359                Some("urn:item"),
360            )],
361        );
362        assert!(exact_namespace.node("plain").is_none());
363        assert!(exact_namespace.node("namespaced").is_some());
364    }
365
366    #[test]
367    fn dtd_id_declarations_register_ids_without_request_registration() {
368        // DTD-determined IDs must survive parser projection, independently of
369        // caller registrations, so references see the same target on both backends.
370        let document = Document::parse_with_options(
371            "<!DOCTYPE root [<!ATTLIST item Token ID #REQUIRED>]><root><item Token=\"target\"/></root>",
372            ParsingOptions {
373                allow_dtd: true,
374                ..ParsingOptions::default()
375            },
376        )
377        .expect("bounded internal DTD fixture must parse");
378
379        let implicit = XmlIdIndex::with_registrations(&document, &[]);
380        assert_eq!(
381            implicit.node("target").map(|node| node.tag_name().name()),
382            Some("item")
383        );
384
385        let registered =
386            XmlIdIndex::with_registrations(&document, &[IdAttributeRegistration::global("Token")]);
387        assert_eq!(
388            registered.node("target").map(|node| node.tag_name().name()),
389            Some("item")
390        );
391    }
392}