use std::borrow::Cow;
use crate::core::escape::write_escaped_attribute;
use crate::core::url_policy;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Attribute {
key: Cow<'static, str>,
value: String,
is_boolean: bool,
is_trusted_url: bool,
}
impl Attribute {
pub fn new(key: impl Into<String>, value: impl AsRef<str>) -> Self {
Self::with_key(Cow::Owned(key.into()), value)
}
pub(crate) fn new_static(key: &'static str, value: impl AsRef<str>) -> Self {
Self::with_key(Cow::Borrowed(key), value)
}
fn with_key(key: Cow<'static, str>, value: impl AsRef<str>) -> Self {
let raw = value.as_ref();
let mut escaped = String::with_capacity(raw.len());
write_escaped_attribute(&mut escaped, raw);
Self {
key,
value: escaped,
is_boolean: false,
is_trusted_url: false,
}
}
pub(crate) fn from_display(key: &'static str, value: &impl std::fmt::Display) -> Self {
use std::fmt::Write as _;
let mut escaped = String::new();
let _ = write!(
crate::core::escape::AttributeWriter(&mut escaped),
"{value}"
);
Self {
key: Cow::Borrowed(key),
value: escaped,
is_boolean: false,
is_trusted_url: false,
}
}
pub(crate) fn push_escaped_word(&mut self, word: &str) {
self.value.push(' ');
write_escaped_attribute(&mut self.value, word);
}
#[must_use]
pub fn trusted_url(key: impl Into<String>, value: impl AsRef<str>) -> Self {
let mut attribute = Self::new(key, value);
attribute.is_trusted_url = true;
attribute
}
pub fn raw(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: Cow::Owned(key.into()),
value: value.into(),
is_boolean: false,
is_trusted_url: false,
}
}
pub fn boolean(key: impl Into<String>) -> Self {
Self {
key: Cow::Owned(key.into()),
value: String::new(),
is_boolean: true,
is_trusted_url: false,
}
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
#[must_use]
pub fn is_boolean(&self) -> bool {
self.is_boolean
}
pub(crate) fn write_into(&self, out: &mut String) {
self.write_into_in(out, false, "");
}
pub(crate) fn write_into_in(&self, out: &mut String, in_raw_text: bool, tag: &str) {
if !is_safe_attribute_name(&self.key) {
return;
}
out.push(' ');
out.push_str(&self.key);
if self.is_boolean {
return;
}
out.push_str("=\"");
if self.is_trusted_url || !url_policy::is_blocked_in(tag, &self.key, &self.value) {
if in_raw_text {
for c in self.value.chars() {
match c {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
} else {
out.push_str(&self.value);
}
} else {
out.push_str(url_policy::replacement_for(&self.key));
}
out.push('"');
}
}
fn is_safe_attribute_name(key: &str) -> bool {
!key.is_empty()
&& !key.chars().any(|scalar| {
scalar.is_control() || matches!(scalar, ' ' | '"' | '\'' | '=' | '<' | '>' | '/')
})
}
impl std::fmt::Display for Attribute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut out = String::new();
self.write_into(&mut out);
f.write_str(&out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn value_is_escaped_at_construction() {
let attr = Attribute::new("title", r#"a "quoted" & <tagged> value"#);
assert_eq!(attr.value(), "a "quoted" & <tagged> value");
}
#[test]
fn boolean_attributes_render_as_a_bare_key() {
assert_eq!(Attribute::boolean("required").to_string(), " required");
assert!(Attribute::boolean("open").is_boolean());
}
#[test]
fn raw_attributes_are_not_escaped() {
assert_eq!(Attribute::raw("property", "og:title").value(), "og:title");
}
#[test]
fn non_boolean_attributes_render_with_a_quoted_value() {
assert_eq!(
Attribute::new("class", "card p-4").to_string(),
r#" class="card p-4""#
);
}
#[test]
fn a_quote_in_the_value_cannot_break_out_of_the_attribute() {
let attr = Attribute::new("class", r#"a" onload="alert(1)"#);
assert!(!attr.value().contains('"'));
}
}