use alloc::borrow::ToOwned as _;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EntityValue {
Internal(String),
External {
system: String,
public: Option<String>,
},
Unparsed,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct Dtd {
pub(crate) general: BTreeMap<String, EntityValue>,
pub(crate) parameters: BTreeMap<String, String>,
pub(crate) incomplete: bool,
pub(crate) external_seen: bool,
pub(crate) external_subset: Option<(String, Option<String>)>,
}
impl Dtd {
pub(crate) fn entity(&self, name: &str) -> Option<&EntityValue> {
self.general.get(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DefaultDecl {
Required,
Implied,
Fixed,
Value,
}
pub(crate) struct DtdParser<'a> {
pub(crate) input: &'a str,
pub(crate) bytes: &'a [u8],
pub(crate) pos: usize,
pub(crate) edition: crate::Edition,
}
pub(crate) type DtdError = (usize, &'static str);
impl<'a> DtdParser<'a> {
pub(crate) const fn new(
input: &'a str,
pos: usize,
edition: crate::Edition,
) -> Self {
Self {
input,
bytes: input.as_bytes(),
pos,
edition,
}
}
fn is_name_start(&self, c: char) -> bool {
match self.edition {
crate::Edition::Fourth => crate::names4e::is_name_start_4e(c),
_ => crate::parser::is_name_start(c),
}
}
fn is_name_char(&self, c: char) -> bool {
match self.edition {
crate::Edition::Fourth => crate::names4e::is_name_char_4e(c),
_ => crate::parser::is_name_char(c),
}
}
fn peek(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
fn starts_with(&self, s: &str) -> bool {
self.bytes[self.pos..].starts_with(s.as_bytes())
}
fn skip_ws(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
self.pos += 1;
}
}
fn require_ws(&mut self) -> Result<(), DtdError> {
if !matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
return Err((self.pos, "whitespace required in a declaration"));
}
self.skip_ws();
Ok(())
}
fn expect(&mut self, b: u8, what: &'static str) -> Result<(), DtdError> {
if self.peek() == Some(b) {
self.pos += 1;
Ok(())
} else {
Err((self.pos, what))
}
}
fn name(&mut self) -> Result<&'a str, DtdError> {
let start = self.pos;
let rest = &self.input[self.pos..];
let mut chars = rest.char_indices();
match chars.next() {
Some((_, c)) if self.is_name_start(c) => {}
_ => return Err((start, "expected a name")),
}
let mut end = rest.len();
for (i, c) in rest.char_indices() {
if !self.is_name_char(c) {
end = i;
break;
}
}
self.pos = start + end;
Ok(&rest[..end])
}
fn quoted(&mut self) -> Result<&'a str, DtdError> {
let Some(quote @ (b'"' | b'\'')) = self.peek() else {
return Err((self.pos, "expected a quoted value"));
};
self.pos += 1;
let start = self.pos;
while let Some(b) = self.peek() {
if b == quote {
let text = &self.input[start..self.pos];
self.pos += 1;
return Ok(text);
}
self.pos += 1;
}
Err((start, "unterminated quoted value"))
}
pub(crate) fn parse_external_subset(
&mut self,
dtd: &mut Dtd,
) -> Result<(), DtdError> {
self.parse_subset_decls(dtd, None, 0)
}
pub(crate) fn parse_doctype(&mut self) -> Result<Dtd, DtdError> {
let start = self.pos;
self.pos += "<!DOCTYPE".len();
let mut dtd = Dtd::default();
self.require_ws()?;
let _root = self.name()?;
self.skip_ws();
if self.starts_with("SYSTEM") || self.starts_with("PUBLIC") {
let is_public = self.starts_with("PUBLIC");
self.pos += 6;
self.require_ws()?;
let ids = if is_public {
let pubid = self.pubid_literal()?.to_owned();
self.skip_ws();
if matches!(self.peek(), Some(b'"' | b'\'')) {
Some((self.quoted()?.to_owned(), Some(pubid)))
} else {
None
}
} else {
Some((self.quoted()?.to_owned(), None))
};
dtd.external_subset = ids;
dtd.incomplete = true;
dtd.external_seen = true;
self.skip_ws();
}
if self.peek() == Some(b'[') {
self.pos += 1;
self.parse_internal_subset(&mut dtd)?;
self.expect(b']', "unterminated internal subset")?;
self.skip_ws();
}
if self.peek() == Some(b'>') {
self.pos += 1;
Ok(dtd)
} else {
Err((start, "unterminated doctype"))
}
}
fn decl_uses_pe(&self) -> bool {
let mut depth = 0usize;
let mut quote: Option<u8> = None;
for (i, &b) in self.bytes[self.pos..].iter().enumerate() {
let rest = &self.bytes[self.pos + i + 1..];
match (quote, b) {
(Some(q), c) if c == q => quote = None,
(None, b'"' | b'\'') => quote = Some(b),
(None, b'<') => depth += 1,
(None, b'>') => {
depth = depth.saturating_sub(1);
if depth == 0 {
return false;
}
}
(None, b'%') => return next_is_name_start(rest),
_ => {}
}
}
false
}
fn skip_decl(&mut self) {
let mut depth = 0usize;
let mut quote: Option<u8> = None;
while let Some(b) = self.peek() {
self.pos += 1;
match (quote, b) {
(Some(q), c) if c == q => quote = None,
(None, b'"' | b'\'') => quote = Some(b),
(None, b'<') => depth += 1,
(None, b'>') => {
depth = depth.saturating_sub(1);
if depth == 0 {
return;
}
}
_ => {}
}
}
}
fn parse_conditional_section(
&mut self,
dtd: &mut Dtd,
depth: usize,
) -> Result<(), DtdError> {
self.skip_ws();
let keyword = if self.peek() == Some(b'%') {
self.pos += 1;
let name = self.name()?.to_owned();
self.expect(b';', "unterminated parameter entity reference")?;
let Some(text) = dtd.parameters.get(&name) else {
dtd.incomplete = true;
return self.skip_conditional_section();
};
text.trim().to_owned()
} else {
self.name()?.to_owned()
};
self.skip_ws();
self.expect(b'[', "expected `[` in a conditional section")?;
let body_start = self.pos;
self.skip_conditional_section()?;
let body = &self.input[body_start..self.pos - 3];
match keyword.as_str() {
"IGNORE" => Ok(()),
"INCLUDE" => {
let mut sub = DtdParser::new(body, 0, self.edition);
sub.parse_subset_decls(dtd, None, depth + 1)
}
_ => Err((
body_start,
"a conditional section must say INCLUDE or IGNORE",
)),
}
}
fn skip_conditional_section(&mut self) -> Result<(), DtdError> {
let start = self.pos;
let mut depth = 1usize;
while self.pos < self.bytes.len() {
if self.starts_with("<![") {
depth += 1;
self.pos += 3;
} else if self.starts_with("]]>") {
depth -= 1;
self.pos += 3;
if depth == 0 {
return Ok(());
}
} else {
self.pos += 1;
}
}
Err((start, "unterminated conditional section"))
}
fn parse_internal_subset(&mut self, dtd: &mut Dtd) -> Result<(), DtdError> {
self.parse_subset_decls(dtd, Some(b']'), 0)
}
fn parse_subset_decls(
&mut self,
dtd: &mut Dtd,
terminator: Option<u8>,
depth: usize,
) -> Result<(), DtdError> {
loop {
self.skip_ws();
match self.peek() {
None if terminator.is_none() => return Ok(()),
None => return Err((self.pos, "unterminated internal subset")),
Some(b) if Some(b) == terminator => return Ok(()),
Some(b'%') => {
self.pos += 1;
let name = self.name()?.to_owned();
self.expect(
b';',
"unterminated parameter entity reference",
)?;
dtd.external_seen = true;
match dtd.parameters.get(&name).cloned() {
Some(text) if depth < MAX_PE_DEPTH => {
let mut sub =
DtdParser::new(&text, 0, self.edition);
sub.parse_subset_decls(dtd, None, depth + 1)?;
}
Some(_) => {
return Err((
self.pos,
"parameter entities nested too deeply",
));
}
None => dtd.incomplete = true,
}
}
Some(b'<') => {
if terminator.is_none() && self.decl_uses_pe() {
let start = self.pos;
self.skip_decl();
let raw = &self.input[start..self.pos];
match expand_pes(raw, dtd, depth) {
Some(expanded) => {
let mut sub =
DtdParser::new(&expanded, 0, self.edition);
if sub.parse_markup_decl(dtd, depth).is_err() {
dtd.incomplete = true;
}
}
None => dtd.incomplete = true,
}
} else {
self.parse_markup_decl(dtd, depth)?;
}
}
_ => return Err((self.pos, "expected a markup declaration")),
}
}
}
fn parse_markup_decl(
&mut self,
dtd: &mut Dtd,
depth: usize,
) -> Result<(), DtdError> {
if self.starts_with("<!--") {
self.pos += 4;
return match self.input[self.pos..].find("-->") {
Some(i) => {
let body = &self.input[self.pos..self.pos + i];
if body.contains("--") || body.ends_with('-') {
return Err((self.pos, "`--` inside a comment"));
}
self.pos += i + 3;
Ok(())
}
None => Err((self.pos, "unterminated comment")),
};
}
if self.starts_with("<?") {
self.pos += 2;
let target = self.name()?;
if target.eq_ignore_ascii_case("xml") {
return Err((self.pos, "`xml` is a reserved PI target"));
}
if !matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n'))
&& !self.starts_with("?>")
{
return Err((self.pos, "illegal character in a PI target"));
}
return match self.input[self.pos..].find("?>") {
Some(i) => {
self.pos += i + 2;
Ok(())
}
None => Err((self.pos, "unterminated processing instruction")),
};
}
if self.starts_with("<![") {
if !dtd.incomplete {
return Err((
self.pos,
"conditional section in the internal subset",
));
}
self.pos += 3;
return self.parse_conditional_section(dtd, depth);
}
if self.starts_with("<!ELEMENT") {
self.pos += "<!ELEMENT".len();
return self.parse_element_decl();
}
if self.starts_with("<!ATTLIST") {
self.pos += "<!ATTLIST".len();
return self.parse_attlist_decl(dtd);
}
if self.starts_with("<!ENTITY") {
self.pos += "<!ENTITY".len();
return self.parse_entity_decl(dtd);
}
if self.starts_with("<!NOTATION") {
self.pos += "<!NOTATION".len();
return self.parse_notation_decl();
}
Err((self.pos, "unknown markup declaration"))
}
fn parse_element_decl(&mut self) -> Result<(), DtdError> {
self.require_ws()?;
let _ = self.name()?;
self.require_ws()?;
if self.peek() == Some(b'(') {
self.parse_content_spec()?;
} else {
let kw = self.name()?;
if !matches!(kw, "EMPTY" | "ANY") {
return Err((
self.pos,
"expected EMPTY, ANY or a content model",
));
}
}
self.skip_ws();
self.expect(b'>', "unterminated element declaration")
}
fn parse_content_spec(&mut self) -> Result<(), DtdError> {
self.pos += 1; self.skip_ws();
if self.starts_with("#PCDATA") {
self.pos += "#PCDATA".len();
return self.parse_mixed_tail();
}
self.parse_group_tail()?;
if matches!(self.peek(), Some(b'?' | b'*' | b'+')) {
self.pos += 1;
}
Ok(())
}
fn parse_mixed_tail(&mut self) -> Result<(), DtdError> {
let mut had_names = false;
loop {
self.skip_ws();
match self.peek() {
Some(b'|') => {
self.pos += 1;
self.skip_ws();
let _ = self.name()?;
had_names = true;
}
Some(b')') => {
self.pos += 1;
if had_names {
if self.peek() == Some(b'*') {
self.pos += 1;
return Ok(());
}
return Err((
self.pos,
"a mixed model with names must end in `)*`",
));
}
if self.peek() == Some(b'*') {
self.pos += 1;
}
return Ok(());
}
_ => return Err((self.pos, "malformed mixed content model")),
}
}
}
fn parse_group_tail(&mut self) -> Result<(), DtdError> {
let mut separator: Option<u8> = None;
loop {
self.skip_ws();
if self.peek() == Some(b'(') {
self.pos += 1;
self.parse_group_tail()?;
} else {
let _ = self.name()?;
}
if matches!(self.peek(), Some(b'?' | b'*' | b'+')) {
self.pos += 1;
}
self.skip_ws();
match self.peek() {
Some(sep @ (b'|' | b',')) => {
if separator.is_some_and(|s| s != sep) {
return Err((
self.pos,
"a content model group cannot mix `|` and `,`",
));
}
separator = Some(sep);
self.pos += 1;
}
Some(b')') => {
self.pos += 1;
return Ok(());
}
_ => return Err((self.pos, "malformed content model")),
}
}
}
fn parse_attlist_decl(&mut self, dtd: &Dtd) -> Result<(), DtdError> {
self.require_ws()?;
let _ = self.name()?;
loop {
self.skip_ws();
if self.peek() == Some(b'>') {
self.pos += 1;
return Ok(());
}
if self.peek().is_none() {
return Err((self.pos, "unterminated attribute list"));
}
let _ = self.name()?;
self.require_ws()?;
self.parse_att_type()?;
self.require_ws()?;
let _ = self.parse_default_decl(dtd)?;
}
}
fn parse_att_type(&mut self) -> Result<(), DtdError> {
if self.peek() == Some(b'(') {
return self.parse_enumeration(false);
}
let kw = self.name()?;
match kw {
"CDATA" | "ID" | "IDREF" | "IDREFS" | "ENTITY" | "ENTITIES"
| "NMTOKEN" | "NMTOKENS" => Ok(()),
"NOTATION" => {
self.require_ws()?;
if self.peek() != Some(b'(') {
return Err((self.pos, "NOTATION needs a name list"));
}
self.parse_enumeration(true)
}
_ => Err((self.pos, "unknown attribute type")),
}
}
fn parse_enumeration(&mut self, names_only: bool) -> Result<(), DtdError> {
self.pos += 1; loop {
self.skip_ws();
if names_only {
let _ = self.name()?;
} else {
let start = self.pos;
while self.input[self.pos..]
.chars()
.next()
.is_some_and(|c| self.is_name_char(c))
{
self.pos += self.input[self.pos..]
.chars()
.next()
.map_or(0, char::len_utf8);
}
if self.pos == start {
return Err((self.pos, "empty item in an enumeration"));
}
}
self.skip_ws();
match self.peek() {
Some(b'|') => self.pos += 1,
Some(b')') => {
self.pos += 1;
return Ok(());
}
_ => return Err((self.pos, "malformed enumeration")),
}
}
}
fn pubid_literal(&mut self) -> Result<&'a str, DtdError> {
let start = self.pos;
let text = self.quoted()?;
if let Some(bad) = text.chars().find(|c| !is_pubid_char(*c)) {
let _ = bad;
return Err((start, "illegal character in a public identifier"));
}
Ok(text)
}
fn parse_default_decl(
&mut self,
dtd: &Dtd,
) -> Result<DefaultDecl, DtdError> {
if self.peek() == Some(b'#') {
self.pos += 1;
let kw = self.name()?;
return match kw {
"REQUIRED" => Ok(DefaultDecl::Required),
"IMPLIED" => Ok(DefaultDecl::Implied),
"FIXED" => {
self.require_ws()?;
let start = self.pos;
let text = self.quoted()?;
validate_attribute_default(text, start, dtd)?;
Ok(DefaultDecl::Fixed)
}
_ => Err((self.pos, "expected #REQUIRED, #IMPLIED or #FIXED")),
};
}
let start = self.pos;
let text = self.quoted()?;
validate_attribute_default(text, start, dtd)?;
Ok(DefaultDecl::Value)
}
fn parse_entity_decl(&mut self, dtd: &mut Dtd) -> Result<(), DtdError> {
self.require_ws()?;
let parameter = if self.peek() == Some(b'%') {
self.pos += 1;
self.require_ws()?;
true
} else {
false
};
let name_at = self.pos;
let name = self.name()?;
if name.contains(':') {
return Err((name_at, "a colon is not allowed in an entity name"));
}
self.require_ws()?;
let value = if matches!(self.peek(), Some(b'"' | b'\'')) {
let start = self.pos;
let text = self.quoted()?;
validate_entity_value(text, start, dtd.external_seen)?;
EntityValue::Internal(text.to_owned())
} else {
let is_public = self.starts_with("PUBLIC");
if !is_public && !self.starts_with("SYSTEM") {
return Err((
self.pos,
"expected an entity value or ExternalID",
));
}
self.pos += 6;
self.require_ws()?;
let (system, public) = if is_public {
let pubid = self.pubid_literal()?.to_owned();
self.require_ws()?;
(self.quoted()?.to_owned(), Some(pubid))
} else {
(self.quoted()?.to_owned(), None)
};
let before_ws = self.pos;
self.skip_ws();
let had_ws = self.pos > before_ws;
if self.starts_with("NDATA") {
if !had_ws {
return Err((
self.pos,
"whitespace is required before NDATA",
));
}
if parameter {
return Err((
self.pos,
"a parameter entity may not have an NDataDecl",
));
}
self.pos += "NDATA".len();
self.require_ws()?;
let _ = self.name()?;
EntityValue::Unparsed
} else {
EntityValue::External { system, public }
}
};
self.skip_ws();
self.expect(b'>', "unterminated entity declaration")?;
if parameter {
dtd.incomplete = true;
if let EntityValue::Internal(text) = &value {
let expanded = expand_char_refs(text);
let _ =
dtd.parameters.entry(name.to_owned()).or_insert(expanded);
}
} else {
let _ = dtd.general.entry(name.to_owned()).or_insert(value);
}
Ok(())
}
fn parse_notation_decl(&mut self) -> Result<(), DtdError> {
self.require_ws()?;
let name_at = self.pos;
let name = self.name()?;
if name.contains(':') {
return Err((name_at, "a colon is not allowed in a notation name"));
}
self.require_ws()?;
let public = self.starts_with("PUBLIC");
if !public && !self.starts_with("SYSTEM") {
return Err((self.pos, "notation needs SYSTEM or PUBLIC"));
}
self.pos += 6;
self.require_ws()?;
if public {
let _ = self.pubid_literal()?;
} else {
let _ = self.quoted()?;
}
self.skip_ws();
if public && matches!(self.peek(), Some(b'"' | b'\'')) {
let _ = self.quoted()?;
self.skip_ws();
}
self.expect(b'>', "unterminated notation declaration")
}
}
fn expand_char_refs(text: &str) -> String {
if !text.contains("&#") {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("&#") {
let (before, tail) = rest.split_at(i);
out.push_str(before);
let after = &tail[2..];
let Some(semi) = after.find(';') else {
out.push_str(tail);
return out;
};
let digits = &after[..semi];
let value = if let Some(hex) = digits.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()
} else {
digits.parse::<u32>().ok()
};
match value.and_then(char::from_u32) {
Some(c) => out.push(c),
None => {
out.push_str(&tail[..semi + 3]);
}
}
rest = &after[semi + 1..];
}
out.push_str(rest);
out
}
fn next_is_name_start(rest: &[u8]) -> bool {
matches!(rest.first(), Some(b) if b.is_ascii_alphabetic()
|| *b == b'_' || *b == b':' || *b >= 0x80)
}
const MAX_PE_DEPTH: usize = 40;
fn expand_pes(text: &str, dtd: &Dtd, depth: usize) -> Option<String> {
if depth >= MAX_PE_DEPTH {
return None;
}
if !text.contains('%') {
return Some(text.to_owned());
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
let mut quote: Option<char> = None;
while let Some(i) = rest.find(['%', '"', '\'']) {
let (before, tail) = rest.split_at(i);
out.push_str(before);
let c = tail.chars().next()?;
match (quote, c) {
(Some(q), _) if c == q => {
quote = None;
out.push(c);
rest = &tail[c.len_utf8()..];
}
(Some(_), _) => {
out.push(c);
rest = &tail[c.len_utf8()..];
}
(None, '"' | '\'') => {
quote = Some(c);
out.push(c);
rest = &tail[c.len_utf8()..];
}
(None, _) => {
let after = &tail[1..];
let semi = after.find(';')?;
let name = &after[..semi];
let value = dtd.parameters.get(name)?;
out.push_str(&expand_pes(value, dtd, depth + 1)?);
rest = &after[semi + 1..];
}
}
}
out.push_str(rest);
Some(out)
}
fn is_pubid_char(c: char) -> bool {
matches!(c, '\u{20}' | '\u{D}' | '\u{A}' | 'a'..='z' | 'A'..='Z' | '0'..='9')
|| "-'()+,./:=?;!*#@$_%".contains(c)
}
fn validate_attribute_default(
text: &str,
offset: usize,
dtd: &Dtd,
) -> Result<(), DtdError> {
let mut visiting = Vec::new();
check_att_value(text, offset, dtd, &mut visiting)
}
fn check_att_value<'n>(
text: &'n str,
offset: usize,
dtd: &'n Dtd,
visiting: &mut Vec<&'n str>,
) -> Result<(), DtdError> {
if text.contains('<') {
return Err((offset, "`<` is not allowed in an attribute value"));
}
let mut rest = text;
while let Some(i) = rest.find('&') {
let tail = &rest[i + 1..];
let Some(semi) = tail.find(';') else {
return Err((offset, "unterminated reference in a default value"));
};
let name = &tail[..semi];
rest = &tail[semi + 1..];
if name.starts_with('#') || decode_predefined_name(name) {
continue;
}
if visiting.contains(&name) {
return Err((
offset,
"an entity in a default value refers to itself",
));
}
match dtd.general.get(name) {
None => {
if !dtd.incomplete {
return Err((
offset,
"a default value references an entity that is not \
declared, or is declared after it",
));
}
}
Some(EntityValue::External { .. }) => {
return Err((
offset,
"a default value may not reference an external entity",
));
}
Some(EntityValue::Unparsed) => {
return Err((
offset,
"a default value may not reference an unparsed entity",
));
}
Some(EntityValue::Internal(inner)) => {
visiting.push(name);
check_att_value(inner, offset, dtd, visiting)?;
let _ = visiting.pop();
}
}
}
Ok(())
}
fn decode_predefined_name(name: &str) -> bool {
matches!(name, "lt" | "gt" | "amp" | "apos" | "quot")
}
fn is_reference_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(crate::parser::is_name_start)
&& chars.all(crate::parser::is_name_char)
}
fn validate_entity_value(
text: &str,
offset: usize,
external_subset_seen: bool,
) -> Result<(), DtdError> {
let mut rest = text;
while let Some(i) = rest.find(['&', '%']) {
let (kind, tail) = (rest.as_bytes()[i], &rest[i + 1..]);
if kind == b'%' {
if !external_subset_seen {
return Err((
offset,
"a parameter entity reference is not allowed in an \
entity value in the internal subset",
));
}
rest = tail;
continue;
}
let Some(semi) = tail.find(';') else {
return Err((offset, "unterminated reference in an entity value"));
};
let name = &tail[..semi];
rest = &tail[semi + 1..];
if let Some(hex) = name.strip_prefix("#x") {
if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err((
offset,
"malformed hexadecimal character reference",
));
}
} else if let Some(dec) = name.strip_prefix('#') {
if dec.is_empty() || !dec.chars().all(|c| c.is_ascii_digit()) {
return Err((offset, "malformed decimal character reference"));
}
} else if !is_reference_name(name) {
return Err((offset, "not a valid entity reference name"));
}
}
Ok(())
}