1use 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#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct LoadedPlugin {
17 name: String,
18 path: PathBuf,
19 abi_version: u32,
20}
21
22impl LoadedPlugin {
23 #[must_use]
25 pub fn name(&self) -> &str {
26 &self.name
27 }
28
29 #[must_use]
31 pub fn path(&self) -> &Path {
32 &self.path
33 }
34
35 #[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#[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#[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
112pub 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 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 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 status_to_result(unsafe { entry(&host_api, &mut plugin_api) })?;
196 validate_plugin_api(&plugin_api)?;
197
198 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 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 let bridge = unsafe { &mut *registrar.cast::<RegistrarBridge>() };
249 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 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 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 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 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 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}