use std::path::{Path, PathBuf};
use libloading::Library;
use crate::{
ErrorCategory, ErrorCode, FilterDescriptor, FilterRegistry, LogLevel, Logger, MetadataKind,
PIXELFLOW_ABI_VERSION, PIXELFLOW_PLUGIN_ENTRY_SYMBOL, PixelFlowError,
PixelflowFilterDescriptorV1, PixelflowHostApiV1, PixelflowMetadataKind, PixelflowPluginApiV1,
PixelflowPluginEntryV1, PixelflowRegistrar, PixelflowStatus, PixelflowStringView, Result,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LoadedPlugin {
name: String,
path: PathBuf,
abi_version: u32,
}
impl LoadedPlugin {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub const fn abi_version(&self) -> u32 {
self.abi_version
}
}
struct RegistrarBridge {
registry: *mut FilterRegistry,
logger: Logger,
}
#[must_use]
pub fn platform_plugin_directories() -> Vec<PathBuf> {
let mut directories = Vec::new();
#[cfg(target_os = "linux")]
{
directories.push(PathBuf::from("/usr/lib/pixelflow/plugins"));
if let Some(home) = std::env::var_os("HOME") {
directories.push(PathBuf::from(home).join(".local/lib/pixelflow/plugins"));
}
}
#[cfg(target_os = "macos")]
{
directories.push(PathBuf::from(
"/Library/Application Support/pixelflow/plugins",
));
if let Some(home) = std::env::var_os("HOME") {
directories
.push(PathBuf::from(home).join("Library/Application Support/pixelflow/plugins"));
}
}
#[cfg(target_os = "windows")]
{
if let Some(appdata) = std::env::var_os("APPDATA") {
directories.push(PathBuf::from(appdata).join("pixelflow/plugins"));
}
if let Some(programdata) = std::env::var_os("PROGRAMDATA") {
directories.push(PathBuf::from(programdata).join("pixelflow/plugins"));
}
}
directories
}
#[must_use]
pub fn is_dynamic_library(path: &Path) -> bool {
let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
return false;
};
#[cfg(target_os = "linux")]
{
extension == "so"
}
#[cfg(target_os = "macos")]
{
extension == "dylib"
}
#[cfg(target_os = "windows")]
{
extension == "dll"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
false
}
}
pub fn load_plugins_from_directories(
directories: &[PathBuf],
registry: &mut FilterRegistry,
logger: &Logger,
) -> Vec<LoadedPlugin> {
let mut loaded = Vec::new();
for directory in directories {
let Ok(entries) = std::fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !is_dynamic_library(&path) {
continue;
}
match load_plugin(&path, registry, logger) {
Ok(plugin) => loaded.push(plugin),
Err(error) => logger.log(
LogLevel::Warn,
"pixelflow_core::plugin_host",
format!("skipping plugin '{}': {error}", path.display()),
),
}
}
}
loaded
}
fn load_plugin(
path: &Path,
registry: &mut FilterRegistry,
logger: &Logger,
) -> Result<LoadedPlugin> {
let library = unsafe { Library::new(path) }.map_err(|error| {
PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.load_failed"),
format!(
"failed to load plugin library '{}': {error}",
path.display()
),
)
})?;
let entry = unsafe { library.get::<PixelflowPluginEntryV1>(PIXELFLOW_PLUGIN_ENTRY_SYMBOL) }
.map_err(|error| {
PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.entry_symbol_missing"),
format!(
"failed to load plugin entry symbol from '{}': {error}",
path.display()
),
)
})?;
let host_api = PixelflowHostApiV1 {
size: std::mem::size_of::<PixelflowHostApiV1>() as u32,
version: PIXELFLOW_ABI_VERSION,
register_filter: register_filter_callback,
register_metadata_key: register_metadata_key_callback,
log: log_callback,
reserved: [0; 4],
};
let mut plugin_api = PixelflowPluginApiV1 {
size: 0,
version: 0,
plugin_name: placeholder_plugin_name,
register: placeholder_register,
reserved: [0; 5],
};
status_to_result(unsafe { entry(&host_api, &mut plugin_api) })?;
validate_plugin_api(&plugin_api)?;
let name = string_view_to_string(unsafe { (plugin_api.plugin_name)() })?;
let mut bridge = RegistrarBridge {
registry: registry as *mut FilterRegistry,
logger: logger.clone(),
};
status_to_result(unsafe {
(plugin_api.register)(&host_api, (&mut bridge as *mut RegistrarBridge).cast())
})?;
let _library = Box::leak(Box::new(library));
Ok(LoadedPlugin {
name,
path: path.to_path_buf(),
abi_version: plugin_api.version,
})
}
const unsafe extern "C" fn placeholder_plugin_name() -> PixelflowStringView {
PixelflowStringView::from_rust_str("")
}
const unsafe extern "C" fn placeholder_register(
_host: *const PixelflowHostApiV1,
_registrar: *mut PixelflowRegistrar,
) -> PixelflowStatus {
PixelflowStatus::plugin_error(
"plugin.uninitialized_api",
"plugin entry did not initialize registration callback",
)
}
unsafe extern "C" fn register_filter_callback(
registrar: *mut PixelflowRegistrar,
descriptor: *const PixelflowFilterDescriptorV1,
) -> PixelflowStatus {
if registrar.is_null() || descriptor.is_null() {
return PixelflowStatus::plugin_error(
"plugin.null_pointer",
"plugin passed null registration pointer",
);
}
let bridge = unsafe { &mut *registrar.cast::<RegistrarBridge>() };
let descriptor = unsafe { &*descriptor };
if validate_filter_descriptor(descriptor).is_err() {
return PixelflowStatus::plugin_error(
"plugin.invalid_descriptor",
"plugin provided incompatible filter descriptor",
);
}
let Ok(name) = string_view_to_string(descriptor.name) else {
return PixelflowStatus::plugin_error(
"plugin.invalid_filter",
"plugin returned invalid filter name",
);
};
let Ok(publisher) = string_view_to_string(descriptor.publisher) else {
return PixelflowStatus::plugin_error(
"plugin.invalid_filter",
"plugin returned invalid publisher name",
);
};
let Ok(plugin) = string_view_to_string(descriptor.plugin) else {
return PixelflowStatus::plugin_error(
"plugin.invalid_filter",
"plugin returned invalid plugin namespace",
);
};
match unsafe { &mut *bridge.registry }
.register_filter(FilterDescriptor::new(name, publisher, plugin))
{
Ok(()) => PixelflowStatus::ok(),
Err(_) => PixelflowStatus::plugin_error(
"plugin.registration_failed",
"host rejected filter registration",
),
}
}
unsafe extern "C" fn register_metadata_key_callback(
registrar: *mut PixelflowRegistrar,
key: PixelflowStringView,
kind: PixelflowMetadataKind,
) -> PixelflowStatus {
if registrar.is_null() {
return PixelflowStatus::plugin_error(
"plugin.null_pointer",
"plugin passed null registrar pointer",
);
}
let bridge = unsafe { &mut *registrar.cast::<RegistrarBridge>() };
let Ok(key) = string_view_to_string(key) else {
return PixelflowStatus::plugin_error(
"plugin.invalid_metadata_key",
"plugin returned invalid metadata key",
);
};
let kind = match kind {
PixelflowMetadataKind::Bool => MetadataKind::Bool,
PixelflowMetadataKind::Int => MetadataKind::Int,
PixelflowMetadataKind::Float => MetadataKind::Float,
PixelflowMetadataKind::String => MetadataKind::String,
PixelflowMetadataKind::Array => MetadataKind::Array,
PixelflowMetadataKind::Rational => MetadataKind::Rational,
PixelflowMetadataKind::Blob => MetadataKind::Blob,
};
match unsafe { &mut *bridge.registry }.register_metadata_key(&key, kind) {
Ok(()) => PixelflowStatus::ok(),
Err(_) => PixelflowStatus::plugin_error(
"plugin.registration_failed",
"host rejected metadata registration",
),
}
}
unsafe extern "C" fn log_callback(
registrar: *mut PixelflowRegistrar,
level: u32,
message: PixelflowStringView,
) {
if registrar.is_null() {
return;
}
let bridge = unsafe { &mut *registrar.cast::<RegistrarBridge>() };
let Ok(message) = string_view_to_string(message) else {
return;
};
let level = match level {
0 => LogLevel::Trace,
1 => LogLevel::Debug,
2 => LogLevel::Info,
3 => LogLevel::Warn,
_ => LogLevel::Error,
};
bridge
.logger
.log(level, "pixelflow_core::plugin_host::plugin", message);
}
fn string_view_to_string(view: PixelflowStringView) -> Result<String> {
if view.ptr.is_null() && view.len == 0 {
return Ok(String::new());
}
if view.ptr.is_null() {
return Err(PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.invalid_string"),
"plugin returned null string pointer with non-zero length",
));
}
let bytes = unsafe { std::slice::from_raw_parts(view.ptr, view.len) };
std::str::from_utf8(bytes).map(str::to_owned).map_err(|_| {
PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.invalid_utf8"),
"plugin returned invalid UTF-8 string",
)
})
}
fn validate_plugin_api(api: &PixelflowPluginApiV1) -> Result<()> {
if api.size != std::mem::size_of::<PixelflowPluginApiV1>() as u32 {
return Err(PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.invalid_descriptor"),
format!(
"plugin api size {} does not match expected {}",
api.size,
std::mem::size_of::<PixelflowPluginApiV1>()
),
));
}
if api.version != PIXELFLOW_ABI_VERSION {
return Err(PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.abi_version_mismatch"),
format!(
"plugin api version {} does not match host version {}",
api.version, PIXELFLOW_ABI_VERSION
),
));
}
Ok(())
}
fn validate_filter_descriptor(descriptor: &PixelflowFilterDescriptorV1) -> Result<()> {
if descriptor.size != std::mem::size_of::<PixelflowFilterDescriptorV1>() as u32 {
return Err(PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.invalid_descriptor"),
format!(
"filter descriptor size {} does not match expected {}",
descriptor.size,
std::mem::size_of::<PixelflowFilterDescriptorV1>()
),
));
}
if descriptor.version != PIXELFLOW_ABI_VERSION {
return Err(PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.abi_version_mismatch"),
format!(
"filter descriptor version {} does not match host version {}",
descriptor.version, PIXELFLOW_ABI_VERSION
),
));
}
Ok(())
}
fn status_to_result(status: PixelflowStatus) -> Result<()> {
if status.is_ok() {
return Ok(());
}
let code = string_view_to_string(status.error_code)
.unwrap_or_else(|_| "plugin.callback_failed".to_owned());
let message = string_view_to_string(status.message)
.unwrap_or_else(|_| "plugin returned invalid status message".to_owned());
Err(PixelFlowError::new(
ErrorCategory::Plugin,
ErrorCode::new("plugin.callback_failed"),
format!("foreign code {code}: {message}"),
))
}
#[cfg(test)]
mod tests {
#![expect(clippy::indexing_slicing, reason = "allow in tests")]
use std::path::Path;
use std::sync::{Arc, Mutex};
use tempfile::tempdir;
use crate::{FilterRegistry, LogRecord, LogSink, Logger};
use super::{is_dynamic_library, load_plugins_from_directories, platform_plugin_directories};
use super::{status_to_result, validate_filter_descriptor, validate_plugin_api};
use crate::{
ErrorCode, PIXELFLOW_ABI_VERSION, PixelflowErrorCategory, PixelflowFilterDescriptorV1,
PixelflowHostApiV1, PixelflowPluginApiV1, PixelflowRegistrar, PixelflowStatus,
PixelflowStringView,
};
#[test]
fn dynamic_library_filter_matches_current_platform_extension() {
#[cfg(target_os = "linux")]
assert!(is_dynamic_library(Path::new("libsample.so")));
#[cfg(target_os = "macos")]
assert!(is_dynamic_library(Path::new("libsample.dylib")));
#[cfg(target_os = "windows")]
assert!(is_dynamic_library(Path::new("sample.dll")));
assert!(!is_dynamic_library(Path::new("sample.txt")));
}
#[test]
fn platform_plugin_directories_include_conventional_paths() {
let dirs = platform_plugin_directories();
assert!(
dirs.iter()
.any(|path| path.to_string_lossy().contains("pixelflow"))
);
}
#[derive(Default)]
struct RecordingSink {
records: Mutex<Vec<LogRecord>>,
}
impl LogSink for RecordingSink {
fn log(&self, record: &LogRecord) {
self.records
.lock()
.expect("record lock poisoned")
.push(record.clone());
}
}
#[test]
fn load_plugins_warns_and_skips_invalid_dynamic_library_files() {
let tempdir = tempdir().expect("tempdir should exist");
let invalid_path = tempdir.path().join(if cfg!(target_os = "macos") {
"libinvalid.dylib"
} else if cfg!(target_os = "windows") {
"invalid.dll"
} else {
"libinvalid.so"
});
std::fs::write(&invalid_path, b"not a shared library")
.expect("invalid test plugin file should be written");
std::fs::write(tempdir.path().join("notes.txt"), b"ignore me")
.expect("non-library marker should be written");
let sink = Arc::new(RecordingSink::default());
let logger = Logger::new(sink.clone());
let mut registry = FilterRegistry::new();
let loaded =
load_plugins_from_directories(&[tempdir.path().to_path_buf()], &mut registry, &logger);
assert!(loaded.is_empty());
assert!(registry.filter_names().is_empty());
let records = sink.records.lock().expect("record lock poisoned");
assert_eq!(records.len(), 1);
assert!(records[0].message().contains("skipping plugin"));
}
#[test]
fn validate_plugin_api_rejects_wrong_version() {
let api = PixelflowPluginApiV1 {
size: std::mem::size_of::<PixelflowPluginApiV1>() as u32,
version: PIXELFLOW_ABI_VERSION + 1,
plugin_name: placeholder_plugin_name,
register: placeholder_register,
reserved: [0; 5],
};
let error = validate_plugin_api(&api)
.expect_err("plugin api with wrong version should fail validation");
assert_eq!(error.code(), ErrorCode::new("plugin.abi_version_mismatch"));
}
#[test]
fn validate_filter_descriptor_rejects_wrong_size() {
let descriptor = PixelflowFilterDescriptorV1 {
size: 1,
version: PIXELFLOW_ABI_VERSION,
name: PixelflowStringView::from_rust_str("sample.identity"),
publisher: PixelflowStringView::from_rust_str("pixelflow"),
plugin: PixelflowStringView::from_rust_str("sample"),
reserved: [0; 4],
};
let status = validate_filter_descriptor(&descriptor)
.expect_err("descriptor with wrong size should fail validation");
assert_eq!(status.code(), ErrorCode::new("plugin.invalid_descriptor"));
}
#[test]
fn status_to_result_uses_static_host_error_code() {
let status = PixelflowStatus {
size: std::mem::size_of::<PixelflowStatus>() as u32,
version: PIXELFLOW_ABI_VERSION,
status_code: 1,
category: PixelflowErrorCategory::Plugin,
error_code: PixelflowStringView::from_rust_str("plugin.dynamic_code"),
message: PixelflowStringView::from_rust_str("dynamic message"),
reserved: [0; 4],
};
let error = status_to_result(status).expect_err("non-ok status should fail");
assert_eq!(error.code(), ErrorCode::new("plugin.callback_failed"));
assert!(error.message().contains("plugin.dynamic_code"));
assert!(error.message().contains("dynamic message"));
}
unsafe extern "C" fn placeholder_plugin_name() -> PixelflowStringView {
PixelflowStringView::from_rust_str("placeholder")
}
unsafe extern "C" fn placeholder_register(
_host: *const PixelflowHostApiV1,
_registrar: *mut PixelflowRegistrar,
) -> PixelflowStatus {
PixelflowStatus::ok()
}
}