Skip to main content

sup_xml_core/dtd/
mod.rs

1//! DTD validation engine.
2//!
3//! Captures `<!ELEMENT>` and `<!ATTLIST>` declarations during parse,
4//! then validates a parsed [`Document`](crate::dom::Document) against
5//! them.
6//!
7//! # Scope
8//!
9//! Covers the subset of XML 1.0 § 3.2–3.3.4 that real consumers
10//! actually rely on:
11//!
12//! * Element content models: `EMPTY`, `ANY`, Mixed (`(#PCDATA | a | b)*`),
13//!   and children models (`(a, b)`, `(a | b)+`, etc.) with the standard
14//!   occurrence indicators (`?`, `*`, `+`, none).
15//! * Attribute declarations: every type from § 3.3.1 (`CDATA`, `ID`,
16//!   `IDREF`, `IDREFS`, `ENTITY`, `ENTITIES`, `NMTOKEN`, `NMTOKENS`,
17//!   `NOTATION`, enumerated), with all four DefaultDecl flavours
18//!   (`#REQUIRED`, `#IMPLIED`, `#FIXED "..."`, literal default).
19//! * ID uniqueness across the whole document.
20//! * IDREF / IDREFS targets must resolve to an attribute typed `ID`.
21//!
22//! # Not yet covered
23//!
24//! * Default attribute *injection* on parse (when an ATTLIST default
25//!   would supply a missing attribute, our parser ignores it).
26//! * Notation cross-references (`NOTATION` decls are stored but the
27//!   set of declared notations is not consulted).
28//! * Parameter-entity-driven declarations inside the external subset.
29//! * Conditional sections (`<![INCLUDE[ ... ]]>`).
30
31pub mod inject;
32pub mod model;
33pub mod validate;
34
35use std::collections::HashMap;
36
37pub use inject::{inject_defaults, inject_defaults_from};
38pub use model::{
39    AttDecl, AttDefault, AttType, ContentModel, DeclRef, ElementDecl, EntityDecl, Group, GroupKind,
40    Item, Occurrence, Particle,
41};
42pub use validate::{validate, DtdError};
43
44/// Map element local-name → set of ATTLIST-declared `ID`-type
45/// attribute names on that element.  Used by `id()` (XPath 1.0
46/// §4.1) to walk only the DTD-typed attributes; without this map
47/// every attribute literally named `id` is treated as an ID, which
48/// catches some real-world cases but misses DTDs that declare an
49/// ID attribute under a different name.
50pub fn collect_id_attrs(dtd: &Dtd) -> HashMap<String, Vec<String>> {
51    let mut out: HashMap<String, Vec<String>> = HashMap::new();
52    for (elem, decls) in &dtd.attlists {
53        let ids: Vec<String> = decls.iter()
54            .filter(|d| matches!(d.att_type, AttType::Id))
55            .map(|d| d.name.clone())
56            .collect();
57        if !ids.is_empty() {
58            out.insert(elem.clone(), ids);
59        }
60    }
61    out
62}
63
64/// Map element local-name → set of ATTLIST-declared `IDREF` / `IDREFS`
65/// attribute names on that element.  Used by `idref()` (XPath 2.0
66/// §14.5.5) to locate the attributes that reference a candidate ID.
67pub fn collect_idref_attrs(dtd: &Dtd) -> HashMap<String, Vec<String>> {
68    let mut out: HashMap<String, Vec<String>> = HashMap::new();
69    for (elem, decls) in &dtd.attlists {
70        let refs: Vec<String> = decls.iter()
71            .filter(|d| matches!(d.att_type, AttType::IdRef | AttType::IdRefs))
72            .map(|d| d.name.clone())
73            .collect();
74        if !refs.is_empty() {
75            out.insert(elem.clone(), refs);
76        }
77    }
78    out
79}
80
81/// Parsed DTD content used by [`validate`].  Built up incrementally
82/// by the bytes-reader as it encounters `<!ELEMENT>` / `<!ATTLIST>`
83/// declarations in the internal subset.
84#[derive(Debug, Clone, Default)]
85pub struct Dtd {
86    /// Element declarations keyed by element name.
87    pub elements: HashMap<String, ElementDecl>,
88    /// Element names in declaration order — the order libxml2 keeps in
89    /// `xmlDtd.children`, which lxml's `DTD.elements()` exposes.  The
90    /// `elements` map is for lookup; this preserves source order.
91    pub element_order: Vec<String>,
92    /// Attribute declarations keyed by *element* name.  libxml2's
93    /// ATTLIST groups can append (multiple `<!ATTLIST elem ...>` for
94    /// the same `elem` are merged here).
95    pub attlists: HashMap<String, Vec<AttDecl>>,
96    /// Root element name from the DOCTYPE header: `<!DOCTYPE root_name
97    /// ...>`.  Populated for any successfully-parsed doctype, even
98    /// one with no internal subset and no external ID.  Empty when
99    /// the document has no DOCTYPE at all.
100    pub root_name: String,
101    /// `PUBLIC "..."` identifier from the DOCTYPE header, when
102    /// present.  `None` for `SYSTEM`-only or no external ID.
103    pub public_id: Option<String>,
104    /// `SYSTEM "..."` identifier (or the second literal of a `PUBLIC
105    /// "..." "..."` form) from the DOCTYPE header.  `None` when the
106    /// header has no external ID.
107    pub system_id: Option<String>,
108    /// Unparsed external general entities — those declared with an
109    /// `NDATA` annotation per XML 1.0 § 4.2.2.  Keyed by entity name;
110    /// the value carries the SYSTEM identifier (the URI a non-XML
111    /// processor would fetch) and the PUBLIC identifier when present.
112    /// Used by XSLT's `unparsed-entity-uri()` /
113    /// `unparsed-entity-public-id()` functions (XSLT 1.0 § 12.4).
114    pub unparsed_entities: HashMap<String, sup_xml_tree::UnparsedEntity>,
115    /// General `<!ENTITY>` declarations in source order — what lxml's
116    /// `DTD.entities()` exposes and the DTD serializer reconstructs.
117    /// Carries internal, external, and unparsed entities (a superset of
118    /// [`unparsed_entities`](Self::unparsed_entities)) plus parameter
119    /// entities (flagged `parameter`).
120    pub entities: Vec<crate::dtd::model::EntityDecl>,
121    /// Declaration references in source order, so the DTD serializer can
122    /// reproduce libxml2's `xmlDtd.children` ordering.
123    pub decl_order: Vec<crate::dtd::model::DeclRef>,
124    /// Raw markup declarations from the internal subset, in document
125    /// order, each as it appeared in source (`<!ENTITY …>`,
126    /// `<!ELEMENT …>`, `<!ATTLIST …>`, `<!NOTATION …>`).  Captured for
127    /// round-trip serialization of the DOCTYPE's `[ … ]` body; empty
128    /// when the document had no internal subset.  Only declarations
129    /// read directly from the source are captured (not those produced
130    /// by parameter-entity expansion).
131    pub internal_decls: Vec<String>,
132    /// Number of document-level comments/PIs that preceded the
133    /// `<!DOCTYPE …>` in the prolog.  Lets the compat layer splice the
134    /// internal-subset node into the document's sibling chain at its
135    /// true position (`misc[..k]` → DOCTYPE → `misc[k..]` → root) so a
136    /// comment that came before the DOCTYPE serializes before it, as
137    /// libxml2 does.  Zero when the DOCTYPE was the first prolog item
138    /// (the common case) or when there is no DOCTYPE.
139    pub internal_subset_prolog_index: u32,
140}
141
142impl Dtd {
143    /// Empty DTD — no declarations.
144    pub fn new() -> Self { Self::default() }
145
146    /// `true` when no element or attribute declarations have been
147    /// captured.  Used as a fast-path skip in the validator.
148    pub fn is_empty(&self) -> bool {
149        self.elements.is_empty() && self.attlists.is_empty()
150    }
151
152    /// Insert (or replace) an element declaration.  libxml2's
153    /// behaviour on duplicate `<!ELEMENT name ...>` for the same
154    /// `name` is to emit a warning and keep the first declaration;
155    /// we keep the *first* too (this matches the de-facto standard
156    /// across major parsers).
157    pub fn add_element(&mut self, decl: ElementDecl) {
158        // libxml2 keeps the first declaration on a duplicate `<!ELEMENT>`.
159        if !self.elements.contains_key(&decl.name) {
160            self.element_order.push(decl.name.clone());
161            self.decl_order.push(crate::dtd::model::DeclRef::Element(decl.name.clone()));
162            self.elements.insert(decl.name.clone(), decl);
163        }
164    }
165
166    /// Append attribute declarations for an element.  Multiple
167    /// `<!ATTLIST elem ...>` blocks merge — XML 1.0 § 3.3.
168    pub fn add_attlist(&mut self, element: String, mut decls: Vec<AttDecl>) {
169        // Record the ATTLIST's source position once per element (merged
170        // attributes serialize together at the first declaration site).
171        if !self.attlists.contains_key(&element) {
172            self.decl_order.push(crate::dtd::model::DeclRef::Attlist(element.clone()));
173        }
174        let entry = self.attlists.entry(element).or_insert_with(Vec::new);
175        entry.append(&mut decls);
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::options::ParseOptions;
183    use crate::parser::{parse_bytes_with_dtd, parse_external_subset};
184
185    fn parse(src: &str) -> (sup_xml_tree::dom::Document, Dtd) {
186        let opts = ParseOptions { namespace_aware: false, ..ParseOptions::default() };
187        parse_bytes_with_dtd(src.as_bytes(), &opts).expect("parse failed")
188    }
189
190    #[test]
191    fn external_subset_captures_element_and_attlist() {
192        // A standalone DTD — no `<!DOCTYPE>` wrapper — is the external
193        // subset: bare declarations parse directly.
194        let dtd = parse_external_subset(
195            b"<!ELEMENT a (b)>\n<!ATTLIST a x CDATA #IMPLIED>\n<!ELEMENT b EMPTY>",
196            &ParseOptions::default(),
197        ).expect("external subset should parse");
198        assert!(dtd.elements.contains_key("a"));
199        assert!(dtd.elements.contains_key("b"));
200        assert_eq!(dtd.attlists.get("a").map(Vec::len), Some(1));
201    }
202
203    #[test]
204    fn external_subset_allows_conditional_sections() {
205        // Conditional sections are legal in the external subset but not
206        // the internal one; the standalone parser must accept them.
207        let dtd = parse_external_subset(
208            b"<![INCLUDE[ <!ELEMENT keep EMPTY> ]]>\n<![IGNORE[ <!ELEMENT drop EMPTY> ]]>",
209            &ParseOptions::default(),
210        ).expect("conditional sections should parse");
211        assert!(dtd.elements.contains_key("keep"));
212        assert!(!dtd.elements.contains_key("drop"));
213    }
214
215    #[test]
216    fn captures_empty_element_decl() {
217        let src = r#"<?xml version="1.0"?>
218<!DOCTYPE r [
219  <!ELEMENT r EMPTY>
220]>
221<r/>"#;
222        let (_doc, dtd) = parse(src);
223        assert!(matches!(dtd.elements.get("r").map(|e| &e.content), Some(ContentModel::Empty)));
224    }
225
226    #[test]
227    fn captures_mixed_content() {
228        let src = r#"<?xml version="1.0"?>
229<!DOCTYPE r [
230  <!ELEMENT r (#PCDATA | em | b)*>
231  <!ELEMENT em (#PCDATA)>
232  <!ELEMENT b (#PCDATA)>
233]>
234<r>text <em>emp</em> tail</r>"#;
235        let (_doc, dtd) = parse(src);
236        match &dtd.elements.get("r").unwrap().content {
237            ContentModel::Mixed { choices } => {
238                assert_eq!(choices, &vec!["em".to_string(), "b".to_string()]);
239            }
240            other => panic!("expected Mixed, got {:?}", other),
241        }
242    }
243
244    #[test]
245    fn captures_children_with_quantifier() {
246        let src = r#"<?xml version="1.0"?>
247<!DOCTYPE r [
248  <!ELEMENT r (a, b+, c?)>
249  <!ELEMENT a EMPTY>
250  <!ELEMENT b EMPTY>
251  <!ELEMENT c EMPTY>
252]>
253<r><a/><b/><b/></r>"#;
254        let (_doc, dtd) = parse(src);
255        match &dtd.elements.get("r").unwrap().content {
256            ContentModel::Children(g) => {
257                assert_eq!(g.kind, GroupKind::Sequence);
258                assert_eq!(g.items.len(), 3);
259                assert_eq!(g.items[0].occur, Occurrence::One);
260                assert_eq!(g.items[1].occur, Occurrence::OneOrMore);
261                assert_eq!(g.items[2].occur, Occurrence::ZeroOrOne);
262            }
263            other => panic!("expected Children, got {:?}", other),
264        }
265    }
266
267    #[test]
268    fn captures_attlist() {
269        let src = r#"<?xml version="1.0"?>
270<!DOCTYPE r [
271  <!ELEMENT r EMPTY>
272  <!ATTLIST r
273    id    ID       #REQUIRED
274    type  (a | b)  "a"
275    note  CDATA    #IMPLIED>
276]>
277<r id="x1" type="b"/>"#;
278        let (_doc, dtd) = parse(src);
279        let attrs = dtd.attlists.get("r").expect("attrs");
280        assert_eq!(attrs.len(), 3);
281        assert!(matches!(attrs[0].att_type, AttType::Id));
282        assert!(matches!(attrs[0].default, AttDefault::Required));
283        assert!(matches!(&attrs[1].att_type, AttType::Enumeration(v) if v == &vec!["a".to_string(), "b".to_string()]));
284        assert!(matches!(&attrs[1].default, AttDefault::Default(s) if s == "a"));
285        assert!(matches!(attrs[2].att_type, AttType::CData));
286        assert!(matches!(attrs[2].default, AttDefault::Implied));
287    }
288
289    #[test]
290    fn validates_valid_document() {
291        let src = r#"<?xml version="1.0"?>
292<!DOCTYPE r [
293  <!ELEMENT r (a+, b?)>
294  <!ELEMENT a EMPTY>
295  <!ELEMENT b (#PCDATA)>
296  <!ATTLIST a id ID #REQUIRED>
297]>
298<r>
299  <a id="x1"/>
300  <a id="x2"/>
301  <b>hi</b>
302</r>"#;
303        let (doc, dtd) = parse(src);
304        validate(&doc, &dtd).expect("should validate");
305    }
306
307    #[test]
308    fn rejects_missing_required_attr() {
309        let src = r#"<?xml version="1.0"?>
310<!DOCTYPE r [
311  <!ELEMENT r EMPTY>
312  <!ATTLIST r id ID #REQUIRED>
313]>
314<r/>"#;
315        let (doc, dtd) = parse(src);
316        let errs = validate(&doc, &dtd).unwrap_err();
317        assert!(errs.iter().any(|e| e.message.contains("required")), "got: {:?}", errs);
318    }
319
320    #[test]
321    fn rejects_bad_enum_value() {
322        let src = r#"<?xml version="1.0"?>
323<!DOCTYPE r [
324  <!ELEMENT r EMPTY>
325  <!ATTLIST r type (a | b) "a">
326]>
327<r type="c"/>"#;
328        let (doc, dtd) = parse(src);
329        let errs = validate(&doc, &dtd).unwrap_err();
330        assert!(errs.iter().any(|e| e.message.contains("enumeration")), "got: {:?}", errs);
331    }
332
333    #[test]
334    fn rejects_duplicate_id() {
335        let src = r#"<?xml version="1.0"?>
336<!DOCTYPE r [
337  <!ELEMENT r (a, a)>
338  <!ELEMENT a EMPTY>
339  <!ATTLIST a id ID #REQUIRED>
340]>
341<r><a id="x1"/><a id="x1"/></r>"#;
342        let (doc, dtd) = parse(src);
343        let errs = validate(&doc, &dtd).unwrap_err();
344        assert!(errs.iter().any(|e| e.message.contains("duplicate")), "got: {:?}", errs);
345    }
346
347    #[test]
348    fn rejects_unresolved_idref() {
349        let src = r#"<?xml version="1.0"?>
350<!DOCTYPE r [
351  <!ELEMENT r (a, b)>
352  <!ELEMENT a EMPTY>
353  <!ELEMENT b EMPTY>
354  <!ATTLIST a id  ID    #REQUIRED>
355  <!ATTLIST b ref IDREF #REQUIRED>
356]>
357<r><a id="x1"/><b ref="missing"/></r>"#;
358        let (doc, dtd) = parse(src);
359        let errs = validate(&doc, &dtd).unwrap_err();
360        assert!(errs.iter().any(|e| e.message.contains("IDREF")), "got: {:?}", errs);
361    }
362
363    #[test]
364    fn rejects_bad_content_model() {
365        let src = r#"<?xml version="1.0"?>
366<!DOCTYPE r [
367  <!ELEMENT r (a, b)>
368  <!ELEMENT a EMPTY>
369  <!ELEMENT b EMPTY>
370]>
371<r><b/><a/></r>"#;
372        let (doc, dtd) = parse(src);
373        let errs = validate(&doc, &dtd).unwrap_err();
374        assert!(errs.iter().any(|e| e.message.contains("does not match")), "got: {:?}", errs);
375    }
376
377    #[test]
378    fn rejects_deeply_nested_content_model() {
379        // A pathologically nested content model must error rather than
380        // overflow the recursive-descent stack — the declaration comes
381        // from an untrusted DTD, so this is a DoS guard, not a grammar
382        // nicety.  `n` sits well above MAX_CONTENT_MODEL_DEPTH (256).
383        let n = 400usize;
384        let src = format!(
385            "<?xml version=\"1.0\"?>\n<!DOCTYPE r [\n  <!ELEMENT r {}a{}>\n]>\n<r/>",
386            "(".repeat(n),
387            ")".repeat(n),
388        );
389        let opts = ParseOptions { namespace_aware: false, ..ParseOptions::default() };
390        let err = parse_bytes_with_dtd(src.as_bytes(), &opts)
391            .expect_err("deeply nested content model should be rejected");
392        assert!(
393            err.message.contains("nesting depth exceeds limit"),
394            "expected depth-limit error, got: {err}"
395        );
396    }
397
398    #[test]
399    fn loads_external_subset() {
400        use std::io::Write;
401        let mut tmp = std::env::temp_dir();
402        tmp.push("sup_xml_dtd_external.dtd");
403        let mut f = std::fs::File::create(&tmp).unwrap();
404        f.write_all(b"<!ELEMENT r (a+)>\n<!ELEMENT a EMPTY>\n<!ATTLIST a id ID #REQUIRED>\n").unwrap();
405        drop(f);
406
407        let src = format!(
408            r#"<?xml version="1.0"?>
409<!DOCTYPE r SYSTEM "{}">
410<r><a id="x1"/></r>"#,
411            tmp.display()
412        );
413        let opts = ParseOptions {
414            namespace_aware: false,
415            load_external_dtd: true,
416            ..ParseOptions::default()
417        };
418        let (doc, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
419        assert!(dtd.elements.contains_key("r"), "external <!ELEMENT r> missing");
420        assert!(dtd.elements.contains_key("a"), "external <!ELEMENT a> missing");
421        assert!(dtd.attlists.contains_key("a"), "external <!ATTLIST a> missing");
422        // Validation should succeed.
423        validate(&doc, &dtd).expect("valid doc against external DTD");
424
425        let _ = std::fs::remove_file(&tmp);
426    }
427
428    #[test]
429    fn external_subset_off_by_default() {
430        // Without load_external_dtd, SYSTEM path is parsed
431        // syntactically but the file is NOT loaded.  Dtd stays
432        // empty.
433        let src = r#"<?xml version="1.0"?>
434<!DOCTYPE r SYSTEM "/nonexistent/path.dtd">
435<r/>"#;
436        let opts = ParseOptions { namespace_aware: false, ..ParseOptions::default() };
437        // No load_external_dtd → parse succeeds, dtd empty.
438        let (_, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
439        assert!(dtd.is_empty(), "external DTD should not have been loaded");
440    }
441
442    #[test]
443    fn missing_external_subset_is_non_fatal() {
444        // Path doesn't exist — parse should succeed, dtd empty.
445        let src = r#"<?xml version="1.0"?>
446<!DOCTYPE r SYSTEM "/nonexistent/path-that-does-not-exist.dtd">
447<r/>"#;
448        let opts = ParseOptions {
449            namespace_aware: false,
450            load_external_dtd: true,
451            ..ParseOptions::default()
452        };
453        let (_, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
454        assert!(dtd.is_empty(), "missing file should leave dtd empty");
455    }
456
457    #[test]
458    fn internal_and_external_subsets_merge() {
459        use std::io::Write;
460        let mut tmp = std::env::temp_dir();
461        tmp.push("sup_xml_dtd_merge.dtd");
462        let mut f = std::fs::File::create(&tmp).unwrap();
463        f.write_all(b"<!ELEMENT a EMPTY>\n").unwrap();
464        drop(f);
465
466        let src = format!(
467            r#"<?xml version="1.0"?>
468<!DOCTYPE r SYSTEM "{}" [
469  <!ELEMENT r (a+)>
470]>
471<r><a/></r>"#,
472            tmp.display()
473        );
474        let opts = ParseOptions {
475            namespace_aware: false,
476            load_external_dtd: true,
477            ..ParseOptions::default()
478        };
479        let (_, dtd) = parse_bytes_with_dtd(src.as_bytes(), &opts).unwrap();
480        assert!(dtd.elements.contains_key("r"), "internal <!ELEMENT r> missing");
481        assert!(dtd.elements.contains_key("a"), "external <!ELEMENT a> missing");
482        let _ = std::fs::remove_file(&tmp);
483    }
484
485    #[test]
486    fn collects_every_error_in_one_pass() {
487        // Two distinct violations: required attr on first <a>,
488        // duplicate id on the second.  Both should appear.
489        let src = r#"<?xml version="1.0"?>
490<!DOCTYPE r [
491  <!ELEMENT r (a, a)>
492  <!ELEMENT a EMPTY>
493  <!ATTLIST a id ID #REQUIRED>
494]>
495<r><a/><a id="dup"/></r>"#;
496        let (doc, dtd) = parse(src);
497        let errs = validate(&doc, &dtd).unwrap_err();
498        assert!(errs.len() >= 1, "expected at least 1 error, got {:?}", errs);
499        assert!(errs.iter().any(|e| e.message.contains("required")),
500                "missing 'required' error: {:?}", errs);
501    }
502}