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