Skip to main content

topcoat_view/
escape.rs

1use core::fmt;
2
3use memchr::{memchr2, memchr3};
4
5use crate::Formatter;
6
7/// The position in an HTML document that a dynamic value is written into.
8///
9/// Writing through a context makes the value safe for that position. Contexts
10/// where HTML provides an escape mechanism rewrite the significant
11/// characters; ident contexts, where character references are never decoded,
12/// validate instead and panic on characters that could break out of the
13/// position:
14///
15/// | Context          | `&`     | `<`    | `>`    | `"`      | Other        |
16/// |------------------|---------|--------|--------|----------|--------------|
17/// | `Unescaped`      | -       | -      | -      | -        | -            |
18/// | `Text`           | `&amp;` | `&lt;` | `&gt;` | -        | -            |
19/// | `AttributeValue` | `&amp;` | -      | -      | `&quot;` | -            |
20/// | `Comment`        | `&amp;` | -      | `&gt;` | `&quot;` | -            |
21/// | `AttributeKey`   | -       | panic  | panic  | panic    | see below    |
22/// | `ElementName`    | -       | panic  | panic  | panic    | see below    |
23///
24/// The ident contexts reject ASCII whitespace, ASCII control characters,
25/// `"`, `'`, `<`, `>`, `/`, and `=`: the characters the HTML tokenizer can
26/// treat as ending or altering a name token. This guarantees the identifier
27/// cannot terminate or corrupt its token; it does not check full spec
28/// validity.
29#[non_exhaustive]
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum HtmlContext {
32    /// Trusted markup written verbatim.
33    Unescaped,
34    /// A text node between tags. Quotes are not significant here, so the
35    /// three escapable characters are found with a single search.
36    Text,
37    /// A double-quoted attribute value. Only `&` and `"` can terminate or
38    /// alter the value, found with a single search.
39    AttributeValue,
40    /// A machine-readable payload inside an HTML comment, such as the markers
41    /// the interactive runtime emits. Escaping `>` guarantees the payload
42    /// cannot contain `-->` and terminate the comment, while `&` and `"`
43    /// round-trip through entity decoding so double-quoted strings inside the
44    /// payload stay unambiguous. Comment data is never entity-decoded by the
45    /// browser, so the consumer of the payload must decode it.
46    Comment,
47    /// An attribute name, validated as an identifier rather than escaped.
48    AttributeKey,
49    /// A tag name, validated as an identifier rather than escaped.
50    ElementName,
51}
52
53impl HtmlContext {
54    /// Returns a writer that makes everything written to it safe for this
55    /// context before appending it to `f`.
56    #[inline]
57    pub fn writer<'a, 'b>(self, f: &'a mut Formatter<'b>) -> HtmlWriter<'a, 'b> {
58        HtmlWriter { context: self, f }
59    }
60
61    /// Returns `true` if byte `b` can terminate or corrupt an identifier and
62    /// is therefore rejected in the ident contexts.
63    ///
64    /// Every forbidden byte is ASCII, so a byte-level scan is exact across
65    /// multibyte UTF-8: the bytes of a multibyte character are `0x80` or
66    /// above and never match. The comparisons are branchless so a scan over
67    /// them can vectorize.
68    #[inline]
69    const fn forbidden_in_ident(b: u8) -> bool {
70        b <= b' ' || b == 0x7F || matches!(b, b'"' | b'\'' | b'/' | b'<' | b'=' | b'>')
71    }
72
73    /// A human-readable description of the context, used in panic messages.
74    fn description(self) -> &'static str {
75        match self {
76            Self::Unescaped => "unescaped content",
77            Self::Text => "text",
78            Self::AttributeValue => "attribute value",
79            Self::Comment => "comment payload",
80            Self::AttributeKey => "attribute key",
81            Self::ElementName => "element name",
82        }
83    }
84}
85
86/// Maps each byte to its escape sequence in one context, or `None` when the
87/// byte passes through as-is. Only ASCII bytes have entries, so escapes
88/// always fall on UTF-8 character boundaries.
89type EscapeTable = [Option<&'static str>; 256];
90
91const fn escape_table<const N: usize>(escapes: [(u8, &'static str); N]) -> EscapeTable {
92    let mut table: EscapeTable = [None; 256];
93    let mut i = 0;
94    while i < N {
95        table[escapes[i].0 as usize] = Some(escapes[i].1);
96        i += 1;
97    }
98    table
99}
100
101const TEXT_ESCAPES: EscapeTable = escape_table([(b'&', "&amp;"), (b'<', "&lt;"), (b'>', "&gt;")]);
102
103const ATTRIBUTE_VALUE_ESCAPES: EscapeTable = escape_table([(b'&', "&amp;"), (b'"', "&quot;")]);
104
105const COMMENT_ESCAPES: EscapeTable =
106    escape_table([(b'&', "&amp;"), (b'>', "&gt;"), (b'"', "&quot;")]);
107
108/// Generates a write method specialized for one escaping context: a dedicated
109/// search for its escapable bytes and a dedicated lookup table, with no
110/// context dispatch anywhere in the loop.
111macro_rules! impl_write_escaped {
112    ($(#[$doc:meta])* $method:ident, $memchr:ident($($needle:literal),+), $table:ident) => {
113        $(#[$doc])*
114        #[inline]
115        fn $method(&mut self, s: &str) {
116            let bytes = s.as_bytes();
117            let mut start = 0;
118
119            // Escapable bytes are ASCII, so every hit falls on a UTF-8
120            // character boundary.
121            while let Some(offset) = $memchr($($needle,)+ &bytes[start..]) {
122                // Copy the ordinary run preceding the special in one shot.
123                let special = start + offset;
124                self.f.write_str(&s[start..special]);
125
126                // Emit the whole run of consecutive specials from the lookup
127                // table, so the search runs once per run rather than once per
128                // byte.
129                let mut end = special;
130                while let Some(escape) = bytes.get(end).and_then(|&b| $table[b as usize]) {
131                    self.f.write_str(escape);
132                    end += 1;
133                }
134                start = end;
135            }
136
137            self.f.write_str(&s[start..]);
138        }
139    };
140}
141
142/// A writer created by [`HtmlContext::writer`] that makes everything written
143/// to it safe for its context before appending it to the underlying
144/// [`Formatter`].
145///
146/// The inherent methods mirror [`fmt::Write`], which the writer also
147/// implements for use with `write!`.
148pub struct HtmlWriter<'a, 'b> {
149    context: HtmlContext,
150    f: &'a mut Formatter<'b>,
151}
152
153impl HtmlWriter<'_, '_> {
154    /// Writes `s`, escaped or validated for this writer's context.
155    ///
156    /// # Panics
157    ///
158    /// Panics in the ident contexts ([`AttributeKey`](HtmlContext::AttributeKey),
159    /// [`ElementName`](HtmlContext::ElementName)) when `s` contains a
160    /// character that could break out of the identifier, since HTML has no
161    /// escape mechanism there.
162    pub fn write_str(&mut self, s: &str) {
163        match self.context {
164            HtmlContext::Unescaped => self.f.write_str(s),
165            HtmlContext::Text => self.write_text_escaped(s),
166            HtmlContext::AttributeValue => self.write_attribute_value_escaped(s),
167            HtmlContext::Comment => self.write_comment_escaped(s),
168            HtmlContext::AttributeKey | HtmlContext::ElementName => self.write_ident(s),
169        }
170    }
171
172    /// Writes a single character, escaped or validated for this writer's
173    /// context.
174    ///
175    /// # Panics
176    ///
177    /// Panics in the ident contexts when `c` could break out of the
178    /// identifier, like [`write_str`](Self::write_str).
179    pub fn write_char(&mut self, c: char) {
180        let table = match self.context {
181            HtmlContext::Unescaped => return self.f.write_char(c),
182            HtmlContext::Text => &TEXT_ESCAPES,
183            HtmlContext::AttributeValue => &ATTRIBUTE_VALUE_ESCAPES,
184            HtmlContext::Comment => &COMMENT_ESCAPES,
185            HtmlContext::AttributeKey | HtmlContext::ElementName => {
186                assert!(
187                    !u8::try_from(c).is_ok_and(HtmlContext::forbidden_in_ident),
188                    "invalid {}: forbidden character {c:?}",
189                    self.context.description(),
190                );
191                return self.f.write_char(c);
192            }
193        };
194        // A `char` casts to its code point; anything past the table passes
195        // through untouched.
196        match table.get(c as usize).copied().flatten() {
197            Some(escape) => self.f.write_str(escape),
198            None => self.f.write_char(c),
199        }
200    }
201
202    impl_write_escaped!(
203        /// Writes `s` escaped for text node content.
204        write_text_escaped,
205        memchr3(b'&', b'<', b'>'),
206        TEXT_ESCAPES
207    );
208
209    impl_write_escaped!(
210        /// Writes `s` escaped for a double-quoted attribute value.
211        write_attribute_value_escaped,
212        memchr2(b'&', b'"'),
213        ATTRIBUTE_VALUE_ESCAPES
214    );
215
216    impl_write_escaped!(
217        /// Writes `s` escaped for a comment payload.
218        write_comment_escaped,
219        memchr3(b'&', b'>', b'"'),
220        COMMENT_ESCAPES
221    );
222
223    /// Writes `s` verbatim after checking that every byte is allowed in an
224    /// ident context, panicking otherwise.
225    fn write_ident(&mut self, s: &str) {
226        // A branchless fold with no early exit, so the scan can vectorize.
227        // The happy path has to visit every byte anyway; only the failure
228        // path pays for the second scan in `panic_invalid_ident`.
229        let invalid = s.bytes().fold(false, |invalid, b| {
230            invalid | HtmlContext::forbidden_in_ident(b)
231        });
232        if invalid {
233            self.panic_invalid_ident(s);
234        }
235        self.f.write_str(s);
236    }
237
238    /// Panics with the first forbidden character in `s`.
239    #[cold]
240    fn panic_invalid_ident(&self, s: &str) -> ! {
241        let position = s
242            .bytes()
243            .position(HtmlContext::forbidden_in_ident)
244            .expect("an invalid ident contains a forbidden byte");
245        // Forbidden bytes are ASCII, so the byte is the character.
246        let c = s.as_bytes()[position] as char;
247        panic!(
248            "invalid {} {s:?}: forbidden character {c:?}",
249            self.context.description(),
250        );
251    }
252}
253
254impl fmt::Write for HtmlWriter<'_, '_> {
255    fn write_str(&mut self, s: &str) -> fmt::Result {
256        HtmlWriter::write_str(self, s);
257        Ok(())
258    }
259
260    fn write_char(&mut self, c: char) -> fmt::Result {
261        HtmlWriter::write_char(self, c);
262        Ok(())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn write(context: HtmlContext, s: &str) -> String {
271        let mut buf = String::new();
272        let mut f = Formatter::new(&mut buf);
273        context.writer(&mut f).write_str(s);
274        buf
275    }
276
277    fn write_char(context: HtmlContext, c: char) -> String {
278        let mut buf = String::new();
279        let mut f = Formatter::new(&mut buf);
280        context.writer(&mut f).write_char(c);
281        buf
282    }
283
284    #[test]
285    fn unescaped_passthrough() {
286        assert_eq!(write(HtmlContext::Unescaped, "<b>&\"'</b>"), "<b>&\"'</b>");
287        assert_eq!(write_char(HtmlContext::Unescaped, '<'), "<");
288    }
289
290    #[test]
291    fn text_escapes_amp_lt_gt() {
292        assert_eq!(
293            write(HtmlContext::Text, "a < b && c > d"),
294            "a &lt; b &amp;&amp; c &gt; d"
295        );
296    }
297
298    #[test]
299    fn text_leaves_quotes() {
300        assert_eq!(
301            write(HtmlContext::Text, "she said \"hi\" and 'bye'"),
302            "she said \"hi\" and 'bye'"
303        );
304    }
305
306    #[test]
307    fn text_empty_string() {
308        assert_eq!(write(HtmlContext::Text, ""), "");
309    }
310
311    #[test]
312    fn text_run_of_consecutive_specials() {
313        assert_eq!(write(HtmlContext::Text, "abc<>&def"), "abc&lt;&gt;&amp;def");
314    }
315
316    #[test]
317    fn text_quote_inside_special_run_ends_it() {
318        // The '"' has no escape in text, so it terminates the run of escapes
319        // and is copied verbatim with the ordinary bytes.
320        assert_eq!(write(HtmlContext::Text, "<\">"), "&lt;\"&gt;");
321    }
322
323    #[test]
324    fn text_specials_at_boundaries() {
325        assert_eq!(write(HtmlContext::Text, "<middle>"), "&lt;middle&gt;");
326        assert_eq!(write(HtmlContext::Text, "&start"), "&amp;start");
327        assert_eq!(write(HtmlContext::Text, "end&"), "end&amp;");
328    }
329
330    #[test]
331    fn text_long_plain_run() {
332        // Exercises the bulk copy path over more than a SIMD vector width.
333        let s = "abcdefghij".repeat(50);
334        assert_eq!(write(HtmlContext::Text, &s), s);
335    }
336
337    #[test]
338    fn text_special_inside_long_run() {
339        let s = format!("{}<{}", "a".repeat(100), "b".repeat(100));
340        let expected = format!("{}&lt;{}", "a".repeat(100), "b".repeat(100));
341        assert_eq!(write(HtmlContext::Text, &s), expected);
342    }
343
344    #[test]
345    fn text_multibyte_utf8() {
346        assert_eq!(
347            write(HtmlContext::Text, "café < résumé"),
348            "café &lt; résumé"
349        );
350    }
351
352    #[test]
353    fn text_multibyte_hugging_specials() {
354        // Real specials directly adjacent to multibyte characters exercises
355        // the slicing between escapes and multibyte runs.
356        assert_eq!(write(HtmlContext::Text, "é<é>é&é"), "é&lt;é&gt;é&amp;é");
357    }
358
359    #[test]
360    fn text_multibyte_codepoint_embeds_special_byte() {
361        // These characters have code points that contain a special byte value
362        // (U+263C has 0x3C, U+2026 has 0x26, U+203E has 0x3E) but never
363        // encode to that byte, so they pass through untouched while the
364        // interleaved ASCII specials still escape.
365        assert_eq!(
366            write(HtmlContext::Text, "\u{263C}<\u{2026}&\u{203E}>"),
367            "\u{263C}&lt;\u{2026}&amp;\u{203E}&gt;"
368        );
369    }
370
371    #[test]
372    fn text_write_char() {
373        assert_eq!(write_char(HtmlContext::Text, '<'), "&lt;");
374        assert_eq!(write_char(HtmlContext::Text, '"'), "\"");
375        assert_eq!(write_char(HtmlContext::Text, 'é'), "é");
376    }
377
378    #[test]
379    fn attribute_value_escapes_amp_and_quote() {
380        assert_eq!(
381            write(HtmlContext::AttributeValue, "a=\"b\" & c"),
382            "a=&quot;b&quot; &amp; c"
383        );
384    }
385
386    #[test]
387    fn attribute_value_leaves_lt_gt_apostrophe() {
388        assert_eq!(
389            write(HtmlContext::AttributeValue, "<a href='x'>"),
390            "<a href='x'>"
391        );
392    }
393
394    #[test]
395    fn attribute_value_run_of_consecutive_specials() {
396        assert_eq!(write(HtmlContext::AttributeValue, "x&\"y"), "x&amp;&quot;y");
397    }
398
399    #[test]
400    fn attribute_value_write_char() {
401        assert_eq!(write_char(HtmlContext::AttributeValue, '"'), "&quot;");
402        assert_eq!(write_char(HtmlContext::AttributeValue, '<'), "<");
403    }
404
405    #[test]
406    fn comment_escapes_amp_gt_quote() {
407        assert_eq!(
408            write(HtmlContext::Comment, "x --> y && \"z\""),
409            "x --&gt; y &amp;&amp; &quot;z&quot;"
410        );
411    }
412
413    #[test]
414    fn comment_leaves_lt_and_apostrophe() {
415        assert_eq!(write(HtmlContext::Comment, "<!-- 'a'"), "<!-- 'a'");
416    }
417
418    #[test]
419    fn comment_cannot_terminate_comment() {
420        assert!(!write(HtmlContext::Comment, "--> \"js\" -->").contains("-->"));
421    }
422
423    #[test]
424    fn attribute_key_accepts_common_names() {
425        for key in ["class", "data-x", "aria-label", "@click.prevent", ":href"] {
426            assert_eq!(write(HtmlContext::AttributeKey, key), key);
427        }
428    }
429
430    #[test]
431    fn element_name_accepts_custom_elements() {
432        assert_eq!(write(HtmlContext::ElementName, "my-element"), "my-element");
433    }
434
435    #[test]
436    fn ident_contexts_accept_multibyte() {
437        // Custom element names may contain characters outside ASCII.
438        assert_eq!(write(HtmlContext::ElementName, "emotion-😍"), "emotion-😍");
439    }
440
441    #[test]
442    fn ident_contexts_only_reject_ascii() {
443        // Non-ASCII whitespace and controls (here NBSP and NEL) do not end a
444        // name token in the HTML tokenizer, so they pass through.
445        assert_eq!(
446            write(HtmlContext::AttributeKey, "x\u{A0}\u{85}y"),
447            "x\u{A0}\u{85}y"
448        );
449    }
450
451    #[test]
452    #[should_panic(expected = "invalid attribute key")]
453    fn attribute_key_rejects_space() {
454        write(HtmlContext::AttributeKey, "on click");
455    }
456
457    #[test]
458    #[should_panic(expected = "invalid attribute key")]
459    fn attribute_key_rejects_equals() {
460        write(HtmlContext::AttributeKey, "a=b");
461    }
462
463    #[test]
464    #[should_panic(expected = "invalid attribute key")]
465    fn attribute_key_rejects_quote() {
466        write(HtmlContext::AttributeKey, "a\"b");
467    }
468
469    #[test]
470    #[should_panic(expected = "invalid element name")]
471    fn element_name_rejects_slash() {
472        write(HtmlContext::ElementName, "div/onmouseover");
473    }
474
475    #[test]
476    #[should_panic(expected = "invalid element name")]
477    fn element_name_rejects_gt() {
478        write(HtmlContext::ElementName, "div><script");
479    }
480
481    #[test]
482    #[should_panic(expected = "invalid element name")]
483    fn element_name_rejects_control() {
484        write(HtmlContext::ElementName, "di\nv");
485    }
486
487    #[test]
488    #[should_panic(expected = "invalid attribute key")]
489    fn ident_write_char_rejects_forbidden() {
490        write_char(HtmlContext::AttributeKey, '=');
491    }
492
493    #[test]
494    fn fmt_write_goes_through_context() {
495        use core::fmt::Write;
496
497        let tag = "<tag>";
498        let mut buf = String::new();
499        let mut f = Formatter::new(&mut buf);
500        write!(HtmlContext::Text.writer(&mut f), "a {tag} b").unwrap();
501        assert_eq!(buf, "a &lt;tag&gt; b");
502    }
503}