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 & b").to_string(), r#" title="a & 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("<"),
192 '>' => out.push_str(">"),
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 "quoted" & <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}