1use std::path::Path;
4use std::sync::Arc;
5
6use lanekeep_core::limits::{Budget, Limits, RunClock, Trip};
7use lanekeep_lang::Language;
8use rquickjs::context::intrinsic;
9use rquickjs::promise::PromiseState;
10use rquickjs::{CatchResultExt, Context, Ctx, FromJs, Module, Runtime};
11
12use crate::error::SandboxError;
13use crate::host::{HostContext, ReduceContext};
14use crate::loader::{LoadedModules, RuleLoader, RuleResolver, RuleRoot};
15
16type SandboxedIntrinsics = (
34 intrinsic::Eval,
35 intrinsic::RegExpCompiler,
36 intrinsic::RegExp,
37 intrinsic::Json,
38 intrinsic::Proxy,
39 intrinsic::MapSet,
40 intrinsic::TypedArrays,
41 intrinsic::Promise,
42);
43
44const BOOTSTRAP: &str = r"
54 'use strict';
55 delete Math.random;
56 delete globalThis.SharedArrayBuffer;
57 delete globalThis.Atomics;
58";
59
60pub struct Sandbox {
66 runtime: Runtime,
67 context: Context,
68 limits: Limits,
69 budget: Arc<Budget>,
70 loaded: Option<LoadedModules>,
71}
72
73impl std::fmt::Debug for Sandbox {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("Sandbox")
76 .field("limits", &self.limits)
77 .finish_non_exhaustive()
78 }
79}
80
81impl Sandbox {
82 pub fn new(limits: Limits, clock: Arc<RunClock>) -> Result<Self, SandboxError> {
89 let runtime = Runtime::new().map_err(|e| SandboxError::Engine(e.to_string()))?;
90 runtime.set_memory_limit(limits.memory_bytes);
91
92 let context = Context::custom::<SandboxedIntrinsics>(&runtime)
93 .map_err(|e| SandboxError::Engine(e.to_string()))?;
94
95 context.with(|ctx| {
101 ctx.eval::<(), _>(BOOTSTRAP)
102 .catch(&ctx)
103 .map_err(|e| SandboxError::Engine(format!("bootstrap failed: {e}")))
104 })?;
105
106 let budget = Budget::new(clock);
107 let handler_budget = Arc::clone(&budget);
108 runtime.set_interrupt_handler(Some(Box::new(move || handler_budget.should_interrupt())));
109
110 Ok(Self {
111 runtime,
112 context,
113 limits,
114 budget,
115 loaded: None,
116 })
117 }
118
119 pub fn with_modules(
125 limits: Limits,
126 clock: Arc<RunClock>,
127 root: RuleRoot,
128 typescript: Arc<dyn Language>,
129 javascript: Arc<dyn Language>,
130 ) -> Result<Self, SandboxError> {
131 let mut sandbox = Self::new(limits, clock)?;
132 let loader = RuleLoader::new(root.clone(), typescript, javascript);
133 sandbox.loaded = Some(loader.loaded());
134 sandbox.runtime.set_loader(RuleResolver::new(root), loader);
135 Ok(sandbox)
136 }
137
138 #[must_use]
143 pub fn loaded_modules(&self) -> Option<&LoadedModules> {
144 self.loaded.as_ref()
145 }
146
147 pub fn import_default<T>(&self, path: &Path) -> Result<T, SandboxError>
154 where
155 T: for<'js> FromJs<'js>,
156 {
157 self.budget.arm(self.limits.rule_timeout);
158 let outcome = self.context.with(|ctx| {
159 let promise = match Module::import(&ctx, path.display().to_string()) {
160 Ok(promise) => promise,
161 Err(err) => return Err(capture_failure(&ctx, &err)),
162 };
163
164 while promise.state() == PromiseState::Pending && ctx.execute_pending_job() {}
168
169 match promise.finish::<rquickjs::Object<'_>>() {
170 Ok(namespace) => namespace
171 .get::<_, T>("default")
172 .map_err(|err| capture_failure(&ctx, &err)),
173 Err(err) => Err(capture_failure(&ctx, &err)),
174 }
175 });
176 self.budget.disarm();
177
178 outcome.map_err(|raw| self.classify(&raw, self.limits.rule_timeout))
179 }
180
181 pub fn with_limits(limits: Limits) -> Result<Self, SandboxError> {
189 let clock = RunClock::start(limits.global_timeout);
190 Self::new(limits, clock)
191 }
192
193 #[must_use]
195 pub const fn limits(&self) -> &Limits {
196 &self.limits
197 }
198
199 pub fn eval<T>(&self, source: &str) -> Result<T, SandboxError>
206 where
207 T: for<'js> FromJs<'js>,
208 {
209 self.eval_with_timeout(source, self.limits.rule_timeout)
210 }
211
212 pub fn eval_with_timeout<T>(
219 &self,
220 source: &str,
221 timeout: std::time::Duration,
222 ) -> Result<T, SandboxError>
223 where
224 T: for<'js> FromJs<'js>,
225 {
226 self.budget.arm(timeout);
230 let outcome = self.context.with(|ctx| match ctx.eval::<T, _>(source) {
231 Ok(value) => Ok(value),
232 Err(err) => Err(capture_failure(&ctx, &err)),
233 });
234 self.budget.disarm();
235
236 outcome.map_err(|raw| self.classify(&raw, timeout))
240 }
241
242 pub fn eval_module(&self, name: &str, source: &str) -> Result<(), SandboxError> {
252 self.budget.arm(self.limits.rule_timeout);
253 let outcome = self.context.with(|ctx| {
254 let promise = match Module::evaluate(ctx.clone(), name, source) {
255 Ok(promise) => promise,
256 Err(err) => return Err(capture_failure(&ctx, &err)),
257 };
258 while promise.state() == PromiseState::Pending && ctx.execute_pending_job() {}
259 promise
260 .finish::<()>()
261 .map_err(|err| capture_failure(&ctx, &err))
262 });
263 self.budget.disarm();
264
265 outcome.map_err(|raw| self.classify(&raw, self.limits.rule_timeout))
266 }
267
268 pub fn eval_with_host<T>(&self, host: &HostContext, source: &str) -> Result<T, SandboxError>
274 where
275 T: for<'js> FromJs<'js>,
276 {
277 self.eval_with_host_timeout(host, source, self.limits.rule_timeout)
278 }
279
280 pub fn eval_with_host_timeout<T>(
286 &self,
287 host: &HostContext,
288 source: &str,
289 timeout: std::time::Duration,
290 ) -> Result<T, SandboxError>
291 where
292 T: for<'js> FromJs<'js>,
293 {
294 self.budget.arm(timeout);
295 let outcome = self.context.with(|ctx| {
296 let object = match host.build(&ctx) {
297 Ok(object) => object,
298 Err(err) => return Err(capture_failure(&ctx, &err)),
299 };
300 if let Err(err) = ctx.globals().set("ctx", object) {
301 return Err(capture_failure(&ctx, &err));
302 }
303 match ctx.eval::<T, _>(source) {
304 Ok(value) => Ok(value),
305 Err(err) => Err(capture_failure(&ctx, &err)),
306 }
307 });
308 self.budget.disarm();
309
310 outcome.map_err(|raw| self.classify(&raw, timeout))
311 }
312
313 pub fn eval_with_reduce_host<T>(
324 &self,
325 host: &ReduceContext,
326 source: &str,
327 timeout: std::time::Duration,
328 ) -> Result<T, SandboxError>
329 where
330 T: for<'js> FromJs<'js>,
331 {
332 self.budget.arm(timeout);
333 let outcome = self.context.with(|ctx| {
334 let object = match host.build(&ctx) {
335 Ok(object) => object,
336 Err(err) => return Err(capture_failure(&ctx, &err)),
337 };
338 if let Err(err) = ctx.globals().set("ctx", object) {
339 return Err(capture_failure(&ctx, &err));
340 }
341 match ctx.eval::<T, _>(source) {
342 Ok(value) => Ok(value),
343 Err(err) => Err(capture_failure(&ctx, &err)),
344 }
345 });
346 self.budget.disarm();
347
348 outcome.map_err(|raw| self.classify(&raw, timeout))
349 }
350
351 fn classify(&self, raw: &RawFailure, timeout: std::time::Duration) -> SandboxError {
353 match self.budget.take_trip() {
358 Some(Trip::Run) => {
359 return SandboxError::RunTimeout {
360 budget: self.budget.clock().global_timeout(),
361 elapsed: self.budget.clock().elapsed(),
362 };
363 }
364 Some(Trip::Rule) => return SandboxError::RuleTimeout { budget: timeout },
365 None => {}
366 }
367
368 let (message, stack, was_error_object) = match raw {
369 RawFailure::Engine(detail) => return SandboxError::Engine(detail.clone()),
370 RawFailure::Exception {
371 message,
372 stack,
373 was_error_object,
374 } => (message, stack, *was_error_object),
375 };
376
377 let used = u64::try_from(self.runtime.memory_usage().malloc_size).unwrap_or(0);
381 let ceiling = u64::try_from(self.limits.memory_bytes).unwrap_or(u64::MAX);
382 let at_ceiling = ceiling > 0 && used.saturating_mul(10) >= ceiling.saturating_mul(9);
383
384 if at_ceiling && (!was_error_object || message.contains("out of memory")) {
385 return SandboxError::MemoryExceeded {
386 limit_bytes: self.limits.memory_bytes,
387 };
388 }
389 if !was_error_object {
390 return SandboxError::NonErrorThrown;
391 }
392
393 SandboxError::Script {
394 message: message.clone(),
395 stack: stack.clone(),
396 }
397 }
398}
399
400enum RawFailure {
404 Engine(String),
405 Exception {
406 message: String,
407 stack: Option<String>,
408 was_error_object: bool,
409 },
410}
411
412fn capture_failure(ctx: &Ctx<'_>, err: &rquickjs::Error) -> RawFailure {
413 if !matches!(err, rquickjs::Error::Exception) {
414 return RawFailure::Engine(err.to_string());
415 }
416
417 let caught = ctx.catch();
418 caught.as_exception().map_or_else(
419 || RawFailure::Exception {
420 message: String::new(),
421 stack: None,
422 was_error_object: false,
423 },
424 |exception| RawFailure::Exception {
425 message: exception.message().unwrap_or_default(),
426 stack: exception.stack(),
427 was_error_object: true,
428 },
429 )
430}
431
432#[cfg(test)]
433mod tests {
434 use std::time::Duration;
435
436 use super::*;
437
438 fn sandbox() -> Sandbox {
439 Sandbox::with_limits(Limits::default()).expect("sandbox builds")
440 }
441
442 fn type_of(sandbox: &Sandbox, expression: &str) -> String {
444 sandbox
445 .eval::<String>(&format!("typeof ({expression})"))
446 .unwrap_or_else(|e| {
447 let _ = e;
449 "undefined".to_owned()
450 })
451 }
452
453 #[test]
454 fn evaluates_ordinary_javascript() {
455 let s = sandbox();
456 assert_eq!(s.eval::<i32>("1 + 1").expect("evaluates"), 2);
457 assert_eq!(
458 s.eval::<String>("[3,1,2].sort().join('-')")
459 .expect("evaluates"),
460 "1-2-3"
461 );
462 assert_eq!(
463 s.eval::<i32>("function add(a,b){return a+b}; add(20, 22)")
464 .expect("evaluates"),
465 42
466 );
467 }
468
469 #[test]
470 fn keeps_what_rules_actually_need() {
471 let s = sandbox();
472 for global in [
473 "JSON", "RegExp", "Map", "Set", "Promise", "Proxy", "BigInt", "Math",
474 ] {
475 assert_ne!(
476 type_of(&s, global),
477 "undefined",
478 "{global} should be available"
479 );
480 }
481 assert_eq!(
482 s.eval::<String>(r"JSON.stringify({a:1})").expect("json"),
483 "{\"a\":1}"
484 );
485 assert!(s.eval::<bool>(r"/^ab+c$/.test('abbbc')").expect("regexp"));
486 }
487
488 #[test]
491 fn there_is_no_filesystem_or_process_access() {
492 let s = sandbox();
493 for global in [
494 "fs",
495 "process",
496 "require",
497 "child_process",
498 "module",
499 "__dirname",
500 "Deno",
501 "Bun",
502 ] {
503 assert_eq!(type_of(&s, global), "undefined", "{global} must not exist");
504 }
505 }
506
507 #[test]
508 fn there_is_no_network_access() {
509 let s = sandbox();
514 for global in [
515 "fetch",
516 "XMLHttpRequest",
517 "WebSocket",
518 "navigator",
519 "Request",
520 "Response",
521 "Headers",
522 ] {
523 assert_eq!(type_of(&s, global), "undefined", "{global} must not exist");
524 }
525 }
526
527 #[test]
528 fn there_are_no_timers() {
529 let s = sandbox();
536 for global in [
537 "setTimeout",
538 "setInterval",
539 "setImmediate",
540 "requestAnimationFrame",
541 "clearTimeout",
542 "clearInterval",
543 ] {
544 assert_eq!(type_of(&s, global), "undefined", "{global} must not exist");
545 }
546 }
547
548 #[test]
549 fn there_is_no_ambient_output_or_environment() {
550 let s = sandbox();
560 for global in ["console", "location", "self", "WorkerLocation"] {
561 assert_eq!(type_of(&s, global), "undefined", "{global} must not exist");
562 }
563 }
564
565 #[test]
568 fn there_is_no_clock() {
569 let s = sandbox();
577 assert_eq!(type_of(&s, "Date"), "undefined", "Date must not exist");
578 assert_eq!(
579 type_of(&s, "performance"),
580 "undefined",
581 "performance must not exist"
582 );
583 assert_eq!(
584 type_of(&s, "Performance"),
585 "undefined",
586 "Performance must not exist"
587 );
588 }
589
590 #[test]
591 fn there_is_no_randomness() {
592 let s = sandbox();
600 assert_eq!(
601 type_of(&s, "Math.random"),
602 "undefined",
603 "Math.random must be gone"
604 );
605 assert_eq!(type_of(&s, "crypto"), "undefined", "crypto must not exist");
606 for global in ["Crypto", "SubtleCrypto", "CryptoKey"] {
610 assert_eq!(type_of(&s, global), "undefined", "{global} must not exist");
611 }
612 }
613
614 #[test]
615 fn garbage_collection_timing_is_not_observable() {
616 let s = sandbox();
619 assert_eq!(type_of(&s, "WeakRef"), "undefined");
620 assert_eq!(type_of(&s, "FinalizationRegistry"), "undefined");
621 }
622
623 #[test]
624 fn shared_memory_primitives_are_absent() {
625 let s = sandbox();
626 assert_eq!(type_of(&s, "SharedArrayBuffer"), "undefined");
627 assert_eq!(type_of(&s, "Atomics"), "undefined");
628 }
629
630 #[test]
631 fn the_clock_cannot_be_reached_through_a_prototype_chain() {
632 let s = sandbox();
637 let escapes = [
638 "typeof globalThis.Date",
639 "typeof Object.getPrototypeOf(Object).constructor.Date",
640 "typeof Reflect.get(globalThis, 'Date')",
641 "typeof Object.getOwnPropertyDescriptor(globalThis, 'Date')",
642 "typeof new Proxy({}, {}).Date",
643 ];
644 for probe in escapes {
645 let result = s
646 .eval::<String>(probe)
647 .unwrap_or_else(|_| "undefined".to_owned());
648 assert_eq!(result, "undefined", "reached a clock via: {probe}");
649 }
650
651 if let Ok(kind) = s.eval::<String>("typeof (new Function('return typeof Date'))()") {
655 assert_eq!(kind, "string");
656 }
657 let evaluated = s.eval::<String>("(new Function('return typeof Date'))()");
658 if let Ok(value) = evaluated {
659 assert_eq!(value, "undefined", "Function constructor reached a Date");
660 }
661 }
662
663 #[test]
664 fn deleting_random_does_not_break_the_rest_of_math() {
665 let s = sandbox();
666 assert_eq!(s.eval::<i32>("Math.max(1, 5, 3)").expect("max"), 5);
667 assert_eq!(s.eval::<i32>("Math.floor(2.7)").expect("floor"), 2);
668 assert_eq!(s.eval::<i32>("Math.abs(-4)").expect("abs"), 4);
669 }
670
671 #[test]
674 fn sandbox_a_rule_that_never_terminates_is_stopped() {
675 let s =
676 Sandbox::with_limits(Limits::default().with_rule_timeout(Duration::from_millis(120)))
677 .expect("sandbox builds");
678
679 let err = s
680 .eval::<()>("while (true) {}")
681 .expect_err("must be stopped");
682 assert!(
683 matches!(err, SandboxError::RuleTimeout { .. }),
684 "expected a rule timeout, got {err:?}"
685 );
686 assert!(err.is_limit_breach());
687 }
688
689 #[test]
690 fn sandbox_a_tight_allocation_loop_is_stopped() {
691 let s = Sandbox::with_limits(
692 Limits::default()
693 .with_memory_bytes(2 * 1024 * 1024)
694 .with_rule_timeout(Duration::from_secs(10)),
695 )
696 .expect("sandbox builds");
697
698 let err = s
699 .eval::<()>("const a = []; for (;;) { a.push(new Array(5000).fill(1)); }")
700 .expect_err("must be stopped");
701
702 assert!(
703 matches!(err, SandboxError::MemoryExceeded { .. }),
704 "expected a memory breach, got {err:?}"
705 );
706 assert!(err.is_limit_breach());
707 }
708
709 #[test]
710 fn sandbox_the_run_budget_stops_execution_even_with_a_generous_rule_budget() {
711 let clock = RunClock::start(Duration::ZERO);
714 let s = Sandbox::new(
715 Limits::default().with_rule_timeout(Duration::from_hours(1)),
716 clock,
717 )
718 .expect("sandbox builds");
719
720 let err = s
721 .eval::<()>("while (true) {}")
722 .expect_err("must be stopped");
723 assert!(
724 matches!(err, SandboxError::RunTimeout { .. }),
725 "expected a run timeout, got {err:?}"
726 );
727 }
728
729 #[test]
730 fn sandbox_survives_a_breach_and_keeps_working() {
731 let s =
735 Sandbox::with_limits(Limits::default().with_rule_timeout(Duration::from_millis(80)))
736 .expect("sandbox builds");
737
738 assert!(s.eval::<()>("while (true) {}").is_err());
739 assert_eq!(s.eval::<i32>("1 + 1").expect("still usable"), 2);
740 }
741
742 #[test]
743 fn sandbox_a_breach_is_not_reported_twice() {
744 let s =
747 Sandbox::with_limits(Limits::default().with_rule_timeout(Duration::from_millis(80)))
748 .expect("sandbox builds");
749
750 assert!(matches!(
751 s.eval::<()>("while (true) {}"),
752 Err(SandboxError::RuleTimeout { .. })
753 ));
754 assert!(
755 s.eval::<i32>("2 + 2").is_ok(),
756 "the next invocation must start clean"
757 );
758 }
759
760 #[test]
761 fn sandbox_a_per_rule_budget_overrides_the_default() {
762 let s = Sandbox::with_limits(Limits::default().with_rule_timeout(Duration::from_hours(1)))
763 .expect("sandbox builds");
764
765 let err = s
766 .eval_with_timeout::<()>("while (true) {}", Duration::from_millis(80))
767 .expect_err("the explicit budget applies");
768 assert!(matches!(err, SandboxError::RuleTimeout { .. }), "{err:?}");
769 }
770
771 #[test]
774 fn a_thrown_error_is_reported_with_its_message() {
775 let s = sandbox();
776 let err = s
777 .eval::<()>("throw new TypeError('rule blew up')")
778 .expect_err("throws");
779
780 match err {
781 SandboxError::Script { message, .. } => assert_eq!(message, "rule blew up"),
782 other => panic!("expected a script error, got {other:?}"),
783 }
784 }
785
786 #[test]
787 fn a_syntax_error_is_reported_as_a_rule_problem_not_an_engine_one() {
788 let s = sandbox();
789 let err = s
790 .eval::<()>("this is not javascript")
791 .expect_err("does not parse");
792 assert!(matches!(err, SandboxError::Script { .. }), "{err:?}");
793 assert!(!err.is_limit_breach());
794 }
795
796 #[test]
797 fn a_thrown_error_carries_a_stack() {
798 let s = sandbox();
799 let err = s
800 .eval::<()>(
801 "function inner(){ throw new Error('deep') } function outer(){ inner() } outer()",
802 )
803 .expect_err("throws");
804
805 match err {
806 SandboxError::Script { stack, .. } => {
807 let stack = stack.unwrap_or_default();
808 assert!(
809 stack.contains("inner"),
810 "stack should name the frames: {stack}"
811 );
812 }
813 other => panic!("expected a script error, got {other:?}"),
814 }
815 }
816}