Skip to main content

xript_runtime/
sandbox.rs

1use rquickjs::function::Rest;
2use rquickjs::{Context, Ctx, Function, Object, Runtime, Value};
3use std::collections::{HashMap, HashSet};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use crate::error::{Result, XriptError};
9use crate::manifest::{Binding, Manifest, NamespaceBinding};
10
11pub type HostFn =
12    Arc<dyn Fn(&[serde_json::Value]) -> std::result::Result<serde_json::Value, String> + Send + Sync>;
13
14pub struct HostBindings {
15    bindings: HashMap<String, HostBinding>,
16}
17
18enum HostBinding {
19    Function(HostFn),
20    Namespace(HashMap<String, HostFn>),
21}
22
23impl HostBindings {
24    pub fn new() -> Self {
25        Self {
26            bindings: HashMap::new(),
27        }
28    }
29
30    pub fn add_function<F>(&mut self, name: impl Into<String>, f: F)
31    where
32        F: Fn(&[serde_json::Value]) -> std::result::Result<serde_json::Value, String>
33            + Send
34            + Sync
35            + 'static,
36    {
37        self.bindings
38            .insert(name.into(), HostBinding::Function(Arc::new(f)));
39    }
40
41    pub fn add_namespace(&mut self, name: impl Into<String>, members: HashMap<String, HostFn>) {
42        self.bindings
43            .insert(name.into(), HostBinding::Namespace(members));
44    }
45}
46
47impl Default for HostBindings {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53pub struct ConsoleHandler {
54    pub log: Box<dyn Fn(&str) + Send + Sync>,
55    pub warn: Box<dyn Fn(&str) + Send + Sync>,
56    pub error: Box<dyn Fn(&str) + Send + Sync>,
57}
58
59impl Default for ConsoleHandler {
60    fn default() -> Self {
61        Self {
62            log: Box::new(|_| {}),
63            warn: Box::new(|_| {}),
64            error: Box::new(|_| {}),
65        }
66    }
67}
68
69pub struct RuntimeOptions {
70    pub host_bindings: HostBindings,
71    pub capabilities: Vec<String>,
72    pub console: ConsoleHandler,
73}
74
75#[derive(Debug)]
76pub struct ExecutionResult {
77    pub value: serde_json::Value,
78    pub duration_ms: f64,
79}
80
81pub struct XriptRuntime {
82    rt: Runtime,
83    ctx: Context,
84    manifest: Manifest,
85}
86
87impl std::fmt::Debug for XriptRuntime {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("XriptRuntime")
90            .field("manifest_name", &self.manifest.name)
91            .finish_non_exhaustive()
92    }
93}
94
95impl XriptRuntime {
96    pub fn new(manifest: Manifest, options: RuntimeOptions) -> Result<Self> {
97        crate::manifest::validate_structure(&manifest)?;
98
99        let rt = Runtime::new().map_err(|e| XriptError::Engine(e.to_string()))?;
100
101        if let Some(ref limits) = manifest.limits {
102            if let Some(memory_mb) = limits.memory_mb {
103                rt.set_memory_limit(memory_mb as usize * 1024 * 1024);
104            }
105            if let Some(stack) = limits.max_stack_depth {
106                rt.set_max_stack_size(stack * 1024);
107            }
108        }
109
110        let ctx = Context::full(&rt).map_err(|e| XriptError::Engine(e.to_string()))?;
111
112        let granted: HashSet<String> = options.capabilities.into_iter().collect();
113
114        ctx.with(|ctx| -> Result<()> {
115            remove_dangerous_globals(&ctx)?;
116            register_console(&ctx, options.console)?;
117            register_bindings(&ctx, &manifest, options.host_bindings, &granted)?;
118            register_hooks(&ctx, &manifest, &granted)?;
119            register_fragment_hooks(&ctx)?;
120            Ok(())
121        })?;
122
123        Ok(Self { rt, ctx, manifest })
124    }
125
126    pub fn execute(&self, code: &str) -> Result<ExecutionResult> {
127        let timeout_ms = self
128            .manifest
129            .limits
130            .as_ref()
131            .and_then(|l| l.timeout_ms)
132            .unwrap_or(5000);
133
134        let interrupted = Arc::new(AtomicBool::new(false));
135        let interrupted_clone = interrupted.clone();
136        let start = Instant::now();
137        let deadline = start + Duration::from_millis(timeout_ms);
138
139        self.rt
140            .set_interrupt_handler(Some(Box::new(move || {
141                if Instant::now() >= deadline {
142                    interrupted_clone.store(true, Ordering::Relaxed);
143                    true
144                } else {
145                    false
146                }
147            }) as Box<dyn FnMut() -> bool + Send>));
148
149        let result = self.ctx.with(|ctx| {
150            let res: std::result::Result<Value, _> = ctx.eval(code);
151            match res {
152                Ok(val) => {
153                    let json = js_value_to_json(&ctx, &val);
154                    Ok(json)
155                }
156                Err(_) => {
157                    if interrupted.load(Ordering::Relaxed) {
158                        Err(XriptError::ExecutionLimit {
159                            limit: "timeout_ms".into(),
160                        })
161                    } else {
162                        let msg: std::result::Result<String, _> =
163                            ctx.eval("(() => { try { throw undefined; } catch(e) { return String(e); } })()");
164                        let error_msg = msg.unwrap_or_else(|_| "unknown script error".into());
165                        Err(XriptError::Script(error_msg))
166                    }
167                }
168            }
169        });
170
171        self.rt
172            .set_interrupt_handler(None::<Box<dyn FnMut() -> bool + Send>>);
173
174        let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
175
176        result.map(|value| ExecutionResult { value, duration_ms })
177    }
178
179    pub fn manifest(&self) -> &Manifest {
180        &self.manifest
181    }
182
183    pub fn load_mod(
184        &self,
185        mod_manifest_json: &str,
186        fragment_sources: HashMap<String, String>,
187        granted_capabilities: &HashSet<String>,
188    ) -> Result<crate::fragment::ModInstance> {
189        crate::fragment::load_mod(
190            mod_manifest_json,
191            &self.manifest,
192            granted_capabilities,
193            &fragment_sources,
194        )
195    }
196
197    pub fn fire_fragment_hook(
198        &self,
199        fragment_id: &str,
200        lifecycle: &str,
201        bindings: Option<&serde_json::Value>,
202    ) -> Result<Vec<serde_json::Value>> {
203        let bindings_json = match bindings {
204            Some(b) => serde_json::to_string(b).unwrap_or("{}".into()),
205            None => "{}".into(),
206        };
207
208        let code = format!(
209            r#"(function() {{
210                var handlers = globalThis.__xript_fragment_handlers || {{}};
211                var key = "fragment:{lifecycle}:{fid}";
212                var list = handlers[key] || [];
213                var results = [];
214                var bindingsObj = JSON.parse('{bindings_json}');
215                for (var i = 0; i < list.length; i++) {{
216                    var ops = [];
217                    var proxy = {{
218                        toggle: function(sel, cond) {{ ops.push({{ op: "toggle", selector: sel, value: !!cond }}); }},
219                        addClass: function(sel, cls) {{ ops.push({{ op: "addClass", selector: sel, value: cls }}); }},
220                        removeClass: function(sel, cls) {{ ops.push({{ op: "removeClass", selector: sel, value: cls }}); }},
221                        setText: function(sel, txt) {{ ops.push({{ op: "setText", selector: sel, value: txt }}); }},
222                        setAttr: function(sel, attr, val) {{ ops.push({{ op: "setAttr", selector: sel, attr: attr, value: val }}); }},
223                        replaceChildren: function(sel, html) {{ ops.push({{ op: "replaceChildren", selector: sel, value: html }}); }}
224                    }};
225                    list[i](bindingsObj, proxy);
226                    results.push(ops);
227                }}
228                return JSON.stringify(results);
229            }})()"#,
230            lifecycle = lifecycle,
231            fid = fragment_id,
232            bindings_json = bindings_json.replace('\'', "\\'"),
233        );
234
235        let result = self.execute(&code)?;
236        match serde_json::from_str::<Vec<serde_json::Value>>(
237            result.value.as_str().unwrap_or("[]"),
238        ) {
239            Ok(ops) => Ok(ops),
240            Err(_) => Ok(vec![]),
241        }
242    }
243}
244
245fn remove_dangerous_globals(ctx: &Ctx<'_>) -> Result<()> {
246    let script = r#"
247        delete globalThis.eval;
248        if (typeof globalThis.Function !== 'undefined') {
249            Object.defineProperty(globalThis, 'Function', {
250                get: function() { throw new Error("Function constructor is not permitted. Dynamic code generation is disabled in xript."); },
251                configurable: false
252            });
253        }
254    "#;
255    ctx.eval::<(), _>(script)
256        .map_err(|e| XriptError::Engine(e.to_string()))?;
257    Ok(())
258}
259
260fn register_console(ctx: &Ctx<'_>, console: ConsoleHandler) -> Result<()> {
261    let console_obj = Object::new(ctx.clone()).map_err(|e| XriptError::Engine(e.to_string()))?;
262
263    let log = Arc::new(console.log);
264    let log_clone = log.clone();
265    let log_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
266        let msg = args.0.join(" ");
267        log_clone(&msg);
268    })
269    .map_err(|e| XriptError::Engine(e.to_string()))?;
270
271    let warn = Arc::new(console.warn);
272    let warn_clone = warn.clone();
273    let warn_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
274        let msg = args.0.join(" ");
275        warn_clone(&msg);
276    })
277    .map_err(|e| XriptError::Engine(e.to_string()))?;
278
279    let error = Arc::new(console.error);
280    let error_clone = error.clone();
281    let error_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
282        let msg = args.0.join(" ");
283        error_clone(&msg);
284    })
285    .map_err(|e| XriptError::Engine(e.to_string()))?;
286
287    console_obj
288        .set("log", log_fn)
289        .map_err(|e| XriptError::Engine(e.to_string()))?;
290    console_obj
291        .set("warn", warn_fn)
292        .map_err(|e| XriptError::Engine(e.to_string()))?;
293    console_obj
294        .set("error", error_fn)
295        .map_err(|e| XriptError::Engine(e.to_string()))?;
296
297    ctx.globals()
298        .set("console", console_obj)
299        .map_err(|e| XriptError::Engine(e.to_string()))?;
300
301    Ok(())
302}
303
304fn make_throwing_function<'js>(ctx: &Ctx<'js>, message: &str) -> Result<Function<'js>> {
305    let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
306    let script = format!("(function() {{ throw new Error(\"{}\"); }})", escaped);
307    ctx.eval::<Function, _>(script.as_str())
308        .map_err(|e| XriptError::Engine(e.to_string()))
309}
310
311fn register_bindings(
312    ctx: &Ctx<'_>,
313    manifest: &Manifest,
314    host_bindings: HostBindings,
315    granted: &HashSet<String>,
316) -> Result<()> {
317    let Some(ref bindings) = manifest.bindings else {
318        return Ok(());
319    };
320
321    for (name, binding) in bindings {
322        match binding {
323            Binding::Function(func_def) => {
324                if let Some(ref cap) = func_def.capability {
325                    if !granted.contains(cap) {
326                        let msg = format!(
327                            "{}() requires the \"{}\" capability, which hasn't been granted to this script",
328                            name, cap
329                        );
330                        let deny_fn = make_throwing_function(ctx, &msg)?;
331                        ctx.globals()
332                            .set(name.as_str(), deny_fn)
333                            .map_err(|e| XriptError::Engine(e.to_string()))?;
334                        continue;
335                    }
336                }
337
338                match host_bindings.bindings.get(name) {
339                    Some(HostBinding::Function(f)) => {
340                        let js_fn = create_host_function(ctx, name, f.clone())?;
341                        ctx.globals()
342                            .set(name.as_str(), js_fn)
343                            .map_err(|e| XriptError::Engine(e.to_string()))?;
344                    }
345                    _ => {
346                        let msg = format!("host binding '{}' is not provided", name);
347                        let missing_fn = make_throwing_function(ctx, &msg)?;
348                        ctx.globals()
349                            .set(name.as_str(), missing_fn)
350                            .map_err(|e| XriptError::Engine(e.to_string()))?;
351                    }
352                }
353            }
354            Binding::Namespace(ns_def) => {
355                register_namespace_binding(ctx, name, ns_def, &host_bindings, granted)?;
356            }
357        }
358    }
359
360    Ok(())
361}
362
363fn register_namespace_binding(
364    ctx: &Ctx<'_>,
365    name: &str,
366    ns_def: &NamespaceBinding,
367    host_bindings: &HostBindings,
368    granted: &HashSet<String>,
369) -> Result<()> {
370    let ns_obj = Object::new(ctx.clone()).map_err(|e| XriptError::Engine(e.to_string()))?;
371
372    let host_ns = match host_bindings.bindings.get(name) {
373        Some(HostBinding::Namespace(members)) => Some(members),
374        _ => None,
375    };
376
377    for (member_name, member_binding) in &ns_def.members {
378        if let Binding::Function(func_def) = member_binding {
379            let full_name = format!("{}.{}", name, member_name);
380
381            if let Some(ref cap) = func_def.capability {
382                if !granted.contains(cap) {
383                    let msg = format!(
384                        "{}() requires the \"{}\" capability, which hasn't been granted to this script",
385                        full_name, cap
386                    );
387                    let deny_fn = make_throwing_function(ctx, &msg)?;
388                    ns_obj
389                        .set(member_name.as_str(), deny_fn)
390                        .map_err(|e| XriptError::Engine(e.to_string()))?;
391                    continue;
392                }
393            }
394
395            if let Some(host_members) = host_ns {
396                if let Some(f) = host_members.get(member_name) {
397                    let js_fn = create_host_function(ctx, &full_name, f.clone())?;
398                    ns_obj
399                        .set(member_name.as_str(), js_fn)
400                        .map_err(|e| XriptError::Engine(e.to_string()))?;
401                    continue;
402                }
403            }
404
405            let msg = format!("host binding '{}' is not provided", full_name);
406            let missing_fn = make_throwing_function(ctx, &msg)?;
407            ns_obj
408                .set(member_name.as_str(), missing_fn)
409                .map_err(|e| XriptError::Engine(e.to_string()))?;
410        }
411    }
412
413    ctx.globals()
414        .set(name, ns_obj)
415        .map_err(|e| XriptError::Engine(e.to_string()))?;
416
417    let freeze_script = format!(
418        "Object.freeze(globalThis['{}'])",
419        name.replace('\'', "\\'")
420    );
421    ctx.eval::<(), _>(freeze_script.as_str())
422        .map_err(|e| XriptError::Engine(e.to_string()))?;
423
424    Ok(())
425}
426
427fn register_hooks(ctx: &Ctx<'_>, manifest: &Manifest, granted: &HashSet<String>) -> Result<()> {
428    let Some(ref hooks) = manifest.hooks else {
429        return Ok(());
430    };
431
432    if hooks.is_empty() {
433        return Ok(());
434    }
435
436    let mut hook_setup = String::from("globalThis.__xript_hooks = {};\n");
437    hook_setup.push_str("globalThis.hooks = {};\n");
438
439    for (hook_name, hook_def) in hooks {
440        hook_setup.push_str(&format!(
441            "globalThis.__xript_hooks['{}'] = [];\n",
442            hook_name
443        ));
444
445        if let Some(ref phases) = hook_def.phases {
446            if !phases.is_empty() {
447                hook_setup.push_str(&format!("globalThis.hooks['{}'] = {{}};\n", hook_name));
448                for phase in phases {
449                    let registration = if let Some(ref cap) = hook_def.capability {
450                        if !granted.contains(cap) {
451                            format!(
452                                "globalThis.hooks['{hook}']['{phase}'] = function() {{ throw new Error(\"{hook}.{phase}() requires the \\\"{cap}\\\" capability\"); }};",
453                                hook = hook_name, phase = phase, cap = cap
454                            )
455                        } else {
456                            format!(
457                                "globalThis.hooks['{hook}']['{phase}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ phase: '{phase}', handler: handler }}); }};",
458                                hook = hook_name, phase = phase
459                            )
460                        }
461                    } else {
462                        format!(
463                            "globalThis.hooks['{hook}']['{phase}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ phase: '{phase}', handler: handler }}); }};",
464                            hook = hook_name, phase = phase
465                        )
466                    };
467                    hook_setup.push_str(&registration);
468                    hook_setup.push('\n');
469                }
470            }
471        } else {
472            let registration = if let Some(ref cap) = hook_def.capability {
473                if !granted.contains(cap) {
474                    format!(
475                        "globalThis.hooks['{hook}'] = function() {{ throw new Error(\"{hook}() requires the \\\"{cap}\\\" capability\"); }};",
476                        hook = hook_name, cap = cap
477                    )
478                } else {
479                    format!(
480                        "globalThis.hooks['{hook}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ handler: handler }}); }};",
481                        hook = hook_name
482                    )
483                }
484            } else {
485                format!(
486                    "globalThis.hooks['{hook}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ handler: handler }}); }};",
487                    hook = hook_name
488                )
489            };
490            hook_setup.push_str(&registration);
491            hook_setup.push('\n');
492        }
493    }
494
495    hook_setup.push_str("Object.freeze(globalThis.hooks);\n");
496
497    ctx.eval::<(), _>(hook_setup.as_str())
498        .map_err(|e| XriptError::Engine(e.to_string()))?;
499
500    Ok(())
501}
502
503fn register_fragment_hooks(ctx: &Ctx<'_>) -> Result<()> {
504    let script = r#"
505        globalThis.__xript_fragment_handlers = {};
506
507        var existingHooks = {};
508        if (typeof globalThis.hooks === 'object' && globalThis.hooks !== null) {
509            var hookKeys = Object.getOwnPropertyNames(globalThis.hooks);
510            for (var i = 0; i < hookKeys.length; i++) {
511                existingHooks[hookKeys[i]] = globalThis.hooks[hookKeys[i]];
512            }
513        }
514
515        var fragmentNs = {};
516        var lifecycles = ['mount', 'unmount', 'update', 'suspend', 'resume'];
517        for (var j = 0; j < lifecycles.length; j++) {
518            (function(lifecycle) {
519                fragmentNs[lifecycle] = function(fragmentId, handler) {
520                    var key = "fragment:" + lifecycle + ":" + fragmentId;
521                    if (!globalThis.__xript_fragment_handlers[key]) {
522                        globalThis.__xript_fragment_handlers[key] = [];
523                    }
524                    globalThis.__xript_fragment_handlers[key].push(handler);
525                };
526            })(lifecycles[j]);
527        }
528        Object.freeze(fragmentNs);
529        existingHooks.fragment = fragmentNs;
530
531        globalThis.hooks = existingHooks;
532        Object.freeze(globalThis.hooks);
533    "#;
534
535    ctx.eval::<(), _>(script)
536        .map_err(|e| XriptError::Engine(e.to_string()))?;
537
538    Ok(())
539}
540
541fn create_host_function<'js>(
542    ctx: &Ctx<'js>,
543    name: &str,
544    f: HostFn,
545) -> Result<Function<'js>> {
546    let bridge_fn = Function::new(ctx.clone(), move |args_json: String| -> String {
547        let args: Vec<serde_json::Value> = match serde_json::from_str(&args_json) {
548            Ok(a) => a,
549            Err(e) => {
550                let err = serde_json::json!({"__xript_err": format!("invalid args: {}", e)});
551                return serde_json::to_string(&err).unwrap();
552            }
553        };
554        match f(&args) {
555            Ok(result) => {
556                let wrapped = serde_json::json!({"__xript_ok": result});
557                serde_json::to_string(&wrapped).unwrap_or("{\"__xript_ok\":null}".into())
558            }
559            Err(msg) => {
560                let err = serde_json::json!({"__xript_err": msg});
561                serde_json::to_string(&err).unwrap()
562            }
563        }
564    })
565    .map_err(|e| {
566        XriptError::Engine(format!("failed to create host function '{}': {}", name, e))
567    })?;
568
569    ctx.globals()
570        .set("__xript_tmp_bridge", bridge_fn)
571        .map_err(|e| XriptError::Engine(e.to_string()))?;
572
573    let wrapper: Function = ctx.eval(
574        "(function(bridge) { return function() { var args = Array.prototype.slice.call(arguments); var raw = bridge(JSON.stringify(args)); var envelope = JSON.parse(raw); if (envelope.__xript_err !== undefined) { throw new Error(envelope.__xript_err); } return envelope.__xript_ok; }; })(__xript_tmp_bridge)",
575    )
576    .map_err(|e| XriptError::Engine(e.to_string()))?;
577
578    ctx.eval::<(), _>("delete globalThis.__xript_tmp_bridge")
579        .map_err(|e| XriptError::Engine(e.to_string()))?;
580
581    Ok(wrapper)
582}
583
584fn js_value_to_json(ctx: &Ctx<'_>, val: &Value<'_>) -> serde_json::Value {
585    if val.is_undefined() || val.is_null() {
586        return serde_json::Value::Null;
587    }
588
589    if let Some(b) = val.as_bool() {
590        return serde_json::Value::Bool(b);
591    }
592
593    if let Some(n) = val.as_int() {
594        return serde_json::json!(n);
595    }
596
597    if let Some(n) = val.as_float() {
598        if n.is_finite() {
599            return serde_json::json!(n);
600        }
601        return serde_json::Value::Null;
602    }
603
604    if let Some(s) = val.as_string() {
605        if let Ok(s) = s.to_string() {
606            return serde_json::Value::String(s);
607        }
608    }
609
610    let stringify_result: std::result::Result<String, _> = ctx.eval(
611        "((v) => JSON.stringify(v))",
612    );
613    if let Ok(stringify_fn_str) = stringify_result {
614        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&stringify_fn_str) {
615            return v;
616        }
617    }
618
619    serde_json::Value::Null
620}