js_sandbox_ios/
script.rs

1// Copyright (c) 2020-2023 js-sandbox contributors. Zlib license.
2
3use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::fmt::{Debug, Formatter};
6use std::path::Path;
7use std::rc::Rc;
8use std::{thread, time::Duration};
9
10use deno_core::anyhow::Context;
11use deno_core::v8::{Global, Value};
12use deno_core::{op2, serde_v8, v8, Extension, FastString, JsBuffer, JsRuntime, OpDecl, OpState};
13use serde::de::DeserializeOwned;
14use serde::{Deserialize, Serialize};
15
16use crate::{AnyError, CallArgs, JsError, JsValue};
17
18pub trait JsApi<'a> {
19	/// Generate an API from a script
20	fn from_script(script: &'a mut Script) -> Self
21	where
22		Self: Sized;
23}
24
25/// Represents a single JavaScript file that can be executed.
26///
27/// The code can be loaded from a file or from a string in memory.
28/// A typical usage pattern is to load a file with one or more JS function definitions, and then call those functions from Rust.
29pub struct Script {
30	runtime: JsRuntime,
31	last_rid: u32,
32	timeout: Option<Duration>,
33	added_namespaces: BTreeMap<String, Global<Value>>,
34}
35
36impl Debug for Script {
37	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
38		f.debug_struct("Script")
39			.field("runtime", &"...")
40			.field("last_rid", &self.last_rid)
41			.field("timeout", &self.timeout)
42			.finish()
43	}
44}
45
46#[derive(Debug, Clone, Deserialize)]
47#[serde(untagged)]
48enum CallResult<R> {
49	Error { error: String },
50	Result(R),
51}
52
53impl Script {
54	const DEFAULT_FILENAME: &'static str = "sandboxed.js";
55
56	// ----------------------------------------------------------------------------------------------------------------------------------------------
57	// Constructors and builders
58
59	/// Initialize a script with the given JavaScript source code.
60	///
61	/// Returns a new object on success, and an error in case of syntax or initialization error with the code.
62	pub fn from_string(js_code: &str) -> Result<Self, JsError> {
63		// console.log() is not available by default -- add the most basic version with single argument (and no warn/info/... variants)
64		let all_code =
65			"const console = { log: function(expr) { Deno.core.print(expr + '\\n', false); } };"
66				.to_string() + js_code;
67
68		Self::create_script(all_code)
69	}
70
71	/// Initialize a script by loading it from a .js file.
72	///
73	/// To load a file at compile time, you can use [`Self::from_string()`] in combination with the [`include_str!`] macro.
74	/// At the moment, a script is limited to a single file, and you will need to do bundling yourself (e.g. with `esbuild`).
75	///
76	/// Returns a new object on success. Fails if the file cannot be opened or in case of syntax or initialization error with the code.
77	pub fn from_file(file: impl AsRef<Path>) -> Result<Self, JsError> {
78		match std::fs::read_to_string(file) {
79			Ok(js_code) => Self::create_script(js_code),
80			Err(e) => Err(JsError::Runtime(AnyError::from(e))),
81		}
82	}
83
84	pub fn new() -> Self {
85		const DECL: OpDecl = op_return();
86		let ext = Extension {
87			ops: Cow::Owned(vec![DECL]),
88			..Default::default()
89		};
90
91		let runtime = JsRuntime::new(deno_core::RuntimeOptions {
92			module_loader: Some(Rc::new(deno_core::FsModuleLoader)),
93			extensions: vec![ext],
94			..Default::default()
95		});
96
97		Script {
98			runtime,
99			last_rid: 0,
100			timeout: None,
101			added_namespaces: Default::default(),
102		}
103	}
104
105	pub fn add_script(
106		&mut self,
107		namespace: &str,
108		fn_name: &str,
109		js_code: &str,
110	) -> Result<(), JsError> {
111		if self.added_namespaces.contains_key(namespace) {
112			return Ok(());
113		}
114
115		let js_code = format!(
116			"
117			var {namespace} = (function() {{
118				{js_code}
119
120				return {{
121					{fn_name}: function (input) {{
122						try {{
123							return {fn_name}(input)
124						}} catch (e) {{
125							return {{ error: `${{e}}` }}
126						}}
127					}}
128				}}
129			}})();
130			{namespace}.{fn_name}
131		"
132		);
133
134		// We cannot provide a dynamic filename because execute_script() requires a &'static str
135		let global = self
136			.runtime
137			.execute_script(Self::DEFAULT_FILENAME, js_code)?;
138
139		self.added_namespaces.insert(namespace.to_string(), global);
140
141		Ok(())
142	}
143
144	/// Equips this script with a timeout, meaning that any function call is aborted after the specified duration.
145	///
146	/// This requires creating a separate thread for each function call, which tracks time and pulls the plug
147	/// if the JS function does not return in time. Use this for untrusted 3rd-party code, not if you know that
148	/// your functions always return.
149	///
150	/// Panics with invalid timeouts or if this script already has a timeout set.
151	pub fn with_timeout(mut self, timeout: Duration) -> Self {
152		assert!(self.timeout.is_none());
153		assert!(timeout > Duration::ZERO);
154
155		self.timeout = Some(timeout);
156		self
157	}
158
159	// ----------------------------------------------------------------------------------------------------------------------------------------------
160	// Call API
161
162	/// Invokes a JavaScript function.
163	///
164	/// Blocks on asynchronous functions until completion.
165	///
166	/// `args_tuple` needs to be a tuple.
167	///
168	/// Each tuple element is converted to JSON (using serde_json) and passed as a distinct argument to the JS function.
169	pub fn call<A, R>(&mut self, fn_name: &str, args_tuple: A) -> Result<R, JsError>
170	where
171		A: CallArgs,
172		R: DeserializeOwned,
173	{
174		let json_args = args_tuple.into_arg_string()?;
175		let json_result = self.call_impl(None, fn_name, json_args)?;
176		let result: R = serde_json::from_value(json_result)?;
177
178		Ok(result)
179	}
180
181	pub fn call_namespace<A, R>(&mut self, namespace: &str, arg: A) -> Result<R, JsError>
182	where
183		A: Serialize,
184		R: DeserializeOwned,
185	{
186		deno_core::futures::executor::block_on(self.runtime.run_event_loop(Default::default()))?;
187
188		let Some(global) = self.added_namespaces.get(namespace) else {
189			return Err(JsError::Runtime(AnyError::msg(
190				"Failed to get namespace function",
191			)));
192		};
193		let scope = &mut self.runtime.handle_scope();
194		let scope = &mut v8::HandleScope::new(scope);
195		let input = serde_v8::to_v8(scope, arg).with_context(|| "Could not serialize arg")?;
196		let local = v8::Local::new(scope, global);
197		let func = v8::Local::<v8::Function>::try_from(local)
198			.with_context(|| "Could not create function out of local")?;
199		let Some(func_res) = func.call(scope, local, &[input]) else {
200			return Err(JsError::Runtime(AnyError::msg("Failed to call func")));
201		};
202		let deserialized_value = serde_v8::from_v8::<serde_json::Value>(scope, func_res)
203			.with_context(|| "Could not serialize func res")?;
204		let sanitized_value = Self::sanitize_number(deserialized_value)?;
205		let result: CallResult<R> = serde_json::from_value(sanitized_value)?;
206		match result {
207			CallResult::Error { error } => Err(JsError::Runtime(AnyError::msg(error))),
208			CallResult::Result(r) => Ok(r),
209		}
210	}
211
212	fn sanitize_number(value: serde_json::Value) -> Result<serde_json::Value, JsError> {
213		match value {
214			serde_json::Value::Number(number) => {
215				if number.is_f64() {
216					let f = number.as_f64().ok_or_else(|| {
217						JsError::Runtime(AnyError::msg("Failed to convert number to f64"))
218					})?;
219
220					if f.fract() == 0.0 {
221						return Ok(serde_json::Value::Number(serde_json::Number::from(
222							f as i64,
223						)));
224					}
225
226					Ok(serde_json::Value::Number(
227						serde_json::Number::from_f64(f).ok_or_else(|| {
228							JsError::Runtime(AnyError::msg("Failed to convert f64 to number"))
229						})?,
230					))
231				} else if number.is_u64() {
232					Ok(serde_json::Value::Number(
233						number
234							.as_i64()
235							.ok_or_else(|| {
236								JsError::Runtime(AnyError::msg("Failed to convert number to i64"))
237							})?
238							.into(),
239					))
240				} else if number.is_i64() {
241					Ok(serde_json::Value::Number(number))
242				} else {
243					Err(JsError::Runtime(AnyError::msg("Failed to convert number")))
244				}
245			}
246			serde_json::Value::Object(map) => {
247				let mut new_map = serde_json::Map::new();
248				for (key, value) in map {
249					new_map.insert(key, Self::sanitize_number(value)?);
250				}
251				Ok(serde_json::Value::Object(new_map))
252			}
253			serde_json::Value::Array(vec) => {
254				let mut new_vec = Vec::new();
255				for value in vec {
256					new_vec.push(Self::sanitize_number(value)?);
257				}
258				Ok(serde_json::Value::Array(new_vec))
259			}
260			_ => Ok(value),
261		}
262	}
263
264	pub fn bind_api<'a, A>(&'a mut self) -> A
265	where
266		A: JsApi<'a>,
267	{
268		A::from_script(self)
269	}
270
271	pub(crate) fn call_json(&mut self, fn_name: &str, args: &JsValue) -> Result<JsValue, JsError> {
272		self.call_impl(None, fn_name, args.to_string())
273	}
274
275	fn call_impl(
276		&mut self,
277		namespace: Option<&str>,
278		fn_name: &str,
279		json_args: String,
280	) -> Result<JsValue, JsError> {
281		deno_core::futures::executor::block_on(self.call_impl_async(namespace, fn_name, json_args))
282	}
283
284	async fn call_impl_async(
285		&mut self,
286		namespace: Option<&str>,
287		fn_name: &str,
288		json_args: String,
289	) -> Result<JsValue, JsError> {
290		// Note: ops() is required to initialize internal state
291		// Wrap everything in scoped block
292
293		let fn_name = if let Some(namespace) = namespace {
294			Cow::Owned(format!("{namespace}.{fn_name}"))
295		} else {
296			Cow::Borrowed(fn_name)
297		};
298
299		// 'undefined' will cause JSON serialization error, so it needs to be treated as null
300		let js_code: String = format!(
301			"(async () => {{
302				let __rust_result = {fn_name}.constructor.name === 'AsyncFunction'
303					? await {fn_name}({json_args})
304					: {fn_name}({json_args});
305
306				if (typeof __rust_result === 'undefined')
307					__rust_result = null;
308
309				Deno.core.ops.op_return(__rust_result);
310			}})()"
311		);
312
313		if let Some(timeout) = self.timeout {
314			let handle = self.runtime.v8_isolate().thread_safe_handle();
315
316			thread::spawn(move || {
317				thread::sleep(timeout);
318				handle.terminate_execution();
319			});
320		}
321
322		// syncing ops is required cause they sometimes change while preparing the engine
323		// self.runtime.sync_ops_cache();
324
325		// TODO use strongly typed JsError here (downcast)
326		self.runtime
327			.execute_script(Self::DEFAULT_FILENAME, js_code)?;
328
329		self.runtime.run_event_loop(Default::default()).await?;
330
331		let state_rc = self.runtime.op_state();
332		let mut state = state_rc.borrow_mut();
333		let table = &mut state.resource_table;
334
335		// Get resource, and free slot (no longer needed)
336		let entry: Result<Rc<ResultResource>, deno_core::anyhow::Error> = table.take(self.last_rid);
337
338		match entry {
339			Ok(entry) => {
340				let extracted = Rc::try_unwrap(entry);
341
342				if extracted.is_err() {
343					return Err(JsError::Runtime(AnyError::msg(
344						"Failed to unwrap resource entry",
345					)));
346				}
347
348				let extracted = extracted.unwrap();
349
350				self.last_rid += 1;
351
352				Ok(extracted.json_value)
353			}
354			Err(e) => Err(JsError::Runtime(AnyError::from(e))),
355		}
356	}
357
358	fn create_script<S>(js_code: S) -> Result<Self, JsError>
359	where
360		S: Into<FastString>,
361	{
362		let mut script = Self::new();
363		script
364			.runtime
365			.execute_script(Self::DEFAULT_FILENAME, js_code.into())?;
366		Ok(script)
367	}
368}
369
370#[derive(Debug)]
371struct ResultResource {
372	json_value: JsValue,
373}
374
375// Type that is stored inside Deno's resource table
376impl deno_core::Resource for ResultResource {
377	fn name(&self) -> Cow<str> {
378		"__rust_Result".into()
379	}
380}
381
382#[op2]
383#[serde]
384fn op_return(
385	state: &mut OpState,
386	#[serde] args: JsValue,
387	#[buffer] _buf: Option<JsBuffer>,
388) -> Result<JsValue, deno_core::error::AnyError> {
389	let entry = ResultResource { json_value: args };
390	let resource_table = &mut state.resource_table;
391	let _rid = resource_table.add(entry);
392	Ok(serde_json::Value::Null)
393}