use crate::core::escape::write_escaped_attribute;
use crate::core::url_policy;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
key: String,
value: String,
is_boolean: bool,
is_trusted_url: bool,
}
impl Attribute {
pub fn new(key: impl Into<String>, 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: key.into(),
value: escaped,
is_boolean: false,
is_trusted_url: false,
}
}
#[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: key.into(),
value: value.into(),
is_boolean: false,
is_trusted_url: false,
}
}
pub fn boolean(key: impl Into<String>) -> Self {
Self {
key: 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) {
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(&self.key, &self.value) {
out.push_str(&self.value);
} else {
out.push_str(url_policy::REPLACEMENT);
}
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('"'));
}
}