Skip to main content

camel_language_js/engines/
boa.rs

1//! [`BoaEngine`] — JS engine backed by [Boa](https://boajs.dev).
2//!
3//! Evaluation and validation run on a dedicated `camel-js-worker` OS thread
4//! (one worker per limits-configuration; `BoaEngine` clones share the worker
5//! through an `Arc<OnceLock<..>>`). Calls enqueue a job over a bounded
6//! channel and block on a per-call reply channel.
7//!
8//! Per-eval realm strategy: evals run on the worker's ONE stable realm
9//! through an `eval(...)` wrapper with a bounded compiled-wrapper cache.
10//! Each evaluation receives fresh `camel` and `console` bindings and a
11//! fresh declarative environment; configurable global additions are
12//! removed, and a named integrity set (the `globalThis` own keys, the
13//! `eval` function, and the `Object`/`Array`/`Function` prototypes) is
14//! verified between evaluations, with the whole realm recycled on drift
15//! (see `worker.rs` and `integrity.rs`). JavaScript evaluations do
16//! NOT receive realm isolation: global properties, intrinsic state outside
17//! the named integrity set, heap state, and engine-internal state may
18//! survive across exchanges and routes until realm recycling or process
19//! termination — see the crate `CONTEXT.md` "Sandbox posture" section for
20//! the full contract.
21//!
22//! If the worker thread dies, the stale handle surfaces as
23//! `"JS worker unavailable"`; recreating the `JsLanguage` is the recovery
24//! path.
25//!
26//! If [`JsLimitsConfig`](camel_language_api::JsLimitsConfig) fields are `None`, the rust-camel runtime defaults apply:
27//!
28//! | Limit | Default |
29//! |---|---|
30//! | `execution_timeout_ms` | 5,000 ms |
31//! | `max_loop_iterations` | 100,000 (Boa upstream is `u64::MAX`) |
32//! | `max_recursion_depth` | 512 (Boa 0.21 upstream default, pinned) |
33//! | `max_stack_size` | 10,240 (Boa 0.21 upstream default, pinned) |
34//!
35//! **Heap cap:** not supported by Boa 0.21.
36
37/// Maximum source-string size accepted by [`BoaEngine::eval`] (DoS cap, M-L1).
38///
39/// Boa 0.21 exposes no heap/allocation cap (`runtime_limits_mut()` covers only
40/// loop iterations, recursion depth, and stack size). This pre-eval source-size
41/// check neutralizes large-payload bombs before Boa allocates; the residual
42/// in-heap amplification vector (a small source that grows a huge structure via
43/// `String.prototype.repeat` or array builders) cannot be bounded without a Boa
44/// heap API and is accepted as a documented upstream limitation. The existing
45/// loop/recursion/stack/timeout limits neutralize CPU-bombs.
46const MAX_SOURCE_BYTES: usize = 1024 * 1024; // 1 MiB
47
48/// Default wall-clock execution budget (mirrors the `JsLanguage` default) used
49/// as the worker's queuing-deadline backstop for `Eval` jobs.
50const DEFAULT_EXECUTION_TIMEOUT_MS: u64 = 5_000;
51
52use std::sync::mpsc;
53use std::sync::{Arc, OnceLock};
54use std::time::Instant;
55
56use crate::{
57    engine::{JsEngine, JsEvalResult, JsExchange},
58    error::JsLanguageError,
59};
60
61use super::worker::{JsJob, JsWorkerHandle, worker_unavailable};
62
63/// A [`JsEngine`] implementation backed by Boa, executing on a dedicated
64/// worker thread.
65///
66/// All jobs for one limits-configuration run on a single `camel-js-worker`
67/// thread; clones of `BoaEngine` share that worker. Each evaluation
68/// receives fresh `camel`/`console` bindings and a fresh declarative
69/// environment through the worker's stable realm; configurable global
70/// additions are removed and a named integrity set is verified between
71/// evaluations, with the realm recycled on drift. Evaluations do not
72/// receive realm isolation — see the crate `CONTEXT.md` "Sandbox posture"
73/// section.
74#[derive(Debug, Clone)]
75pub struct BoaEngine {
76    limits: camel_language_api::JsLimitsConfig,
77    worker: Arc<OnceLock<JsWorkerHandle>>,
78}
79
80impl BoaEngine {
81    #[must_use]
82    pub fn new(limits: camel_language_api::JsLimitsConfig) -> Self {
83        Self {
84            limits,
85            worker: Arc::new(OnceLock::new()),
86        }
87    }
88
89    /// Lazily spawn (or reuse) the single worker for this limits-configuration.
90    ///
91    /// `get_or_init` guarantees one worker even under concurrent first calls;
92    /// clones share the same `OnceLock` through the `Arc`.
93    fn worker(&self) -> &JsWorkerHandle {
94        self.worker
95            .get_or_init(|| JsWorkerHandle::spawn(self.limits.clone()))
96    }
97
98    /// Send a job and block on its reply. `Err` means the worker is gone.
99    fn dispatch<T>(
100        &self,
101        make_job: impl FnOnce(mpsc::SyncSender<Result<T, JsLanguageError>>) -> JsJob,
102    ) -> Result<T, JsLanguageError> {
103        let (reply_tx, reply_rx) = mpsc::sync_channel(1);
104        self.worker().send(make_job(reply_tx))?;
105        reply_rx.recv().map_err(|_| worker_unavailable())?
106    }
107}
108
109impl Default for BoaEngine {
110    fn default() -> Self {
111        Self::new(camel_language_api::JsLimitsConfig::default())
112    }
113}
114
115// ── Resolver (shared with the worker thread) ──────────────────────────────────
116
117/// Resolved (concrete) JS limits after folding `Option` → `T` with rust-camel
118/// runtime defaults. Produced by [`resolve_js_limits`].
119///
120/// **Heap cap gap:** Boa 0.21 does not expose a heap-size limit. The
121/// [`JsLimitsConfig`] struct intentionally lacks a `max_heap_size` field;
122/// `deny_unknown_fields` in serde rejects it if a user tries to set it.
123///
124/// Note: `execution_timeout_ms` is NOT in this struct — it is applied at the
125/// [`Language`](camel_language_api::Language) level via `eval_async` tokio
126/// timeout in `expression.rs`, not through Boa's `RuntimeLimits`.
127#[derive(Clone)]
128pub(super) struct ResolvedJsLimits {
129    pub(super) max_loop_iterations: u64,
130    pub(super) max_recursion_depth: usize,
131    pub(super) max_stack_size: usize,
132}
133
134/// Resolve a `JsLimitsConfig` (all-`Option`) into concrete values, applying
135/// rust-camel runtime defaults where the user did not specify a value.
136pub(super) fn resolve_js_limits(limits: &camel_language_api::JsLimitsConfig) -> ResolvedJsLimits {
137    ResolvedJsLimits {
138        // Boa upstream default for loop is u64::MAX — unacceptable for buggy scripts.
139        max_loop_iterations: limits.max_loop_iterations.unwrap_or(100_000),
140        max_recursion_depth: limits.max_recursion_depth.unwrap_or(512),
141        max_stack_size: limits.max_stack_size.unwrap_or(10_240),
142    }
143}
144
145impl JsEngine for BoaEngine {
146    fn eval(&self, source: &str, exchange: JsExchange) -> Result<JsEvalResult, JsLanguageError> {
147        // M-L1: pre-eval source-size cap (Boa 0.21 has no heap cap; see const doc).
148        // Stays on the caller side, before the job is sent.
149        if source.len() > MAX_SOURCE_BYTES {
150            return Err(JsLanguageError::Execution {
151                message: format!(
152                    "JS source {} bytes exceeds max source bytes {} (Boa 0.21 has no heap cap; \
153                     reject oversized input before eval)",
154                    source.len(),
155                    MAX_SOURCE_BYTES
156                ),
157            });
158        }
159
160        self.dispatch(|reply| JsJob::Eval {
161            source: Arc::from(source),
162            exchange,
163            // Queuing-deadline backstop: a job that sits in the queue past its
164            // budget is skipped. The wall-clock execution timeout itself is
165            // still applied at the Language level via `eval_async`.
166            timeout_ms: self
167                .limits
168                .execution_timeout_ms
169                .unwrap_or(DEFAULT_EXECUTION_TIMEOUT_MS),
170            enqueued: Instant::now(),
171            reply,
172        })
173    }
174
175    fn validate(&self, source: &str) -> Result<(), JsLanguageError> {
176        self.dispatch(|reply| JsJob::Validate {
177            source: Arc::from(source),
178            reply,
179        })
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use serde_json::json;
187
188    fn make_exchange() -> JsExchange {
189        JsExchange::from_headers_body_properties(
190            [("foo".to_string(), json!("bar"))].into_iter().collect(),
191            json!("hello"),
192            [("key".to_string(), json!("val"))].into_iter().collect(),
193        )
194    }
195
196    #[test]
197    fn test_eval_return_value() {
198        let engine = BoaEngine::default();
199        let result = engine.eval("1 + 1", JsExchange::default()).unwrap();
200        assert_eq!(result.return_value.as_i64().unwrap(), 2);
201    }
202
203    #[test]
204    fn test_eval_header_access() {
205        let engine = BoaEngine::default();
206        let ex = make_exchange();
207        let result = engine.eval("camel.headers.get('foo')", ex).unwrap();
208        assert_eq!(result.return_value.as_str().unwrap(), "bar");
209    }
210
211    #[test]
212    fn test_eval_body_getter() {
213        let engine = BoaEngine::default();
214        let ex = make_exchange();
215        let result = engine.eval("camel.body", ex).unwrap();
216        assert_eq!(result.return_value.as_str().unwrap(), "hello");
217    }
218
219    #[test]
220    fn test_mutating_set_header_propagates() {
221        let engine = BoaEngine::default();
222        let ex = make_exchange();
223        let result = engine
224            .eval("camel.headers.set('newkey', 'newval'); 'done'", ex)
225            .unwrap();
226        assert_eq!(result.return_value.as_str().unwrap(), "done");
227        assert_eq!(
228            result.headers.get("newkey").unwrap().as_str().unwrap(),
229            "newval"
230        );
231    }
232
233    #[test]
234    fn test_mutating_body_propagates() {
235        let engine = BoaEngine::default();
236        let ex = make_exchange();
237        let result = engine
238            .eval("camel.body = 'modified'; camel.body", ex)
239            .unwrap();
240        assert_eq!(result.body.as_str().unwrap(), "modified");
241    }
242
243    #[test]
244    fn test_console_log_no_crash() {
245        let engine = BoaEngine::default();
246        let result = engine
247            .eval("console.log('test'); 42", JsExchange::default())
248            .unwrap();
249        assert_eq!(result.return_value.as_i64().unwrap(), 42);
250    }
251
252    #[test]
253    fn test_validate_valid() {
254        let engine = BoaEngine::default();
255        assert!(engine.validate("let x = 1 + 1;").is_ok());
256    }
257
258    #[test]
259    fn test_validate_invalid() {
260        let engine = BoaEngine::default();
261        assert!(engine.validate("let x = {{{").is_err());
262    }
263
264    #[test]
265    fn test_eval_property_access() {
266        let engine = BoaEngine::default();
267        let ex = make_exchange();
268        let result = engine.eval("camel.properties.get('key')", ex).unwrap();
269        assert_eq!(result.return_value.as_str().unwrap(), "val");
270    }
271
272    #[test]
273    fn test_eval_property_function_access() {
274        let engine = BoaEngine::default();
275        let ex = make_exchange();
276        let result = engine.eval("camel.property('key')", ex).unwrap();
277        assert_eq!(result.return_value.as_str().unwrap(), "val");
278    }
279
280    #[test]
281    fn test_eval_runtime_error_returns_err() {
282        let engine = BoaEngine::default();
283        let result = engine.eval("throw new Error('boom')", JsExchange::default());
284        assert!(result.is_err());
285        let msg = result.unwrap_err().to_string();
286        assert!(
287            msg.contains("boom")
288                || msg.to_lowercase().contains("execution")
289                || msg.to_lowercase().contains("error")
290        );
291    }
292
293    #[test]
294    fn test_eval_syntax_error_returns_err() {
295        let engine = BoaEngine::default();
296        let result = engine.eval("let x = {{{", JsExchange::default());
297        assert!(result.is_err());
298    }
299
300    #[test]
301    fn test_eval_missing_header_returns_undefined() {
302        let engine = BoaEngine::default();
303        let ex = make_exchange();
304        // Getting a key that doesn't exist should return undefined (maps to null in serde_json)
305        let result = engine.eval("camel.headers.get('nonexistent')", ex).unwrap();
306        assert!(result.return_value.is_null());
307    }
308
309    #[test]
310    fn test_properties_mutation_propagates() {
311        let engine = BoaEngine::default();
312        let ex = make_exchange();
313        let result = engine
314            .eval("camel.properties.set('newprop', 'newval'); 'done'", ex)
315            .unwrap();
316        assert_eq!(result.return_value.as_str().unwrap(), "done");
317        assert_eq!(
318            result.properties.get("newprop").unwrap().as_str().unwrap(),
319            "newval"
320        );
321    }
322
323    #[test]
324    fn test_set_property_function_mutation_propagates() {
325        let engine = BoaEngine::default();
326        let ex = make_exchange();
327        let result = engine
328            .eval("camel.set_property('newprop', 'newval'); 'done'", ex)
329            .unwrap();
330        assert_eq!(result.return_value.as_str().unwrap(), "done");
331        assert_eq!(
332            result.properties.get("newprop").unwrap().as_str().unwrap(),
333            "newval"
334        );
335    }
336
337    #[test]
338    fn test_headers_keys() {
339        let engine = BoaEngine::default();
340        let ex = make_exchange();
341        let result = engine.eval("camel.headers.keys()", ex).unwrap();
342        let keys: Vec<&str> = result
343            .return_value
344            .as_array()
345            .unwrap()
346            .iter()
347            .map(|v| v.as_str().unwrap())
348            .collect();
349        assert!(keys.contains(&"foo"));
350    }
351
352    #[test]
353    fn test_headers_has() {
354        let engine = BoaEngine::default();
355        let ex = make_exchange();
356        let r1 = engine.eval("camel.headers.has('foo')", ex.clone()).unwrap();
357        assert!(r1.return_value.as_bool().unwrap());
358        let r2 = engine.eval("camel.headers.has('missing')", ex).unwrap();
359        assert!(!r2.return_value.as_bool().unwrap());
360    }
361
362    #[test]
363    fn test_headers_remove() {
364        let engine = BoaEngine::default();
365        let ex = make_exchange();
366        let result = engine
367            .eval("camel.headers.remove('foo'); camel.headers.has('foo')", ex)
368            .unwrap();
369        assert!(!result.return_value.as_bool().unwrap());
370        assert!(!result.headers.contains_key("foo"));
371    }
372
373    #[test]
374    fn test_boa_infinite_loop_trips_loop_iteration_limit() {
375        use camel_language_api::JsLimitsConfig;
376        let limits = JsLimitsConfig {
377            max_loop_iterations: Some(1_000),
378            ..Default::default()
379        };
380        let engine = BoaEngine::new(limits);
381        let result = engine.eval("while (true) {}", JsExchange::default());
382        assert!(
383            result.is_err(),
384            "while(true) must trip loop_iteration_limit"
385        );
386        let msg = format!("{}", result.unwrap_err());
387        assert!(
388            msg.to_lowercase().contains("loop")
389                || msg.to_lowercase().contains("limit")
390                || msg.to_lowercase().contains("iteration"),
391            "error should reference loop limit: {msg}"
392        );
393    }
394
395    #[test]
396    fn test_eval_rejects_oversized_source() {
397        // M-L1: source larger than MAX_SOURCE_BYTES is rejected before Boa eval.
398        let engine = BoaEngine::default();
399        let big = "x".repeat(MAX_SOURCE_BYTES + 1);
400        let result = engine.eval(&big, JsExchange::default());
401        assert!(result.is_err(), "oversized source must be rejected");
402        let msg = format!("{}", result.unwrap_err());
403        assert!(
404            msg.contains("source") && msg.to_lowercase().contains("bytes"),
405            "error should mention source size: {msg}"
406        );
407    }
408
409    #[test]
410    fn test_eval_accepts_source_under_cap() {
411        let engine = BoaEngine::default();
412        // Small script well under the cap.
413        let result = engine.eval("1 + 1", JsExchange::default()).unwrap();
414        assert_eq!(result.return_value.as_i64().unwrap(), 2);
415    }
416
417    #[test]
418    fn test_documented_heap_amplification_gap() {
419        // M-L1 residual gap documentation test: Boa 0.21 exposes no heap cap.
420        // The existing loop/recursion/stack/timeout limits neutralize CPU-bombs;
421        // an in-heap amplification bomb ('x'.repeat(huge)) cannot be bounded
422        // without a Boa heap API. This test asserts the CPU-bomb variant IS
423        // caught by the loop limit, documenting that the heap-amplification
424        // vector is the accepted residual gap.
425        use camel_language_api::JsLimitsConfig;
426        let limits = JsLimitsConfig {
427            max_loop_iterations: Some(1_000),
428            ..Default::default()
429        };
430        let engine = BoaEngine::new(limits);
431        // A CPU-bound loop is bounded by the iteration limit.
432        let result = engine.eval("let i=0; while(true){i++;}", JsExchange::default());
433        assert!(result.is_err(), "CPU-bomb must trip the loop limit");
434    }
435
436    #[test]
437    fn test_boa_deep_recursion_trips_recursion_limit() {
438        use camel_language_api::JsLimitsConfig;
439        let limits = JsLimitsConfig {
440            max_recursion_depth: Some(10),
441            ..Default::default()
442        };
443        let engine = BoaEngine::new(limits);
444        // Recursive fn that immediately recurses (no base case).
445        let script = "(function f() { return f(); })()";
446        let result = engine.eval(script, JsExchange::default());
447        assert!(result.is_err(), "deep recursion must trip recursion_limit");
448    }
449}