use std::ffi::c_void;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use indexmap::IndexMap;
use libloading::{Library, Symbol};
use drasi_plugin_sdk::ffi::{
FfiPluginRegistration, LifecycleCallbackFn, LogCallbackFn, PluginMetadata,
};
use crate::proxies::bootstrap_provider::BootstrapPluginProxy;
use crate::proxies::identity_provider::IdentityProviderPluginProxy;
use crate::proxies::reaction::ReactionPluginProxy;
use crate::proxies::source::SourcePluginProxy;
#[derive(Debug, Clone)]
pub struct PluginLoaderConfig {
pub plugin_dir: PathBuf,
pub file_patterns: Vec<String>,
}
pub struct LoadedPlugin {
pub source_plugins: Vec<SourcePluginProxy>,
pub reaction_plugins: Vec<ReactionPluginProxy>,
pub bootstrap_plugins: Vec<BootstrapPluginProxy>,
pub identity_provider_plugins: Vec<IdentityProviderPluginProxy>,
pub metadata_info: Option<String>,
pub file_path: PathBuf,
_library: Arc<Library>,
}
impl Drop for LoadedPlugin {
fn drop(&mut self) {
self.source_plugins.clear();
self.reaction_plugins.clear();
self.bootstrap_plugins.clear();
std::mem::forget(self._library.clone());
}
}
const CDYLIB_EXTENSIONS: &[&str] = &[".dylib", ".so", ".dll"];
const ALL_KNOWN_EXTENSIONS: &[&str] = &[".dylib", ".so", ".dll", ".rlib", ".rmeta", ".d"];
pub struct PluginLoader {
config: PluginLoaderConfig,
}
impl PluginLoader {
pub fn new(config: PluginLoaderConfig) -> Self {
Self { config }
}
pub fn load_all(
&self,
log_ctx: *mut c_void,
log_callback: LogCallbackFn,
lifecycle_ctx: *mut c_void,
lifecycle_callback: LifecycleCallbackFn,
) -> anyhow::Result<Vec<LoadedPlugin>> {
let mut plugins = Vec::new();
let plugin_dir = &self.config.plugin_dir;
if !plugin_dir.exists() {
log::warn!("Plugin directory does not exist: {}", plugin_dir.display());
return Ok(plugins);
}
let candidates = discover_plugin_candidates(plugin_dir, &self.config.file_patterns);
for (plugin_name, files) in &candidates {
let cdylib_files: Vec<&PathBuf> = files
.iter()
.filter(|p| {
CDYLIB_EXTENSIONS
.iter()
.any(|ext| p.to_string_lossy().ends_with(ext))
})
.collect();
if cdylib_files.is_empty() {
log::debug!(
"Plugin '{}': no cdylib files found (skipping {} non-cdylib file(s))",
plugin_name,
files.len()
);
continue;
}
if cdylib_files.len() > 1 {
log::error!(
"Plugin '{}': found {} cdylib files — ambiguous. \
Remove duplicates and keep only one: {:?}",
plugin_name,
cdylib_files.len(),
cdylib_files
);
continue;
}
let path = cdylib_files[0];
match self.load_plugin(
path,
log_ctx,
log_callback,
lifecycle_ctx,
lifecycle_callback,
) {
Ok(plugin) => {
log::info!(
"Loaded plugin: {} ({})",
path.display(),
plugin.metadata_info.as_deref().unwrap_or("no metadata")
);
plugins.push(plugin);
}
Err(e) => {
log::error!("Failed to load plugin {}: {}", path.display(), e);
}
}
}
Ok(plugins)
}
pub fn load_plugin(
&self,
path: &Path,
log_ctx: *mut c_void,
log_callback: LogCallbackFn,
lifecycle_ctx: *mut c_void,
lifecycle_callback: LifecycleCallbackFn,
) -> anyhow::Result<LoadedPlugin> {
load_plugin_from_path(
path,
log_ctx,
log_callback,
lifecycle_ctx,
lifecycle_callback,
)
}
}
pub fn load_plugin_from_path(
path: &Path,
log_ctx: *mut c_void,
log_callback: LogCallbackFn,
lifecycle_ctx: *mut c_void,
lifecycle_callback: LifecycleCallbackFn,
) -> anyhow::Result<LoadedPlugin> {
let lib = Arc::new(unsafe {
Library::new(path)
.map_err(|e| anyhow::anyhow!("Failed to load {}: {}", path.display(), e))?
});
let metadata_info = read_plugin_metadata(&lib);
validate_plugin_metadata(&lib, path)?;
let init_fn: Symbol<unsafe extern "C" fn() -> *mut FfiPluginRegistration> = unsafe {
lib.get(b"drasi_plugin_init").map_err(|e| {
anyhow::anyhow!("Missing drasi_plugin_init in {}: {}", path.display(), e)
})?
};
let reg_ptr = unsafe { init_fn() };
if reg_ptr.is_null() {
return Err(anyhow::anyhow!(
"drasi_plugin_init returned null (init panicked?) in {}",
path.display()
));
}
let registration = unsafe { Box::from_raw(reg_ptr) };
(registration.set_log_callback)(log_ctx, log_callback);
(registration.set_lifecycle_callback)(lifecycle_ctx, lifecycle_callback);
let source_vtables =
if !registration.source_plugins.is_null() && registration.source_plugin_count > 0 {
Some(unsafe {
Vec::from_raw_parts(
registration.source_plugins,
registration.source_plugin_count,
registration.source_plugin_count,
)
})
} else {
None
};
let reaction_vtables =
if !registration.reaction_plugins.is_null() && registration.reaction_plugin_count > 0 {
Some(unsafe {
Vec::from_raw_parts(
registration.reaction_plugins,
registration.reaction_plugin_count,
registration.reaction_plugin_count,
)
})
} else {
None
};
let bootstrap_vtables =
if !registration.bootstrap_plugins.is_null() && registration.bootstrap_plugin_count > 0 {
Some(unsafe {
Vec::from_raw_parts(
registration.bootstrap_plugins,
registration.bootstrap_plugin_count,
registration.bootstrap_plugin_count,
)
})
} else {
None
};
let identity_provider_vtables: Option<
Vec<drasi_plugin_sdk::ffi::IdentityProviderPluginVtable>,
> = None;
std::mem::forget(registration);
let mut source_plugins = Vec::new();
let mut reaction_plugins = Vec::new();
let mut bootstrap_plugins = Vec::new();
let mut identity_provider_plugins = Vec::new();
for v in source_vtables.into_iter().flatten() {
source_plugins.push(SourcePluginProxy::new(v, lib.clone()));
}
for v in reaction_vtables.into_iter().flatten() {
reaction_plugins.push(ReactionPluginProxy::new(v, lib.clone()));
}
for v in bootstrap_vtables.into_iter().flatten() {
bootstrap_plugins.push(BootstrapPluginProxy::new(v, lib.clone()));
}
for v in identity_provider_vtables.into_iter().flatten() {
identity_provider_plugins.push(IdentityProviderPluginProxy::new(v, lib.clone()));
}
Ok(LoadedPlugin {
source_plugins,
reaction_plugins,
bootstrap_plugins,
identity_provider_plugins,
metadata_info,
file_path: path.to_path_buf(),
_library: lib,
})
}
fn read_plugin_metadata(lib: &Library) -> Option<String> {
unsafe {
if let Ok(meta_fn) =
lib.get::<unsafe extern "C" fn() -> *const PluginMetadata>(b"drasi_plugin_metadata")
{
let meta_ptr = meta_fn();
if !meta_ptr.is_null() {
let meta = &*meta_ptr;
let sdk_ver = meta.sdk_version.to_string();
let core_ver = meta.core_version.to_string();
let plugin_ver = meta.plugin_version.to_string();
let target = meta.target_triple.to_string();
let commit = meta.git_commit.to_string();
let built = meta.build_timestamp.to_string();
Some(format!(
"sdk={sdk_ver} core={core_ver} plugin={plugin_ver} target={target} commit={commit} built={built}"
))
} else {
None
}
} else {
None
}
}
}
fn validate_plugin_metadata(lib: &Library, path: &Path) -> anyhow::Result<()> {
let meta_fn = unsafe {
match lib.get::<unsafe extern "C" fn() -> *const PluginMetadata>(b"drasi_plugin_metadata") {
Ok(f) => f,
Err(_) => {
log::warn!(
"Plugin '{}' does not export drasi_plugin_metadata — skipping version check",
path.display()
);
return Ok(());
}
}
};
let meta_ptr = unsafe { meta_fn() };
if meta_ptr.is_null() {
log::warn!(
"Plugin '{}' returned null metadata — skipping version check",
path.display()
);
return Ok(());
}
let meta = unsafe { &*meta_ptr };
let plugin_sdk_version = unsafe { meta.sdk_version.to_string() };
let host_sdk_version = drasi_plugin_sdk::ffi::metadata::FFI_SDK_VERSION;
let plugin_parts: Vec<&str> = plugin_sdk_version.split('.').collect();
let host_parts: Vec<&str> = host_sdk_version.split('.').collect();
let plugin_major_minor = format!(
"{}.{}",
plugin_parts.first().unwrap_or(&"0"),
plugin_parts.get(1).unwrap_or(&"0")
);
let host_major_minor = format!(
"{}.{}",
host_parts.first().unwrap_or(&"0"),
host_parts.get(1).unwrap_or(&"0")
);
if plugin_major_minor != host_major_minor {
anyhow::bail!(
"Plugin '{}' SDK version mismatch: plugin={}, host={}. \
Major.minor versions must match ({} != {}).",
path.display(),
plugin_sdk_version,
host_sdk_version,
plugin_major_minor,
host_major_minor,
);
}
let plugin_target = unsafe { meta.target_triple.to_string() };
let host_target = drasi_plugin_sdk::ffi::metadata::TARGET_TRIPLE;
if plugin_target != host_target {
anyhow::bail!(
"Plugin '{}' target mismatch: plugin={}, host={}. \
Plugins must be built for the same target platform.",
path.display(),
plugin_target,
host_target,
);
}
log::debug!(
"Plugin '{}' version check passed: sdk={} target={}",
path.display(),
plugin_sdk_version,
plugin_target
);
Ok(())
}
fn discover_plugin_candidates(dir: &Path, patterns: &[String]) -> IndexMap<String, Vec<PathBuf>> {
let mut groups: IndexMap<String, Vec<PathBuf>> = IndexMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return groups,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let base_name = ALL_KNOWN_EXTENSIONS
.iter()
.find_map(|ext| file_name.strip_suffix(ext))
.unwrap_or(&file_name)
.to_string();
let matched = patterns.iter().any(|pattern| {
let pat = ALL_KNOWN_EXTENSIONS
.iter()
.find_map(|ext| pattern.strip_suffix(ext))
.unwrap_or(pattern);
matches_glob(pat, &base_name)
});
if matched {
groups.entry(base_name).or_default().push(path);
}
}
groups
}
fn matches_glob(pattern: &str, name: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
name.starts_with(prefix)
} else if let Some((prefix, suffix)) = pattern.split_once('*') {
name.starts_with(prefix) && name.ends_with(suffix)
} else {
name == pattern
}
}
pub fn plugin_path(dir: &Path, name: &str) -> PathBuf {
if cfg!(target_os = "macos") {
dir.join(format!("lib{name}.dylib"))
} else if cfg!(target_os = "windows") {
dir.join(format!("{name}.dll"))
} else {
dir.join(format!("lib{name}.so"))
}
}
pub const DEFAULT_PLUGIN_FILE_PATTERNS: &[&str] = &[
"libdrasi_source_*",
"libdrasi_reaction_*",
"libdrasi_bootstrap_*",
"drasi_source_*",
"drasi_reaction_*",
"drasi_bootstrap_*",
];
pub const PLUGIN_BINARY_EXTENSIONS: &[&str] = CDYLIB_EXTENSIONS;
pub fn is_plugin_binary(name: &str) -> bool {
CDYLIB_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
}
pub fn plugin_kind_from_filename(filename: &str) -> Option<String> {
let stem = if let Some(stem) = filename.strip_suffix(".so") {
stem.strip_prefix("lib")?
} else if let Some(stem) = filename.strip_suffix(".dll") {
stem
} else if let Some(stem) = filename.strip_suffix(".dylib") {
stem.strip_prefix("lib")?
} else {
return None;
};
let stem = stem.strip_prefix("drasi_")?;
let mut parts = stem.splitn(2, '_');
let ptype = parts.next()?;
let kind = parts.next()?.replace('_', "-");
Some(format!("{ptype}/{kind}"))
}
#[derive(Debug, Clone)]
pub struct PluginMetadataSummary {
pub plugin_id: String,
pub version: String,
pub sdk_version: String,
pub core_version: String,
pub target_triple: String,
pub git_commit: String,
pub build_timestamp: String,
pub file_path: PathBuf,
}
pub fn scan_plugin_metadata(path: &Path) -> Option<PluginMetadataSummary> {
let lib = unsafe { Library::new(path).ok()? };
let meta_fn = unsafe {
lib.get::<unsafe extern "C" fn() -> *const PluginMetadata>(b"drasi_plugin_metadata")
.ok()?
};
let meta_ptr = unsafe { meta_fn() };
if meta_ptr.is_null() {
return None;
}
let meta = unsafe { &*meta_ptr };
let sdk_version = unsafe { meta.sdk_version.to_string() };
let core_version = unsafe { meta.core_version.to_string() };
let plugin_version = unsafe { meta.plugin_version.to_string() };
let target_triple = unsafe { meta.target_triple.to_string() };
let git_commit = unsafe { meta.git_commit.to_string() };
let build_timestamp = unsafe { meta.build_timestamp.to_string() };
let plugin_id = path
.file_name()
.and_then(|f| f.to_str())
.and_then(plugin_kind_from_filename)
.unwrap_or_default();
drop(lib);
Some(PluginMetadataSummary {
plugin_id,
version: plugin_version,
sdk_version,
core_version,
target_triple,
git_commit,
build_timestamp,
file_path: path.to_path_buf(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_temp_dir(files: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for f in files {
fs::write(dir.path().join(f), b"").unwrap();
}
dir
}
#[test]
fn test_matches_glob_prefix_wildcard() {
assert!(matches_glob("libdrasi_source_*", "libdrasi_source_mock"));
assert!(matches_glob("libdrasi_source_*", "libdrasi_source_http"));
assert!(!matches_glob("libdrasi_source_*", "libdrasi_reaction_log"));
}
#[test]
fn test_matches_glob_exact() {
assert!(matches_glob("libdrasi_source_mock", "libdrasi_source_mock"));
assert!(!matches_glob(
"libdrasi_source_mock",
"libdrasi_source_http"
));
}
#[test]
fn test_matches_glob_middle_wildcard() {
assert!(matches_glob("lib*mock", "libdrasi_source_mock"));
assert!(!matches_glob("lib*mock", "libdrasi_source_http"));
}
#[test]
fn test_discover_groups_by_base_name() {
let dir = setup_temp_dir(&[
"libdrasi_source_mock.dylib",
"libdrasi_source_mock.rlib",
"libdrasi_source_mock.d",
]);
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
assert_eq!(groups.len(), 1);
assert!(groups.contains_key("libdrasi_source_mock"));
assert_eq!(groups["libdrasi_source_mock"].len(), 3);
}
#[test]
fn test_discover_ignores_non_matching_files() {
let dir = setup_temp_dir(&[
"libdrasi_source_mock.dylib",
"unrelated_file.txt",
"libfoo.so",
]);
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
assert_eq!(groups.len(), 1);
assert!(groups.contains_key("libdrasi_source_mock"));
}
#[test]
fn test_discover_multiple_plugins() {
let dir = setup_temp_dir(&[
"libdrasi_source_mock.dylib",
"libdrasi_source_mock.rlib",
"libdrasi_source_http.so",
"libdrasi_source_http.rmeta",
]);
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
assert_eq!(groups.len(), 2);
assert!(groups.contains_key("libdrasi_source_mock"));
assert!(groups.contains_key("libdrasi_source_http"));
}
#[test]
fn test_discover_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
assert!(groups.is_empty());
}
#[test]
fn test_discover_nonexistent_dir() {
let groups = discover_plugin_candidates(Path::new("/nonexistent"), &["libdrasi_*".into()]);
assert!(groups.is_empty());
}
#[test]
fn test_cdylib_only_filtering() {
let dir = setup_temp_dir(&[
"libdrasi_source_mock.dylib",
"libdrasi_source_mock.rlib",
"libdrasi_source_mock.d",
]);
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
let files = &groups["libdrasi_source_mock"];
let cdylib_files: Vec<&PathBuf> = files
.iter()
.filter(|p| {
CDYLIB_EXTENSIONS
.iter()
.any(|ext| p.to_string_lossy().ends_with(ext))
})
.collect();
assert_eq!(cdylib_files.len(), 1);
assert!(cdylib_files[0]
.to_string_lossy()
.ends_with("libdrasi_source_mock.dylib"));
}
#[test]
fn test_ambiguous_cdylib_detected() {
let dir = setup_temp_dir(&["libdrasi_source_mock.dylib", "libdrasi_source_mock.so"]);
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
let files = &groups["libdrasi_source_mock"];
let cdylib_files: Vec<&PathBuf> = files
.iter()
.filter(|p| {
CDYLIB_EXTENSIONS
.iter()
.any(|ext| p.to_string_lossy().ends_with(ext))
})
.collect();
assert_eq!(
cdylib_files.len(),
2,
"Should detect 2 ambiguous cdylib files"
);
}
#[test]
fn test_no_cdylib_skips_silently() {
let dir = setup_temp_dir(&["libdrasi_source_mock.rlib", "libdrasi_source_mock.d"]);
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
let files = &groups["libdrasi_source_mock"];
let cdylib_files: Vec<&PathBuf> = files
.iter()
.filter(|p| {
CDYLIB_EXTENSIONS
.iter()
.any(|ext| p.to_string_lossy().ends_with(ext))
})
.collect();
assert!(
cdylib_files.is_empty(),
"Should find no cdylib files when only .rlib and .d exist"
);
}
#[test]
fn test_discover_with_pattern_including_extension() {
let dir = setup_temp_dir(&["libdrasi_source_mock.dylib", "libdrasi_source_mock.rlib"]);
let patterns = vec!["libdrasi_source_*.dylib".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
assert_eq!(groups.len(), 1);
assert!(groups.contains_key("libdrasi_source_mock"));
assert_eq!(groups["libdrasi_source_mock"].len(), 2);
}
#[test]
fn test_discover_multiple_patterns() {
let dir = setup_temp_dir(&[
"libdrasi_source_mock.dylib",
"libdrasi_reaction_log.so",
"libdrasi_bootstrap_mock.dylib",
]);
let patterns = vec![
"libdrasi_source_*".to_string(),
"libdrasi_reaction_*".to_string(),
];
let groups = discover_plugin_candidates(dir.path(), &patterns);
assert_eq!(groups.len(), 2);
assert!(groups.contains_key("libdrasi_source_mock"));
assert!(groups.contains_key("libdrasi_reaction_log"));
assert!(!groups.contains_key("libdrasi_bootstrap_mock"));
}
#[test]
fn test_file_without_known_extension_matched_by_base() {
let dir = setup_temp_dir(&["libdrasi_source_mock"]);
let patterns = vec!["libdrasi_source_*".to_string()];
let groups = discover_plugin_candidates(dir.path(), &patterns);
assert_eq!(groups.len(), 1);
assert!(groups.contains_key("libdrasi_source_mock"));
}
#[test]
fn test_plugin_kind_from_filename_unix() {
assert_eq!(
plugin_kind_from_filename("libdrasi_source_postgres.so"),
Some("source/postgres".to_string())
);
assert_eq!(
plugin_kind_from_filename("libdrasi_reaction_log.dylib"),
Some("reaction/log".to_string())
);
assert_eq!(
plugin_kind_from_filename("libdrasi_bootstrap_postgres.so"),
Some("bootstrap/postgres".to_string())
);
}
#[test]
fn test_plugin_kind_from_filename_windows() {
assert_eq!(
plugin_kind_from_filename("drasi_source_postgres.dll"),
Some("source/postgres".to_string())
);
}
#[test]
fn test_plugin_kind_from_filename_underscore_to_hyphen() {
assert_eq!(
plugin_kind_from_filename("libdrasi_source_postgres_replication.so"),
Some("source/postgres-replication".to_string())
);
}
#[test]
fn test_plugin_kind_from_filename_not_a_plugin() {
assert_eq!(plugin_kind_from_filename("random_lib.so"), None);
assert_eq!(plugin_kind_from_filename("not_a_plugin.txt"), None);
}
#[test]
fn test_is_plugin_binary() {
assert!(is_plugin_binary("libdrasi_source_mock.so"));
assert!(is_plugin_binary("drasi_reaction_log.dll"));
assert!(is_plugin_binary("libdrasi_bootstrap_postgres.dylib"));
assert!(!is_plugin_binary("plugin.rlib"));
assert!(!is_plugin_binary("readme.md"));
}
#[test]
#[allow(clippy::const_is_empty)]
fn test_default_patterns_not_empty() {
assert!(!DEFAULT_PLUGIN_FILE_PATTERNS.is_empty());
}
}