Skip to main content

ferrijs_std/json/
escape.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4static JSON_ESCAPE_CHARS: [u8; 256] = [
5    0u8, 1u8, 2u8, 3u8, 4u8, 5u8, 6u8, 7u8, 8u8, 9u8, 10u8, 11u8, 12u8, 13u8, 14u8, 15u8, 16u8,
6    17u8, 18u8, 19u8, 20u8, 21u8, 22u8, 23u8, 24u8, 25u8, 26u8, 27u8, 28u8, 29u8, 30u8, 31u8, 34u8,
7    34u8, 32u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
8    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
9    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
10    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 33u8, 34u8, 34u8, 34u8, 34u8,
11    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
12    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
13    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
14    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
15    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
16    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
17    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
18    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
19    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
20    34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8, 34u8,
21];
22static JSON_ESCAPE_QUOTES: [&str; 34usize] = [
23    "\\u0000", "\\u0001", "\\u0002", "\\u0003", "\\u0004", "\\u0005", "\\u0006", "\\u0007", "\\b",
24    "\\t", "\\n", "\\u000b", "\\f", "\\r", "\\u000e", "\\u000f", "\\u0010", "\\u0011", "\\u0012",
25    "\\u0013", "\\u0014", "\\u0015", "\\u0016", "\\u0017", "\\u0018", "\\u0019", "\\u001a",
26    "\\u001b", "\\u001c", "\\u001d", "\\u001e", "\\u001f", "\\\"", "\\\\",
27];
28
29const ESCAPE_LEN: usize = 34;
30
31#[cold]
32#[inline(always)]
33fn write_surrogate_escape(result: &mut String, bytes: &[u8], i: usize) -> usize {
34    let code_point = ((bytes[i] as u16 & 0x0F) << 12)
35        | ((bytes[i + 1] as u16 & 0x3F) << 6)
36        | (bytes[i + 2] as u16 & 0x3F);
37
38    result.push_str("\\u");
39    let hex = [
40        (code_point >> 12) as u8,
41        ((code_point >> 8) & 0xF) as u8,
42        ((code_point >> 4) & 0xF) as u8,
43        (code_point & 0xF) as u8,
44    ];
45    for h in hex {
46        result.push(if h < 10 {
47            (b'0' + h) as char
48        } else {
49            (b'a' + h - 10) as char
50        });
51    }
52    3
53}
54
55#[allow(dead_code)]
56pub fn escape_json(bytes: &[u8]) -> String {
57    let mut result = String::new();
58    escape_json_string(&mut result, bytes);
59    result
60}
61
62#[inline(always)]
63fn process_byte(
64    result: &mut String,
65    bytes: &[u8],
66    byte: u8,
67    i: &mut usize,
68    start: &mut usize,
69    len: usize,
70) {
71    // Fast path for simple escapes ({<32, 34, 92}); 0xED is filtered out here
72    // because JSON_ESCAPE_CHARS[0xED] == ESCAPE_LEN.
73    let c = JSON_ESCAPE_CHARS[byte as usize] as usize;
74    if c < ESCAPE_LEN {
75        // SAFETY: c < JSON_ESCAPE_QUOTES.len(); start <= i <= bytes.len().
76        let esc = unsafe { JSON_ESCAPE_QUOTES.get_unchecked(c) }.as_bytes();
77        let pending = unsafe { bytes.get_unchecked(*start..*i) };
78        // Branch-free flush: one reserve + two memcpys (pending may be empty).
79        unsafe {
80            let vec = result.as_mut_vec();
81            let total = pending.len() + esc.len();
82            vec.reserve(total);
83            let cur = vec.len();
84            let dst = vec.as_mut_ptr().add(cur);
85            std::ptr::copy_nonoverlapping(pending.as_ptr(), dst, pending.len());
86            std::ptr::copy_nonoverlapping(esc.as_ptr(), dst.add(pending.len()), esc.len());
87            vec.set_len(cur + total);
88        }
89        *i += 1;
90        *start = *i;
91        return;
92    }
93
94    // WTF-8 lone surrogate (0xED A0..BF 80..BF) -> \uXXXX. Otherwise pass through.
95    if byte == 0xED && *i + 2 < len && (bytes[*i + 1] & 0xF0) >= 0xA0 {
96        if *start < *i {
97            // SAFETY: start <= i <= len; bytes through i are valid UTF-8/WTF-8.
98            result.push_str(unsafe {
99                std::str::from_utf8_unchecked(bytes.get_unchecked(*start..*i))
100            });
101        }
102        *i += write_surrogate_escape(result, bytes, *i);
103        *start = *i;
104        return;
105    }
106    *i += 1;
107}
108
109/// SWAR escape-byte detector: sets the high bit of each byte in the returned
110/// u64 for any input byte matching `< 32 || == 34 || == 92 || == 0xED`. May
111/// produce false positives (caller's `process_byte` re-validates via the
112/// escape table). Little-endian load so byte k -> bit (k*8); recover via
113/// `trailing_zeros() / 8`.
114#[inline(always)]
115fn chunk_escape_mask(chunk: &[u8; 8]) -> u64 {
116    const ONES: u64 = 0x0101_0101_0101_0101;
117    const HIGH: u64 = 0x8080_8080_8080_8080;
118    let x = u64::from_le_bytes(*chunk);
119    let lt32 = x.wrapping_sub(0x20 * ONES) & !x;
120    let eq34 = {
121        let y = x ^ (0x22 * ONES);
122        y.wrapping_sub(ONES) & !y
123    };
124    let eq92 = {
125        let y = x ^ (0x5C * ONES);
126        y.wrapping_sub(ONES) & !y
127    };
128    let eqed = {
129        let y = x ^ (0xED * ONES);
130        y.wrapping_sub(ONES) & !y
131    };
132    (lt32 | eq34 | eq92 | eqed) & HIGH
133}
134
135/// Append a JSON-escaped form of `bytes` to `result`.
136///
137/// Accepts UTF-8 or WTF-8 (QuickJS uses WTF-8 for JS strings with lone
138/// surrogates). Scans 64 bytes at a time as 8x 8-byte SWAR masks; clean
139/// strides are skipped without copying, dirty halves jump byte-to-byte via
140/// `trailing_zeros`. The trailing <64 bytes are swept the same way and the
141/// final <8 fall through to `process_byte`.
142#[inline(always)]
143pub fn escape_json_string_simple(result: &mut String, bytes: &[u8]) {
144    let len = bytes.len();
145    let mut start = 0;
146    let mut i = 0;
147    // Headroom: small strings can expand up to 6x (all-control to \uXXXX);
148    // larger inputs see <25% density in practice. No-op when `result` is
149    // already pre-sized (common stringify-accumulator case).
150    let headroom = if len < 128 {
151        len * 5 + 16
152    } else {
153        len / 4 + 16
154    };
155    result.reserve(len + headroom);
156
157    let (chunks64, tail) = bytes.as_chunks::<64>();
158
159    let mut base = 0usize;
160    for chunk64 in chunks64 {
161        // Hand-unrolled to keep 8 independent SWAR dependency chains visible;
162        // LLVM doesn't reliably do this from a fixed-size array loop.
163        macro_rules! mask_at {
164            ($off:expr) => {
165                chunk_escape_mask((&chunk64[$off..$off + 8]).try_into().unwrap())
166            };
167        }
168        let m_0 = mask_at!(0);
169        let m_1 = mask_at!(8);
170        let m_2 = mask_at!(16);
171        let m_3 = mask_at!(24);
172        let m_4 = mask_at!(32);
173        let m_5 = mask_at!(40);
174        let m_6 = mask_at!(48);
175        let m_7 = mask_at!(56);
176        if (m_0 | m_1 | m_2 | m_3 | m_4 | m_5 | m_6 | m_7) == 0 {
177            i = base + 64;
178        } else {
179            macro_rules! dispatch {
180                ($off:expr, $mask:expr) => {
181                    process_dirty_half(result, bytes, base + $off, $mask, &mut i, &mut start, len)
182                };
183            }
184            dispatch!(0, m_0);
185            dispatch!(8, m_1);
186            dispatch!(16, m_2);
187            dispatch!(24, m_3);
188            dispatch!(32, m_4);
189            dispatch!(40, m_5);
190            dispatch!(48, m_6);
191            dispatch!(56, m_7);
192        }
193        base += 64;
194    }
195
196    // 0..=63-byte tail: SWAR-sweep 8-byte sub-chunks, then byte-by-byte for <8.
197    let (sub_chunks, _sub_tail) = tail.as_chunks::<8>();
198    for (k, sub) in sub_chunks.iter().enumerate() {
199        let mask = chunk_escape_mask(sub);
200        process_dirty_half(result, bytes, base + k * 8, mask, &mut i, &mut start, len);
201    }
202
203    while i < len {
204        process_byte(result, bytes, bytes[i], &mut i, &mut start, len);
205    }
206
207    if start < len {
208        result.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..len]) });
209    }
210}
211
212#[inline(always)]
213fn process_dirty_half(
214    result: &mut String,
215    bytes: &[u8],
216    half_start: usize,
217    mask: u64,
218    i: &mut usize,
219    start: &mut usize,
220    len: usize,
221) {
222    let half_end = half_start + 8;
223    if mask == 0 {
224        *i = (*i).max(half_end);
225        return;
226    }
227    // A surrogate from the previous half may have consumed up to 2 bytes
228    // into this one; drop mask bits for those positions.
229    let mut m = mask & (!0u64 << ((*i - half_start) * 8));
230    // Single-bit fast path skips the loop's mask-clearing shift.
231    if m.count_ones() == 1 {
232        *i = half_start + (m.trailing_zeros() as usize) / 8;
233        process_byte(result, bytes, bytes[*i], i, start, len);
234        *i = (*i).max(half_end);
235        return;
236    }
237    while m != 0 {
238        *i = half_start + (m.trailing_zeros() as usize) / 8;
239        process_byte(result, bytes, bytes[*i], i, start, len);
240        // checked_shl handles consumed >= 8 (shift >= 64) by zeroing m.
241        let consumed = *i - half_start;
242        m &= (!0u64).checked_shl((consumed as u32) * 8).unwrap_or(0);
243    }
244    *i = (*i).max(half_end);
245}
246
247pub fn escape_json_string(result: &mut String, bytes: &[u8]) {
248    escape_json_string_simple(result, bytes);
249}
250
251#[cfg(test)]
252mod tests {
253    use crate::json::escape::escape_json;
254
255    #[test]
256    fn escape_json_simple() {
257        assert_eq!(escape_json(b"Hello, World!"), "Hello, World!");
258    }
259
260    #[test]
261    fn escape_json_quotes() {
262        assert_eq!(escape_json(b"\"quoted\""), "\\\"quoted\\\"");
263    }
264
265    #[test]
266    fn escape_json_backslash() {
267        assert_eq!(escape_json(b"back\\slash"), "back\\\\slash");
268    }
269
270    #[test]
271    fn escape_json_newline() {
272        assert_eq!(escape_json(b"line\nbreak"), "line\\nbreak");
273    }
274
275    #[test]
276    fn escape_json_tab() {
277        assert_eq!(escape_json(b"tab\tcharacter"), "tab\\tcharacter");
278    }
279
280    #[test]
281    fn escape_json_unicode() {
282        assert_eq!(
283            escape_json("unicode: \u{1F609}".as_bytes()),
284            "unicode: \u{1F609}"
285        );
286    }
287
288    #[test]
289    fn escape_json_special_characters() {
290        assert_eq!(
291            escape_json(b"!@#$%^&*()_+-=[]{}|;':,.<>?/"),
292            "!@#$%^&*()_+-=[]{}|;':,.<>?/"
293        );
294    }
295
296    #[test]
297    fn escape_json_mixed_characters() {
298        assert_eq!(
299            escape_json(b"123\"\"45678901\"234567"),
300            "123\\\"\\\"45678901\\\"234567"
301        );
302    }
303
304    // WTF-8 lone surrogate sequences — emitted by QuickJS when a String contains
305    // lone surrogate code points (e.g. from JSON.stringify("\uD800")). These must
306    // be escaped as `\uXXXX` even though they're not valid UTF-8.
307    #[test]
308    fn escape_json_lone_surrogate() {
309        // U+D800 in WTF-8 is 0xED 0xA0 0x80.
310        assert_eq!(escape_json(&[0xED, 0xA0, 0x80]), "\\ud800");
311    }
312
313    #[test]
314    fn escape_json_lone_surrogate_with_context() {
315        // Make sure surrogates at different alignments (within, across chunk
316        // boundaries) are handled correctly.
317        let mut input = b"abcdefg".to_vec(); // 7 bytes before surrogate
318        input.extend_from_slice(&[0xED, 0xBF, 0xBF]); // U+DFFF
319        input.extend_from_slice(b"xyz");
320        assert_eq!(escape_json(&input), "abcdefg\\udfffxyz");
321    }
322
323    #[test]
324    fn escape_json_surrogate_at_chunk_boundary() {
325        // Surrogate starts at byte index 6, spans past the 8-byte chunk boundary.
326        let mut input = b"abcdef".to_vec(); // 6 bytes
327        input.extend_from_slice(&[0xED, 0xA0, 0x80]); // U+D800, ends at index 9
328        input.extend_from_slice(b"xyz123456789");
329        let expected = "abcdef\\ud800xyz123456789";
330        assert_eq!(escape_json(&input), expected);
331    }
332
333    #[test]
334    fn escape_json_korean_passthrough() {
335        // Valid Korean Hangul (U+D6C8 "훈") is encoded 0xED 0x9B 0x88 — the
336        // second byte has high nibble 0x90 < 0xA0 so it must NOT be escaped.
337        let s = "훈훈훈";
338        assert_eq!(escape_json(s.as_bytes()), s);
339    }
340}