Skip to main content

pixelflow_core/
plugin_host.rs

1//! Dynamic plugin loading and ABI host callback bridge.
2
3use std::path::{Path, PathBuf};
4
5use libloading::Library;
6
7use crate::{
8    ErrorCategory, ErrorCode, FilterDescriptor, FilterRegistry, LogLevel, Logger, MetadataKind,
9    PIXELFLOW_ABI_VERSION, PIXELFLOW_PLUGIN_ENTRY_SYMBOL, PixelFlowError,
10    PixelflowFilterDescriptorV1, PixelflowHostApiV1, PixelflowMetadataKind, PixelflowPluginApiV1,
11    PixelflowPluginEntryV1, PixelflowRegistrar, PixelflowStatus, PixelflowStringView, Result,
12};
13
14/// Describes one loaded plugin.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct LoadedPlugin {
17    name: String,
18    path: PathBuf,
19    abi_version: u32,
20}
21
22impl LoadedPlugin {
23    /// Returns plugin name reported by ABI table.
24    #[must_use]
25    pub fn name(&self) -> &str {
26        &self.name
27    }
28
29    /// Returns dynamic library path.
30    #[must_use]
31    pub fn path(&self) -> &Path {
32        &self.path
33    }
34
35    /// Returns plugin ABI version reported by callback table.
36    #[must_use]
37    pub const fn abi_version(&self) -> u32 {
38        self.abi_version
39    }
40}
41
42struct RegistrarBridge {
43    registry: *mut FilterRegistry,
44    logger: Logger,
45}
46
47/// Returns conventional platform plugin directories.
48#[must_use]
49pub fn platform_plugin_directories() -> Vec<PathBuf> {
50    let mut directories = Vec::new();
51
52    #[cfg(target_os = "linux")]
53    {
54        directories.push(PathBuf::from("/usr/lib/pixelflow/plugins"));
55        if let Some(home) = std::env::var_os("HOME") {
56            directories.push(PathBuf::from(home).join(".local/lib/pixelflow/plugins"));
57        }
58    }
59
60    #[cfg(target_os = "macos")]
61    {
62        directories.push(PathBuf::from(
63            "/Library/Application Support/pixelflow/plugins",
64        ));
65        if let Some(home) = std::env::var_os("HOME") {
66            directories
67                .push(PathBuf::from(home).join("Library/Application Support/pixelflow/plugins"));
68        }
69    }
70
71    #[cfg(target_os = "windows")]
72    {
73        if let Some(appdata) = std::env::var_os("APPDATA") {
74            directories.push(PathBuf::from(appdata).join("pixelflow/plugins"));
75        }
76        if let Some(programdata) = std::env::var_os("PROGRAMDATA") {
77            directories.push(PathBuf::from(programdata).join("pixelflow/plugins"));
78        }
79    }
80
81    directories
82}
83
84/// Returns true when path has current platform dynamic library extension.
85#[must_use]
86pub fn is_dynamic_library(path: &Path) -> bool {
87    let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
88        return false;
89    };
90
91    #[cfg(target_os = "linux")]
92    {
93        extension == "so"
94    }
95
96    #[cfg(target_os = "macos")]
97    {
98        extension == "dylib"
99    }
100
101    #[cfg(target_os = "windows")]
102    {
103        extension == "dll"
104    }
105
106    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
107    {
108        false
109    }
110}
111
112/// Loads all plugins from configured directories, warning and skipping failures.
113pub fn load_plugins_from_directories(
114    directories: &[PathBuf],
115    registry: &mut FilterRegistry,
116    logger: &Logger,
117) -> Vec<LoadedPlugin> {
118    let mut loaded = Vec::new();
119
120    for directory in directories {
121        let Ok(entries) = std::fs::read_dir(directory) else {
122            continue;
123        };
124
125        for entry in entries.flatten() {
126            let path = entry.path();
127            if !is_dynamic_library(&path) {
128                continue;
129            }
130
131            match load_plugin(&path, registry, logger) {
132                Ok(plugin) => loaded.push(plugin),
133                Err(error) => logger.log(
134                    LogLevel::Warn,
135                    "pixelflow_core::plugin_host",
136                    format!("skipping plugin '{}': {error}", path.display()),
137                ),
138            }
139        }
140    }
141
142    loaded
143}
144
145fn load_plugin(
146    path: &Path,
147    registry: &mut FilterRegistry,
148    logger: &Logger,
149) -> Result<LoadedPlugin> {
150    // SAFETY: Host intentionally loads operator-selected plugin path and keeps handle alive after
151    // successful registration so any resolved symbols do not outlive library storage.
152    let library = unsafe { Library::new(path) }.map_err(|error| {
153        PixelFlowError::new(
154            ErrorCategory::Plugin,
155            ErrorCode::new("plugin.load_failed"),
156            format!(
157                "failed to load plugin library '{}': {error}",
158                path.display()
159            ),
160        )
161    })?;
162
163    // SAFETY: Symbol lookup happens against still-live library handle and returned function pointer
164    // remains valid because loaded library is leaked after successful registration.
165    let entry = unsafe { library.get::<PixelflowPluginEntryV1>(PIXELFLOW_PLUGIN_ENTRY_SYMBOL) }
166        .map_err(|error| {
167            PixelFlowError::new(
168                ErrorCategory::Plugin,
169                ErrorCode::new("plugin.entry_symbol_missing"),
170                format!(
171                    "failed to load plugin entry symbol from '{}': {error}",
172                    path.display()
173                ),
174            )
175        })?;
176
177    let host_api = PixelflowHostApiV1 {
178        size: std::mem::size_of::<PixelflowHostApiV1>() as u32,
179        version: PIXELFLOW_ABI_VERSION,
180        register_filter: register_filter_callback,
181        register_metadata_key: register_metadata_key_callback,
182        log: log_callback,
183        reserved: [0; 4],
184    };
185    let mut plugin_api = PixelflowPluginApiV1 {
186        size: 0,
187        version: 0,
188        plugin_name: placeholder_plugin_name,
189        register: placeholder_register,
190        reserved: [0; 5],
191    };
192
193    // SAFETY: `entry` came from plugin's ABI symbol, and both API structs are valid writable stack
194    // allocations for call duration.
195    status_to_result(unsafe { entry(&host_api, &mut plugin_api) })?;
196    validate_plugin_api(&plugin_api)?;
197
198    // SAFETY: `validate_plugin_api` ensures callback table is initialized before invoking plugin
199    // name accessor, and returned view is copied immediately into owned `String`.
200    let name = string_view_to_string(unsafe { (plugin_api.plugin_name)() })?;
201    let mut bridge = RegistrarBridge {
202        registry: registry as *mut FilterRegistry,
203        logger: logger.clone(),
204    };
205
206    // SAFETY: `validate_plugin_api` ensured register callback exists; host API and registrar bridge
207    // pointers reference live stack data for full callback duration.
208    status_to_result(unsafe {
209        (plugin_api.register)(&host_api, (&mut bridge as *mut RegistrarBridge).cast())
210    })?;
211
212    let _library = Box::leak(Box::new(library));
213
214    Ok(LoadedPlugin {
215        name,
216        path: path.to_path_buf(),
217        abi_version: plugin_api.version,
218    })
219}
220
221const unsafe extern "C" fn placeholder_plugin_name() -> PixelflowStringView {
222    PixelflowStringView::from_rust_str("")
223}
224
225const unsafe extern "C" fn placeholder_register(
226    _host: *const PixelflowHostApiV1,
227    _registrar: *mut PixelflowRegistrar,
228) -> PixelflowStatus {
229    PixelflowStatus::plugin_error(
230        "plugin.uninitialized_api",
231        "plugin entry did not initialize registration callback",
232    )
233}
234
235unsafe extern "C" fn register_filter_callback(
236    registrar: *mut PixelflowRegistrar,
237    descriptor: *const PixelflowFilterDescriptorV1,
238) -> PixelflowStatus {
239    if registrar.is_null() || descriptor.is_null() {
240        return PixelflowStatus::plugin_error(
241            "plugin.null_pointer",
242            "plugin passed null registration pointer",
243        );
244    }
245
246    // SAFETY: Both pointers were checked non-null above and originate from host/plugin ABI call for
247    // this callback invocation.
248    let bridge = unsafe { &mut *registrar.cast::<RegistrarBridge>() };
249    // SAFETY: Descriptor pointer was checked non-null above and remains valid for callback duration.
250    let descriptor = unsafe { &*descriptor };
251
252    if validate_filter_descriptor(descriptor).is_err() {
253        return PixelflowStatus::plugin_error(
254            "plugin.invalid_descriptor",
255            "plugin provided incompatible filter descriptor",
256        );
257    }
258
259    let Ok(name) = string_view_to_string(descriptor.name) else {
260        return PixelflowStatus::plugin_error(
261            "plugin.invalid_filter",
262            "plugin returned invalid filter name",
263        );
264    };
265    let Ok(publisher) = string_view_to_string(descriptor.publisher) else {
266        return PixelflowStatus::plugin_error(
267            "plugin.invalid_filter",
268            "plugin returned invalid publisher name",
269        );
270    };
271    let Ok(plugin) = string_view_to_string(descriptor.plugin) else {
272        return PixelflowStatus::plugin_error(
273            "plugin.invalid_filter",
274            "plugin returned invalid plugin namespace",
275        );
276    };
277
278    // SAFETY: Bridge stores original exclusive `FilterRegistry` pointer from `load_plugin`; host
279    // only uses it synchronously during registration callback.
280    match unsafe { &mut *bridge.registry }
281        .register_filter(FilterDescriptor::new(name, publisher, plugin))
282    {
283        Ok(()) => PixelflowStatus::ok(),
284        Err(_) => PixelflowStatus::plugin_error(
285            "plugin.registration_failed",
286            "host rejected filter registration",
287        ),
288    }
289}
290
291unsafe extern "C" fn register_metadata_key_callback(
292    registrar: *mut PixelflowRegistrar,
293    key: PixelflowStringView,
294    kind: PixelflowMetadataKind,
295) -> PixelflowStatus {
296    if registrar.is_null() {
297        return PixelflowStatus::plugin_error(
298            "plugin.null_pointer",
299            "plugin passed null registrar pointer",
300        );
301    }
302
303    // SAFETY: Pointer was checked non-null above and comes from host-owned bridge for this call.
304    let bridge = unsafe { &mut *registrar.cast::<RegistrarBridge>() };
305    let Ok(key) = string_view_to_string(key) else {
306        return PixelflowStatus::plugin_error(
307            "plugin.invalid_metadata_key",
308            "plugin returned invalid metadata key",
309        );
310    };
311
312    let kind = match kind {
313        PixelflowMetadataKind::Bool => MetadataKind::Bool,
314        PixelflowMetadataKind::Int => MetadataKind::Int,
315        PixelflowMetadataKind::Float => MetadataKind::Float,
316        PixelflowMetadataKind::String => MetadataKind::String,
317        PixelflowMetadataKind::Array => MetadataKind::Array,
318        PixelflowMetadataKind::Rational => MetadataKind::Rational,
319        PixelflowMetadataKind::Blob => MetadataKind::Blob,
320    };
321
322    // SAFETY: Bridge stores original exclusive `FilterRegistry` pointer and callback uses it only
323    // for synchronous metadata registration.
324    match unsafe { &mut *bridge.registry }.register_metadata_key(&key, kind) {
325        Ok(()) => PixelflowStatus::ok(),
326        Err(_) => PixelflowStatus::plugin_error(
327            "plugin.registration_failed",
328            "host rejected metadata registration",
329        ),
330    }
331}
332
333unsafe extern "C" fn log_callback(
334    registrar: *mut PixelflowRegistrar,
335    level: u32,
336    message: PixelflowStringView,
337) {
338    if registrar.is_null() {
339        return;
340    }
341
342    // SAFETY: Pointer was checked non-null above and points at host-owned bridge for this call.
343    let bridge = unsafe { &mut *registrar.cast::<RegistrarBridge>() };
344    let Ok(message) = string_view_to_string(message) else {
345        return;
346    };
347
348    let level = match level {
349        0 => LogLevel::Trace,
350        1 => LogLevel::Debug,
351        2 => LogLevel::Info,
352        3 => LogLevel::Warn,
353        _ => LogLevel::Error,
354    };
355    bridge
356        .logger
357        .log(level, "pixelflow_core::plugin_host::plugin", message);
358}
359
360fn string_view_to_string(view: PixelflowStringView) -> Result<String> {
361    if view.ptr.is_null() && view.len == 0 {
362        return Ok(String::new());
363    }
364    if view.ptr.is_null() {
365        return Err(PixelFlowError::new(
366            ErrorCategory::Plugin,
367            ErrorCode::new("plugin.invalid_string"),
368            "plugin returned null string pointer with non-zero length",
369        ));
370    }
371
372    // SAFETY: Null-with-length case rejected above; plugin ABI requires non-null pointer reference
373    // `view.len` readable bytes, which are copied/validated immediately.
374    let bytes = unsafe { std::slice::from_raw_parts(view.ptr, view.len) };
375    std::str::from_utf8(bytes).map(str::to_owned).map_err(|_| {
376        PixelFlowError::new(
377            ErrorCategory::Plugin,
378            ErrorCode::new("plugin.invalid_utf8"),
379            "plugin returned invalid UTF-8 string",
380        )
381    })
382}
383
384fn validate_plugin_api(api: &PixelflowPluginApiV1) -> Result<()> {
385    if api.size != std::mem::size_of::<PixelflowPluginApiV1>() as u32 {
386        return Err(PixelFlowError::new(
387            ErrorCategory::Plugin,
388            ErrorCode::new("plugin.invalid_descriptor"),
389            format!(
390                "plugin api size {} does not match expected {}",
391                api.size,
392                std::mem::size_of::<PixelflowPluginApiV1>()
393            ),
394        ));
395    }
396    if api.version != PIXELFLOW_ABI_VERSION {
397        return Err(PixelFlowError::new(
398            ErrorCategory::Plugin,
399            ErrorCode::new("plugin.abi_version_mismatch"),
400            format!(
401                "plugin api version {} does not match host version {}",
402                api.version, PIXELFLOW_ABI_VERSION
403            ),
404        ));
405    }
406
407    Ok(())
408}
409
410fn validate_filter_descriptor(descriptor: &PixelflowFilterDescriptorV1) -> Result<()> {
411    if descriptor.size != std::mem::size_of::<PixelflowFilterDescriptorV1>() as u32 {
412        return Err(PixelFlowError::new(
413            ErrorCategory::Plugin,
414            ErrorCode::new("plugin.invalid_descriptor"),
415            format!(
416                "filter descriptor size {} does not match expected {}",
417                descriptor.size,
418                std::mem::size_of::<PixelflowFilterDescriptorV1>()
419            ),
420        ));
421    }
422    if descriptor.version != PIXELFLOW_ABI_VERSION {
423        return Err(PixelFlowError::new(
424            ErrorCategory::Plugin,
425            ErrorCode::new("plugin.abi_version_mismatch"),
426            format!(
427                "filter descriptor version {} does not match host version {}",
428                descriptor.version, PIXELFLOW_ABI_VERSION
429            ),
430        ));
431    }
432
433    Ok(())
434}
435
436fn status_to_result(status: PixelflowStatus) -> Result<()> {
437    if status.is_ok() {
438        return Ok(());
439    }
440
441    let code = string_view_to_string(status.error_code)
442        .unwrap_or_else(|_| "plugin.callback_failed".to_owned());
443    let message = string_view_to_string(status.message)
444        .unwrap_or_else(|_| "plugin returned invalid status message".to_owned());
445    Err(PixelFlowError::new(
446        ErrorCategory::Plugin,
447        ErrorCode::new("plugin.callback_failed"),
448        format!("foreign code {code}: {message}"),
449    ))
450}
451
452#[cfg(test)]
453mod tests {
454    #![expect(clippy::indexing_slicing, reason = "allow in tests")]
455
456    use std::path::Path;
457    use std::sync::{Arc, Mutex};
458
459    use tempfile::tempdir;
460
461    use crate::{FilterRegistry, LogRecord, LogSink, Logger};
462
463    use super::{is_dynamic_library, load_plugins_from_directories, platform_plugin_directories};
464    use super::{status_to_result, validate_filter_descriptor, validate_plugin_api};
465    use crate::{
466        ErrorCode, PIXELFLOW_ABI_VERSION, PixelflowErrorCategory, PixelflowFilterDescriptorV1,
467        PixelflowHostApiV1, PixelflowPluginApiV1, PixelflowRegistrar, PixelflowStatus,
468        PixelflowStringView,
469    };
470
471    #[test]
472    fn dynamic_library_filter_matches_current_platform_extension() {
473        #[cfg(target_os = "linux")]
474        assert!(is_dynamic_library(Path::new("libsample.so")));
475        #[cfg(target_os = "macos")]
476        assert!(is_dynamic_library(Path::new("libsample.dylib")));
477        #[cfg(target_os = "windows")]
478        assert!(is_dynamic_library(Path::new("sample.dll")));
479        assert!(!is_dynamic_library(Path::new("sample.txt")));
480    }
481
482    #[test]
483    fn platform_plugin_directories_include_conventional_paths() {
484        let dirs = platform_plugin_directories();
485        assert!(
486            dirs.iter()
487                .any(|path| path.to_string_lossy().contains("pixelflow"))
488        );
489    }
490
491    #[derive(Default)]
492    struct RecordingSink {
493        records: Mutex<Vec<LogRecord>>,
494    }
495
496    impl LogSink for RecordingSink {
497        fn log(&self, record: &LogRecord) {
498            self.records
499                .lock()
500                .expect("record lock poisoned")
501                .push(record.clone());
502        }
503    }
504
505    #[test]
506    fn load_plugins_warns_and_skips_invalid_dynamic_library_files() {
507        let tempdir = tempdir().expect("tempdir should exist");
508        let invalid_path = tempdir.path().join(if cfg!(target_os = "macos") {
509            "libinvalid.dylib"
510        } else if cfg!(target_os = "windows") {
511            "invalid.dll"
512        } else {
513            "libinvalid.so"
514        });
515        std::fs::write(&invalid_path, b"not a shared library")
516            .expect("invalid test plugin file should be written");
517        std::fs::write(tempdir.path().join("notes.txt"), b"ignore me")
518            .expect("non-library marker should be written");
519
520        let sink = Arc::new(RecordingSink::default());
521        let logger = Logger::new(sink.clone());
522        let mut registry = FilterRegistry::new();
523
524        let loaded =
525            load_plugins_from_directories(&[tempdir.path().to_path_buf()], &mut registry, &logger);
526
527        assert!(loaded.is_empty());
528        assert!(registry.filter_names().is_empty());
529
530        let records = sink.records.lock().expect("record lock poisoned");
531        assert_eq!(records.len(), 1);
532        assert!(records[0].message().contains("skipping plugin"));
533    }
534
535    #[test]
536    fn validate_plugin_api_rejects_wrong_version() {
537        let api = PixelflowPluginApiV1 {
538            size: std::mem::size_of::<PixelflowPluginApiV1>() as u32,
539            version: PIXELFLOW_ABI_VERSION + 1,
540            plugin_name: placeholder_plugin_name,
541            register: placeholder_register,
542            reserved: [0; 5],
543        };
544
545        let error = validate_plugin_api(&api)
546            .expect_err("plugin api with wrong version should fail validation");
547
548        assert_eq!(error.code(), ErrorCode::new("plugin.abi_version_mismatch"));
549    }
550
551    #[test]
552    fn validate_filter_descriptor_rejects_wrong_size() {
553        let descriptor = PixelflowFilterDescriptorV1 {
554            size: 1,
555            version: PIXELFLOW_ABI_VERSION,
556            name: PixelflowStringView::from_rust_str("sample.identity"),
557            publisher: PixelflowStringView::from_rust_str("pixelflow"),
558            plugin: PixelflowStringView::from_rust_str("sample"),
559            reserved: [0; 4],
560        };
561
562        let status = validate_filter_descriptor(&descriptor)
563            .expect_err("descriptor with wrong size should fail validation");
564
565        assert_eq!(status.code(), ErrorCode::new("plugin.invalid_descriptor"));
566    }
567
568    #[test]
569    fn status_to_result_uses_static_host_error_code() {
570        let status = PixelflowStatus {
571            size: std::mem::size_of::<PixelflowStatus>() as u32,
572            version: PIXELFLOW_ABI_VERSION,
573            status_code: 1,
574            category: PixelflowErrorCategory::Plugin,
575            error_code: PixelflowStringView::from_rust_str("plugin.dynamic_code"),
576            message: PixelflowStringView::from_rust_str("dynamic message"),
577            reserved: [0; 4],
578        };
579
580        let error = status_to_result(status).expect_err("non-ok status should fail");
581
582        assert_eq!(error.code(), ErrorCode::new("plugin.callback_failed"));
583        assert!(error.message().contains("plugin.dynamic_code"));
584        assert!(error.message().contains("dynamic message"));
585    }
586
587    unsafe extern "C" fn placeholder_plugin_name() -> PixelflowStringView {
588        PixelflowStringView::from_rust_str("placeholder")
589    }
590
591    unsafe extern "C" fn placeholder_register(
592        _host: *const PixelflowHostApiV1,
593        _registrar: *mut PixelflowRegistrar,
594    ) -> PixelflowStatus {
595        PixelflowStatus::ok()
596    }
597}