camel_language_js/engines/
boa.rs1const MAX_SOURCE_BYTES: usize = 1024 * 1024; const 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#[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 fn worker(&self) -> &JsWorkerHandle {
94 self.worker
95 .get_or_init(|| JsWorkerHandle::spawn(self.limits.clone()))
96 }
97
98 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#[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
134pub(super) fn resolve_js_limits(limits: &camel_language_api::JsLimitsConfig) -> ResolvedJsLimits {
137 ResolvedJsLimits {
138 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 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 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 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 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 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 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 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 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}