aster_forge_xml/
syntax.rs

1//! Shared XML syntax and parser error classification.
2
3use crate::{Error, XmlSafetyError};
4
5pub(crate) const XML_NAMESPACE_URI: &str = "http://www.w3.org/XML/1998/namespace";
6pub(crate) const XMLNS_NAMESPACE_URI: &str = "http://www.w3.org/2000/xmlns/";
7
8pub(crate) fn validate_qualified_name(name: &str) -> Result<(Option<&str>, &str), Error> {
9    let (prefix, local) = split_qualified_name(name);
10    if !valid_name(local)
11        || prefix.is_some_and(|prefix| !valid_name(prefix))
12        || name.matches(':').count() > 1
13    {
14        return Err(XmlSafetyError::Malformed.into());
15    }
16    Ok((prefix, local))
17}
18
19pub(crate) fn split_qualified_name(name: &str) -> (Option<&str>, &str) {
20    match name.split_once(':') {
21        Some((prefix, local)) => (Some(prefix), local),
22        None => (None, name),
23    }
24}
25
26/// Returns whether `name` is one XML Namespaces `NCName` local name.
27///
28/// This validates the XML 1.0 name character ranges while excluding `:`, which separates a
29/// lexical prefix from its local name.
30#[must_use]
31pub fn is_valid_xml_local_name(name: &str) -> bool {
32    let mut characters = name.chars();
33    characters.next().is_some_and(is_name_start) && characters.all(is_name_char)
34}
35
36pub(crate) use is_valid_xml_local_name as valid_name;
37
38fn is_name_start(character: char) -> bool {
39    matches!(
40        character,
41        'A'..='Z'
42            | '_'
43            | 'a'..='z'
44            | '\u{00C0}'..='\u{00D6}'
45            | '\u{00D8}'..='\u{00F6}'
46            | '\u{00F8}'..='\u{02FF}'
47            | '\u{0370}'..='\u{037D}'
48            | '\u{037F}'..='\u{1FFF}'
49            | '\u{200C}'..='\u{200D}'
50            | '\u{2070}'..='\u{218F}'
51            | '\u{2C00}'..='\u{2FEF}'
52            | '\u{3001}'..='\u{D7FF}'
53            | '\u{F900}'..='\u{FDCF}'
54            | '\u{FDF0}'..='\u{FFFD}'
55            | '\u{10000}'..='\u{EFFFF}'
56    )
57}
58
59fn is_name_char(character: char) -> bool {
60    is_name_start(character)
61        || character.is_ascii_digit()
62        || matches!(character, '-' | '.' | '\u{B7}')
63        || ('\u{300}'..='\u{36F}').contains(&character)
64        || ('\u{203F}'..='\u{2040}').contains(&character)
65}
66
67pub(crate) fn validate_namespace_binding(prefix: &str, uri: &str) -> Result<(), Error> {
68    if prefix == "xmlns"
69        || uri == XMLNS_NAMESPACE_URI
70        || (prefix == "xml" && uri != XML_NAMESPACE_URI)
71        || (prefix != "xml" && uri == XML_NAMESPACE_URI)
72        || (!prefix.is_empty() && uri.is_empty())
73        || !is_valid_xml_namespace_name(uri)
74    {
75        Err(XmlSafetyError::Malformed.into())
76    } else {
77        Ok(())
78    }
79}
80
81/// Returns whether a namespace name is a lexically valid URI reference.
82///
83/// The empty value is retained for the XML Namespaces default-namespace undeclaration. Callers
84/// whose protocol requires a concrete namespace must reject it separately.
85#[must_use]
86pub fn is_valid_xml_namespace_name(uri: &str) -> bool {
87    if uri.chars().any(|character| {
88        character.is_control()
89            || character.is_whitespace()
90            || matches!(
91                character,
92                '<' | '>' | '"' | '{' | '}' | '|' | '\\' | '^' | '`'
93            )
94    }) {
95        return false;
96    }
97
98    let mut bytes = uri.as_bytes().iter();
99    while let Some(byte) = bytes.next() {
100        if *byte != b'%' {
101            continue;
102        }
103        let (Some(first), Some(second)) = (bytes.next(), bytes.next()) else {
104            return false;
105        };
106        if !first.is_ascii_hexdigit() || !second.is_ascii_hexdigit() {
107            return false;
108        }
109    }
110    true
111}
112
113pub(crate) fn map_quick_xml_error(error: quick_xml::Error) -> Error {
114    match error {
115        quick_xml::Error::Encoding(_) => XmlSafetyError::InvalidEncoding.into(),
116        error => Error::InvalidXml(error.to_string()),
117    }
118}
119
120// External-entity classification relies on quick-xml reporting `InvalidBangMarkup` with
121// `error_position()` at the opening `<!ENTITY`. The complete-document entry-point tests pin this
122// contract so a quick-xml upgrade cannot silently downgrade the error to generic malformed XML.
123pub(crate) fn map_quick_xml_error_at(
124    error: quick_xml::Error,
125    error_position: usize,
126    input: &[u8],
127    reject_doctype: bool,
128) -> Error {
129    if reject_doctype
130        && matches!(
131            error,
132            quick_xml::Error::Syntax(quick_xml::errors::SyntaxError::InvalidBangMarkup)
133        )
134        && input
135            .get(error_position..)
136            .is_some_and(|input| input.starts_with(b"<!ENTITY"))
137    {
138        XmlSafetyError::ExternalEntity.into()
139    } else {
140        map_quick_xml_error(error)
141    }
142}