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, WireFormat};
21
22use crate::error::LoadError;
23use crate::loader::{self, LoadedPlugin};
24use crate::signing;
25use crate::types::{LoadPolicy, PluginInfo};
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_wire: Option<WireFormat>,
36    expected_strategy: Option<BufferStrategyKind>,
37}
38
39/// Builder for configuring a PluginHost.
40pub struct PluginHostBuilder {
41    search_paths: Vec<PathBuf>,
42    load_policy: LoadPolicy,
43    require_signature: bool,
44    trusted_keys: Vec<VerifyingKey>,
45    expected_hash: Option<u64>,
46    expected_wire: Option<WireFormat>,
47    expected_strategy: Option<BufferStrategyKind>,
48}
49
50impl PluginHostBuilder {
51    fn new() -> Self {
52        Self {
53            search_paths: Vec::new(),
54            load_policy: LoadPolicy::Strict,
55            require_signature: false,
56            trusted_keys: Vec::new(),
57            expected_hash: None,
58            expected_wire: None,
59            expected_strategy: None,
60        }
61    }
62
63    /// Add a directory to search for plugin dylibs.
64    pub fn search_path(mut self, path: impl Into<PathBuf>) -> Self {
65        self.search_paths.push(path.into());
66        self
67    }
68
69    /// Set the load policy (Strict or Lenient).
70    pub fn load_policy(mut self, policy: LoadPolicy) -> Self {
71        self.load_policy = policy;
72        self
73    }
74
75    /// Require plugins to have valid signatures.
76    pub fn require_signature(mut self, require: bool) -> Self {
77        self.require_signature = require;
78        self
79    }
80
81    /// Set trusted Ed25519 public keys for signature verification.
82    pub fn trusted_keys(mut self, keys: &[VerifyingKey]) -> Self {
83        self.trusted_keys = keys.to_vec();
84        self
85    }
86
87    /// Set the expected interface hash for validation.
88    pub fn interface_hash(mut self, hash: u64) -> Self {
89        self.expected_hash = Some(hash);
90        self
91    }
92
93    /// Set the expected wire format for validation.
94    pub fn wire_format(mut self, format: WireFormat) -> Self {
95        self.expected_wire = Some(format);
96        self
97    }
98
99    /// Set the expected buffer strategy for validation.
100    pub fn buffer_strategy(mut self, strategy: BufferStrategyKind) -> Self {
101        self.expected_strategy = Some(strategy);
102        self
103    }
104
105    /// Build the PluginHost.
106    pub fn build(self) -> Result<PluginHost, LoadError> {
107        Ok(PluginHost {
108            search_paths: self.search_paths,
109            load_policy: self.load_policy,
110            require_signature: self.require_signature,
111            trusted_keys: self.trusted_keys,
112            expected_hash: self.expected_hash,
113            expected_wire: self.expected_wire,
114            expected_strategy: self.expected_strategy,
115        })
116    }
117}
118
119impl PluginHost {
120    /// Create a new builder.
121    pub fn builder() -> PluginHostBuilder {
122        PluginHostBuilder::new()
123    }
124
125    /// Discover all valid plugins in the configured search paths.
126    ///
127    /// Scans directories for dylib files, loads each, validates,
128    /// and returns metadata for all valid plugins found.
129    pub fn discover(&self) -> Result<Vec<PluginInfo>, LoadError> {
130        #[cfg(feature = "tracing")]
131        tracing::info!(search_paths = ?self.search_paths, "discovering plugins");
132
133        let mut plugins = Vec::new();
134
135        for search_path in &self.search_paths {
136            if !search_path.is_dir() {
137                continue;
138            }
139
140            let entries = std::fs::read_dir(search_path)?;
141            for entry in entries {
142                let entry = entry?;
143                let path = entry.path();
144
145                if !is_dylib(&path) {
146                    continue;
147                }
148
149                // Verify signature before dlopen to prevent code execution from untrusted dylibs
150                if self.require_signature
151                    && signing::verify_signature(&path, &self.trusted_keys).is_err()
152                {
153                    continue;
154                }
155
156                match loader::load_library(&path) {
157                    Ok(loaded) => {
158                        for plugin in &loaded.plugins {
159                            if let Ok(()) = loader::validate_against_interface(
160                                plugin,
161                                self.expected_hash,
162                                self.expected_wire,
163                                self.expected_strategy,
164                            ) {
165                                plugins.push(plugin.info.clone());
166                            }
167                        }
168                    }
169                    Err(_) => {
170                        // Skip invalid dylibs during discovery
171                        continue;
172                    }
173                }
174            }
175        }
176
177        Ok(plugins)
178    }
179
180    /// Load a specific plugin by name.
181    ///
182    /// Searches all configured paths for a dylib containing a plugin
183    /// with the given name. Returns the loaded plugin ready for calling.
184    pub fn load(&self, name: &str) -> Result<LoadedPlugin, LoadError> {
185        #[cfg(feature = "tracing")]
186        tracing::info!(plugin_name = name, "loading plugin");
187
188        for search_path in &self.search_paths {
189            if !search_path.is_dir() {
190                continue;
191            }
192
193            let entries = std::fs::read_dir(search_path)?;
194            for entry in entries {
195                let entry = entry?;
196                let path = entry.path();
197
198                if !is_dylib(&path) {
199                    continue;
200                }
201
202                // Verify signature if required — always enforced regardless of LoadPolicy
203                if self.require_signature {
204                    signing::verify_signature(&path, &self.trusted_keys)?;
205                }
206
207                match loader::load_library(&path) {
208                    Ok(loaded) => {
209                        for plugin in loaded.plugins {
210                            if plugin.info.name == name {
211                                loader::validate_against_interface(
212                                    &plugin,
213                                    self.expected_hash,
214                                    self.expected_wire,
215                                    self.expected_strategy,
216                                )?;
217                                return Ok(plugin);
218                            }
219                        }
220                    }
221                    Err(_) => continue,
222                }
223            }
224        }
225
226        Err(LoadError::PluginNotFound {
227            name: name.to_string(),
228        })
229    }
230}
231
232/// Check if a path has a platform-appropriate dylib extension.
233fn is_dylib(path: &Path) -> bool {
234    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
235    if cfg!(target_os = "macos") {
236        ext == "dylib"
237    } else if cfg!(target_os = "windows") {
238        ext == "dll"
239    } else {
240        ext == "so"
241    }
242}