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