Skip to main content

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