feat: Phase 9 — developer experience, extensibility, and community growth

New crates:
- quicproquo-bot: Bot SDK with polling API + JSON pipe mode
- quicproquo-kt: Key Transparency Merkle log (RFC 9162 subset)
- quicproquo-plugin-api: no_std C-compatible plugin vtable API
- quicproquo-gen: scaffolding tool (qpq-gen plugin/bot/rpc/hook)

Server features:
- ServerHooks trait wired into all RPC handlers (enqueue, fetch, auth,
  channel, registration) with plugin rejection support
- Dynamic plugin loader (libloading) with --plugin-dir config
- Delivery proof canary tokens (Ed25519 server signatures on enqueue)
- Key Transparency Merkle log with inclusion proofs on resolveUser

Core library:
- Safety numbers (60-digit HMAC-SHA256 key verification codes)
- Verifiable transcript archive (CBOR + ChaCha20-Poly1305 + hash chain)
- Delivery proof verification utility
- Criterion benchmarks (hybrid KEM, MLS, identity, sealed sender, padding)

Client:
- /verify REPL command for out-of-band key verification
- Full-screen TUI via Ratatui (feature-gated --features tui)
- qpq export / qpq export-verify CLI subcommands
- KT inclusion proof verification on user resolution

Also: ROADMAP Phase 9 added, bot SDK docs, server hooks docs,
crate-responsibilities updated, example plugins (rate_limit, logging).
This commit is contained in:
2026-03-03 22:47:38 +01:00
parent b6483dedbc
commit dc4e4e49a0
62 changed files with 6959 additions and 62 deletions

View 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 qpq_plugin_init(HookVTable *vtable);
//! ```
//!
//! The server creates a zeroed [`HookVTable`], passes it to `qpq_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 `quicproquo-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 quicproquo_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 `qpq_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 `qpq_plugin_init`.
///
/// Returns `Err` if the library cannot be opened, the symbol is missing,
/// or `qpq_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"qpq_plugin_init\0") }.map_err(|e| {
anyhow::anyhow!("plugin '{}': missing qpq_plugin_init: {}", name, e)
})?;
let rc = unsafe { init(&mut vtable) };
if rc != PLUGIN_OK {
anyhow::bail!("plugin '{}': qpq_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
}