use boa_engine::{Context, JsValue, Source, js_string};
use crate::{
bindings,
engine::{JsEngine, JsEvalResult, JsExchange},
error::JsLanguageError,
value::js_to_value,
};
#[derive(Debug, Clone)]
pub struct BoaEngine {
limits: camel_language_api::JsLimitsConfig,
}
impl BoaEngine {
#[must_use]
pub fn new(limits: camel_language_api::JsLimitsConfig) -> Self {
Self { limits }
}
}
impl Default for BoaEngine {
fn default() -> Self {
Self::new(camel_language_api::JsLimitsConfig::default())
}
}
struct ResolvedJsLimits {
max_loop_iterations: u64,
max_recursion_depth: usize,
max_stack_size: usize,
}
fn resolve_js_limits(limits: &camel_language_api::JsLimitsConfig) -> ResolvedJsLimits {
ResolvedJsLimits {
max_loop_iterations: limits.max_loop_iterations.unwrap_or(100_000),
max_recursion_depth: limits.max_recursion_depth.unwrap_or(512),
max_stack_size: limits.max_stack_size.unwrap_or(10_240),
}
}
impl JsEngine for BoaEngine {
fn eval(&self, source: &str, exchange: JsExchange) -> Result<JsEvalResult, JsLanguageError> {
let mut ctx = Context::default();
let resolved = resolve_js_limits(&self.limits);
{
let runtime_limits = ctx.runtime_limits_mut();
runtime_limits.set_loop_iteration_limit(resolved.max_loop_iterations);
runtime_limits.set_recursion_limit(resolved.max_recursion_depth);
runtime_limits.set_stack_size_limit(resolved.max_stack_size);
}
bindings::register_console(&mut ctx);
let camel_obj = bindings::build_camel_global(&exchange, &mut ctx).map_err(|e| {
JsLanguageError::Execution {
message: e.to_string(),
}
})?;
ctx.global_object()
.set(
js_string!("camel"),
JsValue::from(camel_obj),
false,
&mut ctx,
)
.map_err(|e| JsLanguageError::Execution {
message: format!("camel global set: {e}"),
})?;
let result = ctx
.eval(Source::from_bytes(source.as_bytes()))
.map_err(|e| JsLanguageError::Execution {
message: e.to_string(),
})?;
let return_value = js_to_value(&result, &mut ctx)?;
let modified = bindings::extract_camel_state(&mut ctx)?;
Ok(JsEvalResult {
return_value,
headers: modified.headers,
body: modified.body,
properties: modified.properties,
})
}
fn validate(&self, source: &str) -> Result<(), JsLanguageError> {
let mut ctx = Context::default();
let _script =
boa_engine::Script::parse(Source::from_bytes(source.as_bytes()), None, &mut ctx)
.map_err(|e| JsLanguageError::Parse {
message: e.to_string(),
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_exchange() -> JsExchange {
JsExchange::from_headers_body_properties(
[("foo".to_string(), json!("bar"))].into_iter().collect(),
json!("hello"),
[("key".to_string(), json!("val"))].into_iter().collect(),
)
}
#[test]
fn test_eval_return_value() {
let engine = BoaEngine::default();
let result = engine.eval("1 + 1", JsExchange::default()).unwrap();
assert_eq!(result.return_value.as_i64().unwrap(), 2);
}
#[test]
fn test_eval_header_access() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine.eval("camel.headers.get('foo')", ex).unwrap();
assert_eq!(result.return_value.as_str().unwrap(), "bar");
}
#[test]
fn test_eval_body_getter() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine.eval("camel.body", ex).unwrap();
assert_eq!(result.return_value.as_str().unwrap(), "hello");
}
#[test]
fn test_mutating_set_header_propagates() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine
.eval("camel.headers.set('newkey', 'newval'); 'done'", ex)
.unwrap();
assert_eq!(result.return_value.as_str().unwrap(), "done");
assert_eq!(
result.headers.get("newkey").unwrap().as_str().unwrap(),
"newval"
);
}
#[test]
fn test_mutating_body_propagates() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine
.eval("camel.body = 'modified'; camel.body", ex)
.unwrap();
assert_eq!(result.body.as_str().unwrap(), "modified");
}
#[test]
fn test_console_log_no_crash() {
let engine = BoaEngine::default();
let result = engine
.eval("console.log('test'); 42", JsExchange::default())
.unwrap();
assert_eq!(result.return_value.as_i64().unwrap(), 42);
}
#[test]
fn test_validate_valid() {
let engine = BoaEngine::default();
assert!(engine.validate("let x = 1 + 1;").is_ok());
}
#[test]
fn test_validate_invalid() {
let engine = BoaEngine::default();
assert!(engine.validate("let x = {{{").is_err());
}
#[test]
fn test_eval_property_access() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine.eval("camel.properties.get('key')", ex).unwrap();
assert_eq!(result.return_value.as_str().unwrap(), "val");
}
#[test]
fn test_eval_property_function_access() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine.eval("camel.property('key')", ex).unwrap();
assert_eq!(result.return_value.as_str().unwrap(), "val");
}
#[test]
fn test_eval_runtime_error_returns_err() {
let engine = BoaEngine::default();
let result = engine.eval("throw new Error('boom')", JsExchange::default());
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("boom")
|| msg.to_lowercase().contains("execution")
|| msg.to_lowercase().contains("error")
);
}
#[test]
fn test_eval_syntax_error_returns_err() {
let engine = BoaEngine::default();
let result = engine.eval("let x = {{{", JsExchange::default());
assert!(result.is_err());
}
#[test]
fn test_eval_missing_header_returns_undefined() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine.eval("camel.headers.get('nonexistent')", ex).unwrap();
assert!(result.return_value.is_null());
}
#[test]
fn test_properties_mutation_propagates() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine
.eval("camel.properties.set('newprop', 'newval'); 'done'", ex)
.unwrap();
assert_eq!(result.return_value.as_str().unwrap(), "done");
assert_eq!(
result.properties.get("newprop").unwrap().as_str().unwrap(),
"newval"
);
}
#[test]
fn test_set_property_function_mutation_propagates() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine
.eval("camel.set_property('newprop', 'newval'); 'done'", ex)
.unwrap();
assert_eq!(result.return_value.as_str().unwrap(), "done");
assert_eq!(
result.properties.get("newprop").unwrap().as_str().unwrap(),
"newval"
);
}
#[test]
fn test_headers_keys() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine.eval("camel.headers.keys()", ex).unwrap();
let keys: Vec<&str> = result
.return_value
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(keys.contains(&"foo"));
}
#[test]
fn test_headers_has() {
let engine = BoaEngine::default();
let ex = make_exchange();
let r1 = engine.eval("camel.headers.has('foo')", ex.clone()).unwrap();
assert!(r1.return_value.as_bool().unwrap());
let r2 = engine.eval("camel.headers.has('missing')", ex).unwrap();
assert!(!r2.return_value.as_bool().unwrap());
}
#[test]
fn test_headers_remove() {
let engine = BoaEngine::default();
let ex = make_exchange();
let result = engine
.eval("camel.headers.remove('foo'); camel.headers.has('foo')", ex)
.unwrap();
assert!(!result.return_value.as_bool().unwrap());
assert!(!result.headers.contains_key("foo"));
}
#[test]
fn test_boa_infinite_loop_trips_loop_iteration_limit() {
use camel_language_api::JsLimitsConfig;
let limits = JsLimitsConfig {
max_loop_iterations: Some(1_000),
..Default::default()
};
let engine = BoaEngine::new(limits);
let result = engine.eval("while (true) {}", JsExchange::default());
assert!(
result.is_err(),
"while(true) must trip loop_iteration_limit"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.to_lowercase().contains("loop")
|| msg.to_lowercase().contains("limit")
|| msg.to_lowercase().contains("iteration"),
"error should reference loop limit: {msg}"
);
}
#[test]
fn test_boa_deep_recursion_trips_recursion_limit() {
use camel_language_api::JsLimitsConfig;
let limits = JsLimitsConfig {
max_recursion_depth: Some(10),
..Default::default()
};
let engine = BoaEngine::new(limits);
let script = "(function f() { return f(); })()";
let result = engine.eval(script, JsExchange::default());
assert!(result.is_err(), "deep recursion must trip recursion_limit");
}
}