use std::borrow::Cow;
use std::fmt;
use std::str::FromStr;
use rama_core::error::BoxError;
use rama_utils::byte_set::{set_each, set_range};
use super::super::tokenizer::{HtmlTag, StartTag};
use super::super::{IntoHtml, escape_attr_value_into};
pub type HandlerResult = Result<(), BoxError>;
const FORBIDDEN_NAME_BYTE: [bool; 256] = set_each(
set_each(set_range([false; 256], 0, 0x20), &[0x7f]),
b" \"'<>/=",
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttributeName(Cow<'static, str>);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidAttributeName {
Empty,
ForbiddenByte(u8),
}
impl fmt::Display for InvalidAttributeName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("empty attribute name"),
Self::ForbiddenByte(b) => write!(f, "forbidden byte {b:#04x} in attribute name"),
}
}
}
impl std::error::Error for InvalidAttributeName {}
impl AttributeName {
#[must_use]
pub const fn from_static(name: &'static str) -> Self {
assert!(
is_valid_name(name.as_bytes()),
"invalid HTML attribute name"
);
Self(Cow::Borrowed(name))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn into_name_bytes(self) -> Cow<'static, [u8]> {
match self.0 {
Cow::Borrowed(s) => Cow::Borrowed(s.as_bytes()),
Cow::Owned(s) => Cow::Owned(s.into_bytes()),
}
}
}
impl TryFrom<&str> for AttributeName {
type Error = InvalidAttributeName;
fn try_from(name: &str) -> Result<Self, Self::Error> {
validate_name(name.as_bytes())?;
Ok(Self(Cow::Owned(name.to_owned())))
}
}
impl FromStr for AttributeName {
type Err = InvalidAttributeName;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s)
}
}
const fn is_valid_name(bytes: &[u8]) -> bool {
if bytes.is_empty() {
return false;
}
let mut i = 0;
while i < bytes.len() {
if FORBIDDEN_NAME_BYTE[bytes[i] as usize] {
return false;
}
i += 1;
}
true
}
fn validate_name(bytes: &[u8]) -> Result<(), InvalidAttributeName> {
if bytes.is_empty() {
return Err(InvalidAttributeName::Empty);
}
match bytes
.iter()
.copied()
.find(|&b| FORBIDDEN_NAME_BYTE[b as usize])
{
Some(b) => Err(InvalidAttributeName::ForbiddenByte(b)),
None => Ok(()),
}
}
pub trait ElementContentHandler {
fn handle_element(&mut self, selector: usize, element: &mut Element<'_>) -> HandlerResult;
}
#[derive(Debug)]
struct EditedAttribute<'t> {
name: Cow<'t, [u8]>,
value: Option<Cow<'t, [u8]>>,
}
#[derive(Debug, Default)]
enum ElementMode {
#[default]
Normal,
Inner(String),
Replace(String),
Remove,
RemoveKeepContent,
}
pub struct Element<'t> {
tag: &'t StartTag<'t>,
before: String,
prepend: String,
append: String,
after: String,
attributes: Option<Vec<EditedAttribute<'t>>>,
mode: ElementMode,
}
impl<'t> Element<'t> {
pub(crate) fn new(tag: &'t StartTag<'t>) -> Self {
Self {
tag,
before: String::new(),
prepend: String::new(),
append: String::new(),
after: String::new(),
attributes: None,
mode: ElementMode::Normal,
}
}
#[must_use]
pub fn tag(&self) -> HtmlTag<'_> {
self.tag.tag()
}
#[must_use]
pub fn attribute(&self, name: &str) -> Option<&[u8]> {
let name = name.as_bytes();
match &self.attributes {
Some(edited) => edited
.iter()
.find(|a| a.name.eq_ignore_ascii_case(name))
.map(|a| a.value.as_deref().unwrap_or(b"")),
None => self
.tag
.attributes()
.find(|a| a.name().eq_ignore_ascii_case(name))
.map(|a| if a.has_value() { a.value() } else { b"" }),
}
}
#[must_use]
pub fn has_attribute(&self, name: &str) -> bool {
self.attribute(name).is_some()
}
pub fn set_attribute(&mut self, name: AttributeName, value: &str) {
self.ensure_attributes();
let Some(attributes) = self.attributes.as_mut() else {
return;
};
let name = name.into_name_bytes();
if let Some(existing) = attributes
.iter_mut()
.find(|a| a.name.eq_ignore_ascii_case(&name))
{
existing.value = Some(Cow::Owned(value.as_bytes().to_vec()));
} else {
attributes.push(EditedAttribute {
name,
value: Some(Cow::Owned(value.as_bytes().to_vec())),
});
}
}
pub fn remove_attribute(&mut self, name: &str) {
self.ensure_attributes();
let Some(attributes) = self.attributes.as_mut() else {
return;
};
let name = name.as_bytes();
attributes.retain(|a| !a.name.eq_ignore_ascii_case(name));
}
pub fn before(&mut self, content: impl IntoHtml) {
reserve_html(&mut self.before, &content);
content.escape_and_write(&mut self.before);
}
pub fn prepend(&mut self, content: impl IntoHtml) {
reserve_html(&mut self.prepend, &content);
content.escape_and_write(&mut self.prepend);
}
pub fn append(&mut self, content: impl IntoHtml) {
reserve_html(&mut self.append, &content);
content.escape_and_write(&mut self.append);
}
pub fn after(&mut self, content: impl IntoHtml) {
reserve_html(&mut self.after, &content);
content.escape_and_write(&mut self.after);
}
pub fn set_inner_content(&mut self, content: impl IntoHtml) {
let mut inner = String::with_capacity(html_capacity(&content));
content.escape_and_write(&mut inner);
self.mode = ElementMode::Inner(inner);
}
pub fn replace(&mut self, content: impl IntoHtml) {
let mut replacement = String::with_capacity(html_capacity(&content));
content.escape_and_write(&mut replacement);
self.mode = ElementMode::Replace(replacement);
}
pub fn remove(&mut self) {
self.mode = ElementMode::Remove;
}
pub fn remove_and_keep_content(&mut self) {
self.mode = ElementMode::RemoveKeepContent;
}
#[must_use]
pub fn is_removed(&self) -> bool {
matches!(
self.mode,
ElementMode::Remove | ElementMode::RemoveKeepContent | ElementMode::Replace(_)
)
}
fn ensure_attributes(&mut self) {
if self.attributes.is_none() {
let attributes = self
.tag
.attributes()
.map(|a| EditedAttribute {
name: Cow::Borrowed(a.name()),
value: a.has_value().then(|| Cow::Borrowed(a.value())),
})
.collect();
self.attributes = Some(attributes);
}
}
pub(crate) fn serialize(self, out: &mut Vec<u8>, visible: bool) -> EndActions {
let Self {
tag,
before,
prepend,
append,
after,
attributes,
mode,
} = self;
match mode {
ElementMode::Normal => {
if visible {
out.extend_from_slice(before.as_bytes());
emit_start_tag(out, tag, attributes.as_deref());
out.extend_from_slice(prepend.as_bytes());
}
EndActions {
append,
after,
suppress_content: false,
suppress_end_tag: false,
}
}
ElementMode::Inner(inner) => {
if visible {
out.extend_from_slice(before.as_bytes());
emit_start_tag(out, tag, attributes.as_deref());
out.extend_from_slice(prepend.as_bytes());
out.extend_from_slice(inner.as_bytes());
}
EndActions {
append,
after,
suppress_content: true,
suppress_end_tag: false,
}
}
ElementMode::Replace(replacement) => {
if visible {
out.extend_from_slice(before.as_bytes());
out.extend_from_slice(replacement.as_bytes());
}
EndActions {
append: String::new(),
after,
suppress_content: true,
suppress_end_tag: true,
}
}
ElementMode::Remove => {
if visible {
out.extend_from_slice(before.as_bytes());
}
EndActions {
append: String::new(),
after,
suppress_content: true,
suppress_end_tag: true,
}
}
ElementMode::RemoveKeepContent => {
if visible {
out.extend_from_slice(before.as_bytes());
out.extend_from_slice(prepend.as_bytes());
}
EndActions {
append,
after,
suppress_content: false,
suppress_end_tag: true,
}
}
}
}
}
pub(crate) struct EndActions {
pub(crate) append: String,
pub(crate) after: String,
pub(crate) suppress_content: bool,
pub(crate) suppress_end_tag: bool,
}
impl EndActions {
pub(crate) fn passthrough() -> Self {
Self {
append: String::new(),
after: String::new(),
suppress_content: false,
suppress_end_tag: false,
}
}
}
fn emit_start_tag(out: &mut Vec<u8>, tag: &StartTag<'_>, edited: Option<&[EditedAttribute<'_>]>) {
match edited {
None => out.extend_from_slice(tag.raw()),
Some(attributes) => {
out.push(b'<');
out.extend_from_slice(tag.name());
for attr in attributes {
out.push(b' ');
out.extend_from_slice(&attr.name);
if let Some(value) = &attr.value {
out.extend_from_slice(b"=\"");
escape_attr_value_into(out, value);
out.push(b'"');
}
}
if tag.is_self_closing() {
out.extend_from_slice(b" />");
} else {
out.push(b'>');
}
}
}
}
fn reserve_html(buf: &mut String, content: &impl IntoHtml) {
buf.reserve(html_capacity(content));
}
fn html_capacity(content: &impl IntoHtml) -> usize {
let hint = content.size_hint();
hint + (hint / 10)
}
#[cfg(test)]
mod tests {
use super::{AttributeName, InvalidAttributeName};
#[test]
fn valid_names() {
for name in ["class", "data-x", "aria-label", "x", "ns:attr"] {
assert_eq!(AttributeName::from_static(name).as_str(), name);
assert_eq!(AttributeName::try_from(name).unwrap().as_str(), name);
assert_eq!(name.parse::<AttributeName>().unwrap().as_str(), name);
}
}
#[test]
fn rejects_invalid_names() {
assert_eq!(
AttributeName::try_from(""),
Err(InvalidAttributeName::Empty)
);
for (input, byte) in [
("a b", b' '),
("a\tb", b'\t'),
("a=b", b'='),
("a>b", b'>'),
("a/b", b'/'),
("a<b", b'<'),
("a\"b", b'"'),
("a'b", b'\''),
("a\x7fb", 0x7f),
] {
assert_eq!(
AttributeName::try_from(input),
Err(InvalidAttributeName::ForbiddenByte(byte)),
"{input:?}"
);
}
}
#[test]
#[should_panic = "invalid HTML attribute name"]
fn from_static_panics_on_injection() {
let _name = AttributeName::from_static("x onload=alert(1)");
}
}