Skip to main content

contextual_encoder/
javascript.rs

1//! javascript contextual output encoders.
2//!
3//! provides five encoding contexts:
4//!
5//! - [`for_javascript`] — universal encoder, safe in HTML attributes, script
6//!   blocks, and standalone .js files
7//! - [`for_javascript_attribute`] — optimized for HTML event attributes
8//!   (e.g., `onclick="..."`)
9//! - [`for_javascript_block`] — optimized for `<script>` blocks
10//! - [`for_javascript_source`] — optimized for standalone .js files
11//! - [`for_js_template`] — for ES6 template literal content (`` `...` ``)
12//!
13//! # security notes
14//!
15//! - the string literal encoders ([`for_javascript`], [`for_javascript_attribute`],
16//!   [`for_javascript_block`], [`for_javascript_source`]) do **not** encode the
17//!   grave accent (`` ` ``). do not use them to embed data inside template
18//!   literals — use [`for_js_template`] instead.
19//! - these encoders are for string/template literal contexts only. they cannot
20//!   make arbitrary javascript expressions, variable names, or property
21//!   accessors safe.
22//! - `for_javascript_block` and `for_javascript_source` use backslash escapes
23//!   for quotes (`\"`, `\'`) which are **not safe in HTML attribute contexts**.
24//! - `for_javascript_attribute` does not escape `<` or `/` and is **not safe
25//!   in `<script>` blocks** where `</script>` could appear.
26//! - none of these encoders produce valid JSON — the `\xHH` escapes they emit
27//!   for control characters are not permitted in JSON. use
28//!   [`for_json`](crate::for_json) for JSON string values.
29
30use std::fmt;
31
32use crate::engine::encode_loop;
33
34/// configuration flags controlling context-specific encoding differences.
35#[derive(Clone, Copy)]
36struct JsConfig {
37    /// true: `"` → `\x22`, `'` → `\x27` (safe in HTML attributes).
38    /// false: `"` → `\"`, `'` → `\'` (more readable, not HTML-attr safe).
39    hex_quotes: bool,
40    /// true: encode `&` as `\x26` (prevents HTML entity interpretation).
41    encode_ampersand: bool,
42    /// true: the output can land in HTML script data, so encode every
43    /// character that can move the tokenizer out of it — `<` → `\x3c` and
44    /// `/` → `\/`.
45    script_data: bool,
46}
47
48const JS_UNIVERSAL: JsConfig = JsConfig {
49    hex_quotes: true,
50    encode_ampersand: true,
51    script_data: true,
52};
53
54const JS_ATTRIBUTE: JsConfig = JsConfig {
55    hex_quotes: true,
56    encode_ampersand: true,
57    script_data: false,
58};
59
60const JS_BLOCK: JsConfig = JsConfig {
61    hex_quotes: false,
62    encode_ampersand: true,
63    script_data: true,
64};
65
66const JS_SOURCE: JsConfig = JsConfig {
67    hex_quotes: false,
68    encode_ampersand: false,
69    script_data: false,
70};
71
72/// encodes `input` for safe embedding in a javascript string literal.
73///
74/// this is the universal javascript encoder — its output is safe in HTML
75/// event attributes, `<script>` blocks, and standalone .js files. it is
76/// slightly more conservative than the context-specific encoders.
77///
78/// # encoding rules
79///
80/// - C0 controls → named escapes (`\b`, `\t`, `\n`, `\f`, `\r`) or hex
81///   (`\xHH`)
82/// - `"` → `\x22`, `'` → `\x27` (hex escapes for HTML attribute safety)
83/// - `&` → `\x26` (prevents HTML entity interpretation)
84/// - `<` → `\x3c`, `/` → `\/` (keeps the HTML tokenizer in script data state,
85///   so the enclosing `</script>` still closes the block)
86/// - `\` → `\\`
87/// - U+2028 → `\u2028`, U+2029 → `\u2029` (javascript line terminators)
88///
89/// # caveat: template literals
90///
91/// this encoder does **not** encode the grave accent (`` ` ``). never
92/// embed untrusted data directly inside template literals. instead:
93///
94/// ```js
95/// // WRONG — vulnerable to XSS:
96/// // `Hello ${unsafeInput}`
97/// //
98/// // RIGHT — encode into a variable first:
99/// // var x = '<encoded>';
100/// // `Hello ${x}`
101/// ```
102///
103/// # examples
104///
105/// ```
106/// use contextual_encoder::for_javascript;
107///
108/// assert_eq!(for_javascript(r#"it's "unsafe" </script>"#),
109///            r"it\x27s \x22unsafe\x22 \x3c\/script>");
110/// assert_eq!(for_javascript("safe"), "safe");
111/// ```
112pub fn for_javascript(input: &str) -> String {
113    encode_js(input, &JS_UNIVERSAL)
114}
115
116/// writes the javascript-encoded form of `input` to `out`.
117///
118/// see [`for_javascript`] for encoding rules.
119pub fn write_javascript<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
120    write_js(out, input, &JS_UNIVERSAL)
121}
122
123/// encodes `input` for safe embedding in a javascript string literal inside
124/// an HTML event attribute (e.g., `onclick="..."`).
125///
126/// identical to [`for_javascript`] except `<` and `/` are **not** escaped. an
127/// attribute value is never tokenized as script data, so neither character can
128/// affect where the enclosing element ends.
129///
130/// **not safe in `<script>` blocks** — use [`for_javascript`] or
131/// [`for_javascript_block`] instead.
132///
133/// # examples
134///
135/// ```
136/// use contextual_encoder::for_javascript_attribute;
137///
138/// assert_eq!(for_javascript_attribute("a/b"), "a/b");
139/// assert_eq!(for_javascript_attribute("a<b"), "a<b");
140/// assert_eq!(for_javascript_attribute("a'b"), r"a\x27b");
141/// ```
142pub fn for_javascript_attribute(input: &str) -> String {
143    encode_js(input, &JS_ATTRIBUTE)
144}
145
146/// writes the javascript-attribute-encoded form of `input` to `out`.
147///
148/// see [`for_javascript_attribute`] for encoding rules.
149pub fn write_javascript_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
150    write_js(out, input, &JS_ATTRIBUTE)
151}
152
153/// encodes `input` for safe embedding in a javascript string literal inside
154/// an HTML `<script>` block.
155///
156/// uses backslash escapes for quotes (`\"`, `\'`) which are more readable
157/// but **not safe in HTML attribute contexts**. still encodes `&` (for XHTML
158/// compatibility) and `<`/`/`, which keep the HTML tokenizer in script data
159/// state so the enclosing `</script>` still closes the block.
160///
161/// # examples
162///
163/// ```
164/// use contextual_encoder::for_javascript_block;
165///
166/// assert_eq!(for_javascript_block(r#"he said "hi""#), r#"he said \"hi\""#);
167/// assert_eq!(for_javascript_block("</script>"), r"\x3c\/script>");
168/// assert_eq!(for_javascript_block("<!--<script>"), r"\x3c!--\x3cscript>");
169/// ```
170pub fn for_javascript_block(input: &str) -> String {
171    encode_js(input, &JS_BLOCK)
172}
173
174/// writes the javascript-block-encoded form of `input` to `out`.
175///
176/// see [`for_javascript_block`] for encoding rules.
177pub fn write_javascript_block<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
178    write_js(out, input, &JS_BLOCK)
179}
180
181/// encodes `input` for safe embedding in a javascript string literal in a
182/// standalone .js file.
183///
184/// the most minimal javascript encoder — does not encode `<`, `/` or `&`
185/// since a standalone .js file is never HTML-tokenized. **not safe for any
186/// HTML-embedded context.**
187///
188/// **not a JSON encoder.** it emits `\'` for single quotes and `\xHH` for
189/// control characters, neither of which JSON permits. use
190/// [`for_json`](crate::for_json) for JSON string values.
191///
192/// # examples
193///
194/// ```
195/// use contextual_encoder::{for_javascript_source, for_json};
196///
197/// assert_eq!(for_javascript_source("a/b&c<d"), "a/b&c<d");
198/// assert_eq!(for_javascript_source("line\nbreak"), r"line\nbreak");
199///
200/// assert_eq!(for_javascript_source("it's"), r"it\'s");
201/// assert_eq!(for_json("it's"), "it's");
202/// ```
203pub fn for_javascript_source(input: &str) -> String {
204    encode_js(input, &JS_SOURCE)
205}
206
207/// writes the javascript-source-encoded form of `input` to `out`.
208///
209/// see [`for_javascript_source`] for encoding rules.
210pub fn write_javascript_source<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
211    write_js(out, input, &JS_SOURCE)
212}
213
214/// encodes `input` for safe embedding inside an ES6 template literal
215/// (`` `...` ``).
216///
217/// template literals use backticks as delimiters and `${...}` for
218/// interpolation. this encoder escapes both so untrusted data cannot break
219/// out of the literal or inject expressions.
220///
221/// # encoding rules
222///
223/// - `` ` `` → `` \` `` (prevents breaking out of the template literal)
224/// - `$` followed by `{` → `\${` (prevents expression interpolation)
225/// - `\` → `\\`
226/// - `<` → `\x3c`, `/` → `\/` (keeps the HTML tokenizer in script data state,
227///   so the enclosing `</script>` still closes the block)
228/// - C0 controls → named escapes (`\b`, `\t`, `\n`, `\f`, `\r`) or hex
229///   (`\xHH`)
230/// - U+2028 → `\u2028`, U+2029 → `\u2029` (line/paragraph separators)
231///
232/// unlike the string literal encoders, this does **not** escape `"` or `'`
233/// (they are ordinary characters inside template literals).
234///
235/// # examples
236///
237/// ```
238/// use contextual_encoder::for_js_template;
239///
240/// assert_eq!(for_js_template("hello `world`"), r"hello \`world\`");
241/// assert_eq!(for_js_template("${alert(1)}"), r"\${alert(1)}");
242/// assert_eq!(for_js_template("safe"), "safe");
243/// assert_eq!(for_js_template("a $ b"), "a $ b");
244/// ```
245pub fn for_js_template(input: &str) -> String {
246    let mut out = String::with_capacity(input.len());
247    write_js_template(&mut out, input).expect("writing to string cannot fail");
248    out
249}
250
251/// writes the template-literal-encoded form of `input` to `out`.
252///
253/// see [`for_js_template`] for encoding rules.
254pub fn write_js_template<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
255    encode_loop(
256        out,
257        input,
258        needs_js_template_encoding,
259        write_js_template_encoded,
260    )
261}
262
263fn needs_js_template_encoding(c: char) -> bool {
264    matches!(
265        c,
266        '\x00'..='\x1F' | '\\' | '`' | '$' | '/' | '<' | '\u{2028}' | '\u{2029}'
267    )
268}
269
270fn write_js_template_encoded<W: fmt::Write>(
271    out: &mut W,
272    c: char,
273    next: Option<char>,
274) -> fmt::Result {
275    match c {
276        // template-specific characters
277        '`' => out.write_str("\\`"),
278        '$' if next == Some('{') => out.write_str("\\$"),
279        '$' => out.write_char('$'),
280        '/' => out.write_str("\\/"),
281        '<' => out.write_str("\\x3c"),
282        // C0 controls, backslash, and line separators
283        c => write_js_shared_escape(out, c),
284    }
285}
286
287fn encode_js(input: &str, config: &JsConfig) -> String {
288    let mut out = String::with_capacity(input.len());
289    write_js(&mut out, input, config).expect("writing to string cannot fail");
290    out
291}
292
293fn write_js<W: fmt::Write>(out: &mut W, input: &str, config: &JsConfig) -> fmt::Result {
294    encode_loop(
295        out,
296        input,
297        |c| needs_js_encoding(c, config),
298        |out, c, _next| write_js_encoded(out, c, config),
299    )
300}
301
302fn needs_js_encoding(c: char, config: &JsConfig) -> bool {
303    match c {
304        '\x00'..='\x1F' | '\\' | '"' | '\'' | '\u{2028}' | '\u{2029}' => true,
305        '&' => config.encode_ampersand,
306        '/' | '<' => config.script_data,
307        _ => false,
308    }
309}
310
311fn write_js_encoded<W: fmt::Write>(out: &mut W, c: char, config: &JsConfig) -> fmt::Result {
312    match c {
313        // string-literal-specific characters
314        '"' if config.hex_quotes => out.write_str("\\x22"),
315        '"' => out.write_str("\\\""),
316        '\'' if config.hex_quotes => out.write_str("\\x27"),
317        '\'' => out.write_str("\\'"),
318        '&' => out.write_str("\\x26"),
319        '/' => out.write_str("\\/"),
320        '<' => out.write_str("\\x3c"),
321        // C0 controls, backslash, and line separators
322        c => write_js_shared_escape(out, c),
323    }
324}
325
326/// writes the C0-control/backslash/line-separator escape shared by both js
327/// encoders. any other character falls back to `\u{...}` so a character a
328/// predicate flags can never be dropped from the output.
329fn write_js_shared_escape<W: fmt::Write>(out: &mut W, c: char) -> fmt::Result {
330    match c {
331        '\x08' => out.write_str("\\b"),
332        '\t' => out.write_str("\\t"),
333        '\n' => out.write_str("\\n"),
334        '\x0B' => out.write_str("\\x0b"),
335        '\x0C' => out.write_str("\\f"),
336        '\r' => out.write_str("\\r"),
337        '\\' => out.write_str("\\\\"),
338        '\u{2028}' => out.write_str("\\u2028"),
339        '\u{2029}' => out.write_str("\\u2029"),
340        '\x00'..='\x1F' => write!(out, "\\x{:02x}", c as u32),
341        c => write!(out, "\\u{{{:x}}}", c as u32),
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    // -- for_javascript (universal) --
350
351    #[test]
352    fn js_no_encoding_needed() {
353        assert_eq!(for_javascript("hello world"), "hello world");
354        assert_eq!(for_javascript(""), "");
355    }
356
357    #[test]
358    fn js_encodes_quotes_as_hex() {
359        assert_eq!(for_javascript(r#"a"b"#), r"a\x22b");
360        assert_eq!(for_javascript("a'b"), r"a\x27b");
361    }
362
363    #[test]
364    fn js_encodes_backslash() {
365        assert_eq!(for_javascript(r"a\b"), r"a\\b");
366    }
367
368    #[test]
369    fn js_encodes_ampersand() {
370        assert_eq!(for_javascript("a&b"), r"a\x26b");
371    }
372
373    #[test]
374    fn js_encodes_slash() {
375        assert_eq!(for_javascript("a/b"), r"a\/b");
376        assert_eq!(for_javascript("</script>"), r"\x3c\/script>");
377    }
378
379    #[test]
380    fn js_encodes_lt() {
381        assert_eq!(for_javascript("a<b"), r"a\x3cb");
382        assert_eq!(for_javascript("<!--<script>"), r"\x3c!--\x3cscript>");
383        assert_eq!(for_javascript("<!--"), r"\x3c!--");
384        assert_eq!(for_javascript("<script"), r"\x3cscript");
385    }
386
387    #[test]
388    fn js_encodes_control_chars() {
389        assert_eq!(for_javascript("\x00"), r"\x00");
390        assert_eq!(for_javascript("\x08"), r"\b");
391        assert_eq!(for_javascript("\t"), r"\t");
392        assert_eq!(for_javascript("\n"), r"\n");
393        assert_eq!(for_javascript("\x0B"), r"\x0b");
394        assert_eq!(for_javascript("\x0C"), r"\f");
395        assert_eq!(for_javascript("\r"), r"\r");
396        assert_eq!(for_javascript("\x1F"), r"\x1f");
397    }
398
399    #[test]
400    fn js_encodes_line_separators() {
401        assert_eq!(for_javascript("\u{2028}"), r"\u2028");
402        assert_eq!(for_javascript("\u{2029}"), r"\u2029");
403    }
404
405    #[test]
406    fn js_preserves_non_ascii() {
407        assert_eq!(for_javascript("café"), "café");
408        assert_eq!(for_javascript("日本語"), "日本語");
409    }
410
411    #[test]
412    fn js_writer_variant() {
413        let mut out = String::new();
414        write_javascript(&mut out, "a'b").unwrap();
415        assert_eq!(out, r"a\x27b");
416    }
417
418    // -- for_javascript_attribute --
419
420    #[test]
421    fn js_attr_does_not_encode_slash_or_lt() {
422        assert_eq!(for_javascript_attribute("a/b"), "a/b");
423        assert_eq!(for_javascript_attribute("<!--<script>"), "<!--<script>");
424    }
425
426    #[test]
427    fn js_attr_encodes_quotes_as_hex() {
428        assert_eq!(for_javascript_attribute("a'b"), r"a\x27b");
429    }
430
431    #[test]
432    fn js_attr_encodes_ampersand() {
433        assert_eq!(for_javascript_attribute("a&b"), r"a\x26b");
434    }
435
436    // -- for_javascript_block --
437
438    #[test]
439    fn js_block_uses_backslash_quotes() {
440        assert_eq!(for_javascript_block(r#"a"b"#), r#"a\"b"#);
441        assert_eq!(for_javascript_block("a'b"), r"a\'b");
442    }
443
444    #[test]
445    fn js_block_encodes_slash() {
446        assert_eq!(for_javascript_block("a/b"), r"a\/b");
447    }
448
449    #[test]
450    fn js_block_encodes_lt() {
451        assert_eq!(for_javascript_block("a<b"), r"a\x3cb");
452        assert_eq!(for_javascript_block("<!--<script>"), r"\x3c!--\x3cscript>");
453        assert_eq!(for_javascript_block("<!--"), r"\x3c!--");
454        assert_eq!(for_javascript_block("<script"), r"\x3cscript");
455    }
456
457    #[test]
458    fn js_block_encodes_ampersand() {
459        assert_eq!(for_javascript_block("a&b"), r"a\x26b");
460    }
461
462    // -- for_javascript_source --
463
464    #[test]
465    fn js_source_uses_backslash_quotes() {
466        assert_eq!(for_javascript_source(r#"a"b"#), r#"a\"b"#);
467        assert_eq!(for_javascript_source("a'b"), r"a\'b");
468    }
469
470    #[test]
471    fn js_source_does_not_encode_slash_ampersand_or_lt() {
472        assert_eq!(for_javascript_source("a/b&c"), "a/b&c");
473        assert_eq!(for_javascript_source("<!--<script>"), "<!--<script>");
474    }
475
476    #[test]
477    fn js_source_encodes_line_separators() {
478        assert_eq!(for_javascript_source("\u{2028}"), r"\u2028");
479    }
480
481    // -- for_js_template --
482
483    #[test]
484    fn js_template_no_encoding_needed() {
485        assert_eq!(for_js_template("hello world"), "hello world");
486        assert_eq!(for_js_template(""), "");
487    }
488
489    #[test]
490    fn js_template_encodes_backtick() {
491        assert_eq!(for_js_template("hello `world`"), r"hello \`world\`");
492        assert_eq!(for_js_template("`"), r"\`");
493    }
494
495    #[test]
496    fn js_template_encodes_interpolation() {
497        assert_eq!(for_js_template("${alert(1)}"), r"\${alert(1)}");
498        assert_eq!(for_js_template("a${b}c"), r"a\${b}c");
499        assert_eq!(for_js_template("${a}${b}"), r"\${a}\${b}");
500    }
501
502    #[test]
503    fn js_template_dollar_without_brace_passes_through() {
504        assert_eq!(for_js_template("a $ b"), "a $ b");
505        assert_eq!(for_js_template("$100"), "$100");
506        assert_eq!(for_js_template("a$"), "a$");
507    }
508
509    #[test]
510    fn js_template_encodes_backslash() {
511        assert_eq!(for_js_template(r"a\b"), r"a\\b");
512    }
513
514    #[test]
515    fn js_template_encodes_slash() {
516        assert_eq!(for_js_template("a/b"), r"a\/b");
517        assert_eq!(for_js_template("</script>"), r"\x3c\/script>");
518    }
519
520    #[test]
521    fn js_template_encodes_lt() {
522        assert_eq!(for_js_template("a<b"), r"a\x3cb");
523        assert_eq!(for_js_template("<!--<script>"), r"\x3c!--\x3cscript>");
524        assert_eq!(for_js_template("<!--"), r"\x3c!--");
525        assert_eq!(for_js_template("<script"), r"\x3cscript");
526    }
527
528    #[test]
529    fn js_template_does_not_encode_quotes() {
530        assert_eq!(for_js_template(r#"a"b"#), r#"a"b"#);
531        assert_eq!(for_js_template("a'b"), "a'b");
532    }
533
534    #[test]
535    fn js_template_encodes_control_chars() {
536        assert_eq!(for_js_template("\x00"), r"\x00");
537        assert_eq!(for_js_template("\x08"), r"\b");
538        assert_eq!(for_js_template("\t"), r"\t");
539        assert_eq!(for_js_template("\n"), r"\n");
540        assert_eq!(for_js_template("\x0B"), r"\x0b");
541        assert_eq!(for_js_template("\x0C"), r"\f");
542        assert_eq!(for_js_template("\r"), r"\r");
543        assert_eq!(for_js_template("\x1F"), r"\x1f");
544    }
545
546    #[test]
547    fn js_template_encodes_line_separators() {
548        assert_eq!(for_js_template("\u{2028}"), r"\u2028");
549        assert_eq!(for_js_template("\u{2029}"), r"\u2029");
550    }
551
552    #[test]
553    fn js_template_preserves_non_ascii() {
554        assert_eq!(for_js_template("café"), "café");
555        assert_eq!(for_js_template("日本語"), "日本語");
556        assert_eq!(for_js_template("😀"), "😀");
557    }
558
559    #[test]
560    fn js_template_mixed_input() {
561        assert_eq!(
562            for_js_template("`Hello ${name}`, welcome\\n"),
563            r"\`Hello \${name}\`, welcome\\n"
564        );
565    }
566
567    #[test]
568    fn js_template_writer_variant() {
569        let input = "`test` ${x} café";
570        let string_result = for_js_template(input);
571        let mut writer_result = String::new();
572        write_js_template(&mut writer_result, input).unwrap();
573        assert_eq!(string_result, writer_result);
574    }
575
576    // -- write_js_shared_escape helper --
577
578    #[test]
579    fn shared_escape_handles_shared_chars() {
580        let cases = [
581            ('\x08', r"\b"),
582            ('\t', r"\t"),
583            ('\n', r"\n"),
584            ('\x0B', r"\x0b"),
585            ('\x0C', r"\f"),
586            ('\r', r"\r"),
587            ('\\', r"\\"),
588            ('\x00', r"\x00"),
589            ('\x1F', r"\x1f"),
590            ('\u{2028}', r"\u2028"),
591            ('\u{2029}', r"\u2029"),
592        ];
593        for (c, expected) in cases {
594            let mut out = String::new();
595            assert_eq!(write_js_shared_escape(&mut out, c), Ok(()));
596            assert_eq!(out, expected);
597        }
598    }
599
600    #[test]
601    fn shared_escape_never_drops_a_character() {
602        for (c, expected) in [('a', r"\u{61}"), ('"', r"\u{22}"), ('é', r"\u{e9}")] {
603            let mut out = String::new();
604            assert_eq!(write_js_shared_escape(&mut out, c), Ok(()));
605            assert_eq!(out, expected);
606        }
607    }
608}