pub mod inject;
pub mod model;
pub mod validate;
use std::collections::HashMap;
pub use inject::{inject_defaults, inject_defaults_from};
pub use model::{
AttDecl, AttDefault, AttType, ContentModel, DeclRef, ElementDecl, EntityDecl, Group, GroupKind,
Item, Occurrence, Particle,
};
pub use validate::{validate, DtdError};
pub fn collect_id_attrs(dtd: &Dtd) -> HashMap<String, Vec<String>> {
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for (elem, decls) in &dtd.attlists {
let ids: Vec<String> = decls.iter()
.filter(|d| matches!(d.att_type, AttType::Id))
.map(|d| d.name.clone())
.collect();
if !ids.is_empty() {
out.insert(elem.clone(), ids);
}
}
out
}
pub fn collect_idref_attrs(dtd: &Dtd) -> HashMap<String, Vec<String>> {
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for (elem, decls) in &dtd.attlists {
let refs: Vec<String> = decls.iter()
.filter(|d| matches!(d.att_type, AttType::IdRef | AttType::IdRefs))
.map(|d| d.name.clone())
.collect();
if !refs.is_empty() {
out.insert(elem.clone(), refs);
}
}
out
}
#[derive(Debug, Clone, Default)]
pub struct Dtd {
pub elements: HashMap<String, ElementDecl>,
pub element_order: Vec<String>,
pub attlists: HashMap<String, Vec<AttDecl>>,
pub root_name: String,
pub public_id: Option<String>,
pub system_id: Option<String>,
pub unparsed_entities: HashMap<String, sup_xml_tree::UnparsedEntity>,
pub entities: Vec<crate::dtd::model::EntityDecl>,
pub decl_order: Vec<crate::dtd::model::DeclRef>,
pub internal_decls: Vec<String>,
pub internal_subset_prolog_index: u32,
}
impl Dtd {
pub fn new() -> Self { Self::default() }
pub fn is_empty(&self) -> bool {
self.elements.is_empty() && self.attlists.is_empty()
}
pub fn add_element(&mut self, decl: ElementDecl) {
if !self.elements.contains_key(&decl.name) {
self.element_order.push(decl.name.clone());
self.decl_order.push(crate::dtd::model::DeclRef::Element(decl.name.clone()));
self.elements.insert(decl.name.clone(), decl);
}
}
pub fn add_attlist(&mut self, element: String, mut decls: Vec<AttDecl>) {
if !self.attlists.contains_key(&element) {
self.decl_order.push(crate::dtd::model::DeclRef::Attlist(element.clone()));
}
let entry = self.attlists.entry(element).or_insert_with(Vec::new);
entry.append(&mut decls);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::ParseOptions;
use crate::parser::{parse_bytes_with_dtd, parse_external_subset};
fn parse(src: &str) -> (sup_xml_tree::dom::Document, Dtd) {
let opts = ParseOptions { namespace_aware: false, ..ParseOptions::default() };
parse_bytes_with_dtd(src.as_bytes(), &opts).expect("parse failed")
}
#[test]
fn external_subset_captures_element_and_attlist() {
let dtd = parse_external_subset(
b"<!ELEMENT a (b)>\n<!ATTLIST a x CDATA #IMPLIED>\n<!ELEMENT b EMPTY>",
&ParseOptions::default(),
).expect("external subset should parse");
assert!(dtd.elements.contains_key("a"));
assert!(dtd.elements.contains_key("b"));
assert_eq!(dtd.attlists.get("a").map(Vec::len), Some(1));
}
#[test]
fn external_subset_allows_conditional_sections() {
let dtd = parse_external_subset(
b"<![INCLUDE[ <!ELEMENT keep EMPTY> ]]>\n<![IGNORE[ <!ELEMENT drop EMPTY> ]]>",
&ParseOptions::default(),
).expect("conditional sections should parse");
assert!(dtd.elements.contains_key("keep"));
assert!(!dtd.elements.contains_key("drop"));
}
#[test]
fn captures_empty_element_decl() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r EMPTY>
]>
<r/>"#;
let (_doc, dtd) = parse(src);
assert!(matches!(dtd.elements.get("r").map(|e| &e.content), Some(ContentModel::Empty)));
}
#[test]
fn captures_mixed_content() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r (#PCDATA | em | b)*>
<!ELEMENT em (#PCDATA)>
<!ELEMENT b (#PCDATA)>
]>
<r>text <em>emp</em> tail</r>"#;
let (_doc, dtd) = parse(src);
match &dtd.elements.get("r").unwrap().content {
ContentModel::Mixed { choices } => {
assert_eq!(choices, &vec!["em".to_string(), "b".to_string()]);
}
other => panic!("expected Mixed, got {:?}", other),
}
}
#[test]
fn captures_children_with_quantifier() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r (a, b+, c?)>
<!ELEMENT a EMPTY>
<!ELEMENT b EMPTY>
<!ELEMENT c EMPTY>
]>
<r><a/><b/><b/></r>"#;
let (_doc, dtd) = parse(src);
match &dtd.elements.get("r").unwrap().content {
ContentModel::Children(g) => {
assert_eq!(g.kind, GroupKind::Sequence);
assert_eq!(g.items.len(), 3);
assert_eq!(g.items[0].occur, Occurrence::One);
assert_eq!(g.items[1].occur, Occurrence::OneOrMore);
assert_eq!(g.items[2].occur, Occurrence::ZeroOrOne);
}
other => panic!("expected Children, got {:?}", other),
}
}
#[test]
fn captures_attlist() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r EMPTY>
<!ATTLIST r
id ID #REQUIRED
type (a | b) "a"
note CDATA #IMPLIED>
]>
<r id="x1" type="b"/>"#;
let (_doc, dtd) = parse(src);
let attrs = dtd.attlists.get("r").expect("attrs");
assert_eq!(attrs.len(), 3);
assert!(matches!(attrs[0].att_type, AttType::Id));
assert!(matches!(attrs[0].default, AttDefault::Required));
assert!(matches!(&attrs[1].att_type, AttType::Enumeration(v) if v == &vec!["a".to_string(), "b".to_string()]));
assert!(matches!(&attrs[1].default, AttDefault::Default(s) if s == "a"));
assert!(matches!(attrs[2].att_type, AttType::CData));
assert!(matches!(attrs[2].default, AttDefault::Implied));
}
#[test]
fn validates_valid_document() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r (a+, b?)>
<!ELEMENT a EMPTY>
<!ELEMENT b (#PCDATA)>
<!ATTLIST a id ID #REQUIRED>
]>
<r>
<a id="x1"/>
<a id="x2"/>
<b>hi</b>
</r>"#;
let (doc, dtd) = parse(src);
validate(&doc, &dtd).expect("should validate");
}
#[test]
fn rejects_missing_required_attr() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r EMPTY>
<!ATTLIST r id ID #REQUIRED>
]>
<r/>"#;
let (doc, dtd) = parse(src);
let errs = validate(&doc, &dtd).unwrap_err();
assert!(errs.iter().any(|e| e.message.contains("required")), "got: {:?}", errs);
}
#[test]
fn rejects_bad_enum_value() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r EMPTY>
<!ATTLIST r type (a | b) "a">
]>
<r type="c"/>"#;
let (doc, dtd) = parse(src);
let errs = validate(&doc, &dtd).unwrap_err();
assert!(errs.iter().any(|e| e.message.contains("enumeration")), "got: {:?}", errs);
}
#[test]
fn rejects_duplicate_id() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r (a, a)>
<!ELEMENT a EMPTY>
<!ATTLIST a id ID #REQUIRED>
]>
<r><a id="x1"/><a id="x1"/></r>"#;
let (doc, dtd) = parse(src);
let errs = validate(&doc, &dtd).unwrap_err();
assert!(errs.iter().any(|e| e.message.contains("duplicate")), "got: {:?}", errs);
}
#[test]
fn rejects_unresolved_idref() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r (a, b)>
<!ELEMENT a EMPTY>
<!ELEMENT b EMPTY>
<!ATTLIST a id ID #REQUIRED>
<!ATTLIST b ref IDREF #REQUIRED>
]>
<r><a id="x1"/><b ref="missing"/></r>"#;
let (doc, dtd) = parse(src);
let errs = validate(&doc, &dtd).unwrap_err();
assert!(errs.iter().any(|e| e.message.contains("IDREF")), "got: {:?}", errs);
}
#[test]
fn rejects_bad_content_model() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r (a, b)>
<!ELEMENT a EMPTY>
<!ELEMENT b EMPTY>
]>
<r><b/><a/></r>"#;
let (doc, dtd) = parse(src);
let errs = validate(&doc, &dtd).unwrap_err();
assert!(errs.iter().any(|e| e.message.contains("does not match")), "got: {:?}", errs);
}
#[test]
fn rejects_deeply_nested_content_model() {
let n = 400usize;
let src = format!(
"<?xml version=\"1.0\"?>\n<!DOCTYPE r [\n <!ELEMENT r {}a{}>\n]>\n<r/>",
"(".repeat(n),
")".repeat(n),
);
let opts = ParseOptions { namespace_aware: false, ..ParseOptions::default() };
let err = parse_bytes_with_dtd(src.as_bytes(), &opts)
.expect_err("deeply nested content model should be rejected");
assert!(
err.message.contains("nesting depth exceeds limit"),
"expected depth-limit error, got: {err}"
);
}
#[test]
fn loads_external_subset() {
use std::io::Write;
let mut tmp = std::env::temp_dir();
tmp.push("sup_xml_dtd_external.dtd");
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"<!ELEMENT r (a+)>\n<!ELEMENT a EMPTY>\n<!ATTLIST a id ID #REQUIRED>\n").unwrap();
drop(f);
let src = format!(
r#"<?xml version="1.0"?>
<!DOCTYPE r SYSTEM "{}">
<r><a id="x1"/></r>"#,
tmp.display()
);
let opts = ParseOptions {
namespace_aware: false,
load_external_dtd: true,
..ParseOptions::default()
};
let (doc, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
assert!(dtd.elements.contains_key("r"), "external <!ELEMENT r> missing");
assert!(dtd.elements.contains_key("a"), "external <!ELEMENT a> missing");
assert!(dtd.attlists.contains_key("a"), "external <!ATTLIST a> missing");
validate(&doc, &dtd).expect("valid doc against external DTD");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn external_subset_off_by_default() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r SYSTEM "/nonexistent/path.dtd">
<r/>"#;
let opts = ParseOptions { namespace_aware: false, ..ParseOptions::default() };
let (_, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
assert!(dtd.is_empty(), "external DTD should not have been loaded");
}
#[test]
fn missing_external_subset_is_non_fatal() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r SYSTEM "/nonexistent/path-that-does-not-exist.dtd">
<r/>"#;
let opts = ParseOptions {
namespace_aware: false,
load_external_dtd: true,
..ParseOptions::default()
};
let (_, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
assert!(dtd.is_empty(), "missing file should leave dtd empty");
}
#[test]
fn internal_and_external_subsets_merge() {
use std::io::Write;
let mut tmp = std::env::temp_dir();
tmp.push("sup_xml_dtd_merge.dtd");
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"<!ELEMENT a EMPTY>\n").unwrap();
drop(f);
let src = format!(
r#"<?xml version="1.0"?>
<!DOCTYPE r SYSTEM "{}" [
<!ELEMENT r (a+)>
]>
<r><a/></r>"#,
tmp.display()
);
let opts = ParseOptions {
namespace_aware: false,
load_external_dtd: true,
..ParseOptions::default()
};
let (_, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
assert!(dtd.elements.contains_key("r"), "internal <!ELEMENT r> missing");
assert!(dtd.elements.contains_key("a"), "external <!ELEMENT a> missing");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn collects_every_error_in_one_pass() {
let src = r#"<?xml version="1.0"?>
<!DOCTYPE r [
<!ELEMENT r (a, a)>
<!ELEMENT a EMPTY>
<!ATTLIST a id ID #REQUIRED>
]>
<r><a/><a id="dup"/></r>"#;
let (doc, dtd) = parse(src);
let errs = validate(&doc, &dtd).unwrap_err();
assert!(errs.len() >= 1, "expected at least 1 error, got {:?}", errs);
assert!(errs.iter().any(|e| e.message.contains("required")),
"missing 'required' error: {:?}", errs);
}
}