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