Skip to main content

fidius_host/
host.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! PluginHost builder and plugin discovery.
16
17use std::path::{Path, PathBuf};
18
19use ed25519_dalek::VerifyingKey;
20use fidius_core::descriptor::BufferStrategyKind;
21
22use crate::error::LoadError;
23use crate::loader::{self, LoadedPlugin};
24use crate::signing;
25use crate::types::{LoadPolicy, PluginInfo, PluginRuntimeKind};
26
27/// Host for loading and managing plugins.
28#[allow(dead_code)] // load_policy will be used for non-security validation (hash/version lenient)
29pub struct PluginHost {
30    search_paths: Vec<PathBuf>,
31    load_policy: LoadPolicy,
32    require_signature: bool,
33    trusted_keys: Vec<VerifyingKey>,
34    expected_hash: Option<u64>,
35    expected_strategy: Option<BufferStrategyKind>,
36}
37
38/// Builder for configuring a PluginHost.
39pub struct PluginHostBuilder {
40    search_paths: Vec<PathBuf>,
41    load_policy: LoadPolicy,
42    require_signature: bool,
43    trusted_keys: Vec<VerifyingKey>,
44    expected_hash: Option<u64>,
45    expected_strategy: Option<BufferStrategyKind>,
46}
47
48impl PluginHostBuilder {
49    fn new() -> Self {
50        Self {
51            search_paths: Vec::new(),
52            load_policy: LoadPolicy::Strict,
53            require_signature: false,
54            trusted_keys: Vec::new(),
55            expected_hash: None,
56            expected_strategy: None,
57        }
58    }
59
60    /// Add a directory to search for plugin dylibs.
61    pub fn search_path(mut self, path: impl Into<PathBuf>) -> Self {
62        self.search_paths.push(path.into());
63        self
64    }
65
66    /// Set the load policy (Strict or Lenient).
67    pub fn load_policy(mut self, policy: LoadPolicy) -> Self {
68        self.load_policy = policy;
69        self
70    }
71
72    /// Require plugins to have valid signatures.
73    pub fn require_signature(mut self, require: bool) -> Self {
74        self.require_signature = require;
75        self
76    }
77
78    /// Set trusted Ed25519 public keys for signature verification.
79    pub fn trusted_keys(mut self, keys: &[VerifyingKey]) -> Self {
80        self.trusted_keys = keys.to_vec();
81        self
82    }
83
84    /// Set the expected interface hash for validation.
85    pub fn interface_hash(mut self, hash: u64) -> Self {
86        self.expected_hash = Some(hash);
87        self
88    }
89
90    /// Set the expected buffer strategy for validation.
91    pub fn buffer_strategy(mut self, strategy: BufferStrategyKind) -> Self {
92        self.expected_strategy = Some(strategy);
93        self
94    }
95
96    /// Build the PluginHost.
97    pub fn build(self) -> Result<PluginHost, LoadError> {
98        Ok(PluginHost {
99            search_paths: self.search_paths,
100            load_policy: self.load_policy,
101            require_signature: self.require_signature,
102            trusted_keys: self.trusted_keys,
103            expected_hash: self.expected_hash,
104            expected_strategy: self.expected_strategy,
105        })
106    }
107}
108
109impl PluginHost {
110    /// Create a new builder.
111    pub fn builder() -> PluginHostBuilder {
112        PluginHostBuilder::new()
113    }
114
115    /// Discover all valid plugins in the configured search paths.
116    ///
117    /// Scans each path for both:
118    /// - dylib files (cdylib plugins, the existing path), and
119    /// - subdirectories containing a `package.toml` with `runtime = "python"`
120    ///   (when the `python` feature is enabled).
121    ///
122    /// Returns owned `PluginInfo` for every valid plugin found, with
123    /// `PluginInfo::runtime` distinguishing the two kinds.
124    pub fn discover(&self) -> Result<Vec<PluginInfo>, LoadError> {
125        #[cfg(feature = "tracing")]
126        tracing::info!(search_paths = ?self.search_paths, "discovering plugins");
127
128        let mut plugins = Vec::new();
129
130        for search_path in &self.search_paths {
131            if !search_path.is_dir() {
132                continue;
133            }
134
135            let entries = std::fs::read_dir(search_path)?;
136            for entry in entries {
137                let entry = entry?;
138                let path = entry.path();
139
140                if is_dylib(&path) {
141                    self.discover_cdylib(&path, &mut plugins);
142                } else if path.is_dir() && path.join("package.toml").exists() {
143                    self.discover_package(&path, &mut plugins);
144                }
145            }
146        }
147
148        Ok(plugins)
149    }
150
151    fn discover_cdylib(&self, path: &Path, plugins: &mut Vec<PluginInfo>) {
152        // Verify signature before dlopen to prevent code execution from untrusted dylibs
153        if self.require_signature && signing::verify_signature(path, &self.trusted_keys).is_err() {
154            return;
155        }
156
157        let Ok(loaded) = loader::load_library(path) else {
158            return; // Skip invalid dylibs during discovery
159        };
160        for plugin in &loaded.plugins {
161            if loader::validate_against_interface(
162                plugin,
163                self.expected_hash,
164                self.expected_strategy,
165            )
166            .is_ok()
167            {
168                plugins.push(plugin.info.clone());
169            }
170        }
171    }
172
173    /// Discover a directory-based package (`package.toml`) and surface it by
174    /// runtime. Rust source packages are discovered via their built dylib (the
175    /// loadable artifact), not here, so they're skipped.
176    fn discover_package(&self, dir: &Path, plugins: &mut Vec<PluginInfo>) {
177        let Ok(manifest) = fidius_core::package::load_manifest_untyped(dir) else {
178            return;
179        };
180        use fidius_core::package::PackageRuntime;
181        let runtime = match manifest.package.runtime() {
182            PackageRuntime::Python => PluginRuntimeKind::Python,
183            PackageRuntime::Wasm => PluginRuntimeKind::Wasm,
184            // The cdylib is the loadable artifact for a Rust package; the
185            // source directory isn't discovered.
186            PackageRuntime::Rust => return,
187        };
188        plugins.push(PluginInfo {
189            name: manifest.package.name.clone(),
190            interface_name: manifest.package.interface.clone(),
191            // Hash is unknown until load (the host validates against the
192            // descriptor at load time, not discovery). Surface 0 so callers
193            // know discovery alone hasn't validated the package.
194            interface_hash: 0,
195            interface_version: manifest.package.interface_version,
196            capabilities: 0,
197            buffer_strategy: BufferStrategyKind::PluginAllocated,
198            runtime,
199        });
200    }
201
202    /// Load a specific plugin by name.
203    ///
204    /// Searches all configured paths for a dylib containing a plugin
205    /// with the given name. Returns the loaded plugin ready for calling.
206    pub fn load(&self, name: &str) -> Result<LoadedPlugin, LoadError> {
207        #[cfg(feature = "tracing")]
208        tracing::info!(plugin_name = name, "loading plugin");
209
210        for search_path in &self.search_paths {
211            if !search_path.is_dir() {
212                continue;
213            }
214
215            let entries = std::fs::read_dir(search_path)?;
216            for entry in entries {
217                let entry = entry?;
218                let path = entry.path();
219
220                if !is_dylib(&path) {
221                    continue;
222                }
223
224                // Verify signature if required — always enforced regardless of LoadPolicy
225                if self.require_signature {
226                    signing::verify_signature(&path, &self.trusted_keys)?;
227                }
228
229                match loader::load_library(&path) {
230                    Ok(loaded) => {
231                        for plugin in loaded.plugins {
232                            if plugin.info.name == name {
233                                loader::validate_against_interface(
234                                    &plugin,
235                                    self.expected_hash,
236                                    self.expected_strategy,
237                                )?;
238                                return Ok(plugin);
239                            }
240                        }
241                    }
242                    Err(_) => continue,
243                }
244            }
245        }
246
247        Err(LoadError::PluginNotFound {
248            name: name.to_string(),
249        })
250    }
251
252    /// Find a python plugin package directory by name across the configured
253    /// search paths. The plugin name is matched against `package.toml`'s
254    /// `[package].name`. Returns the directory path on success.
255    pub fn find_python_package(&self, name: &str) -> Result<PathBuf, LoadError> {
256        for search_path in &self.search_paths {
257            if !search_path.is_dir() {
258                continue;
259            }
260            let entries = std::fs::read_dir(search_path)?;
261            for entry in entries {
262                let entry = entry?;
263                let path = entry.path();
264                if !path.is_dir() {
265                    continue;
266                }
267                if !path.join("package.toml").exists() {
268                    continue;
269                }
270                let Ok(manifest) = fidius_core::package::load_manifest_untyped(&path) else {
271                    continue;
272                };
273                if matches!(
274                    manifest.package.runtime(),
275                    fidius_core::package::PackageRuntime::Python
276                ) && manifest.package.name == name
277                {
278                    return Ok(path);
279                }
280            }
281        }
282        Err(LoadError::PluginNotFound {
283            name: name.to_string(),
284        })
285    }
286
287    /// Load a Python plugin package by name and validate it against the
288    /// supplied interface descriptor.
289    ///
290    /// The caller passes the static `<TraitName>_PYTHON_DESCRIPTOR` emitted
291    /// by the interface crate's `#[plugin_interface]` macro — that's the
292    /// out-of-band hint the loader needs to map method names to vtable
293    /// indices and to check the interface hash.
294    ///
295    /// Available only when fidius-host is built with the `python` feature.
296    #[cfg(feature = "python")]
297    pub fn load_python(
298        &self,
299        name: &str,
300        descriptor: &'static fidius_core::python_descriptor::PythonInterfaceDescriptor,
301    ) -> Result<crate::handle::PluginHandle, LoadError> {
302        let dir = self.find_python_package(name)?;
303        // Signature policy — enforced identically to cdylib/WASM loads.
304        if self.require_signature {
305            signing::verify_package_signature(&dir, &self.trusted_keys)?;
306        }
307        let manifest = fidius_core::package::load_manifest_untyped(&dir)
308            .map_err(|e| LoadError::PythonLoad(e.to_string()))?;
309        let py = fidius_python::load_python_plugin(&dir, descriptor)
310            .map_err(|e| LoadError::PythonLoad(e.to_string()))?;
311        // Build the host-facing metadata from the manifest header + the
312        // interface descriptor. `capabilities`/`buffer_strategy` are cdylib
313        // concepts and take their no-op defaults for Python.
314        let info = crate::types::PluginInfo {
315            name: manifest.package.name.clone(),
316            interface_name: descriptor.interface_name.to_string(),
317            interface_hash: descriptor.interface_hash,
318            interface_version: manifest.package.interface_version,
319            capabilities: 0,
320            buffer_strategy: fidius_core::descriptor::BufferStrategyKind::PluginAllocated,
321            runtime: crate::types::PluginRuntimeKind::Python,
322        };
323        Ok(crate::handle::PluginHandle::from_python(py, info))
324    }
325
326    /// Find a WASM package directory by name across the search paths (matches
327    /// `package.toml` `[package].name` with `runtime = "wasm"`).
328    #[cfg(feature = "wasm")]
329    pub fn find_wasm_package(&self, name: &str) -> Result<PathBuf, LoadError> {
330        for search_path in &self.search_paths {
331            if !search_path.is_dir() {
332                continue;
333            }
334            for entry in std::fs::read_dir(search_path)? {
335                let entry = entry?;
336                let path = entry.path();
337                if !path.is_dir() || !path.join("package.toml").exists() {
338                    continue;
339                }
340                let Ok(manifest) = fidius_core::package::load_manifest_untyped(&path) else {
341                    continue;
342                };
343                if matches!(
344                    manifest.package.runtime(),
345                    fidius_core::package::PackageRuntime::Wasm
346                ) && manifest.package.name == name
347                {
348                    return Ok(path);
349                }
350            }
351        }
352        Err(LoadError::PluginNotFound {
353            name: name.to_string(),
354        })
355    }
356
357    /// Load a WASM component plugin package by name and validate it against the
358    /// supplied interface descriptor (the `<TraitName>_WASM_DESCRIPTOR` the
359    /// interface crate emits). Returns a unified [`crate::handle::PluginHandle`].
360    ///
361    /// The component is sandboxed: WASI is wired into the `Linker` but the guest
362    /// gets a zero-grant `WasiCtx` (no FS preopens, no env, no sockets). The
363    /// capability allow-list in `[wasm].capabilities` is applied in T-0104.
364    ///
365    /// Available only with the `wasm` feature.
366    #[cfg(feature = "wasm")]
367    pub fn load_wasm(
368        &self,
369        name: &str,
370        descriptor: &'static fidius_core::wasm_descriptor::WasmInterfaceDescriptor,
371    ) -> Result<crate::handle::PluginHandle, LoadError> {
372        use crate::executor::wasm::{WasmComponentExecutor, WasmMethod};
373
374        let dir = self.find_wasm_package(name)?;
375        // Signature policy — enforced identically to cdylib/Python loads.
376        if self.require_signature {
377            signing::verify_package_signature(&dir, &self.trusted_keys)?;
378        }
379        let manifest = fidius_core::package::load_manifest_untyped(&dir)
380            .map_err(|e| LoadError::WasmLoad(e.to_string()))?;
381        let wasm_meta = manifest
382            .wasm
383            .as_ref()
384            .ok_or_else(|| LoadError::WasmLoad("manifest is missing the [wasm] section".into()))?;
385
386        let methods: Vec<WasmMethod> = descriptor
387            .methods
388            .iter()
389            .map(|m| WasmMethod {
390                name: m.name.to_string(),
391                wire_raw: m.wire_raw,
392            })
393            .collect();
394        let info = crate::types::PluginInfo {
395            name: manifest.package.name.clone(),
396            interface_name: descriptor.interface_name.to_string(),
397            interface_hash: descriptor.interface_hash,
398            interface_version: manifest.package.interface_version,
399            capabilities: 0,
400            buffer_strategy: fidius_core::descriptor::BufferStrategyKind::PluginAllocated,
401            runtime: crate::types::PluginRuntimeKind::Wasm,
402        };
403        let interface = descriptor.interface_export.to_string();
404        let capabilities = wasm_meta.capabilities.clone();
405
406        // Resolve a precompiled .cwasm: explicit `[wasm].precompiled`, or an
407        // auto-detected sibling `<component-stem>.cwasm`. The AOT path is purely
408        // a load-latency optimization, so a stale/mismatched .cwasm (built by a
409        // different wasmtime) is non-fatal — we log and JIT-compile the
410        // component instead (FIDIUS-T-0107).
411        let cwasm_path = wasm_meta
412            .precompiled
413            .as_ref()
414            .map(|p| dir.join(p))
415            .or_else(|| {
416                let sibling = dir.join(&wasm_meta.component).with_extension("cwasm");
417                sibling.exists().then_some(sibling)
418            });
419
420        let jit = |interface: String, methods, capabilities, info| -> Result<_, LoadError> {
421            let bytes = std::fs::read(dir.join(&wasm_meta.component))?;
422            WasmComponentExecutor::from_component_bytes(
423                &bytes,
424                interface,
425                methods,
426                capabilities,
427                info,
428            )
429            .map_err(|e| LoadError::WasmLoad(e.to_string()))
430        };
431
432        let executor = match cwasm_path {
433            Some(cwasm) if cwasm.exists() => {
434                let bytes = std::fs::read(&cwasm)?;
435                // SAFETY: .cwasm is produced by `fidius pack`
436                // (Engine::precompile_component); wasmtime validates the header
437                // and refuses a mismatched engine/version (→ Err → JIT fallback).
438                let aot = unsafe {
439                    WasmComponentExecutor::from_cwasm(
440                        &bytes,
441                        interface.clone(),
442                        methods.clone(),
443                        capabilities.clone(),
444                        info.clone(),
445                    )
446                };
447                match aot {
448                    Ok(e) => e,
449                    Err(_err) => {
450                        #[cfg(feature = "tracing")]
451                        tracing::warn!(
452                            cwasm = %cwasm.display(),
453                            error = %_err,
454                            "precompiled .cwasm rejected (likely engine/version mismatch); falling back to JIT"
455                        );
456                        jit(interface, methods, capabilities, info)?
457                    }
458                }
459            }
460            _ => jit(interface, methods, capabilities, info)?,
461        };
462
463        // Interface-hash integrity check (parity with cdylib/Python).
464        let got = executor
465            .interface_hash()
466            .map_err(|e| LoadError::WasmLoad(e.to_string()))?;
467        if got != descriptor.interface_hash {
468            return Err(LoadError::InterfaceHashMismatch {
469                got,
470                expected: descriptor.interface_hash,
471            });
472        }
473
474        Ok(crate::handle::PluginHandle::from_wasm(executor))
475    }
476}
477
478/// Check if a path has a platform-appropriate dylib extension.
479fn is_dylib(path: &Path) -> bool {
480    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
481    if cfg!(target_os = "macos") {
482        ext == "dylib"
483    } else if cfg!(target_os = "windows") {
484        ext == "dll"
485    } else {
486        ext == "so"
487    }
488}