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(&self, node: Node<'_, '_>, attribute: dom::Attribute<'_>) -> bool {
108 self.attribute_local_name == attribute.name()
109 && match &self.attribute_namespace {
110 AttributeNamespaceScope::Any => true,
111 AttributeNamespaceScope::Exact(namespace) => {
112 namespace.as_deref() == attribute.namespace()
113 }
114 }
115 && self.matches_node(node)
116 }
117
118 pub(crate) fn attribute_local_name(&self) -> &str {
119 &self.attribute_local_name
120 }
121
122 pub(crate) fn matches_node(&self, node: Node<'_, '_>) -> bool {
123 match &self.element_scope {
124 IdAttributeElementScope::AnyElement => true,
125 IdAttributeElementScope::AnyNamespace { local_name } => {
126 node.tag_name().name() == local_name
127 }
128 IdAttributeElementScope::ExpandedName {
129 local_name,
130 namespace,
131 } => {
132 node.tag_name().name() == local_name
133 && node.tag_name().namespace() == namespace.as_deref()
134 }
135 }
136 }
137}
138
139pub(crate) fn is_id_attribute(
141 node: Node<'_, '_>,
142 attribute: dom::Attribute<'_>,
143 registrations: &[IdAttributeRegistration],
144) -> bool {
145 attribute.is_id()
146 || DEFAULT_ID_ATTRS.contains(&attribute.name())
147 || registrations
148 .iter()
149 .any(|registration| registration.matches(node, attribute))
150}
151
152#[cfg(any(feature = "xmldsig", test))]
154pub(crate) struct XmlIdIndex<'a> {
155 nodes: HashMap<&'a str, Node<'a, 'a>>,
156}
157
158#[cfg(any(feature = "xmldsig", test))]
159impl<'a> XmlIdIndex<'a> {
160 pub(crate) fn with_registrations(
162 document: &'a Document<'a>,
163 registrations: &[IdAttributeRegistration],
164 ) -> Self {
165 let mut nodes = HashMap::new();
166 let mut duplicates = HashSet::new();
167 for node in document.descendants().filter(Node::is_element) {
168 for value in node
171 .attributes()
172 .filter(|attribute| is_id_attribute(node, *attribute, registrations))
173 .map(|attribute| attribute.value())
174 {
175 if duplicates.contains(value) {
176 continue;
177 }
178 match nodes.entry(value) {
179 Entry::Vacant(entry) => {
180 entry.insert(node);
181 }
182 Entry::Occupied(entry) if entry.get().id() != node.id() => {
183 entry.remove();
184 duplicates.insert(value);
185 }
186 Entry::Occupied(_) => {}
187 }
188 }
189 }
190 Self { nodes }
191 }
192
193 #[cfg(feature = "xmldsig")]
194 pub(crate) fn contains(&self, id: &str) -> bool {
195 self.nodes.contains_key(id)
196 }
197
198 #[cfg(feature = "xmldsig")]
199 pub(crate) fn node_id(&self, id: &str) -> Option<NodeId> {
200 self.nodes.get(id).map(|node| node.id())
201 }
202
203 pub(crate) fn node(&self, id: &str) -> Option<Node<'a, 'a>> {
204 self.nodes.get(id).copied()
205 }
206
207 #[cfg(feature = "xmldsig")]
208 pub(crate) fn len(&self) -> usize {
209 self.nodes.len()
210 }
211
212 #[cfg(feature = "xmldsig")]
213 pub(crate) fn into_node_ids(self) -> HashMap<String, NodeId> {
214 self.nodes
215 .into_iter()
216 .map(|(value, node)| (value.to_owned(), node.id()))
217 .collect()
218 }
219}
220
221#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
223pub(crate) fn is_xml_1_0_character(character: char) -> bool {
224 matches!(
227 character,
228 '\u{9}'
229 | '\u{A}'
230 | '\u{D}'
231 | '\u{20}'..='\u{D7FF}'
232 | '\u{E000}'..='\u{FFFD}'
233 | '\u{10000}'..='\u{10FFFF}'
234 )
235}
236
237pub(crate) fn is_xml_ncname(value: &str) -> bool {
239 !value.contains(':') && crate::xml_input::lexical::is_qname(value)
243}
244
245#[cfg(test)]
246mod tests {
247 use crate::xml::dom::{Document, ParsingOptions};
248
249 use super::{IdAttributeRegistration, XmlIdIndex};
250 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
251 use super::{is_xml_1_0_character, is_xml_ncname};
252
253 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
254 #[test]
255 fn xml_1_0_character_boundaries_match_production_two() {
256 for character in [
258 '\u{9}',
259 '\u{A}',
260 '\u{D}',
261 '\u{20}',
262 '\u{D7FF}',
263 '\u{E000}',
264 '\u{FFFD}',
265 '\u{10000}',
266 '\u{10FFFF}',
267 ] {
268 assert!(is_xml_1_0_character(character), "{character:?}");
269 }
270 for character in [
271 '\0', '\u{1}', '\u{B}', '\u{C}', '\u{E}', '\u{1F}', '\u{FFFE}', '\u{FFFF}',
272 ] {
273 assert!(!is_xml_1_0_character(character), "{character:?}");
274 }
275 }
276
277 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
278 #[test]
279 fn ncname_validation_uses_the_xml_unicode_grammar() {
280 for valid in ["id", "_private", "Δοκιμή"] {
281 assert!(is_xml_ncname(valid), "{valid:?}");
282 }
283 for invalid in ["", "1leading", "bad id", "qualified:name"] {
284 assert!(!is_xml_ncname(invalid), "{invalid:?}");
285 }
286 }
287
288 #[test]
289 fn id_index_rejects_duplicate_values_but_not_duplicate_attributes_on_one_node() {
290 let document = Document::parse(
293 r#"<root><one ID="same" Id="same"/><two id="duplicate"/><three ID="duplicate"/></root>"#,
294 )
295 .expect("ID index fixture must be valid XML");
296 let index = XmlIdIndex::with_registrations(&document, &[]);
297
298 assert_eq!(
299 index.node("same").map(|node| node.tag_name().name()),
300 Some("one")
301 );
302 assert!(index.node("duplicate").is_none());
303 }
304
305 #[test]
306 fn id_index_matches_supported_local_names_in_any_namespace() {
307 let document = Document::parse(
310 r#"<root xmlns:wsu="urn:wsu"><one wsu:Id="wsu-target"/><two xml:id="xml-target"/></root>"#,
311 )
312 .expect("namespaced ID fixture must parse");
313 let index = XmlIdIndex::with_registrations(&document, &[]);
314
315 assert_eq!(
316 index.node("wsu-target").map(|node| node.tag_name().name()),
317 Some("one")
318 );
319 assert_eq!(
320 index.node("xml-target").map(|node| node.tag_name().name()),
321 Some("two")
322 );
323 }
324
325 #[test]
326 fn id_registration_distinguishes_any_and_exact_element_namespaces() {
327 let document = Document::parse(
330 r#"<root xmlns:n="urn:item"><item Token="plain"/><n:item Token="namespaced"/></root>"#,
331 )
332 .expect("scope fixture must parse");
333
334 let any_namespace = XmlIdIndex::with_registrations(
335 &document,
336 &[IdAttributeRegistration::scoped_any_namespace(
337 "Token", "item",
338 )],
339 );
340 assert!(any_namespace.node("plain").is_some());
341 assert!(any_namespace.node("namespaced").is_some());
342
343 let no_namespace = XmlIdIndex::with_registrations(
344 &document,
345 &[IdAttributeRegistration::scoped("Token", "item", None)],
346 );
347 assert!(no_namespace.node("plain").is_some());
348 assert!(no_namespace.node("namespaced").is_none());
349
350 let exact_namespace = XmlIdIndex::with_registrations(
351 &document,
352 &[IdAttributeRegistration::scoped(
353 "Token",
354 "item",
355 Some("urn:item"),
356 )],
357 );
358 assert!(exact_namespace.node("plain").is_none());
359 assert!(exact_namespace.node("namespaced").is_some());
360 }
361
362 #[test]
363 fn dtd_id_declarations_register_ids_without_request_registration() {
364 let document = Document::parse_with_options(
367 "<!DOCTYPE root [<!ATTLIST item Token ID #REQUIRED>]><root><item Token=\"target\"/></root>",
368 ParsingOptions {
369 allow_dtd: true,
370 ..ParsingOptions::default()
371 },
372 )
373 .expect("bounded internal DTD fixture must parse");
374
375 let implicit = XmlIdIndex::with_registrations(&document, &[]);
376 assert_eq!(
377 implicit.node("target").map(|node| node.tag_name().name()),
378 Some("item")
379 );
380
381 let registered =
382 XmlIdIndex::with_registrations(&document, &[IdAttributeRegistration::global("Token")]);
383 assert_eq!(
384 registered.node("target").map(|node| node.tag_name().name()),
385 Some("item")
386 );
387 }
388}