Skip to main content

camel_language_js/engines/
boa.rs

1//! [`BoaEngine`] — JS engine backed by [Boa](https://boajs.dev).
2//!
3//! Each call to [`eval`](BoaEngine::eval) creates a fresh `Context`.
4//! If [`JsLimitsConfig`](camel_language_api::JsLimitsConfig) fields are `None`, the rust-camel runtime defaults apply:
5//!
6//! | Limit | Default |
7//! |---|---|
8//! | `execution_timeout_ms` | 5,000 ms |
9//! | `max_loop_iterations` | 100,000 (Boa upstream is `u64::MAX`) |
10//! | `max_recursion_depth` | 512 (Boa 0.21 upstream default, pinned) |
11//! | `max_stack_size` | 10,240 (Boa 0.21 upstream default, pinned) |
12//!
13//! **Heap cap:** not supported by Boa 0.21.
14
15/// Maximum source-string size accepted by [`BoaEngine::eval`] (DoS cap, M-L1).
16///
17/// Boa 0.21 exposes no heap/allocation cap (`runtime_limits_mut()` covers only
18/// loop iterations, recursion depth, and stack size). This pre-eval source-size
19/// check neutralizes large-payload bombs before Boa allocates; the residual
20/// in-heap amplification vector (a small source that grows a huge structure via
21/// `String.prototype.repeat` or array builders) cannot be bounded without a Boa
22/// heap API and is accepted as a documented upstream limitation. The existing
23/// loop/recursion/stack/timeout limits neutralize CPU-bombs.
24const MAX_SOURCE_BYTES: usize = 1024 * 1024; // 1 MiB
25
26use boa_engine::{Context, JsValue, Source, js_string};
27
28use crate::{
29    bindings,
30    engine::{JsEngine, JsEvalResult, JsExchange},
31    error::JsLanguageError,
32    value::js_to_value,
33};
34
35/// A [`JsEngine`] implementation backed by Boa.
36///
37/// Each call to [`eval`](BoaEngine::eval) creates a fresh `Context`.
38/// This is intentional: it prevents state leaks between independent expressions.
39#[derive(Debug, Clone)]
40pub struct BoaEngine {
41    limits: camel_language_api::JsLimitsConfig,
42}
43
44impl BoaEngine {
45    #[must_use]
46    pub fn new(limits: camel_language_api::JsLimitsConfig) -> Self {
47        Self { limits }
48    }
49}
50
51impl Default for BoaEngine {
52    fn default() -> Self {
53        Self::new(camel_language_api::JsLimitsConfig::default())
54    }
55}
56
57// ── Private resolver ──────────────────────────────────────────────────────────
58
59/// Resolved (concrete) JS limits after folding `Option` → `T` with rust-camel
60/// runtime defaults. Produced by [`resolve_js_limits`].
61///
62/// **Heap cap gap:** Boa 0.21 does not expose a heap-size limit. The
63/// [`JsLimitsConfig`] struct intentionally lacks a `max_heap_size` field;
64/// `deny_unknown_fields` in serde rejects it if a user tries to set it.
65///
66/// Note: `execution_timeout_ms` is NOT in this struct — it is applied at the
67/// [`Language`](camel_language_api::Language) level via `eval_async` tokio
68/// timeout in `expression.rs`, not through Boa's `RuntimeLimits`.
69struct ResolvedJsLimits {
70    max_loop_iterations: u64,
71    max_recursion_depth: usize,
72    max_stack_size: usize,
73}
74
75/// Resolve a `JsLimitsConfig` (all-`Option`) into concrete values, applying
76/// rust-camel runtime defaults where the user did not specify a value.
77fn resolve_js_limits(limits: &camel_language_api::JsLimitsConfig) -> ResolvedJsLimits {
78    ResolvedJsLimits {
79        // Boa upstream default for loop is u64::MAX — unacceptable for buggy scripts.
80        max_loop_iterations: limits.max_loop_iterations.unwrap_or(100_000),
81        max_recursion_depth: limits.max_recursion_depth.unwrap_or(512),
82        max_stack_size: limits.max_stack_size.unwrap_or(10_240),
83    }
84}
85
86impl JsEngine for BoaEngine {
87    fn eval(&self, source: &str, exchange: JsExchange) -> Result<JsEvalResult, JsLanguageError> {
88        // M-L1: pre-eval source-size cap (Boa 0.21 has no heap cap; see const doc).
89        if source.len() > MAX_SOURCE_BYTES {
90            return Err(JsLanguageError::Execution {
91                message: format!(
92                    "JS source {} bytes exceeds max source bytes {} (Boa 0.21 has no heap cap; \
93                     reject oversized input before eval)",
94                    source.len(),
95                    MAX_SOURCE_BYTES
96                ),
97            });
98        }
99
100        let mut ctx = Context::default();
101
102        // Apply resource limits before executing any script
103        let resolved = resolve_js_limits(&self.limits);
104        {
105            let runtime_limits = ctx.runtime_limits_mut();
106            runtime_limits.set_loop_iteration_limit(resolved.max_loop_iterations);
107            runtime_limits.set_recursion_limit(resolved.max_recursion_depth);
108            runtime_limits.set_stack_size_limit(resolved.max_stack_size);
109        }
110
111        // Set up console -> tracing
112        bindings::register_console(&mut ctx);
113
114        // Set up `camel` global
115        let camel_obj = bindings::build_camel_global(&exchange, &mut ctx).map_err(|e| {
116            JsLanguageError::Execution {
117                message: e.to_string(),
118            }
119        })?;
120
121        ctx.global_object()
122            .set(
123                js_string!("camel"),
124                JsValue::from(camel_obj),
125                false,
126                &mut ctx,
127            )
128            .map_err(|e| JsLanguageError::Execution {
129                message: format!("camel global set: {e}"),
130            })?;
131
132        // Execute
133        let result = ctx
134            .eval(Source::from_bytes(source.as_bytes()))
135            .map_err(|e| JsLanguageError::Execution {
136                message: e.to_string(),
137            })?;
138
139        let return_value = js_to_value(&result, &mut ctx)?;
140
141        // Extract modified exchange state
142        let modified = bindings::extract_camel_state(&mut ctx)?;
143
144        Ok(JsEvalResult {
145            return_value,
146            headers: modified.headers,
147            body: modified.body,
148            properties: modified.properties,
149        })
150    }
151
152    fn validate(&self, source: &str) -> Result<(), JsLanguageError> {
153        // TODO(perf): Context::default() initializes full JS runtime. For high-throughput
154        // validation, consider a cached or pooled context.
155        let mut ctx = Context::default();
156        let _script =
157            boa_engine::Script::parse(Source::from_bytes(source.as_bytes()), None, &mut ctx)
158                .map_err(|e| JsLanguageError::Parse {
159                    message: e.to_string(),
160                })?;
161
162        Ok(())
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use serde_json::json;
170
171    fn make_exchange() -> JsExchange {
172        JsExchange::from_headers_body_properties(
173            [("foo".to_string(), json!("bar"))].into_iter().collect(),
174            json!("hello"),
175            [("key".to_string(), json!("val"))].into_iter().collect(),
176        )
177    }
178
179    #[test]
180    fn test_eval_return_value() {
181        let engine = BoaEngine::default();
182        let result = engine.eval("1 + 1", JsExchange::default()).unwrap();
183        assert_eq!(result.return_value.as_i64().unwrap(), 2);
184    }
185
186    #[test]
187    fn test_eval_header_access() {
188        let engine = BoaEngine::default();
189        let ex = make_exchange();
190        let result = engine.eval("camel.headers.get('foo')", ex).unwrap();
191        assert_eq!(result.return_value.as_str().unwrap(), "bar");
192    }
193
194    #[test]
195    fn test_eval_body_getter() {
196        let engine = BoaEngine::default();
197        let ex = make_exchange();
198        let result = engine.eval("camel.body", ex).unwrap();
199        assert_eq!(result.return_value.as_str().unwrap(), "hello");
200    }
201
202    #[test]
203    fn test_mutating_set_header_propagates() {
204        let engine = BoaEngine::default();
205        let ex = make_exchange();
206        let result = engine
207            .eval("camel.headers.set('newkey', 'newval'); 'done'", ex)
208            .unwrap();
209        assert_eq!(result.return_value.as_str().unwrap(), "done");
210        assert_eq!(
211            result.headers.get("newkey").unwrap().as_str().unwrap(),
212            "newval"
213        );
214    }
215
216    #[test]
217    fn test_mutating_body_propagates() {
218        let engine = BoaEngine::default();
219        let ex = make_exchange();
220        let result = engine
221            .eval("camel.body = 'modified'; camel.body", ex)
222            .unwrap();
223        assert_eq!(result.body.as_str().unwrap(), "modified");
224    }
225
226    #[test]
227    fn test_console_log_no_crash() {
228        let engine = BoaEngine::default();
229        let result = engine
230            .eval("console.log('test'); 42", JsExchange::default())
231            .unwrap();
232        assert_eq!(result.return_value.as_i64().unwrap(), 42);
233    }
234
235    #[test]
236    fn test_validate_valid() {
237        let engine = BoaEngine::default();
238        assert!(engine.validate("let x = 1 + 1;").is_ok());
239    }
240
241    #[test]
242    fn test_validate_invalid() {
243        let engine = BoaEngine::default();
244        assert!(engine.validate("let x = {{{").is_err());
245    }
246
247    #[test]
248    fn test_eval_property_access() {
249        let engine = BoaEngine::default();
250        let ex = make_exchange();
251        let result = engine.eval("camel.properties.get('key')", ex).unwrap();
252        assert_eq!(result.return_value.as_str().unwrap(), "val");
253    }
254
255    #[test]
256    fn test_eval_property_function_access() {
257        let engine = BoaEngine::default();
258        let ex = make_exchange();
259        let result = engine.eval("camel.property('key')", ex).unwrap();
260        assert_eq!(result.return_value.as_str().unwrap(), "val");
261    }
262
263    #[test]
264    fn test_eval_runtime_error_returns_err() {
265        let engine = BoaEngine::default();
266        let result = engine.eval("throw new Error('boom')", JsExchange::default());
267        assert!(result.is_err());
268        let msg = result.unwrap_err().to_string();
269        assert!(
270            msg.contains("boom")
271                || msg.to_lowercase().contains("execution")
272                || msg.to_lowercase().contains("error")
273        );
274    }
275
276    #[test]
277    fn test_eval_syntax_error_returns_err() {
278        let engine = BoaEngine::default();
279        let result = engine.eval("let x = {{{", JsExchange::default());
280        assert!(result.is_err());
281    }
282
283    #[test]
284    fn test_eval_missing_header_returns_undefined() {
285        let engine = BoaEngine::default();
286        let ex = make_exchange();
287        // Getting a key that doesn't exist should return undefined (maps to null in serde_json)
288        let result = engine.eval("camel.headers.get('nonexistent')", ex).unwrap();
289        assert!(result.return_value.is_null());
290    }
291
292    #[test]
293    fn test_properties_mutation_propagates() {
294        let engine = BoaEngine::default();
295        let ex = make_exchange();
296        let result = engine
297            .eval("camel.properties.set('newprop', 'newval'); 'done'", ex)
298            .unwrap();
299        assert_eq!(result.return_value.as_str().unwrap(), "done");
300        assert_eq!(
301            result.properties.get("newprop").unwrap().as_str().unwrap(),
302            "newval"
303        );
304    }
305
306    #[test]
307    fn test_set_property_function_mutation_propagates() {
308        let engine = BoaEngine::default();
309        let ex = make_exchange();
310        let result = engine
311            .eval("camel.set_property('newprop', 'newval'); 'done'", ex)
312            .unwrap();
313        assert_eq!(result.return_value.as_str().unwrap(), "done");
314        assert_eq!(
315            result.properties.get("newprop").unwrap().as_str().unwrap(),
316            "newval"
317        );
318    }
319
320    #[test]
321    fn test_headers_keys() {
322        let engine = BoaEngine::default();
323        let ex = make_exchange();
324        let result = engine.eval("camel.headers.keys()", ex).unwrap();
325        let keys: Vec<&str> = result
326            .return_value
327            .as_array()
328            .unwrap()
329            .iter()
330            .map(|v| v.as_str().unwrap())
331            .collect();
332        assert!(keys.contains(&"foo"));
333    }
334
335    #[test]
336    fn test_headers_has() {
337        let engine = BoaEngine::default();
338        let ex = make_exchange();
339        let r1 = engine.eval("camel.headers.has('foo')", ex.clone()).unwrap();
340        assert!(r1.return_value.as_bool().unwrap());
341        let r2 = engine.eval("camel.headers.has('missing')", ex).unwrap();
342        assert!(!r2.return_value.as_bool().unwrap());
343    }
344
345    #[test]
346    fn test_headers_remove() {
347        let engine = BoaEngine::default();
348        let ex = make_exchange();
349        let result = engine
350            .eval("camel.headers.remove('foo'); camel.headers.has('foo')", ex)
351            .unwrap();
352        assert!(!result.return_value.as_bool().unwrap());
353        assert!(!result.headers.contains_key("foo"));
354    }
355
356    #[test]
357    fn test_boa_infinite_loop_trips_loop_iteration_limit() {
358        use camel_language_api::JsLimitsConfig;
359        let limits = JsLimitsConfig {
360            max_loop_iterations: Some(1_000),
361            ..Default::default()
362        };
363        let engine = BoaEngine::new(limits);
364        let result = engine.eval("while (true) {}", JsExchange::default());
365        assert!(
366            result.is_err(),
367            "while(true) must trip loop_iteration_limit"
368        );
369        let msg = format!("{}", result.unwrap_err());
370        assert!(
371            msg.to_lowercase().contains("loop")
372                || msg.to_lowercase().contains("limit")
373                || msg.to_lowercase().contains("iteration"),
374            "error should reference loop limit: {msg}"
375        );
376    }
377
378    #[test]
379    fn test_eval_rejects_oversized_source() {
380        // M-L1: source larger than MAX_SOURCE_BYTES is rejected before Boa eval.
381        let engine = BoaEngine::default();
382        let big = "x".repeat(MAX_SOURCE_BYTES + 1);
383        let result = engine.eval(&big, JsExchange::default());
384        assert!(result.is_err(), "oversized source must be rejected");
385        let msg = format!("{}", result.unwrap_err());
386        assert!(
387            msg.contains("source") && msg.to_lowercase().contains("bytes"),
388            "error should mention source size: {msg}"
389        );
390    }
391
392    #[test]
393    fn test_eval_accepts_source_under_cap() {
394        let engine = BoaEngine::default();
395        // Small script well under the cap.
396        let result = engine.eval("1 + 1", JsExchange::default()).unwrap();
397        assert_eq!(result.return_value.as_i64().unwrap(), 2);
398    }
399
400    #[test]
401    fn test_documented_heap_amplification_gap() {
402        // M-L1 residual gap documentation test: Boa 0.21 exposes no heap cap.
403        // The existing loop/recursion/stack/timeout limits neutralize CPU-bombs;
404        // an in-heap amplification bomb ('x'.repeat(huge)) cannot be bounded
405        // without a Boa heap API. This test asserts the CPU-bomb variant IS
406        // caught by the loop limit, documenting that the heap-amplification
407        // vector is the accepted residual gap.
408        use camel_language_api::JsLimitsConfig;
409        let limits = JsLimitsConfig {
410            max_loop_iterations: Some(1_000),
411            ..Default::default()
412        };
413        let engine = BoaEngine::new(limits);
414        // A CPU-bound loop is bounded by the iteration limit.
415        let result = engine.eval("let i=0; while(true){i++;}", JsExchange::default());
416        assert!(result.is_err(), "CPU-bomb must trip the loop limit");
417    }
418
419    #[test]
420    fn test_boa_deep_recursion_trips_recursion_limit() {
421        use camel_language_api::JsLimitsConfig;
422        let limits = JsLimitsConfig {
423            max_recursion_depth: Some(10),
424            ..Default::default()
425        };
426        let engine = BoaEngine::new(limits);
427        // Recursive fn that immediately recurses (no base case).
428        let script = "(function f() { return f(); })()";
429        let result = engine.eval(script, JsExchange::default());
430        assert!(result.is_err(), "deep recursion must trip recursion_limit");
431    }
432}