razeri-plugin-api 0.1.1

Extension traits and registries for Razeri plugins
Documentation
// Copyright (C) 2026 Gabriel Shea
// Licensed under either of the Apache License, Version 2.0 or the MIT
// license, at your option.

//! FFI-safe dynamic plugin loading.
//!
//! Plugins compiled as shared libraries (`.so`, `.dylib`, `.dll`) export a
//! registration function with the [`PLUGIN_REGISTER_SYMBOL`] name. The host
//! loads them at startup via [`load_plugins_from_dir`] and calls the
//! registration function to populate the [`PluginRegistryBuilder`].
//!
//! ## Safety
//!
//! Dynamic plugin loading is inherently unsafe — the host trusts that the
//! loaded library is ABI-compatible. Protections:
//!
//! - **API version check**: before calling `register`, the host verifies
//!   the plugin's reported API version matches [`API_VERSION`].
//! - **`catch_unwind`**: registration calls are wrapped to prevent panics
//!   from unwinding across the FFI boundary.
//! - **Directory permissions**: a warning is logged if the plugin directory
//!   is world-writable.

use crate::builder::PluginRegistryBuilder;

/// Current API version. Plugins must return this from their
/// [`PLUGIN_API_VERSION_SYMBOL`] export to be accepted.
pub const API_VERSION: u32 = 1;

/// Symbol name for the plugin registration function.
///
/// The function signature must be `unsafe fn(&mut PluginRegistryBuilder)`.
pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";

/// Symbol name for the plugin API version function.
///
/// Returns a `u32` matching [`API_VERSION`]. If the plugin does not export
/// this symbol, the host assumes version 1 (backwards-compatible).
pub const PLUGIN_API_VERSION_SYMBOL: &str = "razeri_plugin_api_version";

/// Type signature for the plugin registration entry point.
///
/// # Safety
///
/// The `builder` pointer must be valid and outlive the call.
pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);

/// Type signature for the optional API version query.
pub type ApiVersionFn = unsafe fn() -> u32;

/// Outcome of loading a single plugin library.
#[derive(Debug)]
pub enum PluginLoadResult {
    /// Plugin loaded and registered successfully.
    Loaded {
        /// Display name (file stem).
        name: String,
        /// Path to the shared library.
        path: std::path::PathBuf,
    },
    /// Library found but API version does not match.
    VersionMismatch {
        /// Path to the shared library.
        path: std::path::PathBuf,
        /// Expected API version.
        expected: u32,
        /// API version reported by the library.
        found: u32,
    },
    /// Library could not be loaded or registration failed.
    Failed {
        /// Path to the shared library.
        path: std::path::PathBuf,
        /// Error description.
        error: String,
    },
}

// ── Feature-gated loading implementation ────────────────────────────

// Dynamic library loading (`libloading`/dlopen) is native-only: there is no
// shared-library loading on wasm32-unknown-unknown, and `libloading` is not a
// dependency of the wasm build. The `dynamic` feature is a no-op on wasm.
#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
mod loader {
    use super::{
        API_VERSION, ApiVersionFn, PLUGIN_API_VERSION_SYMBOL, PLUGIN_REGISTER_SYMBOL,
        PluginLoadResult, PluginRegistryBuilder, RegisterPluginFn,
    };
    use std::path::Path;

    /// Load all plugin shared libraries from `dir` and register them
    /// with `builder`.
    ///
    /// Scans `dir` for files with the platform-appropriate extension
    /// (`.so`, `.dylib`, `.dll`). Each library is loaded, its API
    /// version is checked, and its registration function is called.
    ///
    /// Returns one [`PluginLoadResult`] per library file found.
    pub fn load_plugins_from_dir(
        dir: &Path,
        builder: &mut PluginRegistryBuilder,
    ) -> Vec<PluginLoadResult> {
        warn_if_world_writable(dir);

        let entries = match std::fs::read_dir(dir) {
            Ok(e) => e,
            Err(e) => {
                tracing::debug!("Cannot read plugin directory {}: {e}", dir.display());
                return Vec::new();
            }
        };

        let mut results = Vec::new();

        for entry in entries.flatten() {
            let path = entry.path();
            if !is_shared_library(&path) {
                continue;
            }
            results.push(load_single_plugin(&path, builder));
        }

        results
    }

    /// Load a single shared library plugin.
    fn load_single_plugin(path: &Path, builder: &mut PluginRegistryBuilder) -> PluginLoadResult {
        let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();

        // Safety: loading a shared library is inherently unsafe.
        // We trust that files placed in the plugin directory are
        // valid Razeri plugins compiled against a compatible API.
        let lib = match unsafe { libloading::Library::new(path) } {
            Ok(l) => l,
            Err(e) => {
                return PluginLoadResult::Failed {
                    path: path.to_path_buf(),
                    error: format!("Failed to load library: {e}"),
                };
            }
        };

        // Check API version (optional symbol — default to 1 if absent).
        let plugin_version = unsafe {
            match lib.get::<ApiVersionFn>(PLUGIN_API_VERSION_SYMBOL.as_bytes()) {
                Ok(version_fn) => version_fn(),
                Err(_) => 1, // Assume v1 if not exported
            }
        };

        if plugin_version != API_VERSION {
            return PluginLoadResult::VersionMismatch {
                path: path.to_path_buf(),
                expected: API_VERSION,
                found: plugin_version,
            };
        }

        // Look up the registration function.
        let register_fn = unsafe {
            match lib.get::<RegisterPluginFn>(PLUGIN_REGISTER_SYMBOL.as_bytes()) {
                Ok(f) => *f,
                Err(e) => {
                    return PluginLoadResult::Failed {
                        path: path.to_path_buf(),
                        error: format!("Missing symbol '{PLUGIN_REGISTER_SYMBOL}': {e}"),
                    };
                }
            }
        };

        // Call registration wrapped in catch_unwind for safety.
        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            // Safety: we verified the symbol exists and version matches.
            unsafe { register_fn(builder) };
        }));

        match result {
            Ok(()) => {
                tracing::info!("Loaded plugin '{name}' from {}", path.display());
                // Leak the library so it stays loaded for the process lifetime.
                // Unloading shared libraries while trait objects from them are
                // alive would be use-after-free.
                std::mem::forget(lib);
                PluginLoadResult::Loaded { name, path: path.to_path_buf() }
            }
            Err(_) => PluginLoadResult::Failed {
                path: path.to_path_buf(),
                error: "Plugin registration panicked".to_string(),
            },
        }
    }

    /// Check if a path has a shared-library extension.
    fn is_shared_library(path: &Path) -> bool {
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        matches!(ext, "so" | "dylib" | "dll")
    }

    /// Emit a warning if the directory is world-writable (Unix only).
    #[cfg(unix)]
    fn warn_if_world_writable(dir: &Path) {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(meta) = std::fs::metadata(dir) {
            let mode = meta.permissions().mode();
            if mode & 0o002 != 0 {
                tracing::warn!(
                    "Plugin directory {} is world-writable — this is a security risk",
                    dir.display()
                );
            }
        }
    }

    #[cfg(not(unix))]
    fn warn_if_world_writable(_dir: &Path) {
        // No-op on non-Unix platforms.
    }
}

#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
pub use loader::load_plugins_from_dir;

// ── Tests ────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn api_version_is_one() {
        assert_eq!(API_VERSION, 1);
    }

    #[test]
    fn symbol_names_are_stable() {
        assert_eq!(PLUGIN_REGISTER_SYMBOL, "razeri_plugin_register_v1");
        assert_eq!(PLUGIN_API_VERSION_SYMBOL, "razeri_plugin_api_version");
    }

    #[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
    mod dynamic_tests {
        use super::*;
        use std::path::Path;

        #[test]
        fn load_from_nonexistent_dir_returns_empty() {
            let mut builder = PluginRegistryBuilder::new();
            let results = load_plugins_from_dir(
                Path::new("/tmp/razeri-nonexistent-plugin-dir-12345"),
                &mut builder,
            );
            assert!(results.is_empty());
        }

        #[test]
        fn load_from_empty_dir_returns_empty() {
            let dir = tempfile::tempdir().unwrap();
            let mut builder = PluginRegistryBuilder::new();
            let results = load_plugins_from_dir(dir.path(), &mut builder);
            assert!(results.is_empty());
        }

        #[test]
        fn load_skips_non_library_files() {
            let dir = tempfile::tempdir().unwrap();
            std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
            std::fs::write(dir.path().join("data.json"), "{}").unwrap();

            let mut builder = PluginRegistryBuilder::new();
            let results = load_plugins_from_dir(dir.path(), &mut builder);
            assert!(results.is_empty());
        }

        #[test]
        fn load_invalid_so_returns_failed() {
            let dir = tempfile::tempdir().unwrap();
            let lib_name = if cfg!(target_os = "macos") {
                "bad.dylib"
            } else if cfg!(target_os = "windows") {
                "bad.dll"
            } else {
                "bad.so"
            };
            std::fs::write(dir.path().join(lib_name), "not a real library").unwrap();

            let mut builder = PluginRegistryBuilder::new();
            let results = load_plugins_from_dir(dir.path(), &mut builder);
            assert_eq!(results.len(), 1);
            assert!(
                matches!(&results[0], PluginLoadResult::Failed { error, .. } if error.contains("Failed to load library"))
            );
        }
    }
}