camel_language_js/engines/
boa.rs1const MAX_SOURCE_BYTES: usize = 1024 * 1024; use 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#[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
57struct ResolvedJsLimits {
70 max_loop_iterations: u64,
71 max_recursion_depth: usize,
72 max_stack_size: usize,
73}
74
75fn resolve_js_limits(limits: &camel_language_api::JsLimitsConfig) -> ResolvedJsLimits {
78 ResolvedJsLimits {
79 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 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 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 bindings::register_console(&mut ctx);
113
114 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 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 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 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 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 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 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 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 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 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}