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_python_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    fn discover_python_package(&self, dir: &Path, plugins: &mut Vec<PluginInfo>) {
174        let Ok(manifest) = fidius_core::package::load_manifest_untyped(dir) else {
175            return;
176        };
177        if !matches!(
178            manifest.package.runtime(),
179            fidius_core::package::PackageRuntime::Python
180        ) {
181            return;
182        }
183        plugins.push(PluginInfo {
184            name: manifest.package.name.clone(),
185            interface_name: manifest.package.interface.clone(),
186            // Hash is unknown until load (the host validates against the
187            // descriptor at load time, not discovery). Surface 0 so callers
188            // know discovery alone hasn't validated the package.
189            interface_hash: 0,
190            interface_version: manifest.package.interface_version,
191            capabilities: 0,
192            buffer_strategy: BufferStrategyKind::PluginAllocated,
193            runtime: PluginRuntimeKind::Python,
194        });
195    }
196
197    /// Load a specific plugin by name.
198    ///
199    /// Searches all configured paths for a dylib containing a plugin
200    /// with the given name. Returns the loaded plugin ready for calling.
201    pub fn load(&self, name: &str) -> Result<LoadedPlugin, LoadError> {
202        #[cfg(feature = "tracing")]
203        tracing::info!(plugin_name = name, "loading plugin");
204
205        for search_path in &self.search_paths {
206            if !search_path.is_dir() {
207                continue;
208            }
209
210            let entries = std::fs::read_dir(search_path)?;
211            for entry in entries {
212                let entry = entry?;
213                let path = entry.path();
214
215                if !is_dylib(&path) {
216                    continue;
217                }
218
219                // Verify signature if required — always enforced regardless of LoadPolicy
220                if self.require_signature {
221                    signing::verify_signature(&path, &self.trusted_keys)?;
222                }
223
224                match loader::load_library(&path) {
225                    Ok(loaded) => {
226                        for plugin in loaded.plugins {
227                            if plugin.info.name == name {
228                                loader::validate_against_interface(
229                                    &plugin,
230                                    self.expected_hash,
231                                    self.expected_strategy,
232                                )?;
233                                return Ok(plugin);
234                            }
235                        }
236                    }
237                    Err(_) => continue,
238                }
239            }
240        }
241
242        Err(LoadError::PluginNotFound {
243            name: name.to_string(),
244        })
245    }
246
247    /// Find a python plugin package directory by name across the configured
248    /// search paths. The plugin name is matched against `package.toml`'s
249    /// `[package].name`. Returns the directory path on success.
250    pub fn find_python_package(&self, name: &str) -> Result<PathBuf, LoadError> {
251        for search_path in &self.search_paths {
252            if !search_path.is_dir() {
253                continue;
254            }
255            let entries = std::fs::read_dir(search_path)?;
256            for entry in entries {
257                let entry = entry?;
258                let path = entry.path();
259                if !path.is_dir() {
260                    continue;
261                }
262                if !path.join("package.toml").exists() {
263                    continue;
264                }
265                let Ok(manifest) = fidius_core::package::load_manifest_untyped(&path) else {
266                    continue;
267                };
268                if matches!(
269                    manifest.package.runtime(),
270                    fidius_core::package::PackageRuntime::Python
271                ) && manifest.package.name == name
272                {
273                    return Ok(path);
274                }
275            }
276        }
277        Err(LoadError::PluginNotFound {
278            name: name.to_string(),
279        })
280    }
281
282    /// Load a Python plugin package by name and validate it against the
283    /// supplied interface descriptor.
284    ///
285    /// The caller passes the static `<TraitName>_PYTHON_DESCRIPTOR` emitted
286    /// by the interface crate's `#[plugin_interface]` macro — that's the
287    /// out-of-band hint the loader needs to map method names to vtable
288    /// indices and to check the interface hash.
289    ///
290    /// Available only when fidius-host is built with the `python` feature.
291    #[cfg(feature = "python")]
292    pub fn load_python(
293        &self,
294        name: &str,
295        descriptor: &'static fidius_core::python_descriptor::PythonInterfaceDescriptor,
296    ) -> Result<fidius_python::PythonPluginHandle, LoadError> {
297        let dir = self.find_python_package(name)?;
298        fidius_python::load_python_plugin(&dir, descriptor)
299            .map_err(|e| LoadError::PythonLoad(e.to_string()))
300    }
301}
302
303/// Check if a path has a platform-appropriate dylib extension.
304fn is_dylib(path: &Path) -> bool {
305    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
306    if cfg!(target_os = "macos") {
307        ext == "dylib"
308    } else if cfg!(target_os = "windows") {
309        ext == "dll"
310    } else {
311        ext == "so"
312    }
313}