Skip to main content

hyperlight_js/sandbox/
js_sandbox.rs

1/*
2Copyright 2026  The Hyperlight Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8    http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16use std::collections::HashMap;
17use std::fmt::Debug;
18use std::sync::Arc;
19
20use hyperlight_host::sandbox::snapshot::Snapshot;
21use hyperlight_host::{new_error, MultiUseSandbox, Result};
22use tracing::{instrument, Level};
23
24use super::loaded_js_sandbox::LoadedJSSandbox;
25use crate::sandbox::metrics::SandboxMetricsGuard;
26use crate::Script;
27
28/// A Hyperlight Sandbox with a JavaScript run time loaded but no guest code.
29pub struct JSSandbox {
30    pub(super) inner: MultiUseSandbox,
31    handlers: HashMap<String, Script>,
32    // Snapshot of state before any handlers are added.
33    // This is used to restore state back to a neutral JSSandbox.
34    snapshot: Arc<Snapshot>,
35    // metric drop guard to manage sandbox metric
36    _metric_guard: SandboxMetricsGuard<JSSandbox>,
37}
38
39impl JSSandbox {
40    #[instrument(err(Debug), skip(inner), level=Level::INFO)]
41    pub(super) fn new(mut inner: MultiUseSandbox) -> Result<Self> {
42        let snapshot = inner.snapshot()?;
43        Ok(Self {
44            inner,
45            handlers: HashMap::new(),
46            snapshot,
47            _metric_guard: SandboxMetricsGuard::new(),
48        })
49    }
50
51    /// Creates a new `JSSandbox` from a `MultiUseSandbox` and a `Snapshot` of state before any handlers were added.
52    pub(crate) fn from_loaded(
53        mut loaded: MultiUseSandbox,
54        snapshot: Arc<Snapshot>,
55    ) -> Result<Self> {
56        loaded.restore(snapshot.clone())?;
57        Ok(Self {
58            inner: loaded,
59            handlers: HashMap::new(),
60            snapshot,
61            _metric_guard: SandboxMetricsGuard::new(),
62        })
63    }
64
65    /// Adds a new handler function to the sandboxes collection of handlers. This Handler will be
66    /// available to the host to call once `get_loaded_sandbox` is called.
67    #[instrument(err(Debug), skip(self, script), level=Level::DEBUG)]
68    pub fn add_handler<F>(&mut self, function_name: F, script: Script) -> Result<()>
69    where
70        F: Into<String> + std::fmt::Debug,
71    {
72        let function_name = function_name.into();
73        if function_name.is_empty() {
74            return Err(new_error!("Handler name must not be empty"));
75        }
76        if self.handlers.contains_key(&function_name) {
77            return Err(new_error!(
78                "Handler already exists for function name: {}",
79                function_name
80            ));
81        }
82
83        self.handlers.insert(function_name, script);
84        Ok(())
85    }
86
87    /// Removes a handler function from the sandboxes collection of handlers.
88    #[instrument(err(Debug), skip(self), level=Level::DEBUG)]
89    pub fn remove_handler(&mut self, function_name: &str) -> Result<()> {
90        if function_name.is_empty() {
91            return Err(new_error!("Handler name must not be empty"));
92        }
93        match self.handlers.remove(function_name) {
94            Some(_) => Ok(()),
95            None => Err(new_error!(
96                "Handler does not exist for function name: {}",
97                function_name
98            )),
99        }
100    }
101
102    /// Clears all handlers from the sandbox.
103    #[instrument(skip_all, level=Level::TRACE)]
104    pub fn clear_handlers(&mut self) {
105        self.handlers.clear();
106    }
107
108    /// Returns whether the sandbox is currently poisoned.
109    ///
110    /// A poisoned sandbox is in an inconsistent state due to the guest not running to completion.
111    /// This can happen when guest execution is interrupted (e.g., via `InterruptHandle::kill()`),
112    /// when the guest panics, or when memory violations occur.
113    ///
114    pub fn poisoned(&self) -> bool {
115        self.inner.poisoned()
116    }
117
118    #[cfg(test)]
119    fn get_number_of_handlers(&self) -> usize {
120        self.handlers.len()
121    }
122
123    /// Creates a new `LoadedJSSandbox` with the handlers that have been added to this `JSSandbox`.
124    #[instrument(err(Debug), skip_all, level=Level::TRACE)]
125    pub fn get_loaded_sandbox(mut self) -> Result<LoadedJSSandbox> {
126        if self.handlers.is_empty() {
127            return Err(new_error!("No handlers have been added to the sandbox"));
128        }
129
130        let handlers = self.handlers.clone();
131        for (function_name, script) in handlers {
132            let content = script.content().to_owned();
133
134            let path = script
135                .base_path()
136                .map(|p| p.to_string_lossy().to_string())
137                .unwrap_or_default();
138            self.inner
139                .call::<()>("register_handler", (function_name, content, path))?;
140        }
141
142        LoadedJSSandbox::new(self.inner, self.snapshot)
143    }
144    /// Generate a crash dump of the current state of the VM underlying this sandbox.
145    ///
146    /// Creates an ELF core dump file that can be used for debugging. The dump
147    /// captures the current state of the sandbox including registers, memory regions,
148    /// and other execution context.
149    ///
150    /// The location of the core dump file is determined by the `HYPERLIGHT_CORE_DUMP_DIR`
151    /// environment variable. If not set, it defaults to the system's temporary directory.
152    ///
153    /// This is only available when the `crashdump` feature is enabled and then only if the sandbox
154    /// is also configured to allow core dumps (which is the default behavior).
155    ///
156    /// This can be useful for generating a crash dump from gdb when trying to debug issues in the
157    /// guest that dont cause crashes (e.g. a guest function that does not return)
158    ///
159    /// # Examples
160    ///
161    /// Attach to your running process with gdb and call this function:
162    ///
163    /// ```shell
164    /// sudo gdb -p <pid_of_your_process>
165    /// (gdb) info threads
166    /// # find the thread that is running the guest function you want to debug
167    /// (gdb) thread <thread_number>
168    /// # switch to the frame where you have access to your MultiUseSandbox instance
169    /// (gdb) backtrace
170    /// (gdb) frame <frame_number>
171    /// # get the pointer to your MultiUseSandbox instance
172    /// # Get the sandbox pointer
173    /// (gdb) print sandbox
174    /// # Call the crashdump function
175    /// call sandbox.generate_crashdump()
176    /// ```
177    /// The crashdump should be available in crash dump directory (see `HYPERLIGHT_CORE_DUMP_DIR` env var).
178    ///
179    #[cfg(feature = "crashdump")]
180    pub fn generate_crashdump(&self) -> Result<()> {
181        self.inner.generate_crashdump()
182    }
183}
184
185impl Debug for JSSandbox {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.debug_struct("JSSandbox")
188            .field("handlers", &self.handlers)
189            .finish()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::SandboxBuilder;
197
198    #[test]
199    fn test_add_handler() {
200        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
201        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
202        sandbox.add_handler("handler1", "script1".into()).unwrap();
203        sandbox.add_handler("handler2", "script2".into()).unwrap();
204
205        assert_eq!(sandbox.get_number_of_handlers(), 2);
206    }
207
208    #[test]
209    fn test_remove_handler() {
210        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
211        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
212        sandbox.add_handler("handler1", "script1".into()).unwrap();
213        sandbox.add_handler("handler2", "script2".into()).unwrap();
214
215        sandbox.remove_handler("handler1").unwrap();
216
217        assert_eq!(sandbox.get_number_of_handlers(), 1);
218    }
219
220    #[test]
221    fn test_clear_handlers() {
222        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
223        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
224        sandbox.add_handler("handler1", "script1".into()).unwrap();
225        sandbox.add_handler("handler2", "script2".into()).unwrap();
226
227        sandbox.clear_handlers();
228
229        assert_eq!(sandbox.get_number_of_handlers(), 0);
230    }
231
232    #[test]
233    fn test_get_loaded_sandbox() {
234        let proto_js_sandbox = SandboxBuilder::new().build().unwrap();
235        let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
236        sandbox
237            .add_handler(
238                "handler1",
239                Script::from_content(
240                    r#"function handler(event) {
241                    event.request.uri = "/redirected.html";
242                    return event
243                }"#,
244                ),
245            )
246            .unwrap();
247
248        let res = sandbox.get_loaded_sandbox();
249        assert!(res.is_ok());
250    }
251
252    // ── Auto-export heuristic tests (issue #39) ──────────────────────────
253    // The auto-export logic must only detect actual ES export statements,
254    // not the word "export" inside string literals, comments, or identifiers.
255
256    #[test]
257    fn handler_with_export_in_string_literal() {
258        // "export" appears inside a string — auto-export should still fire
259        let handler = Script::from_content(
260            r#"
261        function handler(event) {
262            const xml = '<config mode="export">value</config>';
263            return { result: xml };
264        }
265        "#,
266        );
267
268        let proto = SandboxBuilder::new().build().unwrap();
269        let mut sandbox = proto.load_runtime().unwrap();
270        sandbox.add_handler("handler", handler).unwrap();
271        let mut loaded = sandbox.get_loaded_sandbox().unwrap();
272
273        let res = loaded
274            .handle_event("handler", "{}".to_string(), None)
275            .unwrap();
276        assert_eq!(
277            res,
278            r#"{"result":"<config mode=\"export\">value</config>"}"#
279        );
280    }
281
282    #[test]
283    fn handler_with_export_in_comment() {
284        // "export" appears in a comment — auto-export should still fire
285        let handler = Script::from_content(
286            r#"
287        function handler(event) {
288            // TODO: export this data to CSV
289            return { result: 42 };
290        }
291        "#,
292        );
293
294        let proto = SandboxBuilder::new().build().unwrap();
295        let mut sandbox = proto.load_runtime().unwrap();
296        sandbox.add_handler("handler", handler).unwrap();
297        let mut loaded = sandbox.get_loaded_sandbox().unwrap();
298
299        let res = loaded
300            .handle_event("handler", "{}".to_string(), None)
301            .unwrap();
302        assert_eq!(res, r#"{"result":42}"#);
303    }
304
305    #[test]
306    fn handler_with_export_in_identifier() {
307        // "export" is part of an identifier — auto-export should still fire
308        let handler = Script::from_content(
309            r#"
310        function handler(event) {
311            const exportPath = "/tmp/out.csv";
312            return { result: exportPath };
313        }
314        "#,
315        );
316
317        let proto = SandboxBuilder::new().build().unwrap();
318        let mut sandbox = proto.load_runtime().unwrap();
319        sandbox.add_handler("handler", handler).unwrap();
320        let mut loaded = sandbox.get_loaded_sandbox().unwrap();
321
322        let res = loaded
323            .handle_event("handler", "{}".to_string(), None)
324            .unwrap();
325        assert_eq!(res, r#"{"result":"/tmp/out.csv"}"#);
326    }
327
328    #[test]
329    fn handler_with_explicit_export_is_not_doubled() {
330        // Script already has an export statement — auto-export should be skipped
331        let handler = Script::from_content(
332            r#"
333        function handler(event) {
334            return { result: "explicit" };
335        }
336        export { handler };
337        "#,
338        );
339
340        let proto = SandboxBuilder::new().build().unwrap();
341        let mut sandbox = proto.load_runtime().unwrap();
342        sandbox.add_handler("handler", handler).unwrap();
343        let mut loaded = sandbox.get_loaded_sandbox().unwrap();
344
345        let res = loaded
346            .handle_event("handler", "{}".to_string(), None)
347            .unwrap();
348        assert_eq!(res, r#"{"result":"explicit"}"#);
349    }
350
351    #[test]
352    fn handler_with_export_default_function() {
353        // `export function` — auto-export should be skipped
354        let handler = Script::from_content(
355            r#"
356        export function handler(event) {
357            return { result: "inline-export" };
358        }
359        "#,
360        );
361
362        let proto = SandboxBuilder::new().build().unwrap();
363        let mut sandbox = proto.load_runtime().unwrap();
364        sandbox.add_handler("handler", handler).unwrap();
365        let mut loaded = sandbox.get_loaded_sandbox().unwrap();
366
367        let res = loaded
368            .handle_event("handler", "{}".to_string(), None)
369            .unwrap();
370        assert_eq!(res, r#"{"result":"inline-export"}"#);
371    }
372}