Skip to main content

contextual_encoder/
json.rs

1//! JSON string encoder.
2//!
3//! encodes untrusted strings for safe embedding in JSON string values.
4//!
5//! - [`for_json`] — safe for JSON string contexts
6//!
7//! # why not `for_javascript_source`?
8//!
9//! JSON looks like JavaScript but has two critical encoding differences:
10//!
11//! - **no `\x` escapes.** JSON only supports `\uHHHH` for unicode escapes.
12//!   the `\xHH` form that JavaScript uses for control characters is invalid JSON.
13//! - **no single-quote escaping.** `\'` is not a valid JSON escape sequence.
14//!   single quotes are ordinary characters in JSON strings.
15//!
16//! using `for_javascript_source` for JSON output produces strings that may be
17//! rejected by strict JSON parsers.
18//!
19//! # encoding rules
20//!
21//! - named escapes: `\b`, `\t`, `\n`, `\f`, `\r`, `\"`, `\\`
22//! - other C0 controls (U+0000–U+001F) → `\u00HH`
23//! - `<` → `\u003c`, `/` → `\/` (keeps the HTML tokenizer in script data
24//!   state when JSON is embedded in an HTML `<script>` block, so the block's
25//!   `</script>` still closes it. RFC 8259 §7 explicitly permits `\/`)
26//! - U+2028 → `\u2028`, U+2029 → `\u2029` (line/paragraph separators;
27//!   mandatory because JSON is often embedded in `<script>` blocks where
28//!   these would terminate the JavaScript string literal)
29//! - all other characters pass through unchanged
30
31use std::fmt;
32
33use crate::engine::encode_loop;
34
35/// encodes `input` for safe embedding in a JSON string value.
36///
37/// produces output suitable for embedding between double quotes in a JSON
38/// document. the result conforms to [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259)
39/// and additionally escapes U+2028/U+2029 for safe embedding in HTML
40/// `<script>` blocks.
41///
42/// # encoding rules
43///
44/// | input | output |
45/// |-------|--------|
46/// | `\b` (U+0008) | `\b` |
47/// | `\t` (U+0009) | `\t` |
48/// | `\n` (U+000A) | `\n` |
49/// | `\f` (U+000C) | `\f` |
50/// | `\r` (U+000D) | `\r` |
51/// | `"` | `\"` |
52/// | `\` | `\\` |
53/// | `/` | `\/` |
54/// | `<` | `\u003c` |
55/// | other C0 controls (U+0000–U+001F) | `\u00HH` |
56/// | U+2028 (line separator) | `\u2028` |
57/// | U+2029 (paragraph separator) | `\u2029` |
58/// | single quotes, `&` | unchanged |
59///
60/// # difference from JavaScript encoders
61///
62/// - single quotes are **not** escaped (JSON has no `\'` escape sequence)
63/// - control characters use `\u00HH` (JSON has no `\xHH` escape sequence)
64///
65/// # examples
66///
67/// ```
68/// use contextual_encoder::for_json;
69///
70/// assert_eq!(for_json(r#"he said "hello""#), r#"he said \"hello\""#);
71/// assert_eq!(for_json("it's fine"), "it's fine");
72/// assert_eq!(for_json("line\nbreak"), r"line\nbreak");
73/// assert_eq!(for_json("\u{2028}"), r"\u2028");
74/// ```
75pub fn for_json(input: &str) -> String {
76    let mut out = String::with_capacity(input.len());
77    write_json(&mut out, input).expect("writing to string cannot fail");
78    out
79}
80
81/// writes the JSON-encoded form of `input` to `out`.
82///
83/// see [`for_json`] for encoding rules.
84pub fn write_json<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
85    encode_loop(out, input, needs_json_encoding, write_json_encoded)
86}
87
88fn needs_json_encoding(c: char) -> bool {
89    matches!(
90        c,
91        '\x00'..='\x1F' | '"' | '\\' | '/' | '<' | '\u{2028}' | '\u{2029}'
92    )
93}
94
95fn write_json_encoded<W: fmt::Write>(out: &mut W, c: char, _next: Option<char>) -> fmt::Result {
96    match c {
97        '\x08' => out.write_str("\\b"),
98        '\t' => out.write_str("\\t"),
99        '\n' => out.write_str("\\n"),
100        '\x0C' => out.write_str("\\f"),
101        '\r' => out.write_str("\\r"),
102        '"' => out.write_str("\\\""),
103        '\\' => out.write_str("\\\\"),
104        '/' => out.write_str("\\/"),
105        '<' => out.write_str("\\u003c"),
106        '\u{2028}' => out.write_str("\\u2028"),
107        '\u{2029}' => out.write_str("\\u2029"),
108        // other C0 controls → \u00HH (JSON does not support \xHH)
109        c => write!(out, "\\u{:04x}", c as u32),
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn passthrough() {
119        assert_eq!(for_json("hello world"), "hello world");
120        assert_eq!(for_json(""), "");
121        assert_eq!(for_json("café"), "café");
122        assert_eq!(for_json("日本語"), "日本語");
123        assert_eq!(for_json("😀"), "😀");
124    }
125
126    #[test]
127    fn single_quotes_not_escaped() {
128        assert_eq!(for_json("it's"), "it's");
129        assert_eq!(for_json("'quoted'"), "'quoted'");
130    }
131
132    #[test]
133    fn double_quotes_escaped() {
134        assert_eq!(for_json(r#"a"b"#), r#"a\"b"#);
135        assert_eq!(for_json(r#""hello""#), r#"\"hello\""#);
136    }
137
138    #[test]
139    fn backslash() {
140        assert_eq!(for_json(r"a\b"), r"a\\b");
141        assert_eq!(for_json(r"\\"), r"\\\\");
142    }
143
144    #[test]
145    fn named_escapes() {
146        assert_eq!(for_json("\x08"), "\\b");
147        assert_eq!(for_json("\t"), "\\t");
148        assert_eq!(for_json("\n"), "\\n");
149        assert_eq!(for_json("\x0C"), "\\f");
150        assert_eq!(for_json("\r"), "\\r");
151    }
152
153    #[test]
154    fn control_chars_use_unicode_escapes() {
155        // JSON requires \u00HH, not \xHH
156        assert_eq!(for_json("\x00"), "\\u0000");
157        assert_eq!(for_json("\x01"), "\\u0001");
158        assert_eq!(for_json("\x07"), "\\u0007");
159        assert_eq!(for_json("\x0B"), "\\u000b");
160        assert_eq!(for_json("\x0E"), "\\u000e");
161        assert_eq!(for_json("\x1F"), "\\u001f");
162    }
163
164    #[test]
165    fn line_separators() {
166        assert_eq!(for_json("\u{2028}"), "\\u2028");
167        assert_eq!(for_json("\u{2029}"), "\\u2029");
168        assert_eq!(for_json("a\u{2028}b\u{2029}c"), "a\\u2028b\\u2029c");
169    }
170
171    #[test]
172    fn forward_slash_escaped() {
173        assert_eq!(for_json("/"), "\\/");
174        assert_eq!(for_json("a/b"), "a\\/b");
175        assert_eq!(for_json("https://example.com"), "https:\\/\\/example.com");
176    }
177
178    #[test]
179    fn ampersand_not_escaped() {
180        assert_eq!(for_json("a&b"), "a&b");
181    }
182
183    #[test]
184    fn script_tag_breakout_prevented() {
185        assert_eq!(for_json("</script>"), "\\u003c\\/script>");
186        assert_eq!(
187            for_json("</script><script>alert(1)//"),
188            "\\u003c\\/script>\\u003cscript>alert(1)\\/\\/"
189        );
190    }
191
192    #[test]
193    fn script_data_escape_prevented() {
194        assert_eq!(for_json("<!--<script>"), "\\u003c!--\\u003cscript>");
195        assert_eq!(for_json("<!--"), "\\u003c!--");
196        assert_eq!(for_json("<script"), "\\u003cscript");
197        assert_eq!(for_json("a<b"), "a\\u003cb");
198    }
199
200    #[test]
201    fn mixed_input() {
202        assert_eq!(
203            for_json("he said \"hello\"\nnew line"),
204            "he said \\\"hello\\\"\\nnew line"
205        );
206    }
207
208    #[test]
209    fn writer_matches_string() {
210        let input = "test\x00\"\\\n\u{2028}café";
211        let string_result = for_json(input);
212        let mut writer_result = String::new();
213        write_json(&mut writer_result, input).unwrap();
214        assert_eq!(string_result, writer_result);
215    }
216
217    // -- key differences from for_javascript_source --
218
219    #[test]
220    fn differs_from_js_source_on_single_quotes() {
221        // JS source escapes single quotes; JSON does not
222        assert_eq!(for_json("a'b"), "a'b");
223        assert_ne!(for_json("a'b"), crate::for_javascript_source("a'b"));
224    }
225
226    #[test]
227    fn differs_from_js_source_on_control_format() {
228        // JS source uses \xHH; JSON uses \u00HH
229        assert_eq!(for_json("\x01"), "\\u0001");
230        assert_eq!(crate::for_javascript_source("\x01"), "\\x01");
231    }
232}