Skip to content

Commit

Permalink
Use JSON.parse instead of literal JS for callback formatting (#1370)
Browse files Browse the repository at this point in the history
Co-authored-by: Lucas Fernandes Nogueira <lucas@tauri.studio>
  • Loading branch information
WilliamVenner and lucasfernog committed Apr 7, 2021
1 parent 9ce0569 commit eeb2030
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 12 deletions.
7 changes: 7 additions & 0 deletions .changes/json-parse-rpc.md
@@ -0,0 +1,7 @@
---
"tauri-api": patch
---

Use ``JSON.parse(String.raw`{arg}`)`` for communicating serialized JSON objects and arrays < 1 GB to the Webview from Rust.

https://github.com/GoogleChromeLabs/json-parse-benchmark
115 changes: 103 additions & 12 deletions tauri-api/src/rpc.rs
@@ -1,8 +1,72 @@
use serde::Serialize;
use serde_json::Value as JsonValue;

/// 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).
///
/// [From MDN:](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length#description)
///
/// ECMAScript 2016 (ed. 7) established a maximum length of 2^53 - 1 elements. Previously, no maximum length was specified.
///
/// 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).
pub const MAX_JSON_STR_LEN: usize = usize::pow(2, 30) - 2;

/// Safely transforms & escapes a JSON String -> JSON.parse('{json}')
// Single quotes are the fastest string for the JavaScript engine to build.
// Directly transforming the string byte-by-byte is faster than a double String::replace()
pub fn escape_json_parse(mut json: String) -> String {
const BACKSLASH_BYTE: u8 = b'\\';
const SINGLE_QUOTE_BYTE: u8 = b'\'';

// Safety:
//
// Directly mutating the bytes of a String is considered unsafe because you could end
// up inserting invalid UTF-8 into the String.
//
// In this case, we are working with single-byte \ (backslash) and ' (single quotes),
// and only INSERTING a backslash in the position proceeding it, which is safe to do.
//
// Note the debug assertion that checks whether the String is valid UTF-8.
// In the test below this assertion will fail if the emojis in the test strings cause problems.

let bytes: &mut Vec<u8> = unsafe { json.as_mut_vec() };
let mut i = 0;
while i < bytes.len() {
let byte = bytes[i];
if matches!(byte, BACKSLASH_BYTE | SINGLE_QUOTE_BYTE) {
bytes.insert(i, BACKSLASH_BYTE);
i += 1;
}
i += 1;
}

debug_assert!(String::from_utf8(bytes.to_vec()).is_ok());

format!("JSON.parse('{}')", json)
}

#[test]
fn test_escape_json_parse() {
let dangerous_json = String::from(
r#"{"test":"don\\🚀🐱‍👤\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don't forget to escape me!","test3":"\\🚀🐱‍👤\\\\'''\\\\🚀🐱‍👤\\\\🚀🐱‍👤\\'''''"}"#,
);

let definitely_escaped_dangerous_json = format!(
"JSON.parse('{}')",
dangerous_json.replace('\\', "\\\\").replace('\'', "\\'")
);
let escape_single_quoted_json_test = escape_json_parse(dangerous_json);

let result = r#"JSON.parse('{"test":"don\\\\🚀🐱‍👤\\\\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don\'t forget to escape me!","test3":"\\\\🚀🐱‍👤\\\\\\\\\'\'\'\\\\\\\\🚀🐱‍👤\\\\\\\\🚀🐱‍👤\\\\\'\'\'\'\'"}')"#;
assert_eq!(definitely_escaped_dangerous_json, result);
assert_eq!(escape_single_quoted_json_test, result);
}

/// Formats a function name and argument to be evaluated as callback.
///
/// This will serialize primitive JSON types (e.g. booleans, strings, numbers, etc.) as JavaScript literals,
/// but will serialize arrays and objects whose serialized JSON string is smaller than 1 GB as `JSON.parse('...')`
/// https://github.com/GoogleChromeLabs/json-parse-benchmark
///
/// # Examples
/// ```
/// use tauri_api::rpc::format_callback;
Expand All @@ -22,20 +86,43 @@ use serde_json::Value as JsonValue;
/// let cb = format_callback("callback-function-name", serde_json::to_value(&MyResponse {
/// value: "some value".to_string()
/// }).expect("failed to serialize"));
/// assert!(cb.contains(r#"window["callback-function-name"]({"value":"some value"})"#));
/// assert!(cb.contains(r#"window["callback-function-name"](JSON.parse('{"value":"some value"}'))"#));
/// ```
pub fn format_callback<T: Into<JsonValue>, S: AsRef<str>>(function_name: S, arg: T) -> String {
format!(
r#"
if (window["{fn}"]) {{
window["{fn}"]({arg})
}} else {{
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.")
}}
"#,
fn = function_name.as_ref(),
arg = arg.into().to_string()
)
macro_rules! format_callback {
( $arg:expr ) => {
format!(
r#"
if (window["{fn}"]) {{
window["{fn}"]({arg})
}} else {{
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.")
}}
"#,
fn = function_name.as_ref(),
arg = $arg
)
}
}

let json_value = arg.into();

// We should only use JSON.parse('{arg}') if it's an array or object.
// We likely won't get any performance benefit from other data types.
if matches!(json_value, JsonValue::Array(_) | JsonValue::Object(_)) {
let as_str = json_value.to_string();

// 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.
drop(json_value);

format_callback!(if as_str.len() < MAX_JSON_STR_LEN {
escape_json_parse(as_str)
} else {
as_str
})
} else {
format_callback!(json_value)
}
}

/// Formats a Result type to its Promise response.
Expand Down Expand Up @@ -85,6 +172,10 @@ mod test {
// call format callback
let fc = format_callback(f.clone(), a.clone());
fc.contains(&format!(
r#"window["{}"](JSON.parse('{}'))"#,
f,
serde_json::Value::String(a.clone()),
)) || fc.contains(&format!(
r#"window["{}"]({})"#,
f,
serde_json::Value::String(a),
Expand Down

0 comments on commit eeb2030

Please sign in to comment.