contextual_encoder/html.rs
1//! HTML / XML contextual output encoders.
2//!
3//! provides four encoding contexts with different safety guarantees:
4//!
5//! - [`for_html`] — safe for both text content and quoted attributes (most conservative)
6//! - [`for_html_content`] — safe for text content only (does not encode quotes)
7//! - [`for_html_attribute`] — safe for quoted attributes only (does not encode `>`)
8//! - [`for_html_unquoted_attribute`] — safe for unquoted attribute values (most aggressive)
9//!
10//! all encoders replace invalid XML characters (C0/C1 controls, DEL, unicode
11//! non-characters) with a replacement character (space or dash depending on
12//! context).
13//!
14//! # security notes
15//!
16//! - these encoders produce output safe for embedding in the specified context.
17//! they do not sanitize HTML — encoding is not a substitute for input validation.
18//! - never use `for_html_content` output in an attribute context.
19//! - never use `for_html_attribute` output in a text content context where `>` matters.
20//! - `for_html` is the safe default when the exact context is unknown.
21//! - tag names, attribute names, and event handler names must be validated
22//! separately — encoding cannot make arbitrary names safe.
23
24use std::fmt;
25
26use crate::engine::{encode_loop, is_invalid_for_xml, is_unicode_noncharacter};
27
28/// encodes `input` for safe embedding in HTML text content and quoted attributes.
29///
30/// this is the most conservative HTML encoder — it encodes characters needed
31/// for both text content and attribute contexts. use [`for_html_content`] or
32/// [`for_html_attribute`] for more minimal encoding when the exact context is
33/// known.
34///
35/// # encoded characters
36///
37/// | input | output |
38/// |-------|--------|
39/// | `&` | `&` |
40/// | `<` | `<` |
41/// | `>` | `>` |
42/// | `"` | `"` |
43/// | `'` | `'` |
44///
45/// invalid XML characters are replaced with a space.
46///
47/// # examples
48///
49/// ```
50/// use contextual_encoder::for_html;
51///
52/// assert_eq!(for_html("<script>alert('xss')</script>"),
53/// "<script>alert('xss')</script>");
54/// assert_eq!(for_html("safe text"), "safe text");
55/// ```
56pub fn for_html(input: &str) -> String {
57 let mut out = String::with_capacity(input.len());
58 write_html(&mut out, input).expect("writing to string cannot fail");
59 out
60}
61
62/// writes the HTML-encoded form of `input` to `out`.
63///
64/// see [`for_html`] for encoding rules.
65pub fn write_html<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
66 encode_loop(out, input, needs_html_encoding, write_html_encoded)
67}
68
69fn needs_html_encoding(c: char) -> bool {
70 matches!(c, '&' | '<' | '>' | '"' | '\'') || is_invalid_for_xml(c)
71}
72
73fn write_html_encoded<W: fmt::Write>(out: &mut W, c: char, _next: Option<char>) -> fmt::Result {
74 match c {
75 '&' => out.write_str("&"),
76 '<' => out.write_str("<"),
77 '>' => out.write_str(">"),
78 '"' => out.write_str("""),
79 '\'' => out.write_str("'"),
80 // invalid XML char → space
81 _ => out.write_char(' '),
82 }
83}
84
85/// encodes `input` for safe embedding in HTML text content.
86///
87/// this encoder does **not** encode quote characters and is therefore
88/// **not safe for attribute values**. use [`for_html`] or
89/// [`for_html_attribute`] for attribute contexts.
90///
91/// # encoded characters
92///
93/// | input | output |
94/// |-------|--------|
95/// | `&` | `&` |
96/// | `<` | `<` |
97/// | `>` | `>` |
98///
99/// invalid XML characters are replaced with a space.
100///
101/// # examples
102///
103/// ```
104/// use contextual_encoder::for_html_content;
105///
106/// assert_eq!(for_html_content("1 < 2 & 3 > 0"), "1 < 2 & 3 > 0");
107/// // quotes are NOT encoded — do not use in attributes
108/// assert_eq!(for_html_content(r#"she said "hi""#), r#"she said "hi""#);
109/// ```
110pub fn for_html_content(input: &str) -> String {
111 let mut out = String::with_capacity(input.len());
112 write_html_content(&mut out, input).expect("writing to string cannot fail");
113 out
114}
115
116/// writes the HTML-content-encoded form of `input` to `out`.
117///
118/// see [`for_html_content`] for encoding rules.
119pub fn write_html_content<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
120 encode_loop(
121 out,
122 input,
123 needs_html_content_encoding,
124 write_html_content_encoded,
125 )
126}
127
128fn needs_html_content_encoding(c: char) -> bool {
129 matches!(c, '&' | '<' | '>') || is_invalid_for_xml(c)
130}
131
132fn write_html_content_encoded<W: fmt::Write>(
133 out: &mut W,
134 c: char,
135 _next: Option<char>,
136) -> fmt::Result {
137 match c {
138 '&' => out.write_str("&"),
139 '<' => out.write_str("<"),
140 '>' => out.write_str(">"),
141 _ => out.write_char(' '),
142 }
143}
144
145/// encodes `input` for safe embedding in a quoted HTML attribute value.
146///
147/// this encoder does **not** encode `>` (harmless inside quoted attributes)
148/// and is slightly more minimal than [`for_html`]. it encodes both `"` and
149/// `'` so the output is safe regardless of which quote delimiter is used.
150///
151/// **not safe for unquoted attributes** — use [`for_html_unquoted_attribute`]
152/// for that context.
153///
154/// # encoded characters
155///
156/// | input | output |
157/// |-------|--------|
158/// | `&` | `&` |
159/// | `<` | `<` |
160/// | `"` | `"` |
161/// | `'` | `'` |
162///
163/// invalid XML characters are replaced with a space.
164///
165/// # examples
166///
167/// ```
168/// use contextual_encoder::for_html_attribute;
169///
170/// // safe for both quote styles
171/// assert_eq!(
172/// for_html_attribute(r#"it's a "test""#),
173/// "it's a "test""
174/// );
175/// // > is not encoded
176/// assert_eq!(for_html_attribute("a > b"), "a > b");
177/// ```
178pub fn for_html_attribute(input: &str) -> String {
179 let mut out = String::with_capacity(input.len());
180 write_html_attribute(&mut out, input).expect("writing to string cannot fail");
181 out
182}
183
184/// writes the HTML-attribute-encoded form of `input` to `out`.
185///
186/// see [`for_html_attribute`] for encoding rules.
187pub fn write_html_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
188 encode_loop(
189 out,
190 input,
191 needs_html_attribute_encoding,
192 write_html_attribute_encoded,
193 )
194}
195
196fn needs_html_attribute_encoding(c: char) -> bool {
197 matches!(c, '&' | '<' | '"' | '\'') || is_invalid_for_xml(c)
198}
199
200fn write_html_attribute_encoded<W: fmt::Write>(
201 out: &mut W,
202 c: char,
203 _next: Option<char>,
204) -> fmt::Result {
205 match c {
206 '&' => out.write_str("&"),
207 '<' => out.write_str("<"),
208 '"' => out.write_str("""),
209 '\'' => out.write_str("'"),
210 _ => out.write_char(' '),
211 }
212}
213
214/// encodes `input` for safe embedding in an unquoted HTML attribute value.
215///
216/// this is the most aggressive HTML encoder, encoding whitespace, quote
217/// characters, grave accents, and many punctuation characters that could
218/// terminate an unquoted attribute value.
219///
220/// **prefer quoted attributes** whenever possible. unquoted attributes are
221/// fragile and this encoder exists only for cases where quoting is not an
222/// option.
223///
224/// # caveat: grave accent
225///
226/// the grave accent (`` ` ``, U+0060) is encoded as ``` because
227/// unpatched internet explorer treats it as an attribute delimiter.
228/// however, numeric character references decode back to the original
229/// character, so this encoding cannot fully protect against the IE bug
230/// in all injection scenarios. the safest mitigation is to avoid
231/// unquoted attributes entirely.
232///
233/// # encoded characters (partial list)
234///
235/// | input | output |
236/// |--------|-----------|
237/// | tab | `	` |
238/// | LF | ` ` |
239/// | FF | `` |
240/// | CR | ` ` |
241/// | space | ` ` |
242/// | `&` | `&` |
243/// | `<` | `<` |
244/// | `>` | `>` |
245/// | `"` | `"` |
246/// | `'` | `'` |
247/// | `/` | `/` |
248/// | `=` | `=` |
249/// | `` ` ``| ``` |
250///
251/// C0/C1 control characters, DEL, and unicode non-characters are replaced
252/// with `-`. NEL (U+0085) is encoded as `…`. line separator (U+2028)
253/// and paragraph separator (U+2029) are encoded as `
` and `
`.
254///
255/// # examples
256///
257/// ```
258/// use contextual_encoder::for_html_unquoted_attribute;
259///
260/// assert_eq!(for_html_unquoted_attribute("hello world"), "hello world");
261/// assert_eq!(for_html_unquoted_attribute("a=b"), "a=b");
262/// ```
263pub fn for_html_unquoted_attribute(input: &str) -> String {
264 let mut out = String::with_capacity(input.len());
265 write_html_unquoted_attribute(&mut out, input).expect("writing to string cannot fail");
266 out
267}
268
269/// writes the unquoted-HTML-attribute-encoded form of `input` to `out`.
270///
271/// see [`for_html_unquoted_attribute`] for encoding rules.
272pub fn write_html_unquoted_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
273 encode_loop(
274 out,
275 input,
276 needs_html_unquoted_attribute_encoding,
277 write_html_unquoted_attribute_encoded,
278 )
279}
280
281fn needs_html_unquoted_attribute_encoding(c: char) -> bool {
282 let cp = c as u32;
283
284 // specific ASCII characters that need encoding
285 if matches!(
286 c,
287 '\t' | '\n' | '\x0C' | '\r' | ' ' | '&' | '<' | '>' | '"' | '\'' | '/' | '=' | '`'
288 ) {
289 return true;
290 }
291
292 // C0 controls not matched above
293 if cp <= 0x1F {
294 return true;
295 }
296
297 // DEL
298 if cp == 0x7F {
299 return true;
300 }
301
302 // C1 controls (includes NEL U+0085)
303 if (0x80..=0x9F).contains(&cp) {
304 return true;
305 }
306
307 // line / paragraph separators
308 if cp == 0x2028 || cp == 0x2029 {
309 return true;
310 }
311
312 // unicode non-characters
313 if is_unicode_noncharacter(cp) {
314 return true;
315 }
316
317 false
318}
319
320fn write_html_unquoted_attribute_encoded<W: fmt::Write>(
321 out: &mut W,
322 c: char,
323 _next: Option<char>,
324) -> fmt::Result {
325 match c {
326 '\t' => out.write_str("	"),
327 '\n' => out.write_str(" "),
328 '\x0C' => out.write_str(""),
329 '\r' => out.write_str(" "),
330 ' ' => out.write_str(" "),
331 '&' => out.write_str("&"),
332 '<' => out.write_str("<"),
333 '>' => out.write_str(">"),
334 '"' => out.write_str("""),
335 '\'' => out.write_str("'"),
336 '/' => out.write_str("/"),
337 '=' => out.write_str("="),
338 '`' => out.write_str("`"),
339 '\u{0085}' => out.write_str("…"),
340 '\u{2028}' => out.write_str("
"),
341 '\u{2029}' => out.write_str("
"),
342 // remaining: C0/C1 controls, DEL, non-characters → dash
343 _ => out.write_char('-'),
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 // -- for_html --
352
353 #[test]
354 fn html_no_encoding_needed() {
355 assert_eq!(for_html("hello world"), "hello world");
356 assert_eq!(for_html(""), "");
357 assert_eq!(for_html("abc123"), "abc123");
358 }
359
360 #[test]
361 fn html_encodes_ampersand() {
362 assert_eq!(for_html("a&b"), "a&b");
363 }
364
365 #[test]
366 fn html_encodes_angle_brackets() {
367 assert_eq!(for_html("<div>"), "<div>");
368 }
369
370 #[test]
371 fn html_encodes_quotes() {
372 assert_eq!(for_html(r#"a"b'c"#), "a"b'c");
373 }
374
375 #[test]
376 fn html_replaces_controls_with_space() {
377 assert_eq!(for_html("a\x01b"), "a b");
378 assert_eq!(for_html("a\x7Fb"), "a b");
379 }
380
381 #[test]
382 fn html_preserves_tab_lf_cr() {
383 assert_eq!(for_html("a\tb\nc\rd"), "a\tb\nc\rd");
384 }
385
386 #[test]
387 fn html_writer_variant() {
388 let mut out = String::new();
389 write_html(&mut out, "<b>").unwrap();
390 assert_eq!(out, "<b>");
391 }
392
393 // -- for_html_content --
394
395 #[test]
396 fn html_content_does_not_encode_quotes() {
397 assert_eq!(for_html_content(r#"a"b'c"#), r#"a"b'c"#);
398 }
399
400 #[test]
401 fn html_content_encodes_angle_brackets_and_amp() {
402 assert_eq!(for_html_content("a<b&c>d"), "a<b&c>d");
403 }
404
405 // -- for_html_attribute --
406
407 #[test]
408 fn html_attribute_does_not_encode_gt() {
409 assert_eq!(for_html_attribute("a>b"), "a>b");
410 }
411
412 #[test]
413 fn html_attribute_encodes_quotes_and_amp_and_lt() {
414 assert_eq!(
415 for_html_attribute(r#"a"b'c&d<e"#),
416 "a"b'c&d<e"
417 );
418 }
419
420 // -- for_html_unquoted_attribute --
421
422 #[test]
423 fn unquoted_attr_encodes_whitespace() {
424 assert_eq!(
425 for_html_unquoted_attribute("a b\tc\nd"),
426 "a b	c d"
427 );
428 }
429
430 #[test]
431 fn unquoted_attr_encodes_grave_accent() {
432 assert_eq!(for_html_unquoted_attribute("a`b"), "a`b");
433 }
434
435 #[test]
436 fn unquoted_attr_encodes_equals_and_slash() {
437 assert_eq!(for_html_unquoted_attribute("a=b/c"), "a=b/c");
438 }
439
440 #[test]
441 fn unquoted_attr_replaces_controls_with_dash() {
442 assert_eq!(for_html_unquoted_attribute("a\x01b"), "a-b");
443 assert_eq!(for_html_unquoted_attribute("a\x7Fb"), "a-b");
444 }
445
446 #[test]
447 fn unquoted_attr_encodes_nel() {
448 assert_eq!(for_html_unquoted_attribute("a\u{0085}b"), "a…b");
449 }
450
451 #[test]
452 fn unquoted_attr_encodes_line_separators() {
453 assert_eq!(
454 for_html_unquoted_attribute("a\u{2028}b\u{2029}c"),
455 "a
b
c"
456 );
457 }
458
459 #[test]
460 fn unquoted_attr_passes_through_safe_chars() {
461 let safe = "ABCxyz019!#$%()*+,-.[]\\^_}";
462 assert_eq!(for_html_unquoted_attribute(safe), safe);
463 }
464
465 #[test]
466 fn unquoted_attr_passes_through_non_ascii() {
467 assert_eq!(for_html_unquoted_attribute("café"), "café");
468 assert_eq!(for_html_unquoted_attribute("日本語"), "日本語");
469 }
470}