1pub 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
15const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"];
17
18#[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 #[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 #[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 #[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 #[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
143pub(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#[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 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 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#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
227pub(crate) fn is_xml_1_0_character(character: char) -> bool {
228 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
241pub(crate) fn is_xml_ncname(value: &str) -> bool {
243 !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 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 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 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 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 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}