Skip to content

Commit eeb2030

Browse files
Use JSON.parse instead of literal JS for callback formatting (#1370)
Co-authored-by: Lucas Fernandes Nogueira <lucas@tauri.studio>
1 parent 9ce0569 commit eeb2030

File tree

2 files changed

+110
-12
lines changed

2 files changed

+110
-12
lines changed

.changes/json-parse-rpc.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"tauri-api": patch
3+
---
4+
5+
Use ``JSON.parse(String.raw`{arg}`)`` for communicating serialized JSON objects and arrays < 1 GB to the Webview from Rust.
6+
7+
https://github.com/GoogleChromeLabs/json-parse-benchmark

tauri-api/src/rpc.rs

+103-12
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,72 @@
11
use serde::Serialize;
22
use serde_json::Value as JsonValue;
33

4+
/// The information about this is quite limited. On Chrome/Edge and Firefox, [the maximum string size is approximately 1 GB](https://stackoverflow.com/a/34958490).
5+
///
6+
/// [From MDN:](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length#description)
7+
///
8+
/// ECMAScript 2016 (ed. 7) established a maximum length of 2^53 - 1 elements. Previously, no maximum length was specified.
9+
///
10+
/// In Firefox, strings have a maximum length of 2\*\*30 - 2 (~1GB). In versions prior to Firefox 65, the maximum length was 2\*\*28 - 1 (~256MB).
11+
pub const MAX_JSON_STR_LEN: usize = usize::pow(2, 30) - 2;
12+
13+
/// Safely transforms & escapes a JSON String -> JSON.parse('{json}')
14+
// Single quotes are the fastest string for the JavaScript engine to build.
15+
// Directly transforming the string byte-by-byte is faster than a double String::replace()
16+
pub fn escape_json_parse(mut json: String) -> String {
17+
const BACKSLASH_BYTE: u8 = b'\\';
18+
const SINGLE_QUOTE_BYTE: u8 = b'\'';
19+
20+
// Safety:
21+
//
22+
// Directly mutating the bytes of a String is considered unsafe because you could end
23+
// up inserting invalid UTF-8 into the String.
24+
//
25+
// In this case, we are working with single-byte \ (backslash) and ' (single quotes),
26+
// and only INSERTING a backslash in the position proceeding it, which is safe to do.
27+
//
28+
// Note the debug assertion that checks whether the String is valid UTF-8.
29+
// In the test below this assertion will fail if the emojis in the test strings cause problems.
30+
31+
let bytes: &mut Vec<u8> = unsafe { json.as_mut_vec() };
32+
let mut i = 0;
33+
while i < bytes.len() {
34+
let byte = bytes[i];
35+
if matches!(byte, BACKSLASH_BYTE | SINGLE_QUOTE_BYTE) {
36+
bytes.insert(i, BACKSLASH_BYTE);
37+
i += 1;
38+
}
39+
i += 1;
40+
}
41+
42+
debug_assert!(String::from_utf8(bytes.to_vec()).is_ok());
43+
44+
format!("JSON.parse('{}')", json)
45+
}
46+
47+
#[test]
48+
fn test_escape_json_parse() {
49+
let dangerous_json = String::from(
50+
r#"{"test":"don\\🚀🐱‍👤\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don't forget to escape me!","test3":"\\🚀🐱‍👤\\\\'''\\\\🚀🐱‍👤\\\\🚀🐱‍👤\\'''''"}"#,
51+
);
52+
53+
let definitely_escaped_dangerous_json = format!(
54+
"JSON.parse('{}')",
55+
dangerous_json.replace('\\', "\\\\").replace('\'', "\\'")
56+
);
57+
let escape_single_quoted_json_test = escape_json_parse(dangerous_json);
58+
59+
let result = r#"JSON.parse('{"test":"don\\\\🚀🐱‍👤\\\\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don\'t forget to escape me!","test3":"\\\\🚀🐱‍👤\\\\\\\\\'\'\'\\\\\\\\🚀🐱‍👤\\\\\\\\🚀🐱‍👤\\\\\'\'\'\'\'"}')"#;
60+
assert_eq!(definitely_escaped_dangerous_json, result);
61+
assert_eq!(escape_single_quoted_json_test, result);
62+
}
63+
464
/// Formats a function name and argument to be evaluated as callback.
565
///
66+
/// This will serialize primitive JSON types (e.g. booleans, strings, numbers, etc.) as JavaScript literals,
67+
/// but will serialize arrays and objects whose serialized JSON string is smaller than 1 GB as `JSON.parse('...')`
68+
/// https://github.com/GoogleChromeLabs/json-parse-benchmark
69+
///
670
/// # Examples
771
/// ```
872
/// use tauri_api::rpc::format_callback;
@@ -22,20 +86,43 @@ use serde_json::Value as JsonValue;
2286
/// let cb = format_callback("callback-function-name", serde_json::to_value(&MyResponse {
2387
/// value: "some value".to_string()
2488
/// }).expect("failed to serialize"));
25-
/// assert!(cb.contains(r#"window["callback-function-name"]({"value":"some value"})"#));
89+
/// assert!(cb.contains(r#"window["callback-function-name"](JSON.parse('{"value":"some value"}'))"#));
2690
/// ```
2791
pub fn format_callback<T: Into<JsonValue>, S: AsRef<str>>(function_name: S, arg: T) -> String {
28-
format!(
29-
r#"
30-
if (window["{fn}"]) {{
31-
window["{fn}"]({arg})
32-
}} else {{
33-
console.warn("[TAURI] Couldn't find callback id {fn} in window. This happens when the app is reloaded while Rust is running an asynchronous operation.")
34-
}}
35-
"#,
36-
fn = function_name.as_ref(),
37-
arg = arg.into().to_string()
38-
)
92+
macro_rules! format_callback {
93+
( $arg:expr ) => {
94+
format!(
95+
r#"
96+
if (window["{fn}"]) {{
97+
window["{fn}"]({arg})
98+
}} else {{
99+
console.warn("[TAURI] Couldn't find callback id {fn} in window. This happens when the app is reloaded while Rust is running an asynchronous operation.")
100+
}}
101+
"#,
102+
fn = function_name.as_ref(),
103+
arg = $arg
104+
)
105+
}
106+
}
107+
108+
let json_value = arg.into();
109+
110+
// We should only use JSON.parse('{arg}') if it's an array or object.
111+
// We likely won't get any performance benefit from other data types.
112+
if matches!(json_value, JsonValue::Array(_) | JsonValue::Object(_)) {
113+
let as_str = json_value.to_string();
114+
115+
// Explicitly drop json_value to avoid storing both the Rust "JSON" and serialized String JSON in memory twice, as <T: Display>.tostring() takes a reference.
116+
drop(json_value);
117+
118+
format_callback!(if as_str.len() < MAX_JSON_STR_LEN {
119+
escape_json_parse(as_str)
120+
} else {
121+
as_str
122+
})
123+
} else {
124+
format_callback!(json_value)
125+
}
39126
}
40127

41128
/// Formats a Result type to its Promise response.
@@ -85,6 +172,10 @@ mod test {
85172
// call format callback
86173
let fc = format_callback(f.clone(), a.clone());
87174
fc.contains(&format!(
175+
r#"window["{}"](JSON.parse('{}'))"#,
176+
f,
177+
serde_json::Value::String(a.clone()),
178+
)) || fc.contains(&format!(
88179
r#"window["{}"]({})"#,
89180
f,
90181
serde_json::Value::String(a),

0 commit comments

Comments
 (0)