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
15#[cfg(any(feature = "xmldsig", test))]
17const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"];
18
19#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct IdAttributeRegistration {
27 attribute_local_name: String,
28 element_scope: IdAttributeElementScope,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32enum IdAttributeElementScope {
33 AnyElement,
34 AnyNamespace {
35 local_name: String,
36 },
37 ExpandedName {
38 local_name: String,
39 namespace: Option<String>,
40 },
41}
42
43impl IdAttributeRegistration {
44 #[must_use]
46 pub fn global(attribute_local_name: impl Into<String>) -> Self {
47 Self {
48 attribute_local_name: attribute_local_name.into(),
49 element_scope: IdAttributeElementScope::AnyElement,
50 }
51 }
52
53 #[must_use]
57 pub fn scoped_any_namespace(
58 attribute_local_name: impl Into<String>,
59 element_local_name: impl Into<String>,
60 ) -> Self {
61 Self {
62 attribute_local_name: attribute_local_name.into(),
63 element_scope: IdAttributeElementScope::AnyNamespace {
64 local_name: element_local_name.into(),
65 },
66 }
67 }
68
69 #[must_use]
74 pub fn scoped(
75 attribute_local_name: impl Into<String>,
76 element_local_name: impl Into<String>,
77 element_namespace: Option<&str>,
78 ) -> Self {
79 Self {
80 attribute_local_name: attribute_local_name.into(),
81 element_scope: IdAttributeElementScope::ExpandedName {
82 local_name: element_local_name.into(),
83 namespace: element_namespace.map(str::to_owned),
84 },
85 }
86 }
87
88 #[cfg(any(feature = "xmldsig", test))]
89 fn matches(&self, node: Node<'_, '_>, attribute_name: &str) -> bool {
90 self.attribute_local_name == attribute_name && self.matches_node(node)
91 }
92
93 pub(crate) fn attribute_local_name(&self) -> &str {
94 &self.attribute_local_name
95 }
96
97 pub(crate) fn matches_node(&self, node: Node<'_, '_>) -> bool {
98 match &self.element_scope {
99 IdAttributeElementScope::AnyElement => true,
100 IdAttributeElementScope::AnyNamespace { local_name } => {
101 node.tag_name().name() == local_name
102 }
103 IdAttributeElementScope::ExpandedName {
104 local_name,
105 namespace,
106 } => {
107 node.tag_name().name() == local_name
108 && node.tag_name().namespace() == namespace.as_deref()
109 }
110 }
111 }
112}
113
114#[cfg(any(feature = "xmldsig", test))]
116pub(crate) struct XmlIdIndex<'a> {
117 nodes: HashMap<&'a str, Node<'a, 'a>>,
118}
119
120#[cfg(any(feature = "xmldsig", test))]
121impl<'a> XmlIdIndex<'a> {
122 pub(crate) fn with_registrations(
124 document: &'a Document<'a>,
125 registrations: &[IdAttributeRegistration],
126 ) -> Self {
127 let mut nodes = HashMap::new();
128 let mut duplicates = HashSet::new();
129 for node in document.descendants().filter(Node::is_element) {
130 for value in node
133 .attributes()
134 .filter(|attribute| {
135 DEFAULT_ID_ATTRS.contains(&attribute.name())
136 || registrations
137 .iter()
138 .any(|registration| registration.matches(node, attribute.name()))
139 })
140 .map(|attribute| attribute.value())
141 {
142 if duplicates.contains(value) {
143 continue;
144 }
145 match nodes.entry(value) {
146 Entry::Vacant(entry) => {
147 entry.insert(node);
148 }
149 Entry::Occupied(entry) if entry.get().id() != node.id() => {
150 entry.remove();
151 duplicates.insert(value);
152 }
153 Entry::Occupied(_) => {}
154 }
155 }
156 }
157 Self { nodes }
158 }
159
160 #[cfg(feature = "xmldsig")]
161 pub(crate) fn contains(&self, id: &str) -> bool {
162 self.nodes.contains_key(id)
163 }
164
165 #[cfg(feature = "xmldsig")]
166 pub(crate) fn node_id(&self, id: &str) -> Option<NodeId> {
167 self.nodes.get(id).map(|node| node.id())
168 }
169
170 pub(crate) fn node(&self, id: &str) -> Option<Node<'a, 'a>> {
171 self.nodes.get(id).copied()
172 }
173
174 #[cfg(feature = "xmldsig")]
175 pub(crate) fn len(&self) -> usize {
176 self.nodes.len()
177 }
178}
179
180#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
182pub(crate) fn is_xml_1_0_character(character: char) -> bool {
183 matches!(
186 character,
187 '\u{9}'
188 | '\u{A}'
189 | '\u{D}'
190 | '\u{20}'..='\u{D7FF}'
191 | '\u{E000}'..='\u{FFFD}'
192 | '\u{10000}'..='\u{10FFFF}'
193 )
194}
195
196#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
198pub(crate) fn is_xml_ncname(value: &str) -> bool {
199 if value.is_empty() || value.contains(':') {
200 return false;
201 }
202
203 dom::Document::parse(&format!("<{value}/>"))
206 .is_ok_and(|document| document.root_element().tag_name().name() == value)
207}
208
209#[cfg(test)]
210mod tests {
211 use crate::xml::dom::{Document, ParsingOptions};
212
213 use super::{IdAttributeRegistration, XmlIdIndex};
214 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
215 use super::{is_xml_1_0_character, is_xml_ncname};
216
217 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
218 #[test]
219 fn xml_1_0_character_boundaries_match_production_two() {
220 for character in [
222 '\u{9}',
223 '\u{A}',
224 '\u{D}',
225 '\u{20}',
226 '\u{D7FF}',
227 '\u{E000}',
228 '\u{FFFD}',
229 '\u{10000}',
230 '\u{10FFFF}',
231 ] {
232 assert!(is_xml_1_0_character(character), "{character:?}");
233 }
234 for character in [
235 '\0', '\u{1}', '\u{B}', '\u{C}', '\u{E}', '\u{1F}', '\u{FFFE}', '\u{FFFF}',
236 ] {
237 assert!(!is_xml_1_0_character(character), "{character:?}");
238 }
239 }
240
241 #[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
242 #[test]
243 fn ncname_validation_uses_the_xml_unicode_grammar() {
244 for valid in ["id", "_private", "Δοκιμή"] {
245 assert!(is_xml_ncname(valid), "{valid:?}");
246 }
247 for invalid in ["", "1leading", "bad id", "qualified:name"] {
248 assert!(!is_xml_ncname(invalid), "{invalid:?}");
249 }
250 }
251
252 #[test]
253 fn id_index_rejects_duplicate_values_but_not_duplicate_attributes_on_one_node() {
254 let document = Document::parse(
257 r#"<root><one ID="same" Id="same"/><two id="duplicate"/><three ID="duplicate"/></root>"#,
258 )
259 .expect("ID index fixture must be valid XML");
260 let index = XmlIdIndex::with_registrations(&document, &[]);
261
262 assert_eq!(
263 index.node("same").map(|node| node.tag_name().name()),
264 Some("one")
265 );
266 assert!(index.node("duplicate").is_none());
267 }
268
269 #[test]
270 fn id_index_matches_supported_local_names_in_any_namespace() {
271 let document = Document::parse(
274 r#"<root xmlns:wsu="urn:wsu"><one wsu:Id="wsu-target"/><two xml:id="xml-target"/></root>"#,
275 )
276 .expect("namespaced ID fixture must parse");
277 let index = XmlIdIndex::with_registrations(&document, &[]);
278
279 assert_eq!(
280 index.node("wsu-target").map(|node| node.tag_name().name()),
281 Some("one")
282 );
283 assert_eq!(
284 index.node("xml-target").map(|node| node.tag_name().name()),
285 Some("two")
286 );
287 }
288
289 #[test]
290 fn id_registration_distinguishes_any_and_exact_element_namespaces() {
291 let document = Document::parse(
294 r#"<root xmlns:n="urn:item"><item Token="plain"/><n:item Token="namespaced"/></root>"#,
295 )
296 .expect("scope fixture must parse");
297
298 let any_namespace = XmlIdIndex::with_registrations(
299 &document,
300 &[IdAttributeRegistration::scoped_any_namespace(
301 "Token", "item",
302 )],
303 );
304 assert!(any_namespace.node("plain").is_some());
305 assert!(any_namespace.node("namespaced").is_some());
306
307 let no_namespace = XmlIdIndex::with_registrations(
308 &document,
309 &[IdAttributeRegistration::scoped("Token", "item", None)],
310 );
311 assert!(no_namespace.node("plain").is_some());
312 assert!(no_namespace.node("namespaced").is_none());
313
314 let exact_namespace = XmlIdIndex::with_registrations(
315 &document,
316 &[IdAttributeRegistration::scoped(
317 "Token",
318 "item",
319 Some("urn:item"),
320 )],
321 );
322 assert!(exact_namespace.node("plain").is_none());
323 assert!(exact_namespace.node("namespaced").is_some());
324 }
325
326 #[test]
327 fn dtd_id_declarations_do_not_replace_request_registration() {
328 let document = Document::parse_with_options(
331 "<!DOCTYPE root [<!ATTLIST item Token ID #REQUIRED>]><root><item Token=\"target\"/></root>",
332 ParsingOptions {
333 allow_dtd: true,
334 ..ParsingOptions::default()
335 },
336 )
337 .expect("bounded internal DTD fixture must parse");
338
339 let implicit = XmlIdIndex::with_registrations(&document, &[]);
340 assert!(implicit.node("target").is_none());
341
342 let registered =
343 XmlIdIndex::with_registrations(&document, &[IdAttributeRegistration::global("Token")]);
344 assert_eq!(
345 registered.node("target").map(|node| node.tag_name().name()),
346 Some("item")
347 );
348 }
349}