Skip to main content

lanekeep_js/
sandbox.rs

1//! The sandbox: a JavaScript runtime with no ambient authority and enforced budgets.
2
3use 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
16/// The intrinsics rule code gets.
17///
18/// This is an allowlist, and that is the point. Three of the engine's optional intrinsics
19/// are sources of nondeterminism, and leaving them out means there is no original for a
20/// rule to reach — nothing to patch, nothing to restore, no prototype chain leading back to
21/// the real thing:
22///
23/// - `Date` — `Date.now()` and `new Date()` read the system clock.
24/// - `Performance` — `performance.now()` is a clock by another name, and the one most
25///   likely to be forgotten when reasoning about "we removed `Date`".
26/// - `WeakRef` — `WeakRef` and `FinalizationRegistry` make garbage-collection timing
27///   observable, which is nondeterminism that does not look like a clock at all.
28///
29/// `Eval` is included, and has to be: the engine's own script evaluation depends on it, so
30/// omitting it makes the sandbox unable to run anything. It grants a rule no capability it
31/// lacks — a rule is already arbitrary code — though it does mean rule review cannot rely
32/// on reading the source alone.
33type 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
44/// Removes what the intrinsic allowlist cannot.
45///
46/// `Math.random` lives in the base objects, which are not optional, so it has to be deleted
47/// after the fact. `SharedArrayBuffer` and `Atomics` are useless without threads and are
48/// removed to keep the surface small rather than because a concrete attack is known.
49///
50/// Deleting is enough: a rule may define its own `Math.random`, but whatever it writes is
51/// its own code and therefore deterministic. What it cannot do is reach the engine's
52/// entropy source, because the only reference to it is gone.
53const BOOTSTRAP: &str = r"
54    'use strict';
55    delete Math.random;
56    delete globalThis.SharedArrayBuffer;
57    delete globalThis.Atomics;
58";
59
60/// A JavaScript runtime that rule code executes in.
61///
62/// Not `Sync`: the underlying engine runtime is single-threaded, so each rayon worker owns
63/// one. They share a [`RunClock`] so the global budget is measured once for the run rather
64/// than once per worker.
65pub 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    /// Build a sandbox sharing a run clock.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`SandboxError::Engine`] if the runtime cannot be created or the bootstrap
87    /// fails, both of which indicate a broken build rather than anything about a rule.
88    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        // The bootstrap runs before the interrupt handler is installed, deliberately. It is
96        // host code rather than rule code, and a sandbox has to be constructible even when
97        // the run budget is already spent — otherwise an expired run reports "could not
98        // build a sandbox" instead of the timeout it actually is, which sends the reader
99        // looking for a broken installation.
100        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    /// Build a sandbox that can load rule modules from a rules root.
120    ///
121    /// # Errors
122    ///
123    /// As [`Sandbox::new`].
124    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    /// Every module loaded so far, when this sandbox was built with a module root.
139    ///
140    /// The hash of the rule graph is derived from this, so it has to reflect what was
141    /// actually read rather than what the config named.
142    #[must_use]
143    pub fn loaded_modules(&self) -> Option<&LoadedModules> {
144        self.loaded.as_ref()
145    }
146
147    /// Import a rule module and return its default export.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`SandboxError`] on a breached budget, a module that fails to resolve or
152    /// load, or a module that throws while evaluating.
153    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            // The engine is synchronous and the loader does no I/O asynchronously, so an
165            // import settles during this call. Draining the job queue is still required —
166            // module evaluation is scheduled as a job rather than run inline.
167            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    /// Build a sandbox with its own run clock, starting now.
182    ///
183    /// For a single-threaded run or a test. A real run shares one clock across workers.
184    ///
185    /// # Errors
186    ///
187    /// As [`Sandbox::new`].
188    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    /// The budgets in force.
194    #[must_use]
195    pub const fn limits(&self) -> &Limits {
196        &self.limits
197    }
198
199    /// Evaluate source, enforcing the per-invocation budget.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`SandboxError`] on a breached budget or a thrown value. Every variant
204    /// cancels the run.
205    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    /// Evaluate source under an explicit per-invocation budget, for a rule that declared
213    /// its own.
214    ///
215    /// # Errors
216    ///
217    /// As [`Sandbox::eval`].
218    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        // Re-armed per invocation rather than left running. The engine polls the interrupt
227        // handler every so many instructions, so a stale deadline would let a short handler
228        // slip through while stopping a long one at an arbitrary point.
229        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        // Classification happens outside `with`, and has to: reading the runtime's memory
237        // usage while the context still holds its borrow panics on a double borrow. The
238        // split exists for that reason rather than for tidiness.
239        outcome.map_err(|raw| self.classify(&raw, timeout))
240    }
241
242    /// Evaluate a synthetic module under a chosen name.
243    ///
244    /// The name matters: the resolver treats it as the importing module's path, so a
245    /// synthetic entry has to sit inside the rules root for relative specifiers in its
246    /// source to resolve. Naming it outside would make every import look like an escape.
247    ///
248    /// # Errors
249    ///
250    /// As [`Sandbox::eval`].
251    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    /// Evaluate source with a `ctx` object in scope, the way a rule handler runs.
269    ///
270    /// # Errors
271    ///
272    /// As [`Sandbox::eval`].
273    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    /// Evaluate with `ctx` in scope under an explicit budget, for a rule that declared one.
281    ///
282    /// # Errors
283    ///
284    /// As [`Sandbox::eval`].
285    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    /// Evaluate with a reduce-phase `ctx` in scope, under an explicit budget.
314    ///
315    /// A separate entry point rather than a flag on the one above, because the two contexts
316    /// expose different surfaces on purpose — `facts` and `files` here, `emitFact` and the
317    /// tree there. A single builder that switched on a boolean would make it possible to
318    /// get the wrong one, which is precisely what must not happen.
319    ///
320    /// # Errors
321    ///
322    /// As [`Sandbox::eval`].
323    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    /// Turn a raw failure into something that says what actually happened.
352    fn classify(&self, raw: &RawFailure, timeout: std::time::Duration) -> SandboxError {
353        // Our own record first. The engine reports an interrupt as an ordinary Error whose
354        // message happens to read "interrupted", and keying off that string would make the
355        // difference between "looped forever" and "threw" depend on wording this project
356        // does not control.
357        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        // Memory exhaustion surfaces as an ordinary exception. The reliable tell is that
378        // the runtime is sitting at its ceiling, since the allocation that failed is the
379        // one that would have crossed it — so usage rests just below the line, not at it.
380        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
400/// What the engine reported, before deciding what it means.
401///
402/// Extracted inside the context borrow; interpreted outside it.
403enum 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    /// `typeof x` for a global, without tripping a `ReferenceError`.
443    fn type_of(sandbox: &Sandbox, expression: &str) -> String {
444        sandbox
445            .eval::<String>(&format!("typeof ({expression})"))
446            .unwrap_or_else(|e| {
447                // A ReferenceError here also means "absent", which is what the caller asked.
448                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    // --- absence of ambient authority ------------------------------------------------
489
490    #[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        // `fetch` is the capability; `Request`, `Response` and `Headers` are the fetch API's
510        // own constructors and go with it. The general data types it happens to use — `Blob`,
511        // `File`, `FormData`, `URL` — are deliberately not named: they carry no authority once
512        // `fetch` is gone, and this list is not an inventory of everything QuickJS lacks.
513        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        // Timers have no meaning in a synchronous single-pass engine, and `setTimeout`
530        // would be a scheduling primitive a rule could use to observe wall-clock time.
531        //
532        // The `clear*` half is inert once the `set*` half is gone, and is named anyway: half a
533        // pair reads as an oversight, and this list is what the component engine's own
534        // withholding is derived from.
535        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        // None of these has ever been in this engine, and the assertion is here because it is
551        // read: `crates/lanekeep-wasm/tests/js_globals.rs` derives what a WebAssembly rule
552        // component must delete from what this module says is absent, and StarlingMonkey ships
553        // all four.
554        //
555        // `console` is an output channel that goes nowhere — the component build disables
556        // stdio — and a rule that logs should fail the same way in both engines rather than in
557        // one. `location` and its `self`/`WorkerLocation` spellings describe a host
558        // environment a rule has no business observing.
559        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    // --- absence of nondeterminism -------------------------------------------------------
566
567    #[test]
568    fn there_is_no_clock() {
569        // Both of these, not just Date. `performance.now()` is the one that survives a
570        // reviewer thinking "we removed Date, so there is no clock".
571        //
572        // And `Performance` beside `performance`, one level further in: the instance is not
573        // the only way to a clock, since `Performance.prototype.now` is the method it fronts.
574        // Omitting `intrinsic::Performance` takes both away here; an engine that deleted only
575        // the lowercase one would have left the trap this comment is about.
576        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        // `type_of` and not a bare `eval`, though the two say the same thing here. This module's
593        // absence assertions are *read* — `crates/lanekeep-wasm/tests/js_globals.rs` extracts
594        // them and holds the component engine to the same set — and that extraction recognizes
595        // three shapes. An `assert_eq!(s.eval::<String>(...), "undefined", ...)` is a fourth,
596        // and a name added in it would be withheld here and reachable in a component with
597        // nothing going red. `type_of` evaluates `typeof (Math.random)`, so the dotted form
598        // needs nothing special.
599        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        // The constructor forms, on the same footing as `Performance` above: `crypto` is an
607        // instance of `Crypto`, and `Crypto.prototype.getRandomValues` is the entropy source
608        // it fronts.
609        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        // WeakRef and FinalizationRegistry expose when the collector ran, which is
617        // nondeterminism that does not look like a clock and so tends to be overlooked.
618        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        // The failure mode this guards against: removing a global while leaving a path
633        // back to the original through some object's constructor. Because `Date` is never
634        // installed rather than deleted afterwards, there is no original to find — but
635        // that is exactly the kind of claim worth checking rather than asserting.
636        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        // And the sharpest version: constructing one via the Function intrinsic.
652        // Constructing code at runtime must not find one either. An Err is equally fine:
653        // it means nothing was constructed at all.
654        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    // --- limits ----------------------------------------------------------------------------
672
673    #[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        // The backstop case: nothing about this invocation is pathological relative to its
712        // own budget, but the run is already over.
713        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        // A breach cancels the run, but the sandbox itself must not be left poisoned —
732        // otherwise the failure could not be reported, and tests could not assert on the
733        // state afterwards.
734        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        // If the trip record survived, the next invocation would be reported as timing out
745        // without having run at all.
746        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    // --- rule errors ------------------------------------------------------------------------
772
773    #[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}