Skip to main content

xml_sec/
xml.rs

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