Skip to main content

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 `&#x27;` |
10//! | [`escape_attribute`] | `&` `"` `'` | **not** `<` or `>` |
11//! | [`escape_xml`] | `&` `<` `>` `"` `'` | `'` becomes `&apos;`, 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("&amp;"),
29        b'<' => Some("&lt;"),
30        b'>' => Some("&gt;"),
31        b'"' => Some("&quot;"),
32        b'\'' => Some("&#x27;"),
33        _ => None,
34    }
35}
36
37fn text_entity_with_slashes(byte: u8) -> Option<&'static str> {
38    if byte == b'/' {
39        Some("&#x2F;")
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("&amp;"),
49        b'"' => Some("&quot;"),
50        b'\'' => Some("&#x27;"),
51        _ => None,
52    }
53}
54
55/// The only difference from [`text_entity`] is the apostrophe, which XML spells `&apos;`.
56fn xml_entity(byte: u8) -> Option<&'static str> {
57    if byte == b'\'' {
58        Some("&apos;")
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 `&#x2F;`. 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///     "&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;"
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&#x2F;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 &quot;b&quot; &amp; <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 `&apos;`, which is an XML
177/// entity, rather than `&#x27;`.
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 &amp; Jerry&apos;s &lt;show&gt;");
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 `&lt;`, 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}&"), "&lt;&amp;");
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("é<é"), "é&lt;é");
346        assert_eq!(escape_attribute("日\"本"), "日&quot;本");
347        assert_eq!(escape_xml("✓'✓"), "✓&apos;✓");
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("<>&\"'"), "&lt;&gt;&amp;&quot;&#x27;");
356    }
357
358    #[test]
359    fn escapes_a_script_payload() {
360        assert_eq!(
361            escape_text("<script>alert('XSS')</script>"),
362            "&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;"
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 &amp; b &lt; 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&#x2F;09&#x2F;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 &quot;b&quot; &amp; <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&apos;s");
393        assert_eq!(escape_text("it's"), "it&#x27;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;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 &amp; Jerry");
429    }
430
431    #[test]
432    fn double_quotes_become_entities() {
433        assert_eq!(
434            escape_text(r#"He said "Hello""#),
435            "He said &quot;Hello&quot;"
436        );
437    }
438}