molt/core/escape.rs
1//! HTML and XML escaping.
2//!
3//! There are three distinct HTML/XML escapers and the golden fixtures depend on the differences
4//! between them. (The original spec, `docs/history/MOLT_SPEC.md` §3.3, shows only one;
5//! following it would change the output.)
6//!
7//! | function | escapes | notes |
8//! | --- | --- | --- |
9//! | [`escape_text`] | `&` `<` `>` `"` `'` | `&` first; `'` becomes `'` |
10//! | [`escape_attribute`] | `&` `"` `'` | **not** `<` or `>` |
11//! | [`escape_xml`] | `&` `<` `>` `"` `'` | `'` becomes `'`, the XML spelling; XML-illegal characters dropped |
12//! | [`escape_json_script`] | `<` `>` `&` U+2028 U+2029 | JSON `\uXXXX` escapes, for JSON inside `<script>`; not HTML escaping |
13//!
14//! Escaping is applied **once, when content enters the tree** — never at render time.
15//! That is why [`crate::core::Node::Raw`] exists as the documented escape hatch.
16
17use std::borrow::Cow;
18
19/// Each `*_entity` function below maps one byte to its entity, or `None` if the byte is
20/// written as it is. They are passed as generic parameters, never as `fn` pointers, so the
21/// compiler inlines the match into the scanning loop.
22///
23/// Every escaped character is ASCII, and no byte of a multi-byte UTF-8 sequence is, so a
24/// scan over bytes finds exactly the characters a `char` loop would, and slicing the input
25/// at a matched byte never splits a character.
26fn text_entity(byte: u8) -> Option<&'static str> {
27 match byte {
28 b'&' => Some("&"),
29 b'<' => Some("<"),
30 b'>' => Some(">"),
31 b'"' => Some("""),
32 b'\'' => Some("'"),
33 _ => None,
34 }
35}
36
37fn text_entity_with_slashes(byte: u8) -> Option<&'static str> {
38 if byte == b'/' {
39 Some("/")
40 } else {
41 text_entity(byte)
42 }
43}
44
45/// `<` and `>` are not here on purpose: see [`escape_attribute`].
46fn attribute_entity(byte: u8) -> Option<&'static str> {
47 match byte {
48 b'&' => Some("&"),
49 b'"' => Some("""),
50 b'\'' => Some("'"),
51 _ => None,
52 }
53}
54
55/// The only difference from [`text_entity`] is the apostrophe, which XML spells `'`.
56fn xml_entity(byte: u8) -> Option<&'static str> {
57 if byte == b'\'' {
58 Some("'")
59 } else {
60 text_entity(byte)
61 }
62}
63
64/// Appends `input` to `out`, replacing each byte `entity` names.
65///
66/// Copies the stretches between escapes with one `push_str` each instead of pushing a
67/// `char` at a time, which is what makes mostly-clean text cheap.
68fn write_runs(out: &mut String, input: &str, entity: impl Fn(u8) -> Option<&'static str>) {
69 let mut start = 0;
70 for (index, byte) in input.bytes().enumerate() {
71 if let Some(replacement) = entity(byte) {
72 if index > start {
73 out.push_str(&input[start..index]);
74 }
75 out.push_str(replacement);
76 start = index + 1;
77 }
78 }
79 out.push_str(&input[start..]);
80}
81
82/// A [`std::fmt::Write`] that escapes what it receives as element text and appends it to a
83/// buffer.
84///
85/// Lets `html!` format a `Display` value straight into its escaped form instead of going
86/// through `to_string()` and escaping a second copy. Escaping is local to a character, so
87/// escaping each piece a `Display` impl writes gives the same result as escaping the whole.
88pub(crate) struct TextWriter<'a>(pub(crate) &'a mut String);
89
90impl std::fmt::Write for TextWriter<'_> {
91 fn write_str(&mut self, piece: &str) -> std::fmt::Result {
92 write_escaped_text(self.0, piece, false);
93 Ok(())
94 }
95}
96
97/// As [`TextWriter`], with attribute-value escaping.
98pub(crate) struct AttributeWriter<'a>(pub(crate) &'a mut String);
99
100impl std::fmt::Write for AttributeWriter<'_> {
101 fn write_str(&mut self, piece: &str) -> std::fmt::Result {
102 write_escaped_attribute(self.0, piece);
103 Ok(())
104 }
105}
106
107/// Escapes `input` only if some byte needs it, and otherwise hands it back untouched.
108///
109/// Returning [`Cow`] means the common case — text with nothing to escape — costs no
110/// allocation and no copy.
111fn escape_with(input: &str, entity: impl Fn(u8) -> Option<&'static str> + Copy) -> Cow<'_, str> {
112 let Some(first) = input.bytes().position(|byte| entity(byte).is_some()) else {
113 return Cow::Borrowed(input);
114 };
115 // Room for a few entities up front, so a short dirty string does not grow once.
116 let mut out = String::with_capacity(input.len() + input.len() / 4 + 16);
117 out.push_str(&input[..first]);
118 write_runs(&mut out, &input[first..], entity);
119 Cow::Owned(out)
120}
121
122/// Escapes text for use as HTML element content.
123///
124/// Set `escape_slashes` to also turn `/` into `/`. It defaults to off in
125/// [`escape_text`] because `&`, `<`, `>`, `"` and `'` already close the XSS surface, and
126/// escaping every slash turns dates, paths and "and/or" into unreadable entities.
127///
128/// # Examples
129/// ```
130/// use molt::core::escape::escape_text;
131/// assert_eq!(
132/// escape_text("<script>alert('XSS')</script>"),
133/// "<script>alert('XSS')</script>"
134/// );
135/// ```
136#[must_use]
137pub fn escape_text(input: &str) -> Cow<'_, str> {
138 escape_with(input, text_entity)
139}
140
141/// Escapes text for HTML content, optionally escaping `/` as well.
142///
143/// # Examples
144/// ```
145/// use molt::core::escape::escape_text_with_slashes;
146/// assert_eq!(escape_text_with_slashes("a/b"), "a/b");
147/// ```
148#[must_use]
149pub fn escape_text_with_slashes(input: &str) -> Cow<'_, str> {
150 escape_with(input, text_entity_with_slashes)
151}
152
153/// Escapes a value for use inside a double-quoted HTML attribute.
154///
155/// Deliberately narrower than [`escape_text`]: `<` and `>` are left alone, because they
156/// cannot terminate a quoted attribute value, and `marketing-pretty.html` contains attribute
157/// values that prove it.
158///
159/// That holds wherever the parser reads attributes. Under a raw-text ancestor (`noscript`,
160/// `iframe`, `xmp`, `script`, `style`, …) it does not, so the renderer escapes `<` and `>`
161/// itself there — this function cannot, because it never sees the ancestors. See
162/// `SECURITY.md`.
163///
164/// # Examples
165/// ```
166/// use molt::core::escape::escape_attribute;
167/// assert_eq!(escape_attribute(r#"a "b" & <c>"#), "a "b" & <c>");
168/// ```
169#[must_use]
170pub fn escape_attribute(input: &str) -> Cow<'_, str> {
171 escape_with(input, attribute_entity)
172}
173
174/// Escapes text for XML content — used by the sitemap and RSS generators.
175///
176/// Differs from [`escape_text`] in one character: `'` becomes `'`, which is an XML
177/// entity, rather than `'`.
178///
179/// It also **drops** the characters XML 1.0 cannot represent at all, even escaped: the
180/// control characters U+0000–U+0008, U+000B, U+000C and U+000E–U+001F, and the
181/// non-characters U+FFFE and U+FFFF. One of them in a title makes the whole document
182/// ill-formed, so a feed reader or crawler rejects every entry, not just that one. Tab,
183/// newline and carriage return are legal and kept.
184///
185/// # Examples
186/// ```
187/// use molt::core::escape::escape_xml;
188/// assert_eq!(escape_xml("Tom & Jerry's <show>"), "Tom & Jerry's <show>");
189/// assert_eq!(escape_xml("a\u{0}b\u{8}c\u{FFFE}d\te"), "abcd\te");
190/// ```
191#[must_use]
192pub fn escape_xml(input: &str) -> Cow<'_, str> {
193 if !input.chars().any(needs_xml_change) {
194 return Cow::Borrowed(input);
195 }
196 let mut out = String::with_capacity(input.len() + input.len() / 4 + 16);
197 for c in input.chars() {
198 if is_xml_illegal(c) {
199 continue;
200 }
201 match u8::try_from(c).ok().and_then(xml_entity) {
202 Some(entity) => out.push_str(entity),
203 None => out.push(c),
204 }
205 }
206 Cow::Owned(out)
207}
208
209/// Whether XML 1.0 has no way to write `c`, escaped or not.
210fn is_xml_illegal(c: char) -> bool {
211 matches!(c, '\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}')
212}
213
214fn needs_xml_change(c: char) -> bool {
215 is_xml_illegal(c) || u8::try_from(c).ok().and_then(xml_entity).is_some()
216}
217
218/// Makes a JSON document safe to place inside a `<script>` element.
219///
220/// Inside `<script>` the HTML parser looks for `</script` and `<!--` and knows nothing about
221/// JSON, so a string value holding `</script><script>alert(1)</script>` ends the element and
222/// starts another. `<`, `>` and `&` are therefore written as the JSON escapes `\u003c`,
223/// `\u003e` and `\u0026`, which any JSON parser reads back as the same characters, and
224/// U+2028/U+2029 (line terminators that were not legal inside a JavaScript string) as
225/// `\u2028`/`\u2029`. Valid JSON only has those characters inside string values, so the
226/// meaning of the document does not change.
227///
228/// This does **not** validate the JSON, and it is not [`escape_text`]: HTML-escaping would
229/// write `<`, which JSON does not decode.
230///
231/// # Examples
232/// ```
233/// use molt::core::escape::escape_json_script;
234/// assert_eq!(
235/// escape_json_script(r#"{"a":"</script><b>&"}"#),
236/// r#"{"a":"\u003c/script\u003e\u003cb\u003e\u0026"}"#
237/// );
238/// ```
239#[must_use]
240pub fn escape_json_script(json: &str) -> Cow<'_, str> {
241 fn entity(c: char) -> Option<&'static str> {
242 match c {
243 '<' => Some("\\u003c"),
244 '>' => Some("\\u003e"),
245 '&' => Some("\\u0026"),
246 '\u{2028}' => Some("\\u2028"),
247 '\u{2029}' => Some("\\u2029"),
248 _ => None,
249 }
250 }
251 if !json.chars().any(|c| entity(c).is_some()) {
252 return Cow::Borrowed(json);
253 }
254 let mut out = String::with_capacity(json.len() + json.len() / 8 + 16);
255 for c in json.chars() {
256 match entity(c) {
257 Some(escaped) => out.push_str(escaped),
258 None => out.push(c),
259 }
260 }
261 Cow::Owned(out)
262}
263
264/// Appends escaped HTML text to an existing buffer.
265///
266/// The whole renderer writes into one buffer; allocating a fresh `String` per escape
267/// would defeat that.
268pub(crate) fn write_escaped_text(out: &mut String, input: &str, escape_slashes: bool) {
269 out.reserve(input.len());
270 write_runs(
271 out,
272 input,
273 if escape_slashes {
274 text_entity_with_slashes
275 } else {
276 text_entity
277 },
278 );
279}
280
281/// Appends an escaped attribute value to an existing buffer.
282pub(crate) fn write_escaped_attribute(out: &mut String, input: &str) {
283 out.reserve(input.len());
284 write_runs(out, input, attribute_entity);
285}
286
287#[cfg(test)]
288mod tests {
289 /// The boundaries of XML 1.0's `Char` production, one on each side.
290 #[test]
291 fn xml_drops_exactly_the_characters_xml_1_0_cannot_hold() {
292 for illegal in [
293 '\u{0}', '\u{8}', '\u{B}', '\u{C}', '\u{E}', '\u{1F}', '\u{FFFE}', '\u{FFFF}',
294 ] {
295 assert_eq!(
296 escape_xml(&format!("a{illegal}b")),
297 "ab",
298 "U+{:04X}",
299 illegal as u32
300 );
301 }
302 for legal in [
303 '\t',
304 '\n',
305 '\r',
306 ' ',
307 '\u{7F}',
308 '\u{80}',
309 '\u{FFFD}',
310 '\u{10000}',
311 'é',
312 ] {
313 assert_eq!(
314 escape_xml(&format!("a{legal}b")),
315 format!("a{legal}b"),
316 "U+{:04X}",
317 legal as u32
318 );
319 }
320 assert!(matches!(escape_xml("plain"), std::borrow::Cow::Borrowed(_)));
321 assert_eq!(escape_xml("<\u{0}&"), "<&");
322 }
323
324 /// Text with nothing to escape comes back borrowed: no allocation, no copy.
325 #[test]
326 fn clean_input_is_returned_without_allocating() {
327 use std::borrow::Cow;
328 for clean in ["", "plain text", "ação — 日本語 ✓", "2026/09/16"] {
329 assert!(matches!(escape_text(clean), Cow::Borrowed(_)), "{clean:?}");
330 assert!(
331 matches!(escape_attribute(clean), Cow::Borrowed(_)),
332 "{clean:?}"
333 );
334 assert!(matches!(escape_xml(clean), Cow::Borrowed(_)), "{clean:?}");
335 }
336 assert!(matches!(escape_text("a & b"), Cow::Owned(_)));
337 // `/` is only special when asked for, and `<` is not special to an attribute.
338 assert!(matches!(escape_text_with_slashes("a/b"), Cow::Owned(_)));
339 assert!(matches!(escape_attribute("a < b"), Cow::Borrowed(_)));
340 }
341
342 /// The byte scan must agree with the `char` loop for every special, next to multi-byte text.
343 #[test]
344 fn byte_scanning_finds_specials_beside_multibyte_characters() {
345 assert_eq!(escape_text("é<é"), "é<é");
346 assert_eq!(escape_attribute("日\"本"), "日"本");
347 assert_eq!(escape_xml("✓'✓"), "✓'✓");
348 }
349
350 use super::*;
351
352 /// The five HTML characters in one assertion: ampersand, quotes and angle brackets.
353 #[test]
354 fn escapes_the_five_html_characters() {
355 assert_eq!(escape_text("<>&\"'"), "<>&"'");
356 }
357
358 #[test]
359 fn escapes_a_script_payload() {
360 assert_eq!(
361 escape_text("<script>alert('XSS')</script>"),
362 "<script>alert('XSS')</script>"
363 );
364 }
365
366 /// The ampersand must be replaced first, or every other entity gets double-escaped.
367 #[test]
368 fn ampersand_is_escaped_before_the_entities_it_introduces() {
369 assert_eq!(escape_text("a & b < c"), "a & b < c");
370 }
371
372 #[test]
373 fn slashes_are_only_escaped_on_request() {
374 assert_eq!(escape_text("2026/09/16"), "2026/09/16");
375 assert_eq!(
376 escape_text_with_slashes("2026/09/16"),
377 "2026/09/16"
378 );
379 }
380
381 /// Attribute context leaves `<` and `>` alone — they cannot terminate a quoted value.
382 #[test]
383 fn attribute_escaping_leaves_angle_brackets_alone() {
384 assert_eq!(
385 escape_attribute(r#"a "b" & <c>"#),
386 "a "b" & <c>"
387 );
388 }
389
390 #[test]
391 fn xml_uses_the_apos_entity_where_html_uses_a_numeric_reference() {
392 assert_eq!(escape_xml("it's"), "it's");
393 assert_eq!(escape_text("it's"), "it's");
394 }
395
396 /// Escaping is applied exactly once, by the caller, at construction. Applying it
397 /// twice double-escapes, which is the reason the builder never re-escapes an existing
398 /// attribute value.
399 #[test]
400 fn escaping_is_not_idempotent_by_design() {
401 assert_eq!(escape_text(&escape_text("a & b")), "a &amp; b");
402 }
403
404 /// Nothing survives that could open a tag or start an entity.
405 #[test]
406 fn output_never_contains_a_bare_angle_bracket() {
407 for input in ["<", "<<>>", "a<b>c", "&<>\"'"] {
408 let escaped = escape_text(input);
409 assert!(!escaped.contains('<'), "{input:?} produced {escaped:?}");
410 assert!(!escaped.contains('>'), "{input:?} produced {escaped:?}");
411 }
412 }
413
414 #[test]
415 fn non_ascii_passes_through_untouched() {
416 assert_eq!(escape_text("R$ 9,90/mês — ação"), "R$ 9,90/mês — ação");
417 }
418
419 #[test]
420 fn empty_input_produces_empty_output() {
421 assert_eq!(escape_text(""), "");
422 assert_eq!(escape_attribute(""), "");
423 assert_eq!(escape_xml(""), "");
424 }
425
426 #[test]
427 fn an_ampersand_becomes_an_entity() {
428 assert_eq!(escape_text("Tom & Jerry"), "Tom & Jerry");
429 }
430
431 #[test]
432 fn double_quotes_become_entities() {
433 assert_eq!(
434 escape_text(r#"He said "Hello""#),
435 "He said "Hello""
436 );
437 }
438}