Skip to main content

camel_component_wasm/
config.rs

1//! WASM plugin configuration for Processor URI query params and `Camel.toml`
2//! limits blocks used by Bean/AuthorizationPolicy/SecurityPolicy.
3//!
4//! `max_memory_bytes` is enforced at runtime via
5//! `wasmtime::StoreLimitsBuilder::memory_size` in `WasmRuntime::create_host_state`.
6//! The default (50 MiB) is intentionally tight; raise it through `Camel.toml`
7//! (`[default.beans.<name>.limits]` or `[permissions.providers.<name>.limits]`)
8//! or via the `wasm:` URI query string (`?max-memory=N`) for Processor plugins.
9//! Timeout uses epoch interruption.
10
11use std::path::Path;
12use std::time::Duration;
13
14use camel_api::body::DEFAULT_MATERIALIZE_LIMIT;
15
16/// Default execution timeout in seconds.
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18
19/// Default maximum linear memory in bytes (50 MB).
20const DEFAULT_MAX_MEMORY_BYTES: u64 = 50 * 1024 * 1024;
21
22/// Default maximum concurrent `call_process` executions per producer.
23const DEFAULT_MAX_CONCURRENT_CALLS: usize = 4;
24
25/// Default maximum .wasm file size in bytes (10 MB).
26const DEFAULT_MAX_WASM_SIZE_BYTES: u64 = 10 * 1024 * 1024;
27
28/// Default maximum bytes for the streaming body bridge.
29pub(crate) const DEFAULT_MAX_STREAM_BYTES: u64 = DEFAULT_MATERIALIZE_LIMIT as u64;
30
31/// Default maximum core instances per store (matches wasmtime default).
32const DEFAULT_MAX_INSTANCES: usize = 10_000;
33
34/// Default maximum tables per store (matches wasmtime default).
35const DEFAULT_MAX_TABLES: usize = 10_000;
36
37/// Epoch tick interval in milliseconds (same as Surrealism).
38const EPOCH_INTERVAL_MILLIS: u64 = 10;
39
40/// Configuration for a WASM plugin instance.
41///
42/// Parsed from URI query parameters or Camel.toml.
43/// Example URI: `wasm:plugin.wasm?timeout=10&max-memory=52428800`
44#[derive(Debug, Clone)]
45pub struct WasmConfig {
46    /// Maximum execution time per guest call, in seconds.
47    pub timeout_secs: u64,
48
49    /// Maximum linear memory the guest can allocate, in bytes.
50    /// Enforced via `wasmtime::StoreLimitsBuilder::memory_size`.
51    pub max_memory_bytes: u64,
52
53    /// Maximum concurrent `call_process` executions per producer.
54    pub max_concurrent_calls: usize,
55
56    /// Maximum .wasm file size in bytes. Files exceeding this are rejected
57    /// before compilation to prevent DoS via pathologically large modules.
58    /// Default: 10 MB.
59    pub max_wasm_size_bytes: u64,
60
61    /// Comma-separated URI schemes the guest may call via camel_call/camel_poll.
62    /// Empty string = deny all (fail-closed). Example: "log,direct,file".
63    /// Ignored for AuthorizationPolicy/SecurityPolicy worlds (always denied).
64    pub allow_call_schemes: String,
65
66    /// Maximum bytes for the streaming body bridge.
67    pub max_stream_bytes: u64,
68
69    /// Maximum core instances per store. Default 10_000 (matches wasmtime).
70    pub max_instances: usize,
71
72    /// Maximum tables per store. Default 10_000 (matches wasmtime).
73    pub max_tables: usize,
74
75    /// Maximum table elements. `None` = no cap (wasmtime unlimited).
76    pub max_table_elements: Option<usize>,
77}
78
79impl Default for WasmConfig {
80    fn default() -> Self {
81        Self {
82            timeout_secs: DEFAULT_TIMEOUT_SECS,
83            max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
84            max_concurrent_calls: DEFAULT_MAX_CONCURRENT_CALLS,
85            max_wasm_size_bytes: DEFAULT_MAX_WASM_SIZE_BYTES,
86            allow_call_schemes: String::new(),
87            max_stream_bytes: DEFAULT_MAX_STREAM_BYTES,
88            max_instances: DEFAULT_MAX_INSTANCES,
89            max_tables: DEFAULT_MAX_TABLES,
90            max_table_elements: None,
91        }
92    }
93}
94
95impl WasmConfig {
96    /// Build concrete runtime config from optional `Camel.toml` WASM limits.
97    ///
98    /// `None` values use runtime defaults matching `WasmConfig::default()`.
99    /// This constructor is the single source of truth for `WasmConfig` defaults
100    /// sourced from `Camel.toml` — no silent fallback lie elsewhere (ADR-0011).
101    pub fn from_limits(limits: &camel_config::WasmLimitsConfig) -> WasmConfig {
102        WasmConfig {
103            timeout_secs: limits.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS),
104            max_memory_bytes: limits.max_memory.unwrap_or(DEFAULT_MAX_MEMORY_BYTES),
105            max_concurrent_calls: limits
106                .max_concurrent_calls
107                .unwrap_or(DEFAULT_MAX_CONCURRENT_CALLS),
108            max_wasm_size_bytes: limits.max_wasm_size.unwrap_or(DEFAULT_MAX_WASM_SIZE_BYTES),
109            allow_call_schemes: limits.allow_call_schemes.clone().unwrap_or_default(),
110            max_stream_bytes: limits.max_stream_bytes.unwrap_or(DEFAULT_MAX_STREAM_BYTES),
111            max_instances: limits.max_instances.unwrap_or(DEFAULT_MAX_INSTANCES),
112            max_tables: limits.max_tables.unwrap_or(DEFAULT_MAX_TABLES),
113            max_table_elements: limits.max_table_elements,
114        }
115    }
116
117    /// Parse `WasmConfig` from the query portion of a WASM URI.
118    ///
119    /// `uri_without_scheme` is everything after `wasm:`, e.g.
120    /// `plugins/my_processor.wasm?timeout=10&max-memory=52428800`.
121    ///
122    /// Returns `(path, config)` where path has no query string.
123    pub fn from_uri(uri_without_scheme: &str) -> (String, WasmConfig) {
124        let (path, query) = match uri_without_scheme.find('?') {
125            Some(i) => (&uri_without_scheme[..i], Some(&uri_without_scheme[i + 1..])),
126            None => (uri_without_scheme, None),
127        };
128
129        let mut config = WasmConfig::default();
130
131        if let Some(q) = query {
132            for pair in q.split('&') {
133                if let Some((key, value)) = pair.split_once('=') {
134                    match key {
135                        "timeout" => {
136                            if let Ok(secs) = value.parse::<u64>()
137                                && secs > 0
138                            {
139                                config.timeout_secs = secs;
140                            }
141                        }
142                        "max-memory" => {
143                            if let Ok(bytes) = value.parse::<u64>()
144                                && bytes > 0
145                            {
146                                config.max_memory_bytes = bytes;
147                            }
148                        }
149                        "max-concurrent-calls" => {
150                            if let Ok(max) = value.parse::<usize>()
151                                && max > 0
152                            {
153                                config.max_concurrent_calls = max;
154                            }
155                        }
156                        "max-wasm-size" => {
157                            if let Ok(bytes) = value.parse::<u64>()
158                                && bytes > 0
159                            {
160                                config.max_wasm_size_bytes = bytes;
161                            }
162                        }
163                        "allow-call" => {
164                            config.allow_call_schemes = value.to_string();
165                        }
166                        "max-stream-bytes" => {
167                            if let Ok(bytes) = value.parse::<u64>()
168                                && bytes > 0
169                            {
170                                config.max_stream_bytes = bytes;
171                            }
172                        }
173                        "max-instances" => {
174                            if let Ok(n) = value.parse::<usize>()
175                                && n > 0
176                            {
177                                config.max_instances = n;
178                            }
179                        }
180                        "max-tables" => {
181                            if let Ok(n) = value.parse::<usize>()
182                                && n > 0
183                            {
184                                config.max_tables = n;
185                            }
186                        }
187                        "max-table-elements" => {
188                            if let Ok(n) = value.parse::<usize>()
189                                && n > 0
190                            {
191                                config.max_table_elements = Some(n);
192                            }
193                        }
194                        _ => {} // ignore unknown params
195                    }
196                }
197            }
198        }
199
200        (path.to_string(), config)
201    }
202
203    /// Convert the wall-clock timeout to an epoch deadline (number of ticks).
204    ///
205    /// At 10ms per tick: deadline = timeout_secs * 100
206    pub fn epoch_deadline(&self) -> u64 {
207        self.timeout_secs * (1000 / EPOCH_INTERVAL_MILLIS)
208    }
209
210    /// The interval at which the epoch ticker thread increments the epoch.
211    pub fn epoch_interval(&self) -> Duration {
212        Duration::from_millis(EPOCH_INTERVAL_MILLIS)
213    }
214
215    /// Returns the configured epoch interval in milliseconds.
216    pub fn epoch_interval_millis(&self) -> u64 {
217        EPOCH_INTERVAL_MILLIS
218    }
219
220    pub fn classify_error(
221        &self,
222        plugin_path: &Path,
223        e: wasmtime::Error,
224    ) -> crate::error::WasmError {
225        classify_error(self, plugin_path, e)
226    }
227}
228
229/// Hoisted free-function form of [`WasmConfig::classify_error`] so spawned
230/// tasks (which cannot borrow `&self`) can classify wasmtime errors.
231///
232/// Captures `config` + `plugin_path` by value/clone at spawn site; the
233/// wasmtime error is consumed.
234pub fn classify_error(
235    config: &WasmConfig,
236    plugin_path: &Path,
237    e: wasmtime::Error,
238) -> crate::error::WasmError {
239    use crate::error::{TrapReason, WasmError};
240    let name = plugin_path.display().to_string();
241    if let Some(trap) = e.downcast_ref::<wasmtime::Trap>() {
242        match WasmError::classify_trap(trap) {
243            TrapReason::Timeout => WasmError::Timeout {
244                plugin: name,
245                timeout_secs: config.timeout_secs,
246            },
247            TrapReason::OutOfMemory => WasmError::OutOfMemory {
248                plugin: name,
249                max_memory_bytes: config.max_memory_bytes,
250            },
251            other => WasmError::Trap {
252                plugin: name,
253                reason: other,
254            },
255        }
256    } else {
257        WasmError::GuestPanic(e.to_string())
258    }
259}
260
261pub fn validate_wasm_size(path: &std::path::Path, max_bytes: u64) -> Result<(), String> {
262    let metadata = std::fs::metadata(path)
263        .map_err(|e| format!("cannot stat wasm module {}: {}", path.display(), e))?;
264    let size = metadata.len();
265    if size > max_bytes {
266        return Err(format!(
267            "wasm module {} is {} bytes ({} KiB), exceeds cap of {} bytes ({} KiB)",
268            path.display(),
269            size,
270            size / 1024,
271            max_bytes,
272            max_bytes / 1024,
273        ));
274    }
275    Ok(())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_default_config() {
284        let config = WasmConfig::default();
285        assert_eq!(config.timeout_secs, 30);
286        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024);
287        assert_eq!(config.max_concurrent_calls, 4);
288    }
289
290    #[test]
291    fn test_from_uri_no_params() {
292        let (path, config) = WasmConfig::from_uri("plugins/test.wasm");
293        assert_eq!(path, "plugins/test.wasm");
294        assert_eq!(config.timeout_secs, 30);
295        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024);
296        assert_eq!(config.max_concurrent_calls, 4);
297    }
298
299    #[test]
300    fn test_from_uri_with_timeout() {
301        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=10");
302        assert_eq!(path, "plugins/test.wasm");
303        assert_eq!(config.timeout_secs, 10);
304        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024);
305        assert_eq!(config.max_concurrent_calls, 4);
306    }
307
308    #[test]
309    fn test_from_uri_with_max_memory() {
310        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?max-memory=10485760");
311        assert_eq!(path, "plugins/test.wasm");
312        assert_eq!(config.timeout_secs, 30);
313        assert_eq!(config.max_memory_bytes, 10_485_760);
314        assert_eq!(config.max_concurrent_calls, 4);
315    }
316
317    #[test]
318    fn test_from_uri_with_both_params() {
319        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=5&max-memory=1048576");
320        assert_eq!(path, "plugins/test.wasm");
321        assert_eq!(config.timeout_secs, 5);
322        assert_eq!(config.max_memory_bytes, 1_048_576);
323        assert_eq!(config.max_concurrent_calls, 4);
324    }
325
326    #[test]
327    fn test_from_uri_with_max_concurrent_calls() {
328        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?max-concurrent-calls=8");
329        assert_eq!(path, "plugins/test.wasm");
330        assert_eq!(config.max_concurrent_calls, 8);
331    }
332
333    #[test]
334    fn test_from_uri_ignores_unknown_params() {
335        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?foo=bar&timeout=60");
336        assert_eq!(path, "plugins/test.wasm");
337        assert_eq!(config.timeout_secs, 60);
338    }
339
340    #[test]
341    fn test_from_uri_ignores_invalid_values() {
342        let (_path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=abc");
343        assert_eq!(config.timeout_secs, 30); // stays default
344    }
345
346    #[test]
347    fn test_from_uri_ignores_zero_values() {
348        let (_path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=0&max-memory=0");
349        assert_eq!(config.timeout_secs, 30); // stays default
350        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024); // stays default
351        assert_eq!(config.max_concurrent_calls, 4);
352    }
353
354    #[test]
355    fn test_epoch_deadline() {
356        let config = WasmConfig {
357            timeout_secs: 30,
358            max_memory_bytes: 0,
359            max_concurrent_calls: 4,
360            ..WasmConfig::default()
361        };
362        assert_eq!(config.epoch_deadline(), 3000); // 30s * 100 ticks/s
363    }
364
365    #[test]
366    fn test_epoch_deadline_custom_timeout() {
367        let config = WasmConfig {
368            timeout_secs: 5,
369            max_memory_bytes: 0,
370            max_concurrent_calls: 4,
371            ..WasmConfig::default()
372        };
373        assert_eq!(config.epoch_deadline(), 500);
374    }
375
376    #[test]
377    fn test_epoch_interval() {
378        let config = WasmConfig::default();
379        assert_eq!(config.epoch_interval(), Duration::from_millis(10));
380    }
381
382    #[test]
383    fn from_limits_applies_provided_values() {
384        let limits = camel_config::WasmLimitsConfig {
385            timeout_secs: Some(90),
386            max_memory: Some(128 * 1024 * 1024),
387            max_concurrent_calls: Some(2),
388            ..camel_config::WasmLimitsConfig::default()
389        };
390
391        let config = WasmConfig::from_limits(&limits);
392
393        assert_eq!(config.timeout_secs, 90);
394        assert_eq!(config.max_memory_bytes, 128 * 1024 * 1024);
395        assert_eq!(config.max_concurrent_calls, 2);
396    }
397
398    #[test]
399    fn from_limits_falls_back_to_runtime_defaults_when_none() {
400        let limits = camel_config::WasmLimitsConfig::default();
401
402        let config = WasmConfig::from_limits(&limits);
403
404        assert_eq!(config.timeout_secs, DEFAULT_TIMEOUT_SECS);
405        assert_eq!(config.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
406        assert_eq!(config.max_concurrent_calls, 4);
407    }
408
409    #[test]
410    fn from_limits_mixed_some_and_none() {
411        let limits = camel_config::WasmLimitsConfig {
412            timeout_secs: Some(15),
413            max_memory: None,
414            max_concurrent_calls: Some(1),
415            ..camel_config::WasmLimitsConfig::default()
416        };
417
418        let config = WasmConfig::from_limits(&limits);
419
420        assert_eq!(config.timeout_secs, 15);
421        assert_eq!(config.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
422        assert_eq!(config.max_concurrent_calls, 1);
423    }
424
425    #[test]
426    fn test_validate_wasm_size_rejects_oversized() {
427        let dir = tempfile::tempdir().unwrap();
428        let path = dir.path().join("big.wasm");
429        std::fs::write(&path, vec![0u8; 100]).unwrap();
430        let err = validate_wasm_size(&path, 50).unwrap_err();
431        assert!(err.contains("exceeds cap"), "got: {err}");
432    }
433
434    #[test]
435    fn test_validate_wasm_size_allows_within_cap() {
436        let dir = tempfile::tempdir().unwrap();
437        let path = dir.path().join("ok.wasm");
438        std::fs::write(&path, vec![0u8; 100]).unwrap();
439        validate_wasm_size(&path, 200).expect("100 bytes within 200 cap");
440    }
441
442    #[test]
443    fn test_default_max_wasm_size_bytes() {
444        let config = WasmConfig::default();
445        assert_eq!(config.max_wasm_size_bytes, 10 * 1024 * 1024);
446    }
447
448    #[test]
449    fn test_from_uri_max_wasm_size() {
450        let (_path, config) = WasmConfig::from_uri("p.wasm?max-wasm-size=1048576");
451        assert_eq!(config.max_wasm_size_bytes, 1_048_576);
452    }
453
454    #[test]
455    fn test_validate_wasm_size_errors_on_missing_file() {
456        let err = validate_wasm_size(std::path::Path::new("/nonexistent.wasm"), 1000).unwrap_err();
457        assert!(err.contains("cannot stat"));
458    }
459
460    #[test]
461    fn wasm_config_default_max_stream_bytes() {
462        let cfg = WasmConfig::default();
463        assert_eq!(cfg.max_stream_bytes, DEFAULT_MATERIALIZE_LIMIT as u64);
464    }
465
466    #[test]
467    fn wasm_config_from_uri_max_stream_bytes() {
468        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-stream-bytes=52428800");
469        assert_eq!(cfg.max_stream_bytes, 52_428_800);
470    }
471
472    #[test]
473    fn wasm_config_from_uri_max_stream_bytes_ignores_zero() {
474        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-stream-bytes=0");
475        assert_eq!(cfg.max_stream_bytes, DEFAULT_MAX_STREAM_BYTES);
476    }
477
478    #[test]
479    fn wasm_config_from_limits_max_stream_bytes() {
480        let limits = camel_config::WasmLimitsConfig {
481            max_stream_bytes: Some(52_428_800),
482            ..camel_config::WasmLimitsConfig::default()
483        };
484        let cfg = WasmConfig::from_limits(&limits);
485        assert_eq!(cfg.max_stream_bytes, 52_428_800);
486    }
487
488    #[test]
489    fn wasm_config_from_limits_max_stream_bytes_default() {
490        let limits = camel_config::WasmLimitsConfig::default();
491        let cfg = WasmConfig::from_limits(&limits);
492        assert_eq!(cfg.max_stream_bytes, DEFAULT_MAX_STREAM_BYTES);
493    }
494
495    #[test]
496    fn wasm_config_default_instances_tables_table_elements() {
497        let cfg = WasmConfig::default();
498        assert_eq!(cfg.max_instances, 10_000);
499        assert_eq!(cfg.max_tables, 10_000);
500        assert_eq!(cfg.max_table_elements, None);
501    }
502
503    #[test]
504    fn wasm_config_from_limits_instances_tables_table_elements() {
505        let limits = camel_config::WasmLimitsConfig {
506            max_instances: Some(100),
507            max_tables: Some(50),
508            max_table_elements: Some(200),
509            ..camel_config::WasmLimitsConfig::default()
510        };
511        let cfg = WasmConfig::from_limits(&limits);
512        assert_eq!(cfg.max_instances, 100);
513        assert_eq!(cfg.max_tables, 50);
514        assert_eq!(cfg.max_table_elements, Some(200));
515    }
516
517    #[test]
518    fn wasm_config_from_limits_instances_tables_table_elements_defaults() {
519        let limits = camel_config::WasmLimitsConfig::default();
520        let cfg = WasmConfig::from_limits(&limits);
521        assert_eq!(cfg.max_instances, 10_000);
522        assert_eq!(cfg.max_tables, 10_000);
523        assert_eq!(cfg.max_table_elements, None);
524    }
525
526    #[test]
527    fn wasm_config_from_uri_max_instances() {
528        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-instances=100");
529        assert_eq!(cfg.max_instances, 100);
530    }
531
532    #[test]
533    fn wasm_config_from_uri_max_tables() {
534        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-tables=50");
535        assert_eq!(cfg.max_tables, 50);
536    }
537
538    #[test]
539    fn wasm_config_from_uri_max_table_elements() {
540        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-table-elements=200");
541        assert_eq!(cfg.max_table_elements, Some(200));
542    }
543}