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