Skip to main content

molt/core/
attribute.rs

1//! HTML attributes.
2//!
3
4use std::borrow::Cow;
5
6use crate::core::escape::write_escaped_attribute;
7use crate::core::url_policy;
8
9/// A single HTML attribute.
10///
11/// Values are escaped when the attribute is built, not when it is rendered — see
12/// [`crate::core::escape`].
13///
14/// # Examples
15/// ```
16/// use molt::core::Attribute;
17/// assert_eq!(Attribute::new("href", "/a?x=1&y=2").to_string(), r#" href="/a?x=1&y=2""#);
18/// assert_eq!(Attribute::boolean("required").to_string(), " required");
19/// ```
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct Attribute {
22    // `Cow` so the keys this crate writes itself (`class`, `id`, `lang`, …) are borrowed
23    // literals rather than a fresh `String` per attribute. The public constructors still
24    // take `impl Into<String>`, because a caller's key is rarely `'static`.
25    key: Cow<'static, str>,
26    value: String,
27    is_boolean: bool,
28    /// When true, a URL attribute is written as stored. The URL policy reads this flag.
29    is_trusted_url: bool,
30}
31
32impl Attribute {
33    /// Creates an attribute, escaping the value.
34    pub fn new(key: impl Into<String>, value: impl AsRef<str>) -> Self {
35        Self::with_key(Cow::Owned(key.into()), value)
36    }
37
38    /// As [`Self::new`] for a key known at compile time, which costs no allocation.
39    pub(crate) fn new_static(key: &'static str, value: impl AsRef<str>) -> Self {
40        Self::with_key(Cow::Borrowed(key), value)
41    }
42
43    fn with_key(key: Cow<'static, str>, value: impl AsRef<str>) -> Self {
44        let raw = value.as_ref();
45        let mut escaped = String::with_capacity(raw.len());
46        write_escaped_attribute(&mut escaped, raw);
47        Self {
48            key,
49            value: escaped,
50            is_boolean: false,
51            is_trusted_url: false,
52        }
53    }
54
55    /// An attribute whose value is formatted from a `Display` straight into escaped form, for
56    /// a key known at compile time. What `html!` uses for `key = expr`.
57    pub(crate) fn from_display(key: &'static str, value: &impl std::fmt::Display) -> Self {
58        use std::fmt::Write as _;
59        let mut escaped = String::new();
60        // Writing into a `String` cannot fail; a `Display` impl that errors leaves a short value.
61        let _ = write!(
62            crate::core::escape::AttributeWriter(&mut escaped),
63            "{value}"
64        );
65        Self {
66            key: Cow::Borrowed(key),
67            value: escaped,
68            is_boolean: false,
69            is_trusted_url: false,
70        }
71    }
72
73    /// Appends ` word` to the stored value, escaping only the new part.
74    ///
75    /// What `add_class` needs: the stored value is already escaped, so rebuilding the
76    /// attribute through [`Self::raw`] copied the whole value and reallocated the key.
77    pub(crate) fn push_escaped_word(&mut self, word: &str) {
78        self.value.push(' ');
79        write_escaped_attribute(&mut self.value, word);
80    }
81
82    /// A URL attribute rendered as given, even when the value is a script URL.
83    ///
84    /// `href`, `src`, `action` and the other URL attributes render as `#` when the value
85    /// would run script. Use this only for a URL you wrote yourself. The value is still
86    /// attribute-escaped.
87    ///
88    /// # Examples
89    /// ```
90    /// use molt::prelude::*;
91    /// let link = a().add_attribute(Attribute::trusted_url("href", "javascript:print()"));
92    /// assert_eq!(link.render(), "<a href=\"javascript:print()\"></a>");
93    /// ```
94    #[must_use]
95    pub fn trusted_url(key: impl Into<String>, value: impl AsRef<str>) -> Self {
96        let mut attribute = Self::new(key, value);
97        attribute.is_trusted_url = true;
98        attribute
99    }
100
101    /// Creates an attribute **without** escaping the value.
102    ///
103    /// One of the three escape hatches, with [`Node::raw`](crate::core::Node::raw) and
104    /// [`Element::raw_text`](crate::core::Element::raw_text) — grep for the three to audit
105    /// a codebase. Use it for a value you wrote yourself and have already escaped. **Never
106    /// pass user data**: a `"` in the value closes the attribute and what follows is markup.
107    ///
108    /// The URL policy still runs on the stored value, so a raw `javascript:` URL in `href`
109    /// is rendered as `#`; only [`Self::trusted_url`] skips that.
110    ///
111    /// The `meta_*` helpers do not use it: they are `pub` and take `&str`, so they escape.
112    ///
113    /// # Examples
114    /// ```
115    /// use molt::core::Attribute;
116    /// // Already escaped by the caller, so it must not be escaped again.
117    /// assert_eq!(Attribute::raw("title", "a &amp; b").to_string(), r#" title="a &amp; b""#);
118    /// ```
119    pub fn raw(key: impl Into<String>, value: impl Into<String>) -> Self {
120        Self {
121            key: Cow::Owned(key.into()),
122            value: value.into(),
123            is_boolean: false,
124            is_trusted_url: false,
125        }
126    }
127
128    /// Creates a boolean attribute, which renders as a bare key.
129    ///
130    /// `hidden`, `checked`, `required`, `open`, `controls`, `autoplay`, `muted`.
131    pub fn boolean(key: impl Into<String>) -> Self {
132        Self {
133            key: Cow::Owned(key.into()),
134            value: String::new(),
135            is_boolean: true,
136            is_trusted_url: false,
137        }
138    }
139
140    /// The attribute name.
141    #[must_use]
142    pub fn key(&self) -> &str {
143        &self.key
144    }
145
146    /// The escaped attribute value. Empty for boolean attributes.
147    #[must_use]
148    pub fn value(&self) -> &str {
149        &self.value
150    }
151
152    /// Whether this attribute renders as a bare key.
153    #[must_use]
154    pub fn is_boolean(&self) -> bool {
155        self.is_boolean
156    }
157
158    /// Appends ` key="value"`, or ` key` for a boolean attribute, to a buffer.
159    ///
160    /// A name that could break out of the attribute — space, quotes, `=`, `<`, `>`, `/`
161    /// or a control character, and the empty name — is omitted. URL attributes are then
162    /// checked by [`url_policy`](crate::core::url_policy); a blocked value is written as `#`. The check sees the
163    /// stored value, so [`Self::raw`] is covered and [`Self::trusted_url`] is not.
164    pub(crate) fn write_into(&self, out: &mut String) {
165        self.write_into_in(out, false, "");
166    }
167
168    /// As [`Self::write_into`], for an attribute whose element sits inside a raw-text
169    /// ancestor (`noscript`, `iframe`, `xmp`, `script`, `style`, …).
170    ///
171    /// Attribute escaping leaves `<` and `>` alone because neither can end a quoted value —
172    /// and the golden fixtures depend on that. Under a raw-text ancestor, though, the
173    /// parser is not reading attributes at all: a `</noscript>` inside `alt="…"` ends the
174    /// ancestor and what follows is markup. So there, and only there, `<` and `>` are
175    /// written as references. The stored value never contained a `<` reference, so this
176    /// cannot double-escape.
177    pub(crate) fn write_into_in(&self, out: &mut String, in_raw_text: bool, tag: &str) {
178        if !is_safe_attribute_name(&self.key) {
179            return;
180        }
181        out.push(' ');
182        out.push_str(&self.key);
183        if self.is_boolean {
184            return;
185        }
186        out.push_str("=\"");
187        if self.is_trusted_url || !url_policy::is_blocked_in(tag, &self.key, &self.value) {
188            if in_raw_text {
189                for c in self.value.chars() {
190                    match c {
191                        '<' => out.push_str("&lt;"),
192                        '>' => out.push_str("&gt;"),
193                        _ => out.push(c),
194                    }
195                }
196            } else {
197                out.push_str(&self.value);
198            }
199        } else {
200            out.push_str(url_policy::replacement_for(&self.key));
201        }
202        out.push('"');
203    }
204}
205
206/// An attribute name is emitted only when it cannot close the quotes or start a new
207/// attribute. An empty name is omitted for the same reason: ` ="..."` is not a name.
208fn is_safe_attribute_name(key: &str) -> bool {
209    !key.is_empty()
210        && !key.chars().any(|scalar| {
211            scalar.is_control() || matches!(scalar, ' ' | '"' | '\'' | '=' | '<' | '>' | '/')
212        })
213}
214
215impl std::fmt::Display for Attribute {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        let mut out = String::new();
218        self.write_into(&mut out);
219        f.write_str(&out)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn value_is_escaped_at_construction() {
229        let attr = Attribute::new("title", r#"a "quoted" & <tagged> value"#);
230        assert_eq!(attr.value(), "a &quot;quoted&quot; &amp; <tagged> value");
231    }
232
233    #[test]
234    fn boolean_attributes_render_as_a_bare_key() {
235        assert_eq!(Attribute::boolean("required").to_string(), " required");
236        assert!(Attribute::boolean("open").is_boolean());
237    }
238
239    #[test]
240    fn raw_attributes_are_not_escaped() {
241        assert_eq!(Attribute::raw("property", "og:title").value(), "og:title");
242    }
243
244    #[test]
245    fn non_boolean_attributes_render_with_a_quoted_value() {
246        assert_eq!(
247            Attribute::new("class", "card p-4").to_string(),
248            r#" class="card p-4""#
249        );
250    }
251
252    #[test]
253    fn a_quote_in_the_value_cannot_break_out_of_the_attribute() {
254        let attr = Attribute::new("class", r#"a" onload="alert(1)"#);
255        assert!(!attr.value().contains('"'));
256    }
257}