use quick_xml::events::BytesStart;
use rustc_hash::FxHashMap;
use smallvec::SmallVec;
use super::error::ParseError;
use super::wellformed::{check_char_refs, check_chars, check_name, check_text};
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DocState {
Prolog,
InRoot(usize),
Epilog,
}
#[derive(Debug)]
pub(crate) struct WellformedChecker {
state: DocState,
root_seen: bool,
doctype_seen: bool,
dtd_open: bool,
document_started: bool,
ns_scopes: FxHashMap<String, Vec<String>>,
ns_undo: Vec<String>,
ns_marks: Vec<usize>,
ns_generation: u64,
cache_generation: u64,
cache_prefix: String,
cache_bound: bool,
}
impl Default for WellformedChecker {
fn default() -> Self {
Self {
state: DocState::Prolog,
root_seen: false,
doctype_seen: false,
dtd_open: false,
document_started: false,
ns_scopes: FxHashMap::default(),
ns_undo: Vec::new(),
ns_marks: Vec::new(),
ns_generation: 0,
cache_generation: u64::MAX,
cache_prefix: String::new(),
cache_bound: false,
}
}
}
const XML_NAMESPACE: &str = "http://www.w3.org/XML/1998/namespace";
const XMLNS_NAMESPACE: &str = "http://www.w3.org/2000/xmlns/";
fn internal_subset_end(fragment: &str) -> Option<usize> {
let bytes = fragment.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = quote {
if b == q {
quote = None;
}
i += 1;
continue;
}
match b {
b'"' | b'\'' => quote = Some(b),
b']' => return Some(i),
b'<' if bytes[i..].starts_with(b"<!--") => {
if let Some(rel) = fragment[i + 4..].find("-->") {
i += 4 + rel + 3;
continue;
}
return None;
}
_ => {}
}
i += 1;
}
None
}
fn subset_open(raw: &str) -> Option<usize> {
let mut quote: Option<u8> = None;
for (i, b) in raw.bytes().enumerate() {
match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'[' => return Some(i),
_ => {}
},
}
}
None
}
#[inline]
fn is_xml_space(c: char) -> bool {
matches!(c, ' ' | '\t' | '\r' | '\n')
}
fn not_wf(message: impl Into<String>) -> ParseError {
ParseError::NotWellFormed {
message: message.into(),
}
}
fn check_declaration(raw: &str) -> std::result::Result<(), ParseError> {
let rest = raw
.strip_prefix("xml")
.ok_or_else(|| not_wf("malformed XML declaration"))?;
let rest = require_leading_s(rest, "before 'version' in the XML declaration")?;
let rest = rest
.strip_prefix("version")
.ok_or_else(|| not_wf("the XML declaration must begin with 'version'"))?;
let (rest, version) = parse_eq_quoted(rest, "version")?;
if !is_version_num(version) {
return Err(not_wf(format!("unsupported XML version '{version}'")));
}
let rest = match pseudo_attr(rest, "encoding")? {
Some((after, enc)) => {
if !is_enc_name(enc) {
return Err(not_wf(format!("illegal encoding name '{enc}'")));
}
after
}
None => rest,
};
let rest = match pseudo_attr(rest, "standalone")? {
Some((after, sd)) => {
if sd != "yes" && sd != "no" {
return Err(not_wf(format!(
"the standalone declaration must be 'yes' or 'no', not '{sd}'"
)));
}
after
}
None => rest,
};
if !rest.trim_start_matches(is_xml_space).is_empty() {
return Err(not_wf("unexpected content in the XML declaration"));
}
Ok(())
}
fn require_leading_s<'a>(s: &'a str, ctx: &str) -> std::result::Result<&'a str, ParseError> {
let trimmed = s.trim_start_matches(is_xml_space);
if trimmed.len() == s.len() {
return Err(not_wf(format!("white space is required {ctx}")));
}
Ok(trimmed)
}
fn parse_eq_quoted<'a>(
s: &'a str,
name: &str,
) -> std::result::Result<(&'a str, &'a str), ParseError> {
let s = s.trim_start_matches(is_xml_space);
let s = s
.strip_prefix('=')
.ok_or_else(|| not_wf(format!("expected '=' after '{name}'")))?;
let s = s.trim_start_matches(is_xml_space);
let quote = match s.chars().next() {
Some(q @ ('"' | '\'')) => q,
_ => return Err(not_wf(format!("the value of '{name}' must be quoted"))),
};
let after = &s[quote.len_utf8()..];
match after.find(quote) {
Some(end) => Ok((&after[end + quote.len_utf8()..], &after[..end])),
None => Err(not_wf(format!("unterminated value for '{name}'"))),
}
}
fn pseudo_attr<'a>(
s: &'a str,
name: &str,
) -> std::result::Result<Option<(&'a str, &'a str)>, ParseError> {
let trimmed = s.trim_start_matches(is_xml_space);
if !trimmed.starts_with(name) {
return Ok(None);
}
if trimmed.len() == s.len() {
return Err(not_wf(format!("white space is required before '{name}'")));
}
let (rest, value) = parse_eq_quoted(&trimmed[name.len()..], name)?;
Ok(Some((rest, value)))
}
fn is_version_num(v: &str) -> bool {
match v.strip_prefix("1.") {
Some(digits) => !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()),
None => false,
}
}
fn check_default_ns_declaration(uri: &str) -> std::result::Result<(), ParseError> {
match uri {
XML_NAMESPACE => Err(not_wf(
"the XML namespace must not be declared as the default namespace",
)),
XMLNS_NAMESPACE => Err(not_wf(
"the reserved xmlns namespace must not be declared as the default namespace",
)),
_ => Ok(()),
}
}
fn check_ns_declaration(prefix: &str, uri: &str) -> std::result::Result<(), ParseError> {
if prefix.is_empty() {
return Err(not_wf(
"a namespace prefix declared with 'xmlns:' must not be empty",
));
}
if !is_ncname(prefix) {
return Err(not_wf(format!("illegal namespace prefix '{prefix}'")));
}
match prefix {
"xmlns" => Err(not_wf("the prefix 'xmlns' must not be declared")),
"xml" => {
if uri == XML_NAMESPACE {
Ok(())
} else {
Err(not_wf(
"the prefix 'xml' may only be bound to the XML namespace",
))
}
}
_ if uri == XML_NAMESPACE => Err(not_wf(
"the XML namespace may only be bound to the 'xml' prefix",
)),
_ if uri == XMLNS_NAMESPACE => Err(not_wf(
"the reserved xmlns namespace must not be bound to a prefix",
)),
_ if uri.is_empty() => Err(not_wf(format!(
"a namespace prefix must not be unbound (xmlns:{prefix}=\"\")"
))),
_ => Ok(()),
}
}
#[inline]
fn split_qname<'a>(
name: &'a str,
ctx: &str,
) -> std::result::Result<(Option<&'a str>, &'a str), ParseError> {
let bytes = name.as_bytes();
let Some(first) = bytes.iter().position(|&b| b == b':') else {
return Ok((None, name));
};
let prefix = &name[..first];
let local = &name[first + 1..];
let bad = || not_wf(format!("'{name}' is not a valid namespace-qualified {ctx}"));
if prefix.is_empty() {
return Err(bad());
}
match local.chars().next() {
Some(c) if super::wellformed::is_name_start_char(c) => {}
_ => return Err(bad()),
}
if local.as_bytes().contains(&b':') {
return Err(bad());
}
Ok((Some(prefix), local))
}
fn is_ncname(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c != ':' && super::wellformed::is_name_start_char(c) => {}
_ => return false,
}
chars.all(|c| c != ':' && super::wellformed::is_name_char(c))
}
fn is_enc_name(e: &str) -> bool {
let mut chars = e.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
impl WellformedChecker {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn start(&mut self, e: &BytesStart<'_>) -> Result<()> {
self.document_started = true;
let qname = std::str::from_utf8(e.name().into_inner())?;
check_name(qname, "element name")?;
self.ns_marks.push(self.ns_undo.len());
let mut prefixed: SmallVec<[&str; 8]> = SmallVec::new();
for attr_result in e.attributes() {
let attr = attr_result.map_err(|e| ParseError::AttributeError {
message: e.to_string(),
})?;
let key = std::str::from_utf8(attr.key.into_inner())?;
check_name(key, "attribute name")?;
let value = std::str::from_utf8(attr.value.as_ref())?;
check_chars(value, "attribute value")?;
check_char_refs(value, "attribute value")?;
if value.contains('<') {
return Err(not_wf(format!(
"'<' is not allowed in the value of attribute '{key}'"
))
.into());
}
if key == "xmlns" {
check_default_ns_declaration(value)?;
self.bind(String::new(), value.to_string());
} else if let Some(prefix) = key.strip_prefix("xmlns:") {
check_ns_declaration(prefix, value)?;
self.bind(prefix.to_string(), value.to_string());
} else if key.contains(':') {
prefixed.push(key);
}
}
self.check_namespaces(qname, &prefixed)?;
self.open_element()?;
Ok(())
}
fn check_namespaces(&mut self, element_qname: &str, prefixed: &[&str]) -> Result<()> {
if let (Some(prefix), _) = split_qname(element_qname, "element name")? {
if !self.element_prefix_bound(prefix) {
return Err(not_wf(format!(
"the namespace prefix '{prefix}' of element '{element_qname}' is not bound"
))
.into());
}
}
let mut expanded: SmallVec<[(&str, &str); 8]> = SmallVec::new();
for key in prefixed {
let (Some(prefix), local) = split_qname(key, "attribute name")? else {
continue;
};
let Some(uri) = self.resolve(prefix) else {
return Err(not_wf(format!(
"the namespace prefix '{prefix}' of attribute '{key}' is not bound"
))
.into());
};
if expanded.contains(&(uri, local)) {
return Err(not_wf(format!(
"duplicate attribute '{key}' after namespace resolution"
))
.into());
}
expanded.push((uri, local));
}
Ok(())
}
fn bind(&mut self, prefix: String, uri: String) {
self.ns_scopes.entry(prefix.clone()).or_default().push(uri);
self.ns_undo.push(prefix);
self.ns_generation += 1;
}
#[inline]
fn element_prefix_bound(&mut self, prefix: &str) -> bool {
if self.cache_generation == self.ns_generation && self.cache_prefix == prefix {
return self.cache_bound;
}
let bound = self.resolve(prefix).is_some();
self.cache_generation = self.ns_generation;
self.cache_prefix.clear();
self.cache_prefix.push_str(prefix);
self.cache_bound = bound;
bound
}
fn resolve(&self, prefix: &str) -> Option<&str> {
if prefix == "xml" {
return Some(XML_NAMESPACE);
}
self.ns_scopes
.get(prefix)
.and_then(|stack| stack.last())
.and_then(|uri| (!uri.is_empty()).then_some(uri.as_str()))
}
fn open_element(&mut self) -> Result<()> {
match self.state {
DocState::Prolog => {
self.state = DocState::InRoot(1);
self.root_seen = true;
}
DocState::InRoot(depth) => self.state = DocState::InRoot(depth + 1),
DocState::Epilog => {
return Err(not_wf(
"only one document element is allowed; found content after the root element",
)
.into());
}
}
Ok(())
}
pub(crate) fn end(&mut self, qname: &str) -> Result<()> {
check_name(qname, "element name")?;
if let Some(mark) = self.ns_marks.pop() {
while self.ns_undo.len() > mark {
let prefix = self.ns_undo.pop().expect("undo entry for open element");
if let Some(stack) = self.ns_scopes.get_mut(&prefix) {
stack.pop();
}
self.ns_generation += 1;
}
}
if let DocState::InRoot(depth) = self.state {
self.state = if depth <= 1 {
DocState::Epilog
} else {
DocState::InRoot(depth - 1)
};
}
Ok(())
}
pub(crate) fn text(&mut self, raw: &str) -> Result<()> {
self.document_started = true;
if self.dtd_open {
check_chars(raw, "text content")?;
if let Some(end) = internal_subset_end(raw) {
self.dtd_open = false;
let tail = &raw[end + 1..];
let after = tail.strip_prefix('>').unwrap_or(tail);
return self.text(after);
}
return Ok(());
}
check_text(raw, "text content")?;
if matches!(self.state, DocState::Prolog | DocState::Epilog)
&& !raw.chars().all(is_xml_space)
{
return Err(
not_wf("character data is only allowed inside the document element").into(),
);
}
Ok(())
}
pub(crate) fn cdata(&mut self, raw: &str) -> Result<()> {
self.document_started = true;
check_chars(raw, "CDATA section")?;
if !matches!(self.state, DocState::InRoot(_)) {
return Err(
not_wf("a CDATA section is only allowed inside the document element").into(),
);
}
Ok(())
}
pub(crate) fn comment(&mut self, raw: &str) -> Result<()> {
self.document_started = true;
check_chars(raw, "comment")?;
if raw.contains("--") {
return Err(not_wf("'--' is not allowed inside a comment").into());
}
Ok(())
}
pub(crate) fn pi(&mut self, raw: &str) -> Result<()> {
self.document_started = true;
check_chars(raw, "processing instruction")?;
let target = raw.split(is_xml_space).next().unwrap_or("");
if target.is_empty() {
return Err(not_wf("a processing instruction must have a target").into());
}
check_name(target, "processing-instruction target")?;
if target.contains(':') {
return Err(not_wf(format!(
"a processing-instruction target must not contain a colon: '{target}'"
))
.into());
}
if target.eq_ignore_ascii_case("xml") {
return Err(not_wf(format!(
"'{target}' is a reserved processing-instruction target"
))
.into());
}
Ok(())
}
pub(crate) fn decl(&mut self, raw: &str) -> Result<()> {
if self.document_started {
return Err(not_wf(
"the XML declaration must be at the very beginning of the document",
)
.into());
}
self.document_started = true;
check_declaration(raw)?;
Ok(())
}
pub(crate) fn doctype(&mut self, raw: &str) -> Result<()> {
self.document_started = true;
check_chars(raw, "document type declaration")?;
if self.doctype_seen {
return Err(not_wf("only one DOCTYPE declaration is allowed").into());
}
if self.state != DocState::Prolog {
return Err(
not_wf("the DOCTYPE declaration must appear before the document element").into(),
);
}
self.doctype_seen = true;
match subset_open(raw) {
Some(open) if internal_subset_end(&raw[open + 1..]).is_none() => {
self.dtd_open = true;
}
_ => super::dtd::check_doctype(raw)?,
}
Ok(())
}
pub(crate) fn eof(&mut self) -> Result<()> {
if !self.root_seen {
return Err(not_wf("no document element found").into());
}
if matches!(self.state, DocState::InRoot(_)) {
return Err(not_wf("unexpected end of input: an element was left unclosed").into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal_subset_end_honors_quotes_and_comments() {
assert_eq!(internal_subset_end(" x ]>"), Some(3));
assert_eq!(internal_subset_end("<!ENTITY e \"a]b\">]"), Some(17));
assert_eq!(internal_subset_end("<!-- ] -->]"), Some(10));
assert_eq!(internal_subset_end("<!ENTITY gt \">\""), None);
}
#[test]
fn is_space_matches_only_xml_s() {
for c in [' ', '\t', '\r', '\n'] {
assert!(is_xml_space(c));
}
for c in ['\u{0B}', '\u{0C}', 'a', '\u{A0}'] {
assert!(!is_xml_space(c));
}
}
#[test]
fn declaration_accepts_well_formed() {
for raw in [
"xml version=\"1.0\"",
"xml version='1.0'",
"xml version=\"1.1\"",
"xml version=\"1.0\" encoding=\"UTF-8\"",
"xml version=\"1.0\" standalone=\"yes\"",
"xml version=\"1.0\" encoding=\"ISO-8859-1\" standalone='no' ",
] {
assert!(check_declaration(raw).is_ok(), "expected accept: {raw:?}");
}
}
#[test]
fn declaration_rejects_malformed() {
for raw in [
"xml", "xml encoding=\"UTF-8\"", "xml version=\"2.0\"", "xml version=\"1.0\"standalone=\"yes\"", "xml version=\"1.0\" standalone=\"maybe\"", "xml version=\"1.0\" standalone=\"yes\" encoding=\"UTF-8\"", "xml version=\"1.0\" encoding=\"UTF 8\"", "xml version = 1.0", "xml version=\"1.0\" foo=\"bar\"", ] {
assert!(check_declaration(raw).is_err(), "expected reject: {raw:?}");
}
}
#[test]
fn ns_declaration_rules() {
assert!(check_ns_declaration("a", "http://example.org/a").is_ok());
assert!(check_ns_declaration("xml", XML_NAMESPACE).is_ok());
assert!(check_ns_declaration("xml", "http://wrong").is_err());
assert!(check_ns_declaration("xmlns", "http://example.org").is_err());
assert!(check_ns_declaration("", "http://example.org").is_err());
assert!(check_ns_declaration("a", "").is_err());
assert!(check_ns_declaration("yml", XML_NAMESPACE).is_err());
assert!(check_ns_declaration("ymlns", XMLNS_NAMESPACE).is_err());
}
#[test]
fn split_qname_structure() {
assert_eq!(split_qname("foo", "element name").unwrap(), (None, "foo"));
assert_eq!(
split_qname("a:b", "element name").unwrap(),
(Some("a"), "b")
);
assert!(split_qname(":foo", "element name").is_err());
assert!(split_qname("foo:", "element name").is_err());
assert!(split_qname("a:b:c", "element name").is_err());
assert!(split_qname("a:1b", "element name").is_err());
}
#[test]
fn ncname_recognition() {
assert!(is_ncname("a") && is_ncname("_x.y-z0"));
assert!(!is_ncname("") && !is_ncname("a:b") && !is_ncname("0a"));
}
#[test]
fn version_num_and_enc_name() {
assert!(is_version_num("1.0") && is_version_num("1.10"));
assert!(!is_version_num("2.0") && !is_version_num("1.") && !is_version_num("1"));
assert!(is_enc_name("UTF-8") && is_enc_name("a"));
assert!(!is_enc_name("8bit") && !is_enc_name("") && !is_enc_name("x y"));
}
}