Skip to main content

razeri_plugin_api/
dynamic.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3//! FFI-safe dynamic plugin loading.
4//!
5//! Plugins compiled as shared libraries (`.so`, `.dylib`, `.dll`) export a
6//! registration function with the [`PLUGIN_REGISTER_SYMBOL`] name. The host
7//! loads them at startup via [`load_plugins_from_dir`] and calls the
8//! registration function to populate the [`PluginRegistryBuilder`].
9//!
10//! ## Safety
11//!
12//! Dynamic plugin loading is inherently unsafe — the host trusts that the
13//! loaded library is ABI-compatible. Protections:
14//!
15//! - **API version check**: before calling `register`, the host verifies
16//!   the plugin's reported API version matches [`API_VERSION`].
17//! - **`catch_unwind`**: registration calls are wrapped to prevent panics
18//!   from unwinding across the FFI boundary.
19//! - **Directory permissions**: a warning is logged if the plugin directory
20//!   is world-writable.
21
22use crate::builder::PluginRegistryBuilder;
23
24/// Current API version. Plugins must return this from their
25/// [`PLUGIN_API_VERSION_SYMBOL`] export to be accepted.
26pub const API_VERSION: u32 = 1;
27
28/// Symbol name for the plugin registration function.
29///
30/// The function signature must be `unsafe fn(&mut PluginRegistryBuilder)`.
31pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";
32
33/// Symbol name for the plugin API version function.
34///
35/// Returns a `u32` matching [`API_VERSION`]. If the plugin does not export
36/// this symbol, the host assumes version 1 (backwards-compatible).
37pub const PLUGIN_API_VERSION_SYMBOL: &str = "razeri_plugin_api_version";
38
39/// Type signature for the plugin registration entry point.
40///
41/// # Safety
42///
43/// The `builder` pointer must be valid and outlive the call.
44pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);
45
46/// Type signature for the optional API version query.
47pub type ApiVersionFn = unsafe fn() -> u32;
48
49/// Outcome of loading a single plugin library.
50#[derive(Debug)]
51pub enum PluginLoadResult {
52    /// Plugin loaded and registered successfully.
53    Loaded {
54        /// Display name (file stem).
55        name: String,
56        /// Path to the shared library.
57        path: std::path::PathBuf,
58    },
59    /// Library found but API version does not match.
60    VersionMismatch {
61        /// Path to the shared library.
62        path: std::path::PathBuf,
63        /// Expected API version.
64        expected: u32,
65        /// API version reported by the library.
66        found: u32,
67    },
68    /// Library could not be loaded or registration failed.
69    Failed {
70        /// Path to the shared library.
71        path: std::path::PathBuf,
72        /// Error description.
73        error: String,
74    },
75}
76
77// ── Feature-gated loading implementation ────────────────────────────
78
79// Dynamic library loading (`libloading`/dlopen) is native-only: there is no
80// shared-library loading on wasm32-unknown-unknown, and `libloading` is not a
81// dependency of the wasm build. The `dynamic` feature is a no-op on wasm.
82#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
83mod loader {
84    use super::{
85        API_VERSION, ApiVersionFn, PLUGIN_API_VERSION_SYMBOL, PLUGIN_REGISTER_SYMBOL,
86        PluginLoadResult, PluginRegistryBuilder, RegisterPluginFn,
87    };
88    use std::path::Path;
89
90    /// Load all plugin shared libraries from `dir` and register them
91    /// with `builder`.
92    ///
93    /// Scans `dir` for files with the platform-appropriate extension
94    /// (`.so`, `.dylib`, `.dll`). Each library is loaded, its API
95    /// version is checked, and its registration function is called.
96    ///
97    /// Returns one [`PluginLoadResult`] per library file found.
98    pub fn load_plugins_from_dir(
99        dir: &Path,
100        builder: &mut PluginRegistryBuilder,
101    ) -> Vec<PluginLoadResult> {
102        warn_if_world_writable(dir);
103
104        let entries = match std::fs::read_dir(dir) {
105            Ok(e) => e,
106            Err(e) => {
107                tracing::debug!("Cannot read plugin directory {}: {e}", dir.display());
108                return Vec::new();
109            }
110        };
111
112        let mut results = Vec::new();
113
114        for entry in entries.flatten() {
115            let path = entry.path();
116            if !is_shared_library(&path) {
117                continue;
118            }
119            results.push(load_single_plugin(&path, builder));
120        }
121
122        results
123    }
124
125    /// Load a single shared library plugin.
126    fn load_single_plugin(path: &Path, builder: &mut PluginRegistryBuilder) -> PluginLoadResult {
127        let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
128
129        // Safety: loading a shared library is inherently unsafe.
130        // We trust that files placed in the plugin directory are
131        // valid Razeri plugins compiled against a compatible API.
132        let lib = match unsafe { libloading::Library::new(path) } {
133            Ok(l) => l,
134            Err(e) => {
135                return PluginLoadResult::Failed {
136                    path: path.to_path_buf(),
137                    error: format!("Failed to load library: {e}"),
138                };
139            }
140        };
141
142        // Check API version (optional symbol — default to 1 if absent).
143        let plugin_version = unsafe {
144            match lib.get::<ApiVersionFn>(PLUGIN_API_VERSION_SYMBOL.as_bytes()) {
145                Ok(version_fn) => version_fn(),
146                Err(_) => 1, // Assume v1 if not exported
147            }
148        };
149
150        if plugin_version != API_VERSION {
151            return PluginLoadResult::VersionMismatch {
152                path: path.to_path_buf(),
153                expected: API_VERSION,
154                found: plugin_version,
155            };
156        }
157
158        // Look up the registration function.
159        let register_fn = unsafe {
160            match lib.get::<RegisterPluginFn>(PLUGIN_REGISTER_SYMBOL.as_bytes()) {
161                Ok(f) => *f,
162                Err(e) => {
163                    return PluginLoadResult::Failed {
164                        path: path.to_path_buf(),
165                        error: format!("Missing symbol '{PLUGIN_REGISTER_SYMBOL}': {e}"),
166                    };
167                }
168            }
169        };
170
171        // Call registration wrapped in catch_unwind for safety.
172        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
173            // Safety: we verified the symbol exists and version matches.
174            unsafe { register_fn(builder) };
175        }));
176
177        match result {
178            Ok(()) => {
179                tracing::info!("Loaded plugin '{name}' from {}", path.display());
180                // Leak the library so it stays loaded for the process lifetime.
181                // Unloading shared libraries while trait objects from them are
182                // alive would be use-after-free.
183                std::mem::forget(lib);
184                PluginLoadResult::Loaded { name, path: path.to_path_buf() }
185            }
186            Err(_) => PluginLoadResult::Failed {
187                path: path.to_path_buf(),
188                error: "Plugin registration panicked".to_string(),
189            },
190        }
191    }
192
193    /// Check if a path has a shared-library extension.
194    fn is_shared_library(path: &Path) -> bool {
195        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
196        matches!(ext, "so" | "dylib" | "dll")
197    }
198
199    /// Emit a warning if the directory is world-writable (Unix only).
200    #[cfg(unix)]
201    fn warn_if_world_writable(dir: &Path) {
202        use std::os::unix::fs::PermissionsExt;
203        if let Ok(meta) = std::fs::metadata(dir) {
204            let mode = meta.permissions().mode();
205            if mode & 0o002 != 0 {
206                tracing::warn!(
207                    "Plugin directory {} is world-writable — this is a security risk",
208                    dir.display()
209                );
210            }
211        }
212    }
213
214    #[cfg(not(unix))]
215    fn warn_if_world_writable(_dir: &Path) {
216        // No-op on non-Unix platforms.
217    }
218}
219
220#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
221pub use loader::load_plugins_from_dir;
222
223// ── Tests ────────────────────────────────────────────────────────────
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn api_version_is_one() {
231        assert_eq!(API_VERSION, 1);
232    }
233
234    #[test]
235    fn symbol_names_are_stable() {
236        assert_eq!(PLUGIN_REGISTER_SYMBOL, "razeri_plugin_register_v1");
237        assert_eq!(PLUGIN_API_VERSION_SYMBOL, "razeri_plugin_api_version");
238    }
239
240    #[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
241    mod dynamic_tests {
242        use super::*;
243        use std::path::Path;
244
245        #[test]
246        fn load_from_nonexistent_dir_returns_empty() {
247            let mut builder = PluginRegistryBuilder::new();
248            let results = load_plugins_from_dir(
249                Path::new("/tmp/razeri-nonexistent-plugin-dir-12345"),
250                &mut builder,
251            );
252            assert!(results.is_empty());
253        }
254
255        #[test]
256        fn load_from_empty_dir_returns_empty() {
257            let dir = tempfile::tempdir().unwrap();
258            let mut builder = PluginRegistryBuilder::new();
259            let results = load_plugins_from_dir(dir.path(), &mut builder);
260            assert!(results.is_empty());
261        }
262
263        #[test]
264        fn load_skips_non_library_files() {
265            let dir = tempfile::tempdir().unwrap();
266            std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
267            std::fs::write(dir.path().join("data.json"), "{}").unwrap();
268
269            let mut builder = PluginRegistryBuilder::new();
270            let results = load_plugins_from_dir(dir.path(), &mut builder);
271            assert!(results.is_empty());
272        }
273
274        #[test]
275        fn load_invalid_so_returns_failed() {
276            let dir = tempfile::tempdir().unwrap();
277            let lib_name = if cfg!(target_os = "macos") {
278                "bad.dylib"
279            } else if cfg!(target_os = "windows") {
280                "bad.dll"
281            } else {
282                "bad.so"
283            };
284            std::fs::write(dir.path().join(lib_name), "not a real library").unwrap();
285
286            let mut builder = PluginRegistryBuilder::new();
287            let results = load_plugins_from_dir(dir.path(), &mut builder);
288            assert_eq!(results.len(), 1);
289            assert!(
290                matches!(&results[0], PluginLoadResult::Failed { error, .. } if error.contains("Failed to load library"))
291            );
292        }
293    }
294}