1#[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#[cfg(any(feature = "xmldsig", test))]
15const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"];
16
17#[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 #[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 #[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 #[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#[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 #[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, ®istrations)
128 }
129
130 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 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#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
190pub(crate) fn is_xml_1_0_character(character: char) -> bool {
191 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#[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 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 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 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 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 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 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}