1use std::collections::{HashMap, HashSet, hash_map::Entry};
4
5use roxmltree::{Document, Node};
6
7#[cfg(feature = "xmldsig")]
8use roxmltree::NodeId;
9
10const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"];
12
13#[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 #[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 #[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 #[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
102pub(crate) struct XmlIdIndex<'a> {
104 nodes: HashMap<&'a str, Node<'a, 'a>>,
105}
106
107impl<'a> XmlIdIndex<'a> {
108 #[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, ®istrations)
116 }
117
118 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 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
176pub(crate) fn is_xml_1_0_character(character: char) -> bool {
178 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
191pub(crate) fn is_xml_ncname(value: &str) -> bool {
193 if value.is_empty() || value.contains(':') {
194 return false;
195 }
196
197 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 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 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 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 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}