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