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