razeri_plugin_api/
dynamic.rs1use crate::builder::PluginRegistryBuilder;
23
24pub const API_VERSION: u32 = 1;
27
28pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";
32
33pub const PLUGIN_API_VERSION_SYMBOL: &str = "razeri_plugin_api_version";
38
39pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);
45
46pub type ApiVersionFn = unsafe fn() -> u32;
48
49#[derive(Debug)]
51pub enum PluginLoadResult {
52 Loaded {
54 name: String,
56 path: std::path::PathBuf,
58 },
59 VersionMismatch {
61 path: std::path::PathBuf,
63 expected: u32,
65 found: u32,
67 },
68 Failed {
70 path: std::path::PathBuf,
72 error: String,
74 },
75}
76
77#[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 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 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 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 let plugin_version = unsafe {
144 match lib.get::<ApiVersionFn>(PLUGIN_API_VERSION_SYMBOL.as_bytes()) {
145 Ok(version_fn) => version_fn(),
146 Err(_) => 1, }
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 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 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
173 unsafe { register_fn(builder) };
175 }));
176
177 match result {
178 Ok(()) => {
179 tracing::info!("Loaded plugin '{name}' from {}", path.display());
180 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 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 #[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 }
218}
219
220#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
221pub use loader::load_plugins_from_dir;
222
223#[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}