Skip to main content

razeri_plugin_api/
dynamic.rs

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