#![allow(clippy::unwrap_used)]
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use oxixml_xml::XmlVersion;
use oxixml_xml::errors::Error;
use oxixml_xml::events::Event;
use oxixml_xml::name::QName;
use oxixml_xml::reader::{NsReader, Reader};
const SUITES_DIR_ENV: &str = "OXIXML_XML_SUITES_DIR";
const NOT_WF_ALLOWLIST: &[(&str, &str)] = &[
(
"rmt-ns10-012",
"the two `xmlns` values differ literally and only become equal after the \
attribute-value normalization that the declared `NMTOKEN` type mandates; \
applying declared attribute types requires a validating processor",
),
(
"ibm-1-1-not-wf-P77-ibm77n13.xml",
"the violated constraint (section 4.3.4) is about the `VersionNum` of an \
external entity, which a processor that never retrieves external entities \
cannot see",
),
(
"ibm-1-1-not-wf-P77-ibm77n14.xml",
"the violated constraint (section 4.3.4) is about the `VersionNum` of an \
external entity, which a processor that never retrieves external entities \
cannot see",
),
(
"ibm-1-1-not-wf-P77-ibm77n15.xml",
"the violated constraint (section 4.3.4) is about the `VersionNum` of an \
external entity, which a processor that never retrieves external entities \
cannot see",
),
];
#[derive(Debug, Clone)]
struct TestCase {
id: String,
ty: String,
entities: String,
recommendation: String,
edition: Option<String>,
namespace: String,
sections: String,
path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum SkipReason {
OptionalError,
ExternalEntities,
Errata,
VersionOrEdition,
UnsupportedEncoding,
MissingFile,
}
impl SkipReason {
const fn label(self) -> &'static str {
match self {
Self::OptionalError => "TYPE=error (optional)",
Self::ExternalEntities => "external entities",
Self::Errata => "errata recommendation",
Self::VersionOrEdition => "XML version / edition",
Self::UnsupportedEncoding => "unsupported encoding",
Self::MissingFile => "document file missing",
}
}
}
fn find_catalog() -> Option<PathBuf> {
let root = PathBuf::from(std::env::var_os(SUITES_DIR_ENV)?);
let mut candidates: Vec<PathBuf> = fs::read_dir(&root)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name.starts_with("xmlts"))
})
.collect();
candidates.sort();
let catalog = candidates.pop()?.join("xmlconf").join("xmlconf.xml");
catalog.is_file().then_some(catalog)
}
fn parse_entity_declarations(doctype: &str) -> BTreeMap<String, String> {
let mut entities = BTreeMap::new();
let mut rest = doctype;
while let Some(start) = rest.find("<!ENTITY") {
rest = &rest[start + "<!ENTITY".len()..];
let mut fields = rest.split_whitespace();
let (Some(name), Some(kind)) = (fields.next(), fields.next()) else {
break;
};
if kind != "SYSTEM" {
continue;
}
let Some(quote_at) = rest.find(['"', '\'']) else {
break;
};
let quote = rest.as_bytes()[quote_at];
let after = &rest[quote_at + 1..];
let Some(end) = after.find(quote as char) else {
break;
};
entities.insert(name.to_owned(), after[..end].to_owned());
rest = &after[end + 1..];
}
entities
}
fn read_catalog(path: &Path) -> Result<String, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn parse_catalog(path: &Path, base: &Path, out: &mut Vec<TestCase>) -> Result<(), String> {
let text = read_catalog(path)?;
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut entities = BTreeMap::new();
let mut bases = vec![base.to_path_buf()];
let mut reader = Reader::from_str(&text);
reader.config_mut().trim_text(true);
loop {
match reader
.read_event()
.map_err(|err| format!("{}: {err}", path.display()))?
{
Event::Eof => break,
Event::DocType(doctype) => {
let decoded = doctype
.decode()
.map_err(|err| format!("{}: {err}", path.display()))?;
entities = parse_entity_declarations(&decoded);
}
Event::GeneralRef(reference) => {
let name = reference
.decode()
.map_err(|err| format!("{}: {err}", path.display()))?;
if let Some(system) = entities.get(name.as_ref()) {
let current = bases.last().cloned().unwrap_or_default();
parse_catalog(&dir.join(system), ¤t, out)?;
}
}
event @ (Event::Start(_) | Event::Empty(_)) => {
let is_empty = matches!(event, Event::Empty(_));
let start = match event {
Event::Start(start) | Event::Empty(start) => start,
_ => unreachable!("matched above"),
};
let name = start.name();
if !is_empty && (name == QName(b"TESTCASES") || name == QName(b"TESTSUITE")) {
let mut current = bases.last().cloned().unwrap_or_default();
for attr in start.attributes() {
let attr = attr.map_err(|err| format!("{}: {err}", path.display()))?;
if attr.key == QName(b"xml:base") {
let value = attr
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|err| format!("{}: {err}", path.display()))?;
current = current.join(value.as_ref());
}
}
bases.push(current);
} else if name == QName(b"TEST") {
let mut attrs: BTreeMap<String, String> = BTreeMap::new();
for attr in start.attributes() {
let attr = attr.map_err(|err| format!("{}: {err}", path.display()))?;
let key = String::from_utf8_lossy(attr.key.as_ref()).into_owned();
let value = attr
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|err| format!("{}: {err}", path.display()))?;
attrs.insert(key, value.into_owned());
}
let base = bases.last().cloned().unwrap_or_default();
let uri = attrs.get("URI").cloned().unwrap_or_default();
out.push(TestCase {
id: attrs.get("ID").cloned().unwrap_or_default(),
ty: attrs.get("TYPE").cloned().unwrap_or_default(),
entities: attrs
.get("ENTITIES")
.cloned()
.unwrap_or_else(|| "none".to_owned()),
recommendation: attrs
.get("RECOMMENDATION")
.cloned()
.unwrap_or_else(|| "XML1.0".to_owned()),
edition: attrs.get("EDITION").cloned(),
namespace: attrs
.get("NAMESPACE")
.cloned()
.unwrap_or_else(|| "yes".to_owned()),
sections: attrs.get("SECTIONS").cloned().unwrap_or_default(),
path: base.join(uri),
});
}
}
Event::End(end) => {
let name = end.name();
if (name == QName(b"TESTCASES") || name == QName(b"TESTSUITE")) && bases.len() > 1 {
bases.pop();
}
}
_ => {}
}
}
Ok(())
}
const UNSUPPORTED_ENCODINGS: &[&str] = &["shift_jis", "euc-jp", "iso-2022-jp"];
fn declares_unsupported_encoding(bytes: &[u8]) -> bool {
if bytes.starts_with(&[0xFF, 0xFE])
|| bytes.starts_with(&[0xFE, 0xFF])
|| bytes.starts_with(&[0x00, b'<'])
|| bytes.starts_with(&[b'<', 0x00])
{
return true;
}
let head = &bytes[..bytes.len().min(256)];
let head = String::from_utf8_lossy(head).to_ascii_lowercase();
let Some(decl_end) = head.find("?>") else {
return false;
};
let decl = &head[..decl_end];
UNSUPPORTED_ENCODINGS
.iter()
.any(|encoding| decl.contains(encoding))
}
fn parse_document(bytes: &[u8], namespaces: bool) -> Result<(), String> {
let mut buf = Vec::new();
if namespaces {
let mut reader = NsReader::from_reader(bytes);
reader.config_mut().enable_all_checks(true);
loop {
match reader.read_resolved_event_into(&mut buf) {
Ok((_, Event::Eof)) => return Ok(()),
Ok(_) => buf.clear(),
Err(err) => return Err(describe(&err)),
}
}
} else {
let mut reader = Reader::from_reader(bytes);
reader.config_mut().enable_all_checks(true);
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) => return Ok(()),
Ok(_) => buf.clear(),
Err(err) => return Err(describe(&err)),
}
}
}
}
fn describe(error: &Error) -> String {
format!("{error:?}: {error}")
}
fn skip_reason(case: &TestCase, bytes: Option<&[u8]>) -> Option<SkipReason> {
if case.ty == "error" {
return Some(SkipReason::OptionalError);
}
if case.entities != "none" {
return Some(SkipReason::ExternalEntities);
}
if case.recommendation.contains("errata") {
return Some(SkipReason::Errata);
}
if let Some(edition) = &case.edition
&& !edition.split_whitespace().any(|e| e == "5")
{
return Some(SkipReason::VersionOrEdition);
}
match bytes {
None => Some(SkipReason::MissingFile),
Some(bytes) if declares_unsupported_encoding(bytes) => {
Some(SkipReason::UnsupportedEncoding)
}
Some(_) => None,
}
}
#[test]
fn w3c_xml_conformance_suite() -> Result<(), String> {
let Some(catalog) = find_catalog() else {
eprintln!("SKIP: {SUITES_DIR_ENV} is unset or does not contain an `xmlts*` distribution");
return Ok(());
};
let root = catalog.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut cases = Vec::new();
parse_catalog(&catalog, &root, &mut cases)?;
assert!(!cases.is_empty(), "no TEST elements found in {catalog:?}");
let allowlist: BTreeMap<&str, &str> = NOT_WF_ALLOWLIST.iter().copied().collect();
let mut skipped: BTreeMap<SkipReason, usize> = BTreeMap::new();
let mut not_wf_total = 0usize;
let mut not_wf_detected = 0usize;
let mut valid_total = 0usize;
let mut valid_parsed = 0usize;
let mut failures: Vec<String> = Vec::new();
let mut stale_allowlist: Vec<String> = Vec::new();
let mut allowlisted_hits = 0usize;
for case in &cases {
let bytes = fs::read(&case.path).ok();
if let Some(reason) = skip_reason(case, bytes.as_deref()) {
*skipped.entry(reason).or_default() += 1;
continue;
}
let bytes = bytes.unwrap_or_default();
let namespaces = case.namespace != "no";
let outcome = parse_document(&bytes, namespaces);
match case.ty.as_str() {
"not-wf" => {
not_wf_total += 1;
match (outcome, allowlist.get(case.id.as_str())) {
(Err(_), None) => not_wf_detected += 1,
(Err(_), Some(reason)) => {
not_wf_detected += 1;
stale_allowlist.push(format!(
"{}: now rejected, drop the allowlist entry ({reason})",
case.id
));
}
(Ok(()), Some(_)) => allowlisted_hits += 1,
(Ok(()), None) => failures.push(format!(
"not-wf {} ({}) was accepted [{}] SECTIONS={}",
case.id,
case.path.display(),
case.recommendation,
case.sections
)),
}
}
"valid" | "invalid" => {
valid_total += 1;
match outcome {
Ok(()) => valid_parsed += 1,
Err(err) => failures.push(format!(
"{} {} ({}) was rejected: {err}",
case.ty,
case.id,
case.path.display()
)),
}
}
other => failures.push(format!("{}: unknown TYPE {other:?}", case.id)),
}
}
let mut report = String::new();
let _ = writeln!(
report,
"\nW3C xmlts conformance report ({} cases)",
cases.len()
);
let _ = writeln!(
report,
" not-wf : {not_wf_detected}/{not_wf_total} detected ({allowlisted_hits} allowlisted)"
);
let _ = writeln!(
report,
" valid : {valid_parsed}/{valid_total} parsed (valid + invalid documents)"
);
for (reason, count) in &skipped {
let _ = writeln!(report, " skipped: {count:>4} {}", reason.label());
}
for failure in &failures {
println!("FAIL {failure}");
}
for stale in &stale_allowlist {
println!("STALE {stale}");
}
println!("{report}");
if not_wf_total > 0 && not_wf_detected * 100 < not_wf_total * 99 {
return Err(format!(
"{report}only {not_wf_detected} of {not_wf_total} not-wf cases were detected, \
which is below the 99% gate"
));
}
if !failures.is_empty() || !stale_allowlist.is_empty() {
let mut message = report;
let _ = writeln!(
message,
"{} failures, {} stale allowlist entries (run with `--nocapture` for the list)",
failures.len(),
stale_allowlist.len()
);
return Err(message);
}
Ok(())
}