#[cfg(any(feature = "xmldsig", test))]
use std::collections::{HashMap, HashSet, hash_map::Entry};
#[cfg(any(feature = "xmldsig", test))]
use roxmltree::Document;
use roxmltree::Node;
#[cfg(feature = "xmldsig")]
use roxmltree::NodeId;
#[cfg(any(feature = "xmldsig", test))]
const DEFAULT_ID_ATTRS: &[&str] = &["ID", "Id", "id"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IdAttributeRegistration {
attribute_local_name: String,
element_scope: IdAttributeElementScope,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum IdAttributeElementScope {
AnyElement,
AnyNamespace {
local_name: String,
},
ExpandedName {
local_name: String,
namespace: Option<String>,
},
}
impl IdAttributeRegistration {
#[must_use]
pub fn global(attribute_local_name: impl Into<String>) -> Self {
Self {
attribute_local_name: attribute_local_name.into(),
element_scope: IdAttributeElementScope::AnyElement,
}
}
#[must_use]
pub fn scoped_any_namespace(
attribute_local_name: impl Into<String>,
element_local_name: impl Into<String>,
) -> Self {
Self {
attribute_local_name: attribute_local_name.into(),
element_scope: IdAttributeElementScope::AnyNamespace {
local_name: element_local_name.into(),
},
}
}
#[must_use]
pub fn scoped(
attribute_local_name: impl Into<String>,
element_local_name: impl Into<String>,
element_namespace: Option<&str>,
) -> Self {
Self {
attribute_local_name: attribute_local_name.into(),
element_scope: IdAttributeElementScope::ExpandedName {
local_name: element_local_name.into(),
namespace: element_namespace.map(str::to_owned),
},
}
}
#[cfg(any(feature = "xmldsig", test))]
fn matches(&self, node: Node<'_, '_>, attribute_name: &str) -> bool {
self.attribute_local_name == attribute_name && self.matches_node(node)
}
pub(crate) fn attribute_local_name(&self) -> &str {
&self.attribute_local_name
}
pub(crate) fn matches_node(&self, node: Node<'_, '_>) -> bool {
match &self.element_scope {
IdAttributeElementScope::AnyElement => true,
IdAttributeElementScope::AnyNamespace { local_name } => {
node.tag_name().name() == local_name
}
IdAttributeElementScope::ExpandedName {
local_name,
namespace,
} => {
node.tag_name().name() == local_name
&& node.tag_name().namespace() == namespace.as_deref()
}
}
}
}
#[cfg(any(feature = "xmldsig", test))]
pub(crate) struct XmlIdIndex<'a> {
nodes: HashMap<&'a str, Node<'a, 'a>>,
}
#[cfg(any(feature = "xmldsig", test))]
impl<'a> XmlIdIndex<'a> {
#[cfg(feature = "xmldsig")]
pub(crate) fn with_extra_attrs(document: &'a Document<'a>, extra_attrs: &[&str]) -> Self {
let registrations = extra_attrs
.iter()
.map(|name| IdAttributeRegistration::global(*name))
.collect::<Vec<_>>();
Self::with_registrations(document, ®istrations)
}
pub(crate) fn with_registrations(
document: &'a Document<'a>,
registrations: &[IdAttributeRegistration],
) -> Self {
let mut nodes = HashMap::new();
let mut duplicates = HashSet::new();
for node in document.descendants().filter(Node::is_element) {
for value in node
.attributes()
.filter(|attribute| {
DEFAULT_ID_ATTRS.contains(&attribute.name())
|| registrations
.iter()
.any(|registration| registration.matches(node, attribute.name()))
})
.map(|attribute| attribute.value())
{
if duplicates.contains(value) {
continue;
}
match nodes.entry(value) {
Entry::Vacant(entry) => {
entry.insert(node);
}
Entry::Occupied(entry) if entry.get().id() != node.id() => {
entry.remove();
duplicates.insert(value);
}
Entry::Occupied(_) => {}
}
}
}
Self { nodes }
}
#[cfg(feature = "xmldsig")]
pub(crate) fn contains(&self, id: &str) -> bool {
self.nodes.contains_key(id)
}
#[cfg(feature = "xmldsig")]
pub(crate) fn node_id(&self, id: &str) -> Option<NodeId> {
self.nodes.get(id).map(Node::id)
}
pub(crate) fn node(&self, id: &str) -> Option<Node<'a, 'a>> {
self.nodes.get(id).copied()
}
#[cfg(feature = "xmldsig")]
pub(crate) fn len(&self) -> usize {
self.nodes.len()
}
}
#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
pub(crate) fn is_xml_1_0_character(character: char) -> bool {
matches!(
character,
'\u{9}'
| '\u{A}'
| '\u{D}'
| '\u{20}'..='\u{D7FF}'
| '\u{E000}'..='\u{FFFD}'
| '\u{10000}'..='\u{10FFFF}'
)
}
#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
pub(crate) fn is_xml_ncname(value: &str) -> bool {
if value.is_empty() || value.contains(':') {
return false;
}
roxmltree::Document::parse(&format!("<{value}/>"))
.is_ok_and(|document| document.root_element().tag_name().name() == value)
}
#[cfg(test)]
mod tests {
use roxmltree::{Document, ParsingOptions};
use super::{IdAttributeRegistration, XmlIdIndex};
#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
use super::{is_xml_1_0_character, is_xml_ncname};
#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
#[test]
fn xml_1_0_character_boundaries_match_production_two() {
for character in [
'\u{9}',
'\u{A}',
'\u{D}',
'\u{20}',
'\u{D7FF}',
'\u{E000}',
'\u{FFFD}',
'\u{10000}',
'\u{10FFFF}',
] {
assert!(is_xml_1_0_character(character), "{character:?}");
}
for character in [
'\0', '\u{1}', '\u{B}', '\u{C}', '\u{E}', '\u{1F}', '\u{FFFE}', '\u{FFFF}',
] {
assert!(!is_xml_1_0_character(character), "{character:?}");
}
}
#[cfg(any(feature = "xmldsig", feature = "xmlenc"))]
#[test]
fn ncname_validation_uses_the_xml_unicode_grammar() {
for valid in ["id", "_private", "Δοκιμή"] {
assert!(is_xml_ncname(valid), "{valid:?}");
}
for invalid in ["", "1leading", "bad id", "qualified:name"] {
assert!(!is_xml_ncname(invalid), "{invalid:?}");
}
}
#[test]
fn id_index_rejects_duplicate_values_but_not_duplicate_attributes_on_one_node() {
let document = Document::parse(
r#"<root><one ID="same" Id="same"/><two id="duplicate"/><three ID="duplicate"/></root>"#,
)
.expect("ID index fixture must be valid XML");
let index = XmlIdIndex::with_registrations(&document, &[]);
assert_eq!(
index.node("same").map(|node| node.tag_name().name()),
Some("one")
);
assert!(index.node("duplicate").is_none());
}
#[test]
fn id_index_matches_supported_local_names_in_any_namespace() {
let document = Document::parse(
r#"<root xmlns:wsu="urn:wsu"><one wsu:Id="wsu-target"/><two xml:id="xml-target"/></root>"#,
)
.expect("namespaced ID fixture must parse");
let index = XmlIdIndex::with_registrations(&document, &[]);
assert_eq!(
index.node("wsu-target").map(|node| node.tag_name().name()),
Some("one")
);
assert_eq!(
index.node("xml-target").map(|node| node.tag_name().name()),
Some("two")
);
}
#[test]
fn id_registration_distinguishes_any_and_exact_element_namespaces() {
let document = Document::parse(
r#"<root xmlns:n="urn:item"><item Token="plain"/><n:item Token="namespaced"/></root>"#,
)
.expect("scope fixture must parse");
let any_namespace = XmlIdIndex::with_registrations(
&document,
&[IdAttributeRegistration::scoped_any_namespace(
"Token", "item",
)],
);
assert!(any_namespace.node("plain").is_some());
assert!(any_namespace.node("namespaced").is_some());
let no_namespace = XmlIdIndex::with_registrations(
&document,
&[IdAttributeRegistration::scoped("Token", "item", None)],
);
assert!(no_namespace.node("plain").is_some());
assert!(no_namespace.node("namespaced").is_none());
let exact_namespace = XmlIdIndex::with_registrations(
&document,
&[IdAttributeRegistration::scoped(
"Token",
"item",
Some("urn:item"),
)],
);
assert!(exact_namespace.node("plain").is_none());
assert!(exact_namespace.node("namespaced").is_some());
}
#[test]
fn dtd_id_declarations_do_not_replace_request_registration() {
let document = Document::parse_with_options(
"<!DOCTYPE root [<!ATTLIST item Token ID #REQUIRED>]><root><item Token=\"target\"/></root>",
ParsingOptions {
allow_dtd: true,
..ParsingOptions::default()
},
)
.expect("bounded internal DTD fixture must parse");
let implicit = XmlIdIndex::with_registrations(&document, &[]);
assert!(implicit.node("target").is_none());
let registered =
XmlIdIndex::with_registrations(&document, &[IdAttributeRegistration::global("Token")]);
assert_eq!(
registered.node("target").map(|node| node.tag_name().name()),
Some("item")
);
}
}