use std::collections::{HashMap, HashSet};
pub(crate) fn parse_internal_entities(doctype: &str) -> HashMap<String, String> {
let mut raw = HashMap::new();
let mut declared: HashSet<String> = HashSet::new();
let subset = match (doctype.find('['), doctype.rfind(']')) {
(Some(start), Some(end)) if start < end => &doctype[start + 1..end],
_ => return raw,
};
let bytes = subset.as_bytes();
let mut i = 0;
while let Some(pos) = subset[i..].find("<!ENTITY") {
let mut j = i + pos + "<!ENTITY".len();
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && bytes[j] == b'%' {
i = j;
continue;
}
let name_start = j;
while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
j += 1;
}
let name = &subset[name_start..j];
if !name.is_empty() {
declared.insert(name.to_string());
}
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && (bytes[j] == b'"' || bytes[j] == b'\'') {
let quote = bytes[j];
j += 1;
let value_start = j;
while j < bytes.len() && bytes[j] != quote {
j += 1;
}
if j < bytes.len() && !name.is_empty() {
raw.entry(name.to_string())
.or_insert_with(|| subset[value_start..j].to_string());
}
}
i = j.max(i + pos + 1);
}
let external = has_external_subset(doctype);
let pe_referenced = subset_has_pe_reference(subset);
let strict = !external && !pe_referenced;
let mut out = HashMap::new();
for name in raw.keys() {
let mut path = vec![name.clone()];
match expand_checked(&raw[name], &raw, &declared, &mut path) {
Ok(value) => {
out.insert(name.clone(), value);
}
Err(_) if strict => { }
Err(_) => {
out.insert(name.clone(), expand(&raw[name], &raw, 0));
}
}
}
out
}
fn has_external_subset(doctype: &str) -> bool {
let head = match doctype.find('[') {
Some(i) => &doctype[..i],
None => doctype,
};
head.contains("SYSTEM") || head.contains("PUBLIC")
}
fn subset_has_pe_reference(subset: &str) -> bool {
let mut chars = subset.chars().peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(next) = chars.peek()
&& (next.is_alphabetic() || *next == '_' || *next == ':')
{
return true;
}
}
false
}
fn expand_checked(
value: &str,
entities: &HashMap<String, String>,
declared: &HashSet<String>,
path: &mut Vec<String>,
) -> Result<String, ExpandError> {
let mut out = String::with_capacity(value.len());
let mut rest = value;
loop {
if let Some(cd) = rest.find("<![CDATA[") {
out.push_str(&scan_refs(&rest[..cd], entities, declared, path)?);
let body = &rest[cd + "<![CDATA[".len()..];
match body.find("]]>") {
Some(end) => {
out.push_str(&body[..end]);
rest = &body[end + 3..];
continue;
}
None => {
out.push_str(body);
return Ok(out);
}
}
}
out.push_str(&scan_refs(rest, entities, declared, path)?);
return Ok(out);
}
}
fn scan_refs(
value: &str,
entities: &HashMap<String, String>,
declared: &HashSet<String>,
path: &mut Vec<String>,
) -> Result<String, ExpandError> {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let after = &rest[amp + 1..];
let Some(semi) = after.find(';') else {
out.push('&');
rest = after;
continue;
};
let name = &after[..semi];
if let Some(stripped) = name.strip_prefix('#') {
let code = if let Some(hex) = stripped.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()
} else {
stripped.parse::<u32>().ok()
};
if let Some(c) = code.and_then(char::from_u32) {
out.push(c);
}
} else if matches!(name, "lt" | "gt" | "amp" | "quot" | "apos") {
} else if let Some(replacement) = entities.get(name) {
if path.iter().any(|p| p == name) {
return Err(ExpandError::Cycle);
}
path.push(name.to_string());
let expanded = expand_checked(replacement, entities, declared, path)?;
path.pop();
out.push_str(&expanded);
} else if declared.contains(name) {
} else {
return Err(ExpandError::Undeclared);
}
rest = &after[semi + 1..];
}
out.push_str(rest);
Ok(out)
}
enum ExpandError {
Cycle,
Undeclared,
}
fn expand(value: &str, entities: &HashMap<String, String>, depth: usize) -> String {
if depth > 8 {
return value.to_string();
}
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let after = &rest[amp + 1..];
let Some(semi) = after.find(';') else {
out.push('&');
rest = after;
continue;
};
let name = &after[..semi];
if let Some(stripped) = name.strip_prefix('#') {
let code = if let Some(hex) = stripped.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()
} else {
stripped.parse::<u32>().ok()
};
match code.and_then(char::from_u32) {
Some(c) => out.push(c),
None => {
out.push('&');
out.push_str(&after[..=semi]);
}
}
} else if let Some(replacement) = entities.get(name) {
out.push_str(&expand(replacement, entities, depth + 1));
} else {
match name {
"lt" => out.push('<'),
"gt" => out.push('>'),
"amp" => out.push('&'),
"quot" => out.push('"'),
"apos" => out.push('\''),
_ => {
out.push('&');
out.push_str(&after[..=semi]);
}
}
}
rest = &after[semi + 1..];
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_entities() {
let map = parse_internal_entities(
r#"doc [ <!ENTITY internal "text content"> <!ENTITY other 'more'> ]"#,
);
assert_eq!(
map.get("internal").map(String::as_str),
Some("text content")
);
assert_eq!(map.get("other").map(String::as_str), Some("more"));
}
#[test]
fn expands_char_refs_and_nesting() {
let map = parse_internal_entities(r#"doc [ <!ENTITY a "A"> <!ENTITY b "x&a;y"> ]"#);
assert_eq!(map.get("a").map(String::as_str), Some("A"));
assert_eq!(map.get("b").map(String::as_str), Some("xAy"));
}
#[test]
fn skips_parameter_and_external_entities() {
let map = parse_internal_entities(
r#"doc [ <!ENTITY % param "x"> <!ENTITY ext SYSTEM "foo.ent"> <!ENTITY ok "v"> ]"#,
);
assert!(!map.contains_key("param"));
assert!(!map.contains_key("ext"));
assert_eq!(map.get("ok").map(String::as_str), Some("v"));
}
#[test]
fn first_declaration_wins() {
let map = parse_internal_entities(r#"doc [ <!ENTITY e "one"> <!ENTITY e "two"> ]"#);
assert_eq!(map.get("e").map(String::as_str), Some("one"));
}
#[test]
fn poisons_reference_cycle() {
let map = parse_internal_entities(
r#"doc [ <!ENTITY e1 "&e2;"> <!ENTITY e2 "&e3;"> <!ENTITY e3 "&e1;"> ]"#,
);
assert!(map.is_empty(), "cyclic entities must be poisoned: {map:?}");
}
#[test]
fn poisons_reference_to_undeclared_entity() {
let map = parse_internal_entities(r#"doc [ <!ENTITY foo "&bar;"> ]"#);
assert!(
!map.contains_key("foo"),
"an entity referencing an undeclared entity must be poisoned"
);
}
#[test]
fn keeps_reference_to_external_entity() {
let map = parse_internal_entities(
r#"doc [ <!ENTITY e1 "&e2;"> <!ENTITY e2 "&e3;"> <!ENTITY e3 SYSTEM "e.ent"> ]"#,
);
assert!(map.contains_key("e1") && map.contains_key("e2"));
}
#[test]
fn cdata_in_entity_value_is_literal() {
let map = parse_internal_entities(r#"doc [ <!ENTITY e "<![CDATA[&foo;]]>"> ]"#);
assert!(map.contains_key("e"));
}
#[test]
fn does_not_poison_when_parameter_entity_referenced() {
let map = parse_internal_entities(r#"doc [ <!ENTITY % p "x"> %p; <!ENTITY foo "&bar;"> ]"#);
assert!(map.contains_key("foo"));
}
}