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) parameters_external:
BTreeMap<String, Result<String, &'static str>>,
pub(crate) declared_externally: alloc::collections::BTreeSet<String>,
pub(crate) incomplete: bool,
pub(crate) external_seen: bool,
pub(crate) external_subset: Option<(String, Option<String>)>,
pub(crate) id_attributes: BTreeMap<String, Vec<String>>,
pub(crate) tokenized_attributes: BTreeMap<String, Vec<String>>,
pub(crate) pe_spent: usize,
pub(crate) bytes_seen: usize,
}
impl Dtd {
pub(crate) fn entity(&self, name: &str) -> Option<&EntityValue> {
self.general.get(name)
}
pub(crate) fn is_tokenized_attribute(
&self,
element: &str,
attribute: &str,
) -> bool {
self.tokenized_attributes
.get(element)
.is_some_and(|names| names.iter().any(|n| n == attribute))
}
pub(crate) fn is_id_attribute(
&self,
element: &str,
attribute: &str,
) -> bool {
self.id_attributes
.get(element)
.is_some_and(|names| names.iter().any(|n| n == attribute))
}
}
#[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) limits: crate::Limits,
pub(crate) external: &'a dyn crate::external::ExternalSource,
pub(crate) version: crate::parser::Version,
}
pub(crate) type DtdError = (usize, &'static str);
pub(crate) const LIMIT_NAME: &str = "a name exceeds the length limit";
pub(crate) const LIMIT_ATTRIBUTE: &str =
"an attribute default exceeds the size limit";
pub(crate) const LIMIT_PE: &str =
"parameter entity expansion exceeds the limit";
pub(crate) const LIMIT_DTD: &str = "the doctype exceeds the size limit";
pub(crate) const LIMIT_DEPTH: &str = "the doctype exceeds the nesting limit";
pub(crate) fn error_kind(reason: &'static str) -> crate::ErrorKind {
use crate::ErrorKind;
match reason {
LIMIT_NAME => ErrorKind::NameTooLong,
LIMIT_ATTRIBUTE => ErrorKind::AttributeTooLarge,
LIMIT_PE => ErrorKind::EntityLimitExceeded,
LIMIT_DTD => ErrorKind::DtdTooLarge,
LIMIT_DEPTH => ErrorKind::DtdDepthLimitExceeded,
other => ErrorKind::MalformedDtd(other),
}
}
fn charge(
spent: &mut usize,
max: usize,
bytes: usize,
at: usize,
reason: &'static str,
) -> Result<(), DtdError> {
*spent = spent.saturating_add(bytes);
if *spent > max {
return Err((at, reason));
}
Ok(())
}
impl<'a> DtdParser<'a> {
pub(crate) const fn with_limits(
input: &'a str,
pos: usize,
limits: &crate::Limits,
) -> Self {
Self {
input,
bytes: input.as_bytes(),
pos,
limits: *limits,
external: &crate::external::NoExternal,
version: crate::parser::Version::V10,
}
}
fn nested<'b>(&self, text: &'b str) -> DtdParser<'b>
where
'a: 'b,
{
DtdParser {
input: text,
bytes: text.as_bytes(),
pos: 0,
limits: self.limits,
external: self.external,
version: self.version,
}
}
fn charge_dtd_bytes(
&self,
dtd: &mut Dtd,
bytes: usize,
at: usize,
) -> Result<(), DtdError> {
let max = self.limits.max_dtd_bytes.unwrap_or(usize::MAX);
charge(&mut dtd.bytes_seen, max, bytes, at, LIMIT_DTD)
}
fn charge_pe(
&self,
dtd: &mut Dtd,
bytes: usize,
at: usize,
) -> Result<(), DtdError> {
let max = self.limits.max_pe_expansion;
charge(&mut dtd.pe_spent, max, bytes, at, LIMIT_PE)
}
pub(crate) const fn with_external(
mut self,
external: &'a dyn crate::external::ExternalSource,
version: crate::parser::Version,
) -> Self {
self.external = external;
self.version = version;
self
}
fn is_name_start(&self, c: char) -> bool {
match self.limits.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.limits.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[start..];
let Some(end) = self.name_end(rest) else {
return Err((start, "expected a name"));
};
if end > self.limits.max_name_length {
return Err((start, LIMIT_NAME));
}
self.pos = start + end;
Ok(&rest[..end])
}
fn name_end(&self, rest: &str) -> Option<usize> {
let mut chars = rest.char_indices();
let (_, first) = chars.next()?;
if !self.is_name_start(first) {
return None;
}
Some(
chars
.find(|&(_, c)| !self.is_name_char(c))
.map_or(rest.len(), |(i, _)| i),
)
}
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.require_ws()?;
Some((self.quoted()?.to_owned(), Some(pubid)))
} 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> {
let Some((keyword, bracket_from_entity)) =
self.conditional_keyword(dtd)?
else {
return Ok(());
};
if !bracket_from_entity {
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" => {
self.nested(body).parse_subset_decls(dtd, None, depth + 1)
}
_ => Err((
body_start,
"a conditional section must say INCLUDE or IGNORE",
)),
}
}
fn conditional_keyword(
&mut self,
dtd: &mut Dtd,
) -> Result<Option<(String, bool)>, DtdError> {
self.skip_ws();
if self.peek() != Some(b'%') {
return Ok(Some((self.name()?.to_owned(), false)));
}
self.pos += 1;
let name = self.name()?.to_owned();
self.expect(b';', "unterminated parameter entity reference")?;
let Some(text) = self.parameter_text(dtd, &name, self.pos)? else {
dtd.incomplete = true;
self.skip_conditional_section()?;
return Ok(None);
};
let text = text.trim();
Ok(Some(match text.strip_suffix('[') {
Some(keyword) => (keyword.trim().to_owned(), true),
None => (text.to_owned(), false),
}))
}
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> {
self.check_dtd_depth(depth)?;
loop {
self.skip_ws();
let start = self.pos;
if self.subset_declaration(dtd, terminator, depth)? {
return Ok(());
}
if depth == 0 {
self.charge_dtd_bytes(dtd, self.pos - start, start)?;
}
}
}
fn subset_declaration(
&mut self,
dtd: &mut Dtd,
terminator: Option<u8>,
depth: usize,
) -> Result<bool, DtdError> {
match (self.peek(), terminator) {
(None, None) => Ok(true),
(None, _) => Err((self.pos, "unterminated internal subset")),
(Some(b), Some(end)) if b == end => Ok(true),
(Some(b'%'), _) => {
self.subset_pe_reference(dtd, depth).map(|()| false)
}
(Some(b'<'), _) => {
self.subset_markup(dtd, terminator, depth).map(|()| false)
}
_ => Err((self.pos, "expected a markup declaration")),
}
}
fn subset_pe_reference(
&mut self,
dtd: &mut Dtd,
depth: usize,
) -> Result<(), DtdError> {
self.pos += 1;
let name = self.name()?.to_owned();
self.expect(b';', "unterminated parameter entity reference")?;
dtd.external_seen = true;
match self.parameter_text(dtd, &name, self.pos)? {
Some(text) if depth < MAX_PE_DEPTH => {
self.nested(&text).parse_subset_decls(dtd, None, depth + 1)
}
Some(_) => Err((self.pos, "parameter entities nested too deeply")),
None => {
dtd.incomplete = true;
Ok(())
}
}
}
fn subset_markup(
&mut self,
dtd: &mut Dtd,
terminator: Option<u8>,
depth: usize,
) -> Result<(), DtdError> {
if terminator.is_none() && self.starts_with("<![") {
self.pos += 3;
return self.parse_conditional_section(dtd, depth);
}
if terminator.is_some() || !self.decl_uses_pe() {
return self.parse_markup_decl(dtd, depth);
}
self.expanded_markup_decl(dtd, depth)
}
fn expanded_markup_decl(
&mut self,
dtd: &mut Dtd,
depth: usize,
) -> Result<(), DtdError> {
let start = self.pos;
self.skip_decl();
let raw = &self.input[start..self.pos];
let mut budget = PeBudget {
spent: dtd.pe_spent,
max: self.limits.max_pe_expansion,
at: start,
};
let expanded = expand_pes(raw, dtd, depth, &mut budget);
dtd.pe_spent = budget.spent;
let parsed = match expanded? {
Some(text) => {
self.nested(&text).parse_markup_decl(dtd, depth).is_ok()
}
None => false,
};
if !parsed {
dtd.incomplete = true;
}
Ok(())
}
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.check_dtd_depth(1)?;
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 groups = alloc::vec![None];
loop {
self.check_dtd_depth(groups.len())?;
self.skip_ws();
if self.peek() == Some(b'(') {
self.pos += 1;
self.check_dtd_depth(groups.len().saturating_add(1))?;
groups.push(None);
continue;
}
let _ = self.name()?;
if self.finish_group_particle(&mut groups)? {
return Ok(());
}
}
}
fn check_dtd_depth(&self, depth: usize) -> Result<(), DtdError> {
if depth > self.limits.max_dtd_depth {
return Err((self.pos, LIMIT_DEPTH));
}
Ok(())
}
fn finish_group_particle(
&mut self,
groups: &mut Vec<Option<u8>>,
) -> Result<bool, DtdError> {
while let Some(separator) = groups.last_mut() {
if matches!(self.peek(), Some(b'?' | b'*' | b'+')) {
self.pos += 1;
}
self.skip_ws();
if self.group_separator(separator)? {
return Ok(false);
}
let _ = groups.pop();
}
Ok(true)
}
fn group_separator(
&mut self,
separator: &mut Option<u8>,
) -> Result<bool, DtdError> {
let more = 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);
true
}
Some(b')') => false,
_ => return Err((self.pos, "malformed content model")),
};
self.pos += 1;
Ok(more)
}
fn parse_attlist_decl(&mut self, dtd: &mut Dtd) -> Result<(), DtdError> {
self.require_ws()?;
let element = 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 attribute = self.name()?;
self.require_ws()?;
let (is_id, is_cdata) = self.parse_att_type()?;
self.require_ws()?;
let _ = self.parse_default_decl(dtd)?;
if !is_cdata {
let names = dtd
.tokenized_attributes
.entry(element.to_owned())
.or_default();
if !names.iter().any(|n| n == attribute) {
names.push(attribute.to_owned());
}
}
if is_id {
let names =
dtd.id_attributes.entry(element.to_owned()).or_default();
if !names.iter().any(|n| n == attribute) {
names.push(attribute.to_owned());
}
}
}
}
fn parse_att_type(&mut self) -> Result<(bool, bool), DtdError> {
if self.peek() == Some(b'(') {
return self.parse_enumeration(false).map(|()| (false, false));
}
let kw = self.name()?;
match kw {
"ID" => Ok((true, false)),
"CDATA" => Ok((false, true)),
"IDREF" | "IDREFS" | "ENTITY" | "ENTITIES" | "NMTOKEN"
| "NMTOKENS" => Ok((false, false)),
"NOTATION" => {
self.require_ws()?;
if self.peek() != Some(b'(') {
return Err((self.pos, "NOTATION needs a name list"));
}
self.parse_enumeration(true).map(|()| (false, false))
}
_ => 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()?;
self.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()?;
self.validate_attribute_default(text, start, dtd)?;
Ok(DefaultDecl::Value)
}
fn validate_attribute_default(
&self,
text: &str,
offset: usize,
dtd: &Dtd,
) -> Result<(), DtdError> {
if text.len() > self.limits.max_attribute_size {
return Err((offset, LIMIT_ATTRIBUTE));
}
let mut visiting = Vec::new();
check_att_value(text, offset, dtd, &mut visiting)
}
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::External { system, public } = &value {
self.fetch_external_pe(dtd, name, system, public.as_deref())?;
}
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 fetch_external_pe(
&self,
dtd: &mut Dtd,
name: &str,
system: &str,
public: Option<&str>,
) -> Result<(), DtdError> {
let Some(content) = self.external.fetch(system, public) else {
return Ok(());
};
self.charge_dtd_bytes(dtd, content.len(), self.pos)?;
let resolved = self.text_declaration(content);
let _ = dtd
.parameters_external
.entry(name.to_owned())
.or_insert(resolved);
Ok(())
}
fn parameter_text(
&self,
dtd: &mut Dtd,
name: &str,
at: usize,
) -> Result<Option<String>, DtdError> {
let text = match dtd.parameters.get(name) {
Some(text) => text.clone(),
None => match dtd.parameters_external.get(name) {
Some(Ok(body)) => body.clone(),
Some(Err(reason)) => return Err((at, reason)),
None => return Ok(None),
},
};
self.charge_pe(dtd, text.len(), at)?;
Ok(Some(text))
}
fn text_declaration(&self, content: &str) -> Result<String, &'static str> {
let version = crate::parser::entity_version(content, self.version);
let normalized =
crate::parser::normalize_line_endings(content, version);
crate::parser::check_text_decl_position(&normalized, 0)
.map_err(|_| "a text declaration must come first")?;
crate::parser::check_text_decl(&normalized, self.version, 0)
.map_err(|_| "malformed text declaration")?;
let body = crate::parser::strip_text_decl(&normalized);
if body.chars().any(|c| {
!crate::parser::is_literal_char_for(c, version)
|| !crate::parser::is_literal_char_for(c, self.version)
}) {
return Err("a character the document's version forbids");
}
Ok(body.to_owned())
}
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;
struct PeBudget {
spent: usize,
max: usize,
at: usize,
}
fn expand_pes(
text: &str,
dtd: &Dtd,
depth: usize,
budget: &mut PeBudget,
) -> Result<Option<String>, DtdError> {
if depth >= MAX_PE_DEPTH {
return Ok(None);
}
if !text.contains('%') {
return Ok(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().unwrap_or('%');
if quote.is_some() || c != '%' {
quote = toggle_quote(quote, c);
out.push(c);
rest = &tail[c.len_utf8()..];
continue;
}
let Some((value, after)) = pe_reference(&tail[1..], dtd) else {
return Ok(None);
};
charge(
&mut budget.spent,
budget.max,
value.len(),
budget.at,
LIMIT_PE,
)?;
let Some(expanded) = expand_pes(value, dtd, depth + 1, budget)? else {
return Ok(None);
};
out.push_str(&expanded);
rest = after;
}
out.push_str(rest);
Ok(Some(out))
}
fn toggle_quote(quote: Option<char>, c: char) -> Option<char> {
match quote {
Some(q) if q == c => None,
Some(q) => Some(q),
None if matches!(c, '"' | '\'') => Some(c),
None => None,
}
}
fn pe_reference<'d>(
after: &'d str,
dtd: &'d Dtd,
) -> Option<(&'d str, &'d str)> {
let semi = after.find(';')?;
let value = dtd.parameters.get(&after[..semi])?;
Some((value.as_str(), &after[semi + 1..]))
}
fn is_pubid_char(c: char) -> bool {
matches!(c, '\u{20}' | '\u{D}' | '\u{A}' | 'a'..='z' | 'A'..='Z' | '0'..='9')
|| "-'()+,./:=?;!*#@$_%".contains(c)
}
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",
));
}
let Some(semi) = tail.find(';') else {
return Err((
offset,
"a `%` in an entity value must begin a parameter \
entity reference",
));
};
if !is_reference_name(&tail[..semi]) {
return Err((
offset,
"a `%` in an entity value must begin a parameter \
entity reference",
));
}
rest = &tail[semi + 1..];
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(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ErrorKind, Limits};
use alloc::format;
fn doctype(doc: &str, limits: &Limits) -> Result<Dtd, DtdError> {
DtdParser::with_limits(doc, 0, limits).parse_doctype()
}
#[test]
fn each_limit_reason_maps_to_the_kind_that_names_the_bound() {
assert_eq!(error_kind(LIMIT_NAME), ErrorKind::NameTooLong);
assert_eq!(error_kind(LIMIT_ATTRIBUTE), ErrorKind::AttributeTooLarge);
assert_eq!(error_kind(LIMIT_PE), ErrorKind::EntityLimitExceeded);
assert_eq!(error_kind(LIMIT_DTD), ErrorKind::DtdTooLarge);
assert_eq!(
error_kind("expected a name"),
ErrorKind::MalformedDtd("expected a name")
);
}
#[test]
fn with_limits_threads_the_name_bound_into_declarations() {
let doc = format!("<!DOCTYPE d [<!ELEMENT {} ANY>]>", "n".repeat(300));
assert!(doctype(&doc, &Limits::default()).is_ok());
let err = doctype(&doc, &Limits::strict()).expect_err("256 cap");
assert_eq!(
err,
(23, LIMIT_NAME),
"strict() bounds names at 256 and reports the name's start"
);
}
#[test]
fn new_applies_the_defaults_with_the_given_edition() {
let limits = crate::Limits {
edition: crate::Edition::Fourth,
..crate::Limits::default()
};
let p = DtdParser::with_limits("", 0, &limits);
assert_eq!(p.limits.edition, crate::Edition::Fourth);
assert_eq!(p.limits.max_name_length, Limits::default().max_name_length);
}
#[test]
fn an_attribute_default_is_bounded_by_the_attribute_size() {
let doc = format!(
"<!DOCTYPE d [<!ATTLIST d a CDATA \"{}\">]>",
"v".repeat(9_000)
);
assert!(doctype(&doc, &Limits::default()).is_ok());
let err = doctype(&doc, &Limits::strict()).expect_err("8 KiB cap");
assert_eq!(err.1, LIMIT_ATTRIBUTE);
}
#[test]
fn a_parameter_entity_referenced_between_declarations_is_charged() {
let doc = "<!DOCTYPE d SYSTEM \"s\" [\
<!ENTITY % a \"<!-- forty bytes of comment text here -->\">\
%a;%a;%a;]>";
let mut limits = Limits {
max_pe_expansion: 100,
..Limits::default()
};
let err = doctype(doc, &limits).expect_err("three references");
assert_eq!(err.1, LIMIT_PE);
limits.max_pe_expansion = 200;
assert!(doctype(doc, &limits).is_ok());
}
#[test]
fn a_parameter_entity_inside_a_declaration_is_charged() {
let subset = "<!ENTITY % m \"(#PCDATA)\">\
<!ELEMENT a %m;><!ELEMENT b %m;><!ELEMENT c %m;>";
let limits = Limits {
max_pe_expansion: 20,
..Limits::default()
};
let mut dtd = Dtd::default();
let err = DtdParser::with_limits(subset, 0, &limits)
.parse_external_subset(&mut dtd)
.expect_err("three references of nine bytes");
assert_eq!(err.1, LIMIT_PE);
assert_eq!(dtd.pe_spent, 27, "charged before refusing");
}
#[test]
fn the_doctype_budget_counts_declaration_text_once() {
let subset = "<!ELEMENT a ANY>".repeat(10);
let mut limits = Limits {
max_dtd_bytes: Some(subset.len()),
..Limits::default()
};
let mut dtd = Dtd::default();
DtdParser::with_limits(&subset, 0, &limits)
.parse_external_subset(&mut dtd)
.expect("exactly the budget");
assert_eq!(dtd.bytes_seen, subset.len());
limits.max_dtd_bytes = Some(subset.len() - 1);
let err = DtdParser::with_limits(&subset, 0, &limits)
.parse_external_subset(&mut Dtd::default())
.expect_err("one byte over");
assert_eq!(err.1, LIMIT_DTD);
}
#[test]
fn an_external_parameter_entity_counts_against_the_doctype_budget() {
struct Big;
impl crate::external::ExternalSource for Big {
fn fetch(&self, _: &str, _: Option<&str>) -> Option<&str> {
Some("<!-- an external parameter entity's content -->")
}
}
let doc = "<!DOCTYPE d [<!ENTITY % e SYSTEM \"e\">]>";
let limits = Limits {
max_dtd_bytes: Some(32),
..Limits::default()
};
let err = DtdParser::with_limits(doc, 0, &limits)
.with_external(&Big, crate::parser::Version::V10)
.parse_doctype()
.expect_err("fetched content is charged before it is held");
assert_eq!(err.1, LIMIT_DTD);
}
#[test]
fn expand_pes_charges_every_reference_it_resolves() {
let mut dtd = Dtd::default();
let _ = dtd.parameters.insert("a".into(), "xxxxxxxxxx".into());
let _ = dtd.parameters.insert("b".into(), "%a;%a;%a;".into());
let mut budget = PeBudget {
spent: 0,
max: usize::MAX,
at: 0,
};
let out = expand_pes("<!ELEMENT e (%b;)>", &dtd, 0, &mut budget)
.expect("within budget")
.expect("all known");
assert_eq!(out, format!("<!ELEMENT e ({})>", "x".repeat(30)));
assert_eq!(budget.spent, 9 + 30);
let mut budget = PeBudget {
spent: 0,
max: 20,
at: 7,
};
assert_eq!(
expand_pes("<!ELEMENT e (%b;)>", &dtd, 0, &mut budget),
Err((7, LIMIT_PE))
);
}
#[test]
fn a_percent_inside_a_literal_is_data() {
let dtd = Dtd::default();
let mut budget = PeBudget {
spent: 0,
max: 0,
at: 0,
};
assert_eq!(
expand_pes("<!ENTITY x \"50%\">", &dtd, 0, &mut budget),
Ok(Some("<!ENTITY x \"50%\">".to_owned()))
);
}
}