//! Dynamic plugin loader for server-side hook extensions. //! //! Loads shared libraries (`*.so` / `*.dylib`) from a directory at server //! startup. Each library must export: //! //! ```c //! extern "C" int32_t qpc_plugin_init(HookVTable *vtable); //! ``` //! //! The server creates a zeroed [`HookVTable`], passes it to `qpc_plugin_init`, //! and wraps the resulting vtable in a [`PluginHooks`] that implements //! [`ServerHooks`]. Multiple plugins are chained via [`ChainedHooks`]. //! //! # Safety model //! //! Dynamic loading is inherently unsafe. The plugin binary MUST: //! - be compiled against the same `quicprochat-plugin-api` version //! - not store the event-struct pointers beyond the callback duration //! - be `Send + Sync` (the wrapper is put behind an `Arc`) //! //! The server operator is responsible for only loading trusted plugin binaries. use std::path::Path; use libloading::{Library, Symbol}; use quicprochat_plugin_api::{ CAuthEvent, CChannelEvent, CFetchEvent, CMessageEvent, HookVTable, HOOK_CONTINUE, PLUGIN_OK, }; use crate::hooks::{AuthEvent, ChannelEvent, FetchEvent, HookAction, MessageEvent, ServerHooks}; // ── PluginHooks ─────────────────────────────────────────────────────────────── /// A [`ServerHooks`] implementation backed by a dynamically loaded plugin vtable. /// /// Holds the [`Library`] alive alongside the vtable so that the loaded code /// is not unmapped while the vtable function pointers are still reachable. pub struct PluginHooks { /// The vtable filled by `qpc_plugin_init`. vtable: HookVTable, /// Keeps the shared library mapped. Must be dropped after `vtable`. _lib: Library, /// Name of the plugin file, for diagnostics. name: String, } impl PluginHooks { /// Load a plugin from `path` and call `qpc_plugin_init`. /// /// Returns `Err` if the library cannot be opened, the symbol is missing, /// or `qpc_plugin_init` returns a non-zero error code. pub fn load(path: &Path) -> anyhow::Result { let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()); // Safety: loading arbitrary shared libraries is inherently unsafe. // The server operator is responsible for only loading trusted plugins. let lib = unsafe { Library::new(path) } .map_err(|e| anyhow::anyhow!("plugin '{}': load failed: {}", name, e))?; // Zero-initialise the vtable so unused slots are null. let mut vtable = HookVTable { user_data: core::ptr::null_mut(), on_message_enqueue: None, on_batch_enqueue: None, on_auth: None, on_channel_created: None, on_fetch: None, on_user_registered: None, error_message: None, destroy: None, on_shutdown: None, }; // Safety: the symbol must have the exact signature declared in the API crate. let init: Symbol i32> = unsafe { lib.get(b"qpc_plugin_init\0") }.map_err(|e| { anyhow::anyhow!("plugin '{}': missing qpc_plugin_init: {}", name, e) })?; let rc = unsafe { init(&mut vtable) }; if rc != PLUGIN_OK { anyhow::bail!("plugin '{}': qpc_plugin_init returned error {}", name, rc); } tracing::info!(plugin = %name, "loaded plugin"); Ok(Self { vtable, _lib: lib, name }) } /// Human-readable plugin name (filename). pub fn name(&self) -> &str { &self.name } /// Retrieve the rejection reason from the plugin, falling back to a generic string. fn rejection_reason(&self) -> String { if let Some(f) = self.vtable.error_message { let ptr = unsafe { f(self.vtable.user_data) }; if !ptr.is_null() { // Safety: plugin must return a valid null-terminated UTF-8 (or ASCII) string. let cstr = unsafe { std::ffi::CStr::from_ptr(ptr as *const core::ffi::c_char) }; return cstr.to_string_lossy().into_owned(); } } "rejected by plugin".to_string() } } impl Drop for PluginHooks { fn drop(&mut self) { if let Some(destroy) = self.vtable.destroy { // Safety: destroy must be safe to call at any time after init. unsafe { destroy(self.vtable.user_data) }; } } } impl ServerHooks for PluginHooks { fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction { let f = match self.vtable.on_message_enqueue { Some(f) => f, None => return HookAction::Continue, }; let sender_ptr = event .sender_identity .as_deref() .map(|s| s.as_ptr()) .unwrap_or(core::ptr::null()); let sender_len = event.sender_identity.as_deref().map_or(0, |s| s.len()); let c_event = CMessageEvent { sender_identity: sender_ptr, sender_identity_len: sender_len, recipient_key: event.recipient_key.as_ptr(), recipient_key_len: event.recipient_key.len(), channel_id: event.channel_id.as_ptr(), channel_id_len: event.channel_id.len(), payload_len: event.payload_len, seq: event.seq, }; let rc = unsafe { f(self.vtable.user_data, &c_event) }; if rc == HOOK_CONTINUE { HookAction::Continue } else { HookAction::Reject(self.rejection_reason()) } } fn on_batch_enqueue(&self, events: &[MessageEvent]) { let f = match self.vtable.on_batch_enqueue { Some(f) => f, None => return, }; let c_events: Vec = events .iter() .map(|e| { let sender_ptr = e .sender_identity .as_deref() .map(|s| s.as_ptr()) .unwrap_or(core::ptr::null()); let sender_len = e.sender_identity.as_deref().map_or(0, |s| s.len()); CMessageEvent { sender_identity: sender_ptr, sender_identity_len: sender_len, recipient_key: e.recipient_key.as_ptr(), recipient_key_len: e.recipient_key.len(), channel_id: e.channel_id.as_ptr(), channel_id_len: e.channel_id.len(), payload_len: e.payload_len, seq: e.seq, } }) .collect(); unsafe { f(self.vtable.user_data, c_events.as_ptr(), c_events.len()) }; } fn on_auth(&self, event: &AuthEvent) { let f = match self.vtable.on_auth { Some(f) => f, None => return, }; let c_event = CAuthEvent { username: event.username.as_ptr(), username_len: event.username.len(), success: if event.success { 1 } else { 0 }, failure_reason: event.failure_reason.as_ptr(), failure_reason_len: event.failure_reason.len(), }; unsafe { f(self.vtable.user_data, &c_event) }; } fn on_channel_created(&self, event: &ChannelEvent) { let f = match self.vtable.on_channel_created { Some(f) => f, None => return, }; let c_event = CChannelEvent { channel_id: event.channel_id.as_ptr(), channel_id_len: event.channel_id.len(), initiator_key: event.initiator_key.as_ptr(), initiator_key_len: event.initiator_key.len(), peer_key: event.peer_key.as_ptr(), peer_key_len: event.peer_key.len(), was_new: if event.was_new { 1 } else { 0 }, }; unsafe { f(self.vtable.user_data, &c_event) }; } fn on_fetch(&self, event: &FetchEvent) { let f = match self.vtable.on_fetch { Some(f) => f, None => return, }; let c_event = CFetchEvent { recipient_key: event.recipient_key.as_ptr(), recipient_key_len: event.recipient_key.len(), channel_id: event.channel_id.as_ptr(), channel_id_len: event.channel_id.len(), message_count: event.message_count, }; unsafe { f(self.vtable.user_data, &c_event) }; } fn on_user_registered(&self, username: &str, identity_key: &[u8]) { let f = match self.vtable.on_user_registered { Some(f) => f, None => return, }; unsafe { f( self.vtable.user_data, username.as_ptr(), username.len(), identity_key.as_ptr(), identity_key.len(), ) }; } fn on_shutdown(&self) { let f = match self.vtable.on_shutdown { Some(f) => f, None => return, }; unsafe { f(self.vtable.user_data) }; } } // ── ChainedHooks ───────────────────────────────────────────────────────────── /// Composes multiple [`ServerHooks`] implementations into one. /// /// For filtering hooks (`on_message_enqueue`), the first rejection short-circuits /// the chain. For fire-and-forget hooks, all plugins are called in order. pub struct ChainedHooks { hooks: Vec>, } impl ChainedHooks { pub fn new(hooks: Vec>) -> Self { Self { hooks } } } impl ServerHooks for ChainedHooks { fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction { for h in &self.hooks { match h.on_message_enqueue(event) { HookAction::Continue => {} reject => return reject, } } HookAction::Continue } fn on_batch_enqueue(&self, events: &[MessageEvent]) { for h in &self.hooks { h.on_batch_enqueue(events); } } fn on_auth(&self, event: &AuthEvent) { for h in &self.hooks { h.on_auth(event); } } fn on_channel_created(&self, event: &ChannelEvent) { for h in &self.hooks { h.on_channel_created(event); } } fn on_fetch(&self, event: &FetchEvent) { for h in &self.hooks { h.on_fetch(event); } } fn on_user_registered(&self, username: &str, identity_key: &[u8]) { for h in &self.hooks { h.on_user_registered(username, identity_key); } } fn on_shutdown(&self) { for h in &self.hooks { h.on_shutdown(); } } } // ── load_plugins_from_dir ───────────────────────────────────────────────────── /// Load all `*.so` / `*.dylib` files from `dir` as plugins. /// /// Non-fatal errors (unreadable files, init failures) are logged as warnings /// and skipped; the server continues with the plugins that did load. /// Returns the full list of successfully loaded plugins. pub fn load_plugins_from_dir(dir: &Path) -> Vec { let mut plugins = Vec::new(); let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(e) => { tracing::warn!(dir = %dir.display(), error = %e, "plugin_dir unreadable; no plugins loaded"); return plugins; } }; for entry in entries.flatten() { let path = entry.path(); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if ext != "so" && ext != "dylib" { continue; } match PluginHooks::load(&path) { Ok(p) => { tracing::info!(plugin = %p.name(), "plugin loaded successfully"); plugins.push(p); } Err(e) => { tracing::warn!(path = %path.display(), error = %e, "failed to load plugin; skipping"); } } } plugins }