Skip to main content

sen_plugin_host/
loader.rs

1//! Plugin loader using wasmtime
2//!
3//! Loads Wasm plugins and provides safe execution with sandboxing.
4
5use sen_plugin_api::{Effect, EffectResult, ExecuteResult, PluginManifest, API_VERSION};
6use thiserror::Error;
7use wasmtime::*;
8
9/// Errors that can occur during plugin loading
10#[derive(Debug, Error)]
11pub enum LoaderError {
12    #[error("Engine creation failed: {0}")]
13    EngineCreation(#[source] anyhow::Error),
14
15    #[error("Module compilation failed: {0}")]
16    ModuleCompilation(#[source] anyhow::Error),
17
18    #[error("Instantiation failed: {0}")]
19    Instantiation(#[source] anyhow::Error),
20
21    #[error("Function not found: {0}")]
22    FunctionNotFound(String),
23
24    #[error("Function call failed: {function} - {source}")]
25    FunctionCall {
26        function: &'static str,
27        #[source]
28        source: anyhow::Error,
29    },
30
31    #[error("API version mismatch: expected {expected}, got {actual}")]
32    ApiVersionMismatch { expected: u32, actual: u32 },
33
34    #[error("Deserialization failed: {0}")]
35    Deserialization(#[source] rmp_serde::decode::Error),
36
37    #[error("Memory access error: {0}")]
38    MemoryAccess(String),
39
40    #[error("Fuel exhausted (CPU limit exceeded)")]
41    FuelExhausted,
42
43    #[error("Store configuration failed: {0}")]
44    StoreConfig(String),
45}
46
47/// Plugin loader with wasmtime engine
48pub struct PluginLoader {
49    engine: Engine,
50}
51
52/// A loaded plugin ready for execution
53pub struct LoadedPlugin {
54    /// Plugin manifest with command specification
55    pub manifest: PluginManifest,
56
57    /// Plugin instance for execution
58    pub instance: PluginInstance,
59}
60
61/// Plugin instance that can execute commands
62pub struct PluginInstance {
63    store: Store<()>,
64    instance: Instance,
65    memory: Memory,
66    alloc_fn: TypedFunc<i32, i32>,
67    dealloc_fn: TypedFunc<(i32, i32), ()>,
68}
69
70/// Unpack ptr and len from a packed i64
71#[inline]
72fn unpack_ptr_len(packed: i64) -> (i32, i32) {
73    let ptr = (packed >> 32) as i32;
74    let len = (packed & 0xFFFFFFFF) as i32;
75    (ptr, len)
76}
77
78impl PluginLoader {
79    /// Create a new plugin loader with security settings
80    ///
81    /// Configures:
82    /// - Fuel limits (CPU usage) - 10M instructions per execution
83    /// - Stack limits - 1MB maximum WASM stack
84    /// - Memory64 disabled for wasm32 compatibility
85    pub fn new() -> Result<Self, LoaderError> {
86        let mut config = Config::new();
87
88        // Security: Enable fuel for CPU limiting
89        config.consume_fuel(true);
90
91        // Security: Limit WASM stack size (1MB) to prevent stack overflow
92        config.max_wasm_stack(1024 * 1024);
93
94        // Disable memory64 for wasm32 compatibility
95        config.wasm_memory64(false);
96
97        let engine = Engine::new(&config).map_err(LoaderError::EngineCreation)?;
98
99        Ok(Self { engine })
100    }
101
102    /// Load a plugin from Wasm bytes
103    pub fn load(&self, wasm_bytes: &[u8]) -> Result<LoadedPlugin, LoaderError> {
104        // 1. Compile module
105        let module =
106            Module::new(&self.engine, wasm_bytes).map_err(LoaderError::ModuleCompilation)?;
107
108        // 2. Create store with fuel limit (no WASI for MVP)
109        let mut store = Store::new(&self.engine, ());
110        store
111            .set_fuel(10_000_000)
112            .map_err(|e| LoaderError::StoreConfig(format!("Failed to set fuel: {}", e)))?;
113
114        // 3. Create linker (empty for now, no WASI imports)
115        let linker = Linker::new(&self.engine);
116
117        // 4. Instantiate
118        let instance = linker
119            .instantiate(&mut store, &module)
120            .map_err(LoaderError::Instantiation)?;
121
122        // 5. Get memory
123        let memory = instance
124            .get_memory(&mut store, "memory")
125            .ok_or_else(|| LoaderError::FunctionNotFound("memory".to_string()))?;
126
127        // 6. Get allocator functions
128        let alloc_fn = instance
129            .get_typed_func::<i32, i32>(&mut store, "plugin_alloc")
130            .map_err(|_| LoaderError::FunctionNotFound("plugin_alloc".to_string()))?;
131
132        let dealloc_fn = instance
133            .get_typed_func::<(i32, i32), ()>(&mut store, "plugin_dealloc")
134            .map_err(|_| LoaderError::FunctionNotFound("plugin_dealloc".to_string()))?;
135
136        // 7. Call manifest function (returns packed i64)
137        let manifest_fn = instance
138            .get_typed_func::<(), i64>(&mut store, "plugin_manifest")
139            .map_err(|_| LoaderError::FunctionNotFound("plugin_manifest".to_string()))?;
140
141        let packed = manifest_fn.call(&mut store, ()).map_err(|e| {
142            if e.downcast_ref::<Trap>()
143                .is_some_and(|t| *t == Trap::OutOfFuel)
144            {
145                LoaderError::FuelExhausted
146            } else {
147                LoaderError::FunctionCall {
148                    function: "plugin_manifest",
149                    source: e,
150                }
151            }
152        })?;
153
154        let (ptr, len) = unpack_ptr_len(packed);
155
156        // Validate pointer and length are non-negative
157        if ptr < 0 || len < 0 {
158            return Err(LoaderError::MemoryAccess(format!(
159                "Invalid manifest pointer/length: ptr={}, len={}",
160                ptr, len
161            )));
162        }
163
164        // 8. Read manifest from memory
165        let manifest_bytes = Self::read_memory(&store, &memory, ptr as usize, len as usize)?;
166        let manifest: PluginManifest =
167            rmp_serde::from_slice(&manifest_bytes).map_err(LoaderError::Deserialization)?;
168
169        // 9. Validate API version
170        if manifest.api_version != API_VERSION {
171            return Err(LoaderError::ApiVersionMismatch {
172                expected: API_VERSION,
173                actual: manifest.api_version,
174            });
175        }
176
177        // 10. Deallocate manifest memory
178        dealloc_fn
179            .call(&mut store, (ptr, len))
180            .map_err(|e| LoaderError::FunctionCall {
181                function: "plugin_dealloc",
182                source: e,
183            })?;
184
185        Ok(LoadedPlugin {
186            manifest,
187            instance: PluginInstance {
188                store,
189                instance,
190                memory,
191                alloc_fn,
192                dealloc_fn,
193            },
194        })
195    }
196
197    fn read_memory(
198        store: &Store<()>,
199        memory: &Memory,
200        ptr: usize,
201        len: usize,
202    ) -> Result<Vec<u8>, LoaderError> {
203        let data = memory.data(store);
204        let end = ptr.checked_add(len).ok_or_else(|| {
205            LoaderError::MemoryAccess(format!("Integer overflow: ptr={}, len={}", ptr, len))
206        })?;
207        if end > data.len() {
208            return Err(LoaderError::MemoryAccess(format!(
209                "Out of bounds: ptr={}, len={}, memory_size={}",
210                ptr,
211                len,
212                data.len()
213            )));
214        }
215        Ok(data[ptr..end].to_vec())
216    }
217}
218
219impl PluginInstance {
220    /// Execute the plugin with given arguments
221    pub fn execute(&mut self, args: &[String]) -> Result<ExecuteResult, LoaderError> {
222        // 1. Serialize arguments
223        let args_bytes = rmp_serde::to_vec(args)
224            .map_err(|e| LoaderError::MemoryAccess(format!("Failed to serialize args: {}", e)))?;
225
226        // 2. Allocate memory in guest
227        let args_len: i32 = args_bytes.len().try_into().map_err(|_| {
228            LoaderError::MemoryAccess(format!(
229                "Arguments too large: {} bytes exceeds i32::MAX",
230                args_bytes.len()
231            ))
232        })?;
233        let args_ptr = self.alloc_fn.call(&mut self.store, args_len).map_err(|e| {
234            LoaderError::FunctionCall {
235                function: "plugin_alloc",
236                source: e,
237            }
238        })?;
239
240        // 3. Write args to guest memory
241        self.memory
242            .write(&mut self.store, args_ptr as usize, &args_bytes)
243            .map_err(|e| LoaderError::MemoryAccess(format!("Failed to write args: {}", e)))?;
244
245        // 4. Call execute function (returns packed i64)
246        let execute_fn = self
247            .instance
248            .get_typed_func::<(i32, i32), i64>(&mut self.store, "plugin_execute")
249            .map_err(|_| LoaderError::FunctionNotFound("plugin_execute".to_string()))?;
250
251        // Reset fuel for execution
252        self.store
253            .set_fuel(10_000_000)
254            .map_err(|e| LoaderError::StoreConfig(format!("Failed to reset fuel: {}", e)))?;
255
256        let packed = execute_fn
257            .call(&mut self.store, (args_ptr, args_len))
258            .map_err(|e| {
259                if e.downcast_ref::<Trap>()
260                    .is_some_and(|t| *t == Trap::OutOfFuel)
261                {
262                    LoaderError::FuelExhausted
263                } else {
264                    LoaderError::FunctionCall {
265                        function: "plugin_execute",
266                        source: e,
267                    }
268                }
269            })?;
270
271        let (result_ptr, result_len) = unpack_ptr_len(packed);
272
273        // Validate result pointer and length are non-negative
274        if result_ptr < 0 || result_len < 0 {
275            return Err(LoaderError::MemoryAccess(format!(
276                "Invalid result pointer/length: ptr={}, len={}",
277                result_ptr, result_len
278            )));
279        }
280
281        // 5. Read result from memory
282        let result_bytes = PluginLoader::read_memory(
283            &self.store,
284            &self.memory,
285            result_ptr as usize,
286            result_len as usize,
287        )?;
288
289        let result: ExecuteResult =
290            rmp_serde::from_slice(&result_bytes).map_err(LoaderError::Deserialization)?;
291
292        // 6. Deallocate args and result memory
293        if let Err(e) = self.dealloc_fn.call(&mut self.store, (args_ptr, args_len)) {
294            tracing::warn!(error = %e, ptr = args_ptr, len = args_len, "Failed to deallocate args memory in plugin");
295        }
296        if let Err(e) = self
297            .dealloc_fn
298            .call(&mut self.store, (result_ptr, result_len))
299        {
300            tracing::warn!(error = %e, ptr = result_ptr, len = result_len, "Failed to deallocate result memory in plugin");
301        }
302
303        Ok(result)
304    }
305
306    /// Resume plugin execution after an effect completes
307    ///
308    /// Called by the host when an effect (HTTP request, sleep, etc.) completes.
309    /// Passes the result back to the plugin to continue execution.
310    ///
311    /// # Arguments
312    /// * `effect_id` - The ID of the completed effect
313    /// * `result` - The result of the effect
314    pub fn resume(
315        &mut self,
316        effect_id: u32,
317        result: &EffectResult,
318    ) -> Result<ExecuteResult, LoaderError> {
319        // 1. Serialize effect result
320        let result_bytes = rmp_serde::to_vec_named(result).map_err(|e| {
321            LoaderError::MemoryAccess(format!("Failed to serialize effect result: {}", e))
322        })?;
323
324        // 2. Allocate memory in guest
325        let result_len: i32 = result_bytes.len().try_into().map_err(|_| {
326            LoaderError::MemoryAccess(format!(
327                "Effect result too large: {} bytes exceeds i32::MAX",
328                result_bytes.len()
329            ))
330        })?;
331        let result_ptr = self
332            .alloc_fn
333            .call(&mut self.store, result_len)
334            .map_err(|e| LoaderError::FunctionCall {
335                function: "plugin_alloc",
336                source: e,
337            })?;
338
339        // 3. Write result to guest memory
340        self.memory
341            .write(&mut self.store, result_ptr as usize, &result_bytes)
342            .map_err(|e| {
343                LoaderError::MemoryAccess(format!("Failed to write effect result: {}", e))
344            })?;
345
346        // 4. Call resume function (returns packed i64)
347        let resume_fn = self
348            .instance
349            .get_typed_func::<(u32, i32, i32), i64>(&mut self.store, "plugin_resume")
350            .map_err(|_| LoaderError::FunctionNotFound("plugin_resume".to_string()))?;
351
352        // Reset fuel for execution
353        self.store
354            .set_fuel(10_000_000)
355            .map_err(|e| LoaderError::StoreConfig(format!("Failed to reset fuel: {}", e)))?;
356
357        let packed = resume_fn
358            .call(&mut self.store, (effect_id, result_ptr, result_len))
359            .map_err(|e| {
360                if e.downcast_ref::<Trap>()
361                    .is_some_and(|t| *t == Trap::OutOfFuel)
362                {
363                    LoaderError::FuelExhausted
364                } else {
365                    LoaderError::FunctionCall {
366                        function: "plugin_resume",
367                        source: e,
368                    }
369                }
370            })?;
371
372        let (exec_result_ptr, exec_result_len) = unpack_ptr_len(packed);
373
374        // Validate result pointer and length are non-negative
375        if exec_result_ptr < 0 || exec_result_len < 0 {
376            return Err(LoaderError::MemoryAccess(format!(
377                "Invalid result pointer/length: ptr={}, len={}",
378                exec_result_ptr, exec_result_len
379            )));
380        }
381
382        // 5. Read result from memory
383        let exec_result_bytes = PluginLoader::read_memory(
384            &self.store,
385            &self.memory,
386            exec_result_ptr as usize,
387            exec_result_len as usize,
388        )?;
389
390        let exec_result: ExecuteResult =
391            rmp_serde::from_slice(&exec_result_bytes).map_err(LoaderError::Deserialization)?;
392
393        // 6. Deallocate memory
394        if let Err(e) = self
395            .dealloc_fn
396            .call(&mut self.store, (result_ptr, result_len))
397        {
398            tracing::warn!(error = %e, ptr = result_ptr, len = result_len, "Failed to deallocate effect result memory");
399        }
400        if let Err(e) = self
401            .dealloc_fn
402            .call(&mut self.store, (exec_result_ptr, exec_result_len))
403        {
404            tracing::warn!(error = %e, ptr = exec_result_ptr, len = exec_result_len, "Failed to deallocate resume result memory");
405        }
406
407        Ok(exec_result)
408    }
409
410    /// Check if plugin supports effects (has plugin_resume function)
411    pub fn supports_effects(&mut self) -> bool {
412        self.instance
413            .get_typed_func::<(u32, i32, i32), i64>(&mut self.store, "plugin_resume")
414            .is_ok()
415    }
416}
417
418/// Effect handler trait for processing plugin effects
419///
420/// Implement this trait to handle effects from plugins.
421/// The host calls this handler when a plugin yields an effect.
422#[async_trait::async_trait]
423pub trait EffectHandler: Send + Sync {
424    /// Handle an effect and return the result
425    async fn handle(&self, effect: Effect) -> EffectResult;
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_loader_creation() {
434        let loader = PluginLoader::new();
435        assert!(loader.is_ok());
436    }
437
438    #[test]
439    fn test_pack_unpack() {
440        let ptr = 0x12345678_i32;
441        let len = 0x00000100_i32;
442        let packed = ((ptr as i64) << 32) | (len as i64 & 0xFFFFFFFF);
443        let (up, ul) = unpack_ptr_len(packed);
444        assert_eq!(up, ptr);
445        assert_eq!(ul, len);
446    }
447}