razeri_plugin_api/
dynamic.rs1use crate::builder::PluginRegistryBuilder;
25
26pub const API_VERSION: u32 = 1;
29
30pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";
34
35pub const PLUGIN_API_VERSION_SYMBOL: &str = "razeri_plugin_api_version";
40
41pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);
47
48pub type ApiVersionFn = unsafe fn() -> u32;
50
51#[derive(Debug)]
53pub enum PluginLoadResult {
54 Loaded {
56 name: String,
58 path: std::path::PathBuf,
60 },
61 VersionMismatch {
63 path: std::path::PathBuf,
65 expected: u32,
67 found: u32,
69 },
70 Failed {
72 path: std::path::PathBuf,
74 error: String,
76 },
77}
78
79#[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 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 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 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 let plugin_version = unsafe {
146 match lib.get::<ApiVersionFn>(PLUGIN_API_VERSION_SYMBOL.as_bytes()) {
147 Ok(version_fn) => version_fn(),
148 Err(_) => 1, }
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 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 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
175 unsafe { register_fn(builder) };
177 }));
178
179 match result {
180 Ok(()) => {
181 tracing::info!("Loaded plugin '{name}' from {}", path.display());
182 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 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 #[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 }
220}
221
222#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
223pub use loader::load_plugins_from_dir;
224
225#[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}