Skip to main content

camel_language_api/
language_limits.rs

1//! Tunable resource limits for in-process scripting engines (Rhai, Boa JS).
2//!
3//! These types live in `camel-language-api` (the lowest shared language contract
4//! crate) to avoid a circular dependency: `camel-language-rhai` / `camel-language-js`
5//! already depend on `camel-language-api`, so the limit types must be defined here
6//! rather than in `camel-config` (which those crates cannot depend on).
7//!
8//! All fields are `Option`; `None` means "use the rust-camel runtime default" —
9//! never the upstream engine's unlimited default (per ADR-0011). The resolve
10//! functions in each language crate document the concrete defaults.
11
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// Rhai limits
16// ---------------------------------------------------------------------------
17
18/// Tunable resource limits for a single Rhai `Engine` instance.
19///
20/// Surfaced in `Camel.toml` as:
21///
22/// ```toml
23/// [languages.rhai.limits]
24/// max-operations = 500000
25/// max-string-size = 10485760
26/// max-array-size = 100000
27/// max-map-size = 100000
28/// max-expression-depth = 10
29/// max-function-expression-depth = 5
30/// execution-timeout-ms = 5000
31/// ```
32#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(rename_all = "kebab-case", deny_unknown_fields)]
34pub struct RhaiLimitsConfig {
35    /// Maximum number of operations before Rhai terminates the script
36    /// (rhai: `max_operations`). Counter resets each call.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub max_operations: Option<u64>,
39
40    /// Maximum string size in bytes (rhai: `max_string_size`).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub max_string_size: Option<usize>,
43
44    /// Maximum array size in elements (rhai: `max_array_size`).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub max_array_size: Option<usize>,
47
48    /// Maximum map size in key-value pairs (rhai: `max_map_size`).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub max_map_size: Option<usize>,
51
52    /// Maximum nesting depth for expressions (rhai: `max_expression_depth`).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub max_expression_depth: Option<u32>,
55
56    /// Maximum nesting depth for function call expressions
57    /// (rhai: `max_function_expression_depth`).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub max_function_expression_depth: Option<u32>,
60
61    /// Maximum execution wall-clock time in milliseconds.
62    /// Rhai has no built-in timeout; the consuming code enforces this via
63    /// `Engine::on_progress` or a tokio timeout wrapper.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub execution_timeout_ms: Option<u64>,
66}
67
68// ---------------------------------------------------------------------------
69// JS (Boa) limits
70// ---------------------------------------------------------------------------
71
72/// Tunable resource limits for a single Boa JS `Context` instance.
73///
74/// Surfaced in `Camel.toml` as:
75///
76/// ```toml
77/// [languages.js.limits]
78/// execution-timeout-ms = 5000
79/// max-loop-iterations = 1000000
80/// max-recursion-depth = 64
81/// max-stack-size = 1048576
82/// ```
83#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
84#[serde(rename_all = "kebab-case", deny_unknown_fields)]
85pub struct JsLimitsConfig {
86    /// Maximum execution wall-clock time in milliseconds.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub execution_timeout_ms: Option<u64>,
89
90    /// Maximum number of loop iterations before Boa terminates execution.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub max_loop_iterations: Option<u64>,
93
94    /// Maximum recursion depth for function calls.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub max_recursion_depth: Option<usize>,
97
98    /// Maximum Boa VM stack size, in stack slots (not bytes).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub max_stack_size: Option<usize>,
101}
102
103// ---------------------------------------------------------------------------
104// MiniJinja limits
105// ---------------------------------------------------------------------------
106
107/// Tunable resource limits for a single MiniJinja `Environment` instance.
108///
109/// Surfaced in `Camel.toml` as:
110///
111/// ```toml
112/// [languages.minijinja.limits]
113/// max-template-source-size = 1048576
114/// max-context-size = 4194304
115/// max-output-size = 4194304
116/// fuel = 100000
117/// max-recursion-depth = 64
118/// execution-timeout-ms = 5000
119/// ```
120#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
121#[serde(rename_all = "kebab-case", deny_unknown_fields)]
122pub struct MinijinjaLimitsConfig {
123    /// Maximum size of the compiled template source in bytes.
124    /// (minijinja: `Environment::set_max_template_source_size`)
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub max_template_source_size: Option<usize>,
127
128    /// Maximum serialised context size in bytes.
129    /// (minijinja: `Environment::set_max_context_size`)
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub max_context_size: Option<usize>,
132
133    /// Maximum rendered output size in bytes.
134    /// (minijinja: `Environment::set_max_output_size`)
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub max_output_size: Option<usize>,
137
138    /// Fuel limit for the MiniJinja VM (coarse instruction budget).
139    /// (minijinja: `Environment::set_fuel`)
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub fuel: Option<u64>,
142
143    /// Maximum recursion depth for template includes/blocks.
144    /// (minijinja: `Environment::set_max_recursion_depth`)
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub max_recursion_depth: Option<u32>,
147
148    /// Maximum execution wall-clock time in milliseconds.
149    /// The consuming code enforces this via a tokio timeout wrapper.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub execution_timeout_ms: Option<u64>,
152}
153
154// ---------------------------------------------------------------------------
155// Wrapper structs for Camel.toml sections
156// ---------------------------------------------------------------------------
157
158/// Rhai engine configuration block in `Camel.toml`.
159#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
160#[serde(rename_all = "kebab-case", deny_unknown_fields)]
161pub struct RhaiEngineConfig {
162    /// Resource limits for the Rhai engine.
163    #[serde(default)]
164    pub limits: RhaiLimitsConfig,
165}
166
167/// JS (Boa) engine configuration block in `Camel.toml`.
168#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
169#[serde(rename_all = "kebab-case", deny_unknown_fields)]
170pub struct JsEngineConfig {
171    /// Resource limits for the Boa JS engine.
172    #[serde(default)]
173    pub limits: JsLimitsConfig,
174}
175
176/// MiniJinja engine configuration block in `Camel.toml`.
177#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
178#[serde(rename_all = "kebab-case", deny_unknown_fields)]
179pub struct MinijinjaEngineConfig {
180    /// Resource limits for the MiniJinja engine.
181    #[serde(default)]
182    pub limits: MinijinjaLimitsConfig,
183}
184
185/// Top-level `[languages]` section in `Camel.toml`.
186///
187/// ```toml
188/// [languages.rhai.limits]
189/// max-operations = 500000
190///
191/// [languages.js.limits]
192/// execution-timeout-ms = 5000
193/// ```
194#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
195#[serde(rename_all = "kebab-case", deny_unknown_fields)]
196pub struct LanguagesConfig {
197    /// Rhai engine configuration.
198    #[serde(default)]
199    pub rhai: RhaiEngineConfig,
200
201    /// JS (Boa) engine configuration.
202    #[serde(default)]
203    pub js: JsEngineConfig,
204
205    /// MiniJinja engine configuration.
206    #[serde(default)]
207    pub minijinja: MinijinjaEngineConfig,
208}
209
210// ---------------------------------------------------------------------------
211// Tests
212// ---------------------------------------------------------------------------
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    // -- RhaiLimitsConfig tests -------------------------------------------
219
220    #[test]
221    fn rhai_defaults_to_all_none() {
222        let cfg = RhaiLimitsConfig::default();
223        assert_eq!(cfg.max_operations, None);
224        assert_eq!(cfg.max_string_size, None);
225        assert_eq!(cfg.max_array_size, None);
226        assert_eq!(cfg.max_map_size, None);
227        assert_eq!(cfg.max_expression_depth, None);
228        assert_eq!(cfg.max_function_expression_depth, None);
229        assert_eq!(cfg.execution_timeout_ms, None);
230    }
231
232    #[test]
233    fn rhai_deserialises_full_block() {
234        let toml = toml::toml! {
235            max-operations = 500000i64
236            max-string-size = 10485760i64
237            max-array-size = 100000i64
238            max-map-size = 100000i64
239            max-expression-depth = 10
240            max-function-expression-depth = 5
241            execution-timeout-ms = 5000i64
242        };
243        let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
244        assert_eq!(cfg.max_operations, Some(500_000));
245        assert_eq!(cfg.max_string_size, Some(10_485_760));
246        assert_eq!(cfg.max_array_size, Some(100_000));
247        assert_eq!(cfg.max_map_size, Some(100_000));
248        assert_eq!(cfg.max_expression_depth, Some(10));
249        assert_eq!(cfg.max_function_expression_depth, Some(5));
250        assert_eq!(cfg.execution_timeout_ms, Some(5000));
251    }
252
253    #[test]
254    fn rhai_deserialises_partial_block() {
255        let toml = toml::toml! {
256            max-operations = 100000i64
257            execution-timeout-ms = 3000i64
258        };
259        let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
260        assert_eq!(cfg.max_operations, Some(100_000));
261        assert_eq!(cfg.execution_timeout_ms, Some(3000));
262        // All other fields should be None
263        assert_eq!(cfg.max_string_size, None);
264        assert_eq!(cfg.max_expression_depth, None);
265    }
266
267    #[test]
268    fn rhai_rejects_unknown_field() {
269        let toml = toml::toml! {
270            max-operations = 100000i64
271            fuel = 1000i64
272        };
273        let result: Result<RhaiLimitsConfig, _> = toml.try_into();
274        assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
275    }
276
277    #[test]
278    fn rhai_serde_round_trip_preserves_set_fields() {
279        let original = RhaiLimitsConfig {
280            max_operations: Some(200_000),
281            max_string_size: Some(5_242_880),
282            execution_timeout_ms: Some(10_000),
283            ..Default::default()
284        };
285        let serialized = toml::to_string(&original).expect("serialize");
286        let back: RhaiLimitsConfig = toml::from_str(&serialized).expect("deserialize");
287        assert_eq!(original, back);
288    }
289
290    #[test]
291    fn rhai_skip_serializing_none_fields() {
292        let cfg = RhaiLimitsConfig {
293            max_operations: Some(100_000),
294            max_string_size: None,
295            execution_timeout_ms: Some(5000),
296            ..Default::default()
297        };
298        let s = toml::to_string(&cfg).expect("serialize");
299        assert!(s.contains("max-operations"));
300        assert!(s.contains("execution-timeout-ms"));
301        assert!(!s.contains("max-string-size"));
302        assert!(!s.contains("max-expression-depth"));
303    }
304
305    // -- JsLimitsConfig tests ---------------------------------------------
306
307    #[test]
308    fn js_defaults_to_all_none() {
309        let cfg = JsLimitsConfig::default();
310        assert_eq!(cfg.execution_timeout_ms, None);
311        assert_eq!(cfg.max_loop_iterations, None);
312        assert_eq!(cfg.max_recursion_depth, None);
313        assert_eq!(cfg.max_stack_size, None);
314    }
315
316    #[test]
317    fn js_deserialises_full_block() {
318        let toml = toml::toml! {
319            execution-timeout-ms = 5000i64
320            max-loop-iterations = 1000000i64
321            max-recursion-depth = 64i64
322            max-stack-size = 1048576i64
323        };
324        let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
325        assert_eq!(cfg.execution_timeout_ms, Some(5000));
326        assert_eq!(cfg.max_loop_iterations, Some(1_000_000));
327        assert_eq!(cfg.max_recursion_depth, Some(64));
328        assert_eq!(cfg.max_stack_size, Some(1_048_576));
329    }
330
331    #[test]
332    fn js_deserialises_partial_block() {
333        let toml = toml::toml! {
334            execution-timeout-ms = 3000i64
335            max-recursion-depth = 32i64
336        };
337        let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
338        assert_eq!(cfg.execution_timeout_ms, Some(3000));
339        assert_eq!(cfg.max_recursion_depth, Some(32));
340        assert_eq!(cfg.max_loop_iterations, None);
341        assert_eq!(cfg.max_stack_size, None);
342    }
343
344    #[test]
345    fn js_rejects_unknown_field() {
346        let toml = toml::toml! {
347            execution-timeout-ms = 5000i64
348            fuel = 1000i64
349        };
350        let result: Result<JsLimitsConfig, _> = toml.try_into();
351        assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
352    }
353
354    #[test]
355    fn js_serde_round_trip_preserves_set_fields() {
356        let original = JsLimitsConfig {
357            execution_timeout_ms: Some(10_000),
358            max_loop_iterations: Some(500_000),
359            ..Default::default()
360        };
361        let serialized = toml::to_string(&original).expect("serialize");
362        let back: JsLimitsConfig = toml::from_str(&serialized).expect("deserialize");
363        assert_eq!(original, back);
364    }
365
366    #[test]
367    fn js_skip_serializing_none_fields() {
368        let cfg = JsLimitsConfig {
369            execution_timeout_ms: Some(5000),
370            max_loop_iterations: Some(1_000_000),
371            ..Default::default()
372        };
373        let s = toml::to_string(&cfg).expect("serialize");
374        assert!(s.contains("execution-timeout-ms"));
375        assert!(s.contains("max-loop-iterations"));
376        assert!(!s.contains("max-recursion-depth"));
377        assert!(!s.contains("max-stack-size"));
378    }
379
380    // -- Wrapper struct tests ---------------------------------------------
381
382    #[test]
383    fn rhai_engine_config_defaults() {
384        let cfg = RhaiEngineConfig::default();
385        assert_eq!(cfg.limits, RhaiLimitsConfig::default());
386    }
387
388    #[test]
389    fn js_engine_config_defaults() {
390        let cfg = JsEngineConfig::default();
391        assert_eq!(cfg.limits, JsLimitsConfig::default());
392    }
393
394    #[test]
395    fn languages_config_defaults() {
396        let cfg = LanguagesConfig::default();
397        assert_eq!(cfg.rhai.limits, RhaiLimitsConfig::default());
398        assert_eq!(cfg.js.limits, JsLimitsConfig::default());
399        assert_eq!(cfg.minijinja.limits, MinijinjaLimitsConfig::default());
400    }
401
402    #[test]
403    fn languages_deserialises_both_engines() {
404        let toml_str = r#"
405            [rhai.limits]
406            max-operations = 500000
407            execution-timeout-ms = 5000
408
409            [js.limits]
410            execution-timeout-ms = 3000
411            max-loop-iterations = 1000000
412        "#;
413        let cfg: LanguagesConfig = toml::from_str(toml_str).expect("deserialize");
414        assert_eq!(cfg.rhai.limits.max_operations, Some(500_000));
415        assert_eq!(cfg.rhai.limits.execution_timeout_ms, Some(5000));
416        assert_eq!(cfg.js.limits.execution_timeout_ms, Some(3000));
417        assert_eq!(cfg.js.limits.max_loop_iterations, Some(1_000_000));
418    }
419
420    #[test]
421    fn languages_serde_round_trip() {
422        let original = LanguagesConfig {
423            rhai: RhaiEngineConfig {
424                limits: RhaiLimitsConfig {
425                    max_operations: Some(100_000),
426                    ..Default::default()
427                },
428            },
429            js: JsEngineConfig::default(),
430            minijinja: MinijinjaEngineConfig::default(),
431        };
432        let serialized = toml::to_string(&original).expect("serialize");
433        let back: LanguagesConfig = toml::from_str(&serialized).expect("deserialize");
434        assert_eq!(original, back);
435    }
436
437    // -- MinijinjaLimitsConfig tests ---------------------------------------
438
439    #[test]
440    fn minijinja_defaults_to_all_none() {
441        let cfg = MinijinjaLimitsConfig::default();
442        assert_eq!(cfg.max_template_source_size, None);
443        assert_eq!(cfg.max_context_size, None);
444        assert_eq!(cfg.max_output_size, None);
445        assert_eq!(cfg.fuel, None);
446        assert_eq!(cfg.max_recursion_depth, None);
447        assert_eq!(cfg.execution_timeout_ms, None);
448    }
449
450    #[test]
451    fn minijinja_deserialises_full_block() {
452        let toml = toml::toml! {
453            max-template-source-size = 1048576i64
454            max-context-size = 4194304i64
455            max-output-size = 4194304i64
456            fuel = 100000i64
457            max-recursion-depth = 64
458            execution-timeout-ms = 5000i64
459        };
460        let cfg: MinijinjaLimitsConfig = toml.try_into().expect("deserialize");
461        assert_eq!(cfg.max_template_source_size, Some(1_048_576));
462        assert_eq!(cfg.max_context_size, Some(4_194_304));
463        assert_eq!(cfg.max_output_size, Some(4_194_304));
464        assert_eq!(cfg.fuel, Some(100_000));
465        assert_eq!(cfg.max_recursion_depth, Some(64));
466        assert_eq!(cfg.execution_timeout_ms, Some(5_000));
467    }
468
469    #[test]
470    fn minijinja_rejects_unknown_field() {
471        let toml = toml::toml! {
472            fuel = 1000i64
473            bogus = 1i64
474        };
475        let result: Result<MinijinjaLimitsConfig, _> = toml.try_into();
476        assert!(result.is_err(), "deny_unknown_fields must reject `bogus`");
477    }
478
479    #[test]
480    fn minijinja_skip_serializing_none_fields() {
481        let cfg = MinijinjaLimitsConfig {
482            fuel: Some(100_000),
483            max_recursion_depth: Some(64),
484            ..Default::default()
485        };
486        let s = toml::to_string(&cfg).expect("serialize");
487        assert!(s.contains("fuel") && s.contains("max-recursion-depth"));
488        assert!(!s.contains("max-template-source-size") && !s.contains("max-context-size"));
489    }
490}