chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
342
crates/quicprochat-server/src/plugin_loader.rs
Normal file
342
crates/quicprochat-server/src/plugin_loader.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
//! 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<Self> {
|
||||
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,
|
||||
};
|
||||
|
||||
// Safety: the symbol must have the exact signature declared in the API crate.
|
||||
let init: Symbol<unsafe extern "C" fn(*mut HookVTable) -> 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<CMessageEvent> = 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(),
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<Box<dyn ServerHooks>>,
|
||||
}
|
||||
|
||||
impl ChainedHooks {
|
||||
pub fn new(hooks: Vec<Box<dyn ServerHooks>>) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<PluginHooks> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user