diff --git a/Cargo.lock b/Cargo.lock index e8149dbc..856921c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,51 @@ dependencies = [ "tracing", ] +[[package]] +name = "cpex-dynamic-plugin" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin-example", + "cpex-dynamic-plugin-multi-handler-example", + "cpex-dynamic-plugin-multi-plugin-example", + "libloading", + "paste", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "cpex-dynamic-plugin-example" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin", +] + +[[package]] +name = "cpex-dynamic-plugin-multi-handler-example" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin", +] + +[[package]] +name = "cpex-dynamic-plugin-multi-plugin-example" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "cpex-dynamic-plugin", +] + [[package]] name = "cpex-ffi" version = "0.1.0" @@ -310,6 +355,16 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -380,6 +435,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index 62f40dac..cba63e20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,10 @@ members = [ "crates/cpex-core", "crates/cpex-sdk", "crates/cpex-ffi", + "crates/cpex-dynamic-plugin", + "crates/cpex-dynamic-plugin/examples/single-plugin", + "crates/cpex-dynamic-plugin/examples/multi-handler", + "crates/cpex-dynamic-plugin/examples/multi-plugin", "examples/go-demo/ffi", ] diff --git a/crates/cpex-core/src/factory.rs b/crates/cpex-core/src/factory.rs index 95297d67..3f49b819 100644 --- a/crates/cpex-core/src/factory.rs +++ b/crates/cpex-core/src/factory.rs @@ -88,17 +88,38 @@ pub struct PluginInstance { /// The host populates this before calling `PluginManager::from_config()`. /// Each factory knows how to create plugins of a specific kind. /// +/// # Two dispatch modes +/// +/// Factories register under one of two patterns: +/// +/// * **Exact-match `kind`** — `register("rate_limiter", factory)`. +/// Matches plugins whose `kind:` is exactly `"rate_limiter"`. This +/// is the standard pattern for in-tree factories. +/// * **Scheme prefix** — `register_scheme("lib", factory)`. Matches +/// plugins whose `kind:` starts with `"lib:"` (e.g., +/// `kind: "lib:/opt/plugins/foo.so#bar"`). The factory's +/// `create()` receives the full kind string and parses the +/// scheme-specific format itself. Used by dynamic loaders +/// (cdylib, WASM, gRPC) where the kind needs to carry a +/// resource locator alongside the plugin name. +/// +/// Exact matches win over scheme matches when both are registered. +/// /// # Examples /// /// ```rust,ignore /// let mut factories = PluginFactoryRegistry::new(); -/// factories.register("builtin/rate_limit", Box::new(RateLimiterFactory)); -/// factories.register("builtin/identity", Box::new(IdentityFactory)); +/// factories.register("rate_limiter", Box::new(RateLimiterFactory)); +/// factories.register_scheme("lib", Box::new(DynamicPluginFactory::new())); /// /// let manager = PluginManager::from_config(path, &factories)?; /// ``` pub struct PluginFactoryRegistry { + /// Factories registered for exact `kind` matches. factories: HashMap>, + /// Factories registered for `:...` style kinds. The + /// key is the scheme alone (e.g., `"lib"`). + scheme_factories: HashMap>, } impl PluginFactoryRegistry { @@ -106,28 +127,61 @@ impl PluginFactoryRegistry { pub fn new() -> Self { Self { factories: HashMap::new(), + scheme_factories: HashMap::new(), } } - /// Register a factory for a given `kind` name. + /// Register a factory for a given `kind` name (exact match). pub fn register(&mut self, kind: impl Into, factory: Box) { self.factories.insert(kind.into(), factory); } - /// Look up a factory by `kind` name. + /// Register a factory that handles all kinds starting with + /// `:`. The factory's `create()` receives the full + /// kind string (including the scheme prefix) and is + /// responsible for parsing the scheme-specific format. + /// + /// Example: `register_scheme("lib", ...)` matches plugins with + /// `kind: "lib:/path/to/foo.so"`, `kind: "lib:/other.so#handler"`, + /// etc. + pub fn register_scheme( + &mut self, + scheme: impl Into, + factory: Box, + ) { + self.scheme_factories.insert(scheme.into(), factory); + } + + /// Look up a factory by `kind` name. Tries exact match first; + /// falls back to scheme-prefix match if the kind contains a + /// `:` separator. pub fn get(&self, kind: &str) -> Option<&dyn PluginFactory> { - self.factories.get(kind).map(|f| f.as_ref()) + if let Some(f) = self.factories.get(kind) { + return Some(f.as_ref()); + } + if let Some((scheme, _rest)) = kind.split_once(':') { + if !scheme.is_empty() { + return self.scheme_factories.get(scheme).map(|f| f.as_ref()); + } + } + None } - /// Whether a factory exists for the given `kind`. + /// Whether a factory exists for the given `kind` (exact or + /// scheme-prefix match). pub fn has(&self, kind: &str) -> bool { - self.factories.contains_key(kind) + self.get(kind).is_some() } - /// All registered kind names. + /// All registered exact-match kind names. pub fn kinds(&self) -> Vec<&str> { self.factories.keys().map(|s| s.as_str()).collect() } + + /// All registered scheme names (without the trailing `:`). + pub fn schemes(&self) -> Vec<&str> { + self.scheme_factories.keys().map(|s| s.as_str()).collect() + } } impl Default for PluginFactoryRegistry { @@ -135,3 +189,124 @@ impl Default for PluginFactoryRegistry { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::PluginConfig; + + /// Fake factory that records a tag so tests can verify which + /// factory was dispatched to. `create()` always errors with the + /// tag embedded — tests look at the error message instead of + /// constructing real PluginInstances. + struct TagFactory(&'static str); + impl PluginFactory for TagFactory { + fn create( + &self, + _config: &PluginConfig, + ) -> Result> { + Err(Box::new(PluginError::Config { + message: format!("dispatched-to:{}", self.0), + })) + } + } + + fn make_cfg(kind: &str) -> PluginConfig { + PluginConfig { + name: "test".into(), + kind: kind.into(), + ..Default::default() + } + } + + /// Pull the dispatch tag out of a TagFactory error. Uses match + /// instead of `unwrap_err()` because `PluginInstance` (the Ok + /// variant) holds `Arc` and doesn't impl Debug. + fn dispatch_tag(result: Result>) -> String { + match result { + Err(boxed) => match *boxed { + PluginError::Config { message } => message + .strip_prefix("dispatched-to:") + .map(String::from) + .unwrap_or(message), + _ => panic!("unexpected error variant"), + }, + Ok(_) => panic!("TagFactory should always Err"), + } + } + + #[test] + fn exact_match_dispatches_to_registered_factory() { + let mut reg = PluginFactoryRegistry::new(); + reg.register("rate_limit", Box::new(TagFactory("rate_limit"))); + let factory = reg.get("rate_limit").expect("factory found"); + assert_eq!(dispatch_tag(factory.create(&make_cfg("rate_limit"))), "rate_limit"); + } + + #[test] + fn unknown_kind_returns_none() { + let reg = PluginFactoryRegistry::new(); + assert!(reg.get("nonexistent").is_none()); + assert!(!reg.has("nonexistent")); + } + + #[test] + fn scheme_match_dispatches_when_no_exact_match() { + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("lib", Box::new(TagFactory("lib-loader"))); + // kind starts with `lib:` → dispatch to scheme factory. + let factory = reg.get("lib:/opt/plugins/foo.so#bar").expect("factory found"); + assert_eq!( + dispatch_tag(factory.create(&make_cfg("lib:/opt/plugins/foo.so#bar"))), + "lib-loader", + ); + } + + #[test] + fn exact_match_wins_over_scheme_match() { + let mut reg = PluginFactoryRegistry::new(); + reg.register("lib", Box::new(TagFactory("exact-lib"))); + reg.register_scheme("lib", Box::new(TagFactory("scheme-lib"))); + let exact = reg.get("lib").unwrap(); + assert_eq!(dispatch_tag(exact.create(&make_cfg("lib"))), "exact-lib"); + let prefixed = reg.get("lib:/path/to.so").unwrap(); + assert_eq!( + dispatch_tag(prefixed.create(&make_cfg("lib:/path/to.so"))), + "scheme-lib", + ); + } + + #[test] + fn empty_scheme_does_not_match() { + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("", Box::new(TagFactory("would-be-empty"))); + assert!( + reg.get(":foo").is_none(), + "leading-colon kind must not dispatch even when empty scheme is registered", + ); + } + + #[test] + fn kind_with_colons_in_path_dispatches_correctly() { + // Windows path with drive-letter colon: `lib:/C:/plugins/foo.dll`. + // `split_once(':')` splits on the FIRST colon only — scheme is + // `"lib"`, rest with embedded colons passes through unchanged. + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("lib", Box::new(TagFactory("lib-loader"))); + let factory = reg.get("lib:/C:/plugins/foo.dll").unwrap(); + assert_eq!( + dispatch_tag(factory.create(&make_cfg("lib:/C:/plugins/foo.dll"))), + "lib-loader", + ); + } + + #[test] + fn schemes_lists_registered_schemes() { + let mut reg = PluginFactoryRegistry::new(); + reg.register_scheme("lib", Box::new(TagFactory("a"))); + reg.register_scheme("wasm", Box::new(TagFactory("b"))); + let mut names: Vec<&str> = reg.schemes(); + names.sort(); + assert_eq!(names, vec!["lib", "wasm"]); + } +} diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 16764d49..4bc1067a 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -386,6 +386,44 @@ impl PluginManager { .register(kind, factory); } + /// Register a factory that handles all plugin `kind`s starting + /// with `:`. Used by dynamic loaders (cdylib, WASM, + /// gRPC) where the kind string carries a resource locator + /// alongside the plugin name — e.g. + /// `kind: "lib:/opt/plugins/foo.so#bar"`. + /// + /// The factory's `create()` receives the full kind string + /// (including the scheme prefix) and is responsible for + /// parsing the scheme-specific format. + /// + /// Exact-match `register_factory` registrations win over + /// scheme matches when both could apply. + /// + /// # Examples + /// + /// ```rust,ignore + /// // Once at host startup: + /// manager.register_factory_scheme( + /// "lib", + /// Box::new(DynamicPluginFactory::new()), + /// ); + /// + /// // Operators then write in unified-config YAML: + /// // plugins: + /// // - name: rate-limit + /// // kind: "lib:/opt/plugins/rate_limit.so#default" + /// ``` + pub fn register_factory_scheme( + &self, + scheme: impl Into, + factory: Box, + ) { + self.factories + .write() + .unwrap_or_else(|p| p.into_inner()) + .register_scheme(scheme, factory); + } + // ----------------------------------------------------------------------- // Config Loading // ----------------------------------------------------------------------- diff --git a/crates/cpex-dynamic-plugin/Cargo.toml b/crates/cpex-dynamic-plugin/Cargo.toml new file mode 100644 index 00000000..484ff9a2 --- /dev/null +++ b/crates/cpex-dynamic-plugin/Cargo.toml @@ -0,0 +1,76 @@ +# Location: ./crates/cpex-dynamic-plugin/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# cpex-dynamic-plugin — load Rust cdylib plugins at runtime via +# libloading. +# +# # Design notes +# +# See `docs/specs/cpex-rust-spec.md` §17 (Dynamic Plugin Loading). +# Plugin and host compile against the same `cpex-core` version +# (same-version-only Rust ABI). `Arc` crosses +# the dlopen boundary as the stable vtable type; no serialization +# of payloads, extensions, or results. +# +# # Two roles, one crate, feature-gated +# +# This crate has two audiences: +# +# * **Plugin authors** — depend on this crate to get the entry- +# point types + helper macros. The default feature set is what +# they need; libloading is NOT pulled. +# * **Hosts** — depend on this crate with the `host` feature to +# pull in libloading + `DynamicPluginFactory`. Hosts register +# the factory under a kind (default `"dynamic"`) and operators +# reference dynamic plugins via that kind in unified config. +# +# Keeping it one crate avoids two-crate version-skew issues +# (plugin and host always see the same ABI types because they're +# in the same package). + +[package] +name = "cpex-dynamic-plugin" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[features] +# Plugin-side default — no libloading, no extra deps. Plugin +# authors get just the ABI types + helper macros. +default = [] +# Host-side opt-in — pulls libloading + the DynamicPluginFactory. +host = ["dep:libloading"] + +[dependencies] +cpex-core = { path = "../cpex-core" } + +# libloading is the de-facto Rust crate for dlopen / LoadLibraryW. +# Optional: only pulled when the `host` feature is enabled, so +# plugin-side builds stay light. +libloading = { version = "0.8", optional = true } + +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +# `paste` is used by the `cpex_dynamic_plugins!` macro to build +# `cpex_plugin_create_` identifiers from the user-supplied +# entry name. Plugin authors don't reference paste directly; the +# macro re-exports it under `$crate::paste`. +paste = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +# Depending on the example plugin crate triggers cargo to build +# its cdylib artifact alongside this crate's tests. We don't +# actually USE anything from the rlib at the Rust level — the +# integration test loads the built `.so` / `.dylib` / `.dll` +# directly via libloading. The dep edge is purely a build-order +# trigger. +cpex-dynamic-plugin-example = { path = "examples/single-plugin" } +cpex-dynamic-plugin-multi-handler-example = { path = "examples/multi-handler" } +cpex-dynamic-plugin-multi-plugin-example = { path = "examples/multi-plugin" } diff --git a/crates/cpex-dynamic-plugin/README.md b/crates/cpex-dynamic-plugin/README.md new file mode 100644 index 00000000..f71072af --- /dev/null +++ b/crates/cpex-dynamic-plugin/README.md @@ -0,0 +1,654 @@ +# cpex-dynamic-plugin + +Load Rust CPEX plugins at runtime from `.so` / `.dylib` / `.dll` +files. Plugin authors write the same `async fn handle(...)` code +they would for an in-tree plugin; the only difference is the +plugin compiles as a `cdylib` and the host loads it via `libloading`. + +**No serialization across the FFI boundary.** Payloads and +extensions cross as pointers through `Arc` — +same in-memory representation in plugin and host. All immutability +guarantees, capability gating, monotonic-set protections, and +panic isolation from in-tree plugins apply identically. See +[`docs/specs/cpex-rust-spec.md` §17][spec-§17] for the architecture +rationale. + +[spec-§17]: ../../docs/specs/cpex-rust-spec.md + +--- + +## Quick start + +### 1. Project layout + +``` +my-plugin/ +├── Cargo.toml +└── src/ + └── lib.rs +``` + +A dynamic plugin is just a regular Cargo crate with `crate-type = +["cdylib"]`. Most plugins are a single source file plus the +manifest. + +### 2. `Cargo.toml` + +```toml +[package] +name = "my-rate-limiter" +version = "0.1.0" +edition = "2021" + +[lib] +# `cdylib` is what the host dlopens. Add `rlib` too if other Rust +# crates need to depend on this plugin as a normal library (rare; +# usually only needed for the workspace-internal pattern where +# tests dev-depend on a plugin to trigger the cdylib build). +crate-type = ["cdylib"] + +[dependencies] +# Both deps MUST be pinned to the same versions the HOST is built +# against. Same-version-only Rust ABI is the load-bearing constraint +# (see "ABI versioning" below). +cpex-core = "..." # whatever your host uses +cpex-dynamic-plugin = "..." # same +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +``` + +### 3. `src/lib.rs` + +```rust +use std::sync::Arc; +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; + +/// Typed *view* of the same fields the operator put under +/// `config:` in their YAML — i.e. of `cfg.config`. We deserialize +/// once at construction so `handle()` doesn't re-parse JSON on +/// the hot path AND so structural mismatches surface as a +/// startup-time `InitializationError` instead of at first invoke. +/// +/// This is **not** a separate config source. The operator only +/// ever writes one `config:` block per plugin; `ParsedConfig` is +/// just how this plugin chooses to materialize that block in +/// memory. +#[derive(serde::Deserialize)] +struct ParsedConfig { + max_per_second: u32, + #[serde(default = "default_burst")] + burst: u32, +} + +fn default_burst() -> u32 { 10 } + +struct MyRateLimiter { + /// The operator's `PluginConfig` as received. Kept around + /// because the `Plugin::config()` trait method returns + /// `&PluginConfig` — the executor needs it for capability + /// gating, on_error policy, etc. + cfg: PluginConfig, + /// Cached typed view of `cfg.config`. Built once in `new()`. + parsed: ParsedConfig, + // ... any other runtime state: counters, expiry trackers, etc. +} + +impl MyRateLimiter { + /// Single constructor entry point. Follows the framework + /// convention: plugin takes ONLY `PluginConfig` and derives + /// all internal state from `cfg.config`. Operators never pass + /// pre-built typed pieces; everything flows through the + /// unified config pipeline. + fn new(cfg: PluginConfig) -> Result { + let raw = cfg + .config + .as_ref() + .ok_or_else(|| "rate-limit plugin requires a `config:` block".to_string())?; + // Deserialize cfg.config into the typed view. This is the + // ONLY config materialization — operators don't supply + // settings any other way. + let parsed: ParsedConfig = serde_json::from_value(raw.clone()) + .map_err(|e| format!("invalid rate-limit config: {e}"))?; + Ok(Self { cfg, parsed }) + } +} + +#[async_trait] +impl Plugin for MyRateLimiter { + fn config(&self) -> &PluginConfig { &self.cfg } +} + +impl HookHandler for MyRateLimiter { + async fn handle( + &self, + _payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + // self.parsed.max_per_second is the same value the operator + // wrote at `config.max_per_second` in YAML — just typed + // and cached. No JSON parsing on the hot path. + let _budget = self.parsed.max_per_second; + // ... rate-limit logic ... + PluginResult::allow() + } +} + +// The macro generates the `#[no_mangle] pub unsafe extern "C" fn +// cpex_plugin_create(...)` entry point. ABI handshake, config +// parsing, catch_unwind, and ownership transfer of the +// PluginRegistration are all handled inside the macro expansion. +cpex_dynamic_plugin! { + |cfg: PluginConfig| -> Result { + let plugin = Arc::new(MyRateLimiter::new(cfg)?); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "my-rate-limiter", + env!("CARGO_PKG_VERSION"), + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) + } +} +``` + +### 4. Build + +```sh +cargo build --release +``` + +Output lands at `target/release/libmy_rate_limiter.{so,dylib,dll}` +(Cargo converts hyphens to underscores in the artifact filename; +`lib` prefix appears on Unix, not Windows). + +### 5. Use it + +The operator references the plugin in unified-config YAML by its +absolute path: + +```yaml +plugins: + - name: rate-limit # operator's name for the plugin + kind: "lib:/opt/plugins/libmy_rate_limiter.so" + hooks: [cmf.tool_pre_invoke] + capabilities: [read_headers] + config: + max_per_second: 200 # ← the plugin reads these from cfg.config + burst: 50 # ← (deserialized once into ParsedConfig) +``` + +The host wires the factory once at startup: + +```rust +mgr.register_factory_scheme( + "lib", + Box::new(cpex_dynamic_plugin::DynamicPluginFactory::new()), +); +mgr.load_config_file(Path::new("plugins.yaml"))?; +mgr.initialize().await?; +``` + +--- + +## Names and identifiers + +Two `name` fields show up around a dynamic plugin, and they +serve different purposes. They can be the same string if you +want — but they don't have to be. + +| Where | Set by | Used for | +|---|---|---| +| YAML `plugins[i].name:` (→ `PluginConfig.name`) | **Operator** | Operational identifier. Hook registration keys, per-plugin context state, error messages (`"plugin 'rate-limit' denied: ..."`), audit logs. The framework treats this as authoritative. | +| `PluginRegistration::new(name, ...)` (→ `PluginRegistration.name`) | **Plugin author** | Diagnostic-only self-report. Surfaces in the loader's `tracing::info!` line as `plugin_reported_name = "..."` so operators can sanity-check that the cdylib they loaded is the one they expected. The framework doesn't route on this. | + +In Quick Start §3 / §5 the operator writes `name: rate-limit` in +YAML while the plugin author writes `"my-rate-limiter"` in +`PluginRegistration::new`. Both are fine — they're different +identifiers serving different concerns. Setting them to the same +string is also fine; many operators do exactly that for clarity. + +**Two scenarios where keeping them distinct is useful:** + +1. **Multiple operator-instances of the same plugin code.** An + operator can load the same cdylib twice with different + settings, each under its own operator name: + + ```yaml + plugins: + - name: rate-limit-api # operator's name #1 + kind: "lib:/opt/plugins/libmy_rate_limiter.so" + config: { max_per_second: 200 } + + - name: rate-limit-admin # operator's name #2 + kind: "lib:/opt/plugins/libmy_rate_limiter.so" + config: { max_per_second: 10 } + ``` + + Both load the same cdylib, both report + `plugin_reported_name = "my-rate-limiter"`, but the + operational identifiers stay distinct. Audit logs and + per-plugin context state correctly attribute work to the + right instance. + +2. **Sanity-check at load time.** If the wrong `.so` got dropped + into the plugins directory, the operator's name says + "innocent-rate-limit" but the load log surfaces + `plugin_reported_name = "evil-keylogger"`. Mismatch between + the operator's expectation and the plugin's self-report is + visible without grepping through binaries. + +If those don't apply to you, just use the same string in both +places. + +--- + +## Plugin construction convention + +Plugins follow a single rule: **the constructor takes only +`PluginConfig`.** All runtime state is derived from `cfg.config` +inside `new()`. No alternate constructors that accept already- +built typed pieces. + +**Why:** consistent instantiation via the unified-config pipeline. +The operator writes one YAML block; the host's factory +deserializes the `PluginConfig`; the plugin's `new()` extracts +and validates the typed config. Tests follow the same path — +construct a `PluginConfig` with the right `config:` value and +exercise `new()` like production code does. This catches +config-parsing regressions automatically. + +**Don't do this:** +```rust +// ✗ separate typed parameters bypass the config-driven path +MyRateLimiter::new(cfg, max_per_second, claim_mapper) +``` + +**Do this:** +```rust +// ✓ everything flows through cfg.config +MyRateLimiter::new(cfg) +``` + +--- + +## Multiple handlers per plugin + +A single plugin crate can register more than one handler. There +are two common patterns: + +### Pattern A — one struct, multiple hook names (same `HookTypeDef`) + +Most common for plugins that participate in multiple CMF phases +(pre + post, args + result). The same struct implements +`HookHandler` once and is wired under multiple hook +names: + +```rust +let plugin = Arc::new(MyPlugin::new(cfg)?); + +let pre_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), +); +let post_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), +); + +Ok(PluginRegistration::new( + "my-plugin", + env!("CARGO_PKG_VERSION"), + plugin as Arc, + vec![ + ("cmf.tool_pre_invoke".to_string(), pre_adapter), + ("cmf.tool_post_invoke".to_string(), post_adapter), + ], +)) +``` + +The operator's `hooks:` array in YAML lists which of the +registered hooks should actually fire for that plugin instance. + +### Pattern B — multiple structs / multiple `HookTypeDef`s + +If the plugin does conceptually different things at different +hooks (e.g., identity resolution AND CMF policy), wire each +behavior as its own struct + adapter. Each sub-struct still gets +the full `PluginConfig` and derives its own state from it: + +```rust +let identity = Arc::new(MyIdentityResolver::new(cfg.clone())?); +let policy = Arc::new(MyPolicyGate::new(cfg.clone())?); + +let id_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&identity)), +); +let policy_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&policy)), +); + +// Pick which one becomes the plugin's "primary" representation — +// usually the higher-level / authoritative one. PluginRegistration +// only carries a single Plugin handle; for plugins with multiple +// distinct components, use whichever you'd want surfaced in +// diagnostics. +Ok(PluginRegistration::new( + "auth-bundle", + env!("CARGO_PKG_VERSION"), + identity as Arc, + vec![ + ("identity.resolve".to_string(), id_adapter), + ("cmf.tool_pre_invoke".to_string(), policy_adapter), + ], +)) +``` + +### Selecting a specific handler from YAML + +When a single cdylib registers multiple handlers but the operator +only wants one of them active for a given plugin entry, add a +fragment to the `kind:` string: + +```yaml +plugins: + # Same cdylib, two YAML entries, two handlers selected. + - name: id + kind: "lib:/opt/plugins/libauth_bundle.so#identity.resolve" + hooks: [identity.resolve] + - name: policy + kind: "lib:/opt/plugins/libauth_bundle.so#cmf.tool_pre_invoke" + hooks: [cmf.tool_pre_invoke] +``` + +The `#` fragment names the hook the operator wants kept; all +other handlers from the registration are filtered out. Without a +fragment, every registered handler is wired. + +--- + +## Multiple plugins per cdylib + +The `cpex_dynamic_plugin!` macro (singular) emits one plugin per +shared library. If you want to ship several unrelated plugins in +one binary — different code, different identities, different +versions — use the `cpex_dynamic_plugins!` macro (plural) instead. + +### Why pick this over multi-handler? + +The two shapes solve different problems: + +| Shape | Macro | One PluginRegistration per | When | +|-------|-------|---------------------------|------| +| **Multi-handler** | `cpex_dynamic_plugin!` | cdylib (with many `(hook, handler)` pairs inside) | One plugin that participates in several lifecycle hooks. | +| **Multi-plugin** | `cpex_dynamic_plugins!` | `?entry=` selector | Several genuinely distinct plugins packaged together for deployment convenience. | + +Use multi-handler unless you specifically need multiple +*independent* plugins. The two shapes are not mutually exclusive — +each entry in a multi-plugin cdylib can itself register multiple +handlers. + +### Plugin author side + +```rust +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_dynamic_plugin::{cpex_dynamic_plugins, PluginRegistration}; + +fn build_rate_limiter(cfg: PluginConfig) -> Result { + // ... build and return PluginRegistration ... +# unimplemented!() +} + +fn build_audit(cfg: PluginConfig) -> Result { + // ... build and return PluginRegistration ... +# unimplemented!() +} + +cpex_dynamic_plugins! { + rate_limiter => { + name: "Rate Limiter", + version: "1.0.0", + description: "Token-bucket rate limiter", + create: build_rate_limiter, + }, + audit => { + name: "Audit Logger", + version: "0.5.0", + description: "Writes hook events to disk", + create: build_audit, + }, +} +``` + +The macro generates: + +* One `cpex_plugin_create_` symbol per entry (the ident + before `=>`). The host resolves these by composing the entry + name from the operator's `?entry=` URL. +* A `cpex_plugin_list` discovery symbol. Hosts read it to validate + the operator's `?entry=` against the available entries up-front, + so unknown entries get a friendly "available: [rate_limiter, + audit]" error instead of a raw "symbol not found" from dlsym. + +The entry name (`rate_limiter`, `audit`) MUST be a valid Rust +identifier — the macro requires that. It also has to be a valid C +identifier so the generated symbol name is well-formed; the host +validates the operator's `?entry=` against +`[a-zA-Z_][a-zA-Z0-9_]*` before any symbol lookup. + +### Operator side + +Each entry is addressable from YAML via `?entry=`: + +```yaml +plugins: + - name: edge-rate-limit + kind: "lib:/opt/plugins/libmulti.so?entry=rate_limiter" + hooks: [cmf.tool_pre_invoke] + config: + max_per_second: 100 + - name: audit-trail + kind: "lib:/opt/plugins/libmulti.so?entry=audit" + hooks: [cmf.tool_post_invoke] + config: + log_path: /var/log/cpex-audit.log +``` + +The shared library is `dlopen`'d once (the OS dedupes), but each +entry produces an independent `PluginInstance` with its own +config, name, and handler set. + +URL component order is `:[?entry=][#handler]`, +so `?entry=` and `#handler` can be combined for a multi-plugin +cdylib whose entries themselves register multiple handlers: + +```yaml +kind: "lib:/opt/plugins/libmulti.so?entry=audit#cmf.tool_post_invoke" +``` + +### Single-plugin migration is opt-in + +Cdylibs built with the singular `cpex_dynamic_plugin!` keep +working exactly as before — they export `cpex_plugin_create` with +no entry suffix, no manifest, and YAML keeps using +`kind: "lib:/path/foo.so"` with no `?entry=`. Nothing changes +unless you migrate to `cpex_dynamic_plugins!`. The two macros are +independent; pick one per cdylib based on whether you're shipping +one plugin or several. + +--- + +## Plugin configuration + +Plugins read their settings from `cfg.config` — the +`Option` field on `PluginConfig`. Operators +populate it from the unified-config YAML's `config:` block: + +```yaml +plugins: + - name: rate-limit + kind: "lib:/opt/plugins/librate_limit.so" + config: # ← this is cfg.config + max_per_second: 200 + burst: 50 + whitelist: ["10.0.0.0/8"] +``` + +There is only ever one place a plugin's settings live: `cfg.config`. +The pattern shown in Quick Start §3 (define `ParsedConfig`, +deserialize once in `new()`, store the typed view on `self`) is a +performance/ergonomics optimization — `ParsedConfig` is a *cached +typed view* of `cfg.config`, not a separate config channel. +Initialization errors (missing required fields, unparseable +values, etc.) — return `Err(String)` from `new()`. The +`cpex_dynamic_plugin!` macro propagates that into +`EntryPointResult::InitializationError`, which the host surfaces +via `PluginError::Config` with the cdylib path included in the +diagnostic. + +--- + +## What does NOT go in `config:` + +The operator's `kind:` string is the right place for loader +concerns, not the plugin's `config:` block. Specifically: + +| Loader concern | Goes in `kind:` | +|---|---| +| Library path | `lib:/opt/plugins/foo.so` | +| Handler filter | `...#cmf.tool_pre_invoke` | + +The reason: `config:` is plugin-specific config the plugin's own +typed view deserializes from. Mixing loader fields into it would +force plugins to know about the loader's reserved keys, and +operators would lose the natural separation between "where does +this plugin come from" (a deployment concern) and "how does this +plugin behave" (a runtime concern). + +--- + +## ABI versioning and the same-version constraint + +The Rust ABI is unstable across compiler versions and across +patch versions of dependencies. **The plugin's cdylib and the +host MUST be compiled against the same versions of:** + + * `cpex-core` + * `cpex-dynamic-plugin` + * The Rust compiler (`rustc --version`) + +Mismatches are checked at load time via the +`cpex_dynamic_plugin::ABI_VERSION` constant. When the host's +`DynamicPluginFactory` calls into the plugin's entry point, the +plugin compares the host's reported `ABI_VERSION` to its own +compiled-against value and returns `EntryPointResult::AbiMismatch` +on disagreement. The host surfaces this as a `PluginError::Config` +with the actionable text: *"Rebuild the plugin against the same +cpex-core / cpex-dynamic-plugin versions the host is using."* + +`ABI_VERSION` is bumped on any breaking change to: + + * the entry-point function signature + * `PluginRegistration` field layout + * `AnyHookHandler` trait shape + * `Extensions` / `MessagePayload` layout + +In practice this means a plugin built against `cpex-core 0.2.0` +won't load into a host running `cpex-core 0.3.0`. Operators +should rebuild plugins whenever they upgrade the host. + +### Why no abi_stable + +We considered `abi_stable` for true cross-version compatibility. +It adds significant surface (every trait needs `#[sabi_trait]` +wrappers, every type needs `StableAbi` derives) and changes the +plugin-author API. Same-version-only is the simpler default; we +can revisit if multi-vendor plugin marketplaces become a real +need. + +--- + +## Error diagnostics + +When a plugin fails to load, the host reports a `PluginError::Config` +with a human-readable message embedding the failure mode. Common +ones: + +| Symptom | Cause | Fix | +|---|---|---| +| `failed to dlopen ''` | File doesn't exist, wrong permissions, or wrong arch (e.g., x86_64 plugin on arm64 host). | Verify path, file mode, and `lipo -info `. | +| `cdylib does not export 'cpex_plugin_create'` | Plugin doesn't use the `cpex_dynamic_plugin!` macro, or it's declared without `#[no_mangle] pub extern "C"`. | Use the macro; don't write the entry point by hand. | +| `cdylib was compiled against a different cpex-dynamic-plugin ABI version` | Plugin built against a different `cpex-core` / `cpex-dynamic-plugin` version than the host. | Rebuild the plugin against the host's exact dep versions. | +| `cdylib rejected its PluginConfig` | Operator's YAML has a structural mismatch with the plugin's expected config schema. | Check the plugin's documented config schema. | +| `cdylib failed to initialize` | Plugin's `new()` returned `Err(_)` (config validation, key load, network probe, etc.). | Check the cdylib's logs / stderr for the underlying error. | +| `cdylib panicked during construction` | Plugin code unwound inside `new()` or the macro closure. Caught at the FFI boundary, didn't crash the host. | Check the cdylib's logs / stderr for the panic backtrace. | +| `returned no handler named ''` | The `#` fragment in the kind selected a handler that the plugin didn't register. | Check the cdylib's documentation for which handler names it exposes, or omit the fragment to take all handlers. | + +All errors include the operator-supplied plugin `name` and the +absolute library path for ops debugging. + +--- + +## Limitations and trade-offs + +* **Load-at-startup only.** No hot reload. Loaded libraries are + leaked (`Box::leak`) and stay mapped until process exit. This + is the standard Rust plugin-loader pattern (Bevy and others + follow it). Hot reload requires reference-counting the library + alongside all derived `Arc` / `Arc` references — out of scope for v0. +* **No sandbox.** Loaded plugins run in-process with full host + privileges (file system, network, memory, syscalls). Operators + vet plugins before deploying them. Capability gating still + applies to extension access (just like in-tree plugins), but + it does not stop a malicious plugin from making arbitrary + syscalls. +* **Allocator.** Plugin and host must share an allocator. Both + use `std::alloc::System` by default — don't override the + allocator in your plugin (`#[global_allocator]`) unless the + host uses the same one. +* **No nested `block_on`.** Async handlers must not `block_on` + inside `handle()` — the future is already running on a tokio + task, and nested blocking will panic. Same rule as in-tree + plugins, but easier to forget when the plugin lives in another + repo. +* **Same-version-only** (see "ABI versioning" above). Rebuild + plugins on host upgrades. + +--- + +## Reference examples + +All under [`examples/`](./examples), each a standalone cdylib +crate built alongside this crate's integration tests: + +* **[`single-plugin/`](./examples/single-plugin)** — minimal + allow-everything plugin. The simplest possible `cpex_dynamic_plugin!` + (singular) shape; ~50 lines. Start here. +* **[`multi-handler/`](./examples/multi-handler)** — one plugin + with two handlers wired to different hooks (pre-invoke allow, + post-invoke deny). Demonstrates Pattern A from the multi-handler + section above. +* **[`multi-plugin/`](./examples/multi-plugin)** — two distinct + plugins (allow + deny) packaged in one cdylib via + `cpex_dynamic_plugins!` (plural). Operator selects via + `?entry=allow` or `?entry=deny`. + +## Tests + +* `cargo test -p cpex-dynamic-plugin` — unit tests for the + plugin-side ABI helpers and the kind-string parser (no + dlopen involved). +* `cargo test -p cpex-dynamic-plugin --features host` — full + suite including the dlopen integration tests that load every + reference example above at runtime. diff --git a/crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml b/crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml new file mode 100644 index 00000000..b6fee876 --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml @@ -0,0 +1,34 @@ +# Location: ./crates/cpex-dynamic-plugin/examples/multi-handler/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Reference cdylib plugin that registers TWO handlers with +# distinguishable behaviors: +# +# * `cmf.tool_pre_invoke` → returns `PluginResult::allow()` +# * `cmf.tool_post_invoke` → returns `PluginResult::deny(...)` +# +# Used by `cpex-dynamic-plugin`'s integration tests to verify: +# * The `#handler` fragment in the kind string correctly filters +# a multi-handler registration to just the named handler. +# * Multiple dynamic plugins can coexist in one PluginManager +# (paired with the allow-gate `cpex-dynamic-plugin-example`). +# +# Pattern A from the README — same plugin struct, multiple +# adapters wired under different hook names. + +[package] +name = "cpex-dynamic-plugin-multi-handler-example" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +cpex-core = { path = "../../../cpex-core" } +cpex-dynamic-plugin = { path = "../.." } +async-trait = { workspace = true } diff --git a/crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs b/crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs new file mode 100644 index 00000000..832c8b55 --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs @@ -0,0 +1,114 @@ +// Location: ./crates/cpex-dynamic-plugin/examples/multi-handler/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Multi-handler reference plugin. Registers two CmfHook handlers +// with intentionally different verdicts so tests can distinguish +// "which handler fired" from the pipeline outcome alone: +// +// * `cmf.tool_pre_invoke` → AllowHandler → continue_processing = true +// * `cmf.tool_post_invoke` → DenyHandler → continue_processing = false, +// violation.code = "test.multi_handler.post_deny" +// +// Pattern A from the README: one Plugin instance, two adapters +// over different `HookHandler` impls. Lets the integration +// tests verify the `#handler` fragment filter works against a +// real multi-handler cdylib. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginViolation; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; + +/// Pre-invoke handler — always allows. +struct AllowOnPre { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowOnPre { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowOnPre { + async fn handle( + &self, + _payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +/// Post-invoke handler — always denies with a distinctive code so +/// tests can identify which handler fired by inspecting the +/// violation. +struct DenyOnPost { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyOnPost { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyOnPost { + async fn handle( + &self, + _payload: &MessagePayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "test.multi_handler.post_deny", + "deny-on-post handler fired", + )) + } +} + +cpex_dynamic_plugin! { + |cfg: PluginConfig| -> Result { + // Two distinct plugin structs, one Arc each. The plugin + // exposed via PluginRegistration is the AllowOnPre — the + // post handler is technically a separate Plugin instance, + // but the registration only carries one "primary" Plugin + // handle for diagnostic purposes. Functionally, both + // handlers run independently when their respective hooks + // fire. + let allow = Arc::new(AllowOnPre { cfg: cfg.clone() }); + let deny = Arc::new(DenyOnPost { cfg: cfg.clone() }); + + let allow_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&allow)), + ); + let deny_adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&deny)), + ); + + Ok(PluginRegistration::new( + "cpex-dynamic-plugin-multi-handler-example", + env!("CARGO_PKG_VERSION"), + allow as Arc, + vec![ + ("cmf.tool_pre_invoke".to_string(), allow_adapter), + ("cmf.tool_post_invoke".to_string(), deny_adapter), + ], + )) + } +} diff --git a/crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml b/crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml new file mode 100644 index 00000000..86229912 --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml @@ -0,0 +1,41 @@ +# Location: ./crates/cpex-dynamic-plugin/examples/multi-plugin/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Reference cdylib that packages MULTIPLE distinct plugins in one +# shared library. Uses the `cpex_dynamic_plugins!` (plural) macro +# to emit: +# +# * cpex_plugin_create_allow — always-allow gate. +# * cpex_plugin_create_deny — always-deny gate with a +# recognizable violation code. +# * cpex_plugin_list — manifest discovery symbol. +# +# Operator selects which one to load via `?entry=` in the +# kind URL: +# +# kind: "lib:/path/multi_plugin.so?entry=allow" +# kind: "lib:/path/multi_plugin.so?entry=deny" +# +# Used by `cpex-dynamic-plugin`'s integration tests to verify: +# * Multiple plugins coexist in one cdylib. +# * The host's symbol resolution + manifest validation works. +# * Friendly errors for unknown entries. +# * Default `cpex_plugin_create` symbol absence doesn't break +# anything (operator MUST specify `?entry=` for this cdylib). + +[package] +name = "cpex-dynamic-plugin-multi-plugin-example" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +cpex-core = { path = "../../../cpex-core" } +cpex-dynamic-plugin = { path = "../.." } +async-trait = { workspace = true } diff --git a/crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs b/crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs new file mode 100644 index 00000000..3bbc6aba --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs @@ -0,0 +1,138 @@ +// Location: ./crates/cpex-dynamic-plugin/examples/multi-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Multi-plugin reference cdylib. Packages two truly distinct +// plugins (different structs, different behaviors, different +// versions) inside one shared library, addressable via the +// operator's `?entry=` URL parameter. +// +// This complements `cpex-dynamic-plugin-multi-handler-example`, +// which has ONE plugin registering MULTIPLE handlers. The two +// shapes are independent: +// +// * Multi-handler (cpex_dynamic_plugin! singular): one plugin +// hooks several lifecycle points. One entry point, several +// `(hook_name, handler)` pairs. +// * Multi-plugin (cpex_dynamic_plugins! plural): several +// unrelated plugins shipped in one binary for deployment +// convenience. Several entry points, one PluginRegistration +// per call. +// +// Tests use the verdict + violation code to identify which +// entry-point function the host actually called. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::error::PluginViolation; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugins, PluginRegistration}; + +// ----- Plugin 1: Allow gate ----- + +struct AllowGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +fn build_allow(cfg: PluginConfig) -> Result { + let plugin = Arc::new(AllowGate { cfg }); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "allow-gate", + "1.0.0", + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) +} + +// ----- Plugin 2: Deny gate ----- + +struct DenyGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for DenyGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for DenyGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new( + "test.multi_plugin.deny", + "deny-gate plugin fired", + )) + } +} + +fn build_deny(cfg: PluginConfig) -> Result { + let plugin = Arc::new(DenyGate { cfg }); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "deny-gate", + "0.5.0", + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) +} + +// ----- Multi-plugin registration ----- +// +// Generates `cpex_plugin_create_allow`, `cpex_plugin_create_deny`, +// and `cpex_plugin_list`. Note: this cdylib does NOT expose the +// default `cpex_plugin_create` symbol — operators MUST use +// `?entry=`. That's deliberate: if you're packaging multiple +// plugins, there's no sensible default. +cpex_dynamic_plugins! { + allow => { + name: "Allow Gate", + version: "1.0.0", + description: "Always allows; useful for smoke-testing the pipeline", + create: build_allow, + }, + deny => { + name: "Deny Gate", + version: "0.5.0", + description: "Always denies with code test.multi_plugin.deny", + create: build_deny, + }, +} diff --git a/crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml b/crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml new file mode 100644 index 00000000..d49cbb7c --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml @@ -0,0 +1,49 @@ +# Location: ./crates/cpex-dynamic-plugin/examples/single-plugin/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Reference dynamic plugin — compiles as a cdylib loaded at runtime +# by `cpex-dynamic-plugin`'s `DynamicPluginFactory`. Used by +# `cpex-dynamic-plugin`'s integration tests as the load-bearing +# "everything actually works" check. +# +# # Crate types +# +# * `cdylib` — what the host actually loads via `libloading`. +# Produces `libcpex_dynamic_plugin_example.{so,dylib,dll}` +# in the workspace target dir. +# * `rlib` — lets other workspace crates (specifically +# `cpex-dynamic-plugin`'s dev-dep on this) trigger +# cargo to build the cdylib as a side effect of +# running tests. Without the rlib, cargo can't +# depend on the cdylib in the Rust sense. +# +# # What this plugin does +# +# Trivial CMF plugin that always allows. Confirms end-to-end: +# 1. `cpex_dynamic_plugin!` macro generates the entry point. +# 2. Host's `DynamicPluginFactory` dlopens + binds + calls it. +# 3. The returned `Arc` + handler survive across the +# FFI boundary intact and the executor can invoke them. +# +# Doesn't try to test the full surface (config-driven behavior, +# violations, payload mutation) — that's what later, deployment- +# specific plugins would exercise. + +[package] +name = "cpex-dynamic-plugin-example" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +# `cdylib` is what dlopens; `rlib` is the rustc-level handle other +# workspace crates can depend on to trigger building both. +crate-type = ["cdylib", "rlib"] + +[dependencies] +cpex-core = { path = "../../../cpex-core" } +cpex-dynamic-plugin = { path = "../.." } +async-trait = { workspace = true } diff --git a/crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs b/crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs new file mode 100644 index 00000000..dd457efe --- /dev/null +++ b/crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs @@ -0,0 +1,68 @@ +// Location: ./crates/cpex-dynamic-plugin/examples/single-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Reference cdylib plugin — the bare-minimum shape a dynamic plugin +// takes. Used as the integration-test fixture for +// `cpex-dynamic-plugin`. Plugin authors write code that looks +// essentially like this. + +use std::sync::Arc; + +use async_trait::async_trait; + +use cpex_core::cmf::{CmfHook, MessagePayload}; +use cpex_core::context::PluginContext; +use cpex_core::hooks::adapter::TypedHandlerAdapter; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::plugin::{Plugin, PluginConfig}; +use cpex_core::registry::AnyHookHandler; + +use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; + +/// Minimal allow-everything plugin. Real plugins do more, but the +/// goal here is to prove the load + invoke path through the dlopen +/// boundary works. +struct AllowGate { + cfg: PluginConfig, +} + +#[async_trait] +impl Plugin for AllowGate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for AllowGate { + async fn handle( + &self, + _payload: &MessagePayload, + _extensions: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::allow() + } +} + +// The macro generates the `#[no_mangle] pub unsafe extern "C" fn +// cpex_plugin_create(...)` entry point. Plugin author writes a +// closure that builds the registration; macro handles all the +// FFI safety glue (abi-check, config parse, catch_unwind, raw +// pointer ownership transfer). +cpex_dynamic_plugin! { + |cfg: PluginConfig| -> Result { + let plugin = Arc::new(AllowGate { cfg }); + let adapter: Arc = Arc::new( + TypedHandlerAdapter::::new(Arc::clone(&plugin)), + ); + Ok(PluginRegistration::new( + "cpex-dynamic-plugin-example", + env!("CARGO_PKG_VERSION"), + plugin as Arc, + vec![("cmf.tool_pre_invoke".to_string(), adapter)], + )) + } +} diff --git a/crates/cpex-dynamic-plugin/src/abi.rs b/crates/cpex-dynamic-plugin/src/abi.rs new file mode 100644 index 00000000..c070f310 --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/abi.rs @@ -0,0 +1,222 @@ +// Location: ./crates/cpex-dynamic-plugin/src/abi.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Shared ABI types between plugin (cdylib) and host (loader). +// +// # Layout-stable contract +// +// Plugin and host MUST be compiled against the same `cpex-core` +// version. Rust's `repr(Rust)` types don't have a stable layout +// across compiler versions, so even patch-version bumps to +// `cpex-core` invalidate the contract. The `ABI_VERSION` constant +// below is bumped on every change to: +// +// * `PluginRegistration` field layout (added/removed/reordered) +// * `EntryPointResult` discriminants +// * `cpex_core::registry::AnyHookHandler` trait shape (methods, +// order, signatures) +// * `cpex_core::hooks::payload` types (`Extensions`, `MessagePayload`) +// +// Plugin's entry point reports its compiled-against ABI_VERSION; +// host rejects load if mismatched. Same-version-only is the +// load-bearing constraint; the runtime check makes mismatches +// loud instead of UB. +// +// # The entry-point contract +// +// Each plugin cdylib exports a single C function named +// `cpex_plugin_create` with the [`EntryPointFn`] signature. The +// plugin-author macro [`crate::cpex_dynamic_plugin!`] generates +// this function so authors don't write unsafe FFI by hand. +// +// Ownership: the plugin allocates the `PluginRegistration` via +// `Box::new(...)` + `Box::into_raw(...)`, writes the pointer to +// `out_registration`. Host takes ownership via `Box::from_raw(...)`. +// Same default allocator on both sides (`std::alloc::System`) means +// the host can drop the box safely. + +use std::sync::Arc; + +use cpex_core::plugin::Plugin; +use cpex_core::registry::AnyHookHandler; + +/// Bumped on any breaking change to the ABI surface (see module +/// docs). Plugin and host must report identical values or the load +/// is rejected. +pub const ABI_VERSION: u32 = 1; + +/// Status code the plugin's entry point returns to the host. +#[repr(u32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryPointResult { + /// Plugin constructed successfully. `out_registration` is + /// populated; host takes ownership of the boxed registration. + Ok = 0, + /// Plugin's `ABI_VERSION` didn't match the host's. The + /// plugin did NOT touch `out_registration`; host should not + /// read it. + AbiMismatch = 1, + /// Plugin couldn't parse its serialized `PluginConfig`. + ConfigParseError = 2, + /// Plugin's own initialization failed (key load, network + /// probe, missing required claim, etc.). + InitializationError = 3, + /// Plugin's entry-point body panicked. Caught at the FFI + /// boundary so the host doesn't get an unwinding panic + /// across `extern "C"` (which is UB). + Panic = 4, +} + +/// The plugin's entry-point function signature. Plugins export +/// this as `cpex_plugin_create`; host's loader uses +/// `libloading::Symbol` to bind to it. +/// +/// Arguments: +/// +/// * `abi_version` — value the *host* was compiled against. The +/// plugin compares this to its own [`ABI_VERSION`] and returns +/// [`EntryPointResult::AbiMismatch`] on a mismatch. +/// * `plugin_config_json` / `plugin_config_len` — serialized +/// `PluginConfig` (the operator's YAML block, JSON-encoded). +/// Plugin deserializes; uses it to construct its handlers. +/// * `out_registration` — out-parameter. On `Ok`, plugin writes +/// a `Box::into_raw(Box::new(PluginRegistration { ... }))` +/// pointer. On any error variant, plugin leaves this untouched. +pub type EntryPointFn = unsafe extern "C" fn( + abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut PluginRegistration, +) -> EntryPointResult; + +/// The symbol name a single-plugin cdylib exports. Host looks +/// this up via `libloading::Library::get(ENTRY_POINT_SYMBOL)`. +/// +/// Multi-plugin cdylibs use the `cpex_plugin_create_` +/// naming convention instead; the operator selects an entry via +/// the `?entry=` query parameter in the kind URL. +pub const ENTRY_POINT_SYMBOL: &[u8] = b"cpex_plugin_create"; + +/// Symbol name for the OPTIONAL multi-plugin discovery function. +/// +/// A cdylib that packages multiple plugins MAY export this symbol +/// to advertise which entries are available. Single-plugin cdylibs +/// don't need to expose it; the host falls back to plain dlsym +/// errors when the manifest is absent. +/// +/// See [`ListFn`] and [`PluginManifest`]. +pub const LIST_SYMBOL: &[u8] = b"cpex_plugin_list"; + +/// Signature of the optional discovery function exported as +/// [`LIST_SYMBOL`]. Returns a pointer to a `'static` +/// [`PluginManifest`] baked into the cdylib's read-only data. +/// +/// # Returned pointer +/// +/// The pointer is to static data that lives as long as the cdylib +/// is mapped. Since `DynamicPluginFactory` leaks the `Library` +/// handle (so vtables don't dangle), the manifest is effectively +/// `'static` from the host's perspective. The host never frees it +/// and the plugin never reallocates it — it's a compile-time +/// constant. +/// +/// A null return value means "no manifest available" and is +/// equivalent to the symbol being absent. +pub type ListFn = unsafe extern "C" fn() -> *const PluginManifest; + +/// One entry in a cdylib's plugin manifest. All fields are +/// `&'static str` because the data is baked into the cdylib's +/// read-only memory at compile time — nothing for the host to +/// free, nothing for the plugin to reallocate. +/// +/// # ABI shape +/// +/// `&'static str` is a fat pointer (data + length) with same- +/// version Rust layout. Because the whole `cpex-dynamic-plugin` +/// ABI is already same-version-only (the `AnyHookHandler` vtable +/// has the same constraint), reusing Rust slices here doesn't +/// add new ABI assumptions. Marking `repr(C)` would be a lie — +/// fat pointers aren't C-shaped. +#[derive(Debug, Clone, Copy)] +pub struct PluginManifestEntry { + /// The entry-point suffix. Full exported symbol is + /// `cpex_plugin_create_`. Goes in the kind URL's + /// `?entry=` selector. MUST be a valid C identifier + /// (`[a-zA-Z_][a-zA-Z0-9_]*`); the host rejects entries that + /// violate this on the parse side before any symbol lookup. + pub entry: &'static str, + /// Human-readable display name (used by the discovery + /// tooling, NOT used for symbol resolution). + pub name: &'static str, + /// Plugin version, conventionally `env!("CARGO_PKG_VERSION")`. + pub version: &'static str, + /// One-line description for the discovery tooling. + pub description: &'static str, +} + +/// What [`ListFn`] returns a pointer to. Wrapper around the +/// manifest's `'static` entry slice plus an ABI-version tag. +/// +/// The `abi_version` field lets the host detect manifest-layout +/// drift within the same major ABI. For hard-breaking changes +/// to the manifest shape, bump the symbol name itself (e.g., +/// `cpex_plugin_list_v2`) rather than relying on the version +/// field alone. +pub struct PluginManifest { + /// ABI version this manifest was produced against. Host + /// rejects manifests whose `abi_version` doesn't match its + /// own [`ABI_VERSION`]. + pub abi_version: u32, + /// The cdylib's advertised plugin entries. + pub entries: &'static [PluginManifestEntry], +} + +/// What the plugin hands the host through `out_registration`. +/// +/// The plugin allocates this with `Box::new(...)` and transfers +/// ownership to the host. Host drops it after extracting the +/// `plugin` + `handlers` into a `PluginInstance`. +/// +/// `#[repr(Rust)]` — same-version Rust ABI applies. Both sides +/// must see identical layout, which they will when compiled +/// against the same `cpex-core` version. +pub struct PluginRegistration { + /// ABI version this plugin reports. The host's loader has + /// already checked the version through the entry-point's + /// return code, but the field is included for diagnostics + /// (plugin's view of its own ABI). + pub abi_version: u32, + /// Plugin's reported name. Surfaced in operator-facing + /// diagnostics ("plugin 'rate-limit' (version 0.3.1) loaded + /// from /opt/plugins/rate_limit.so"). + pub name: String, + /// Plugin's reported version (typically `CARGO_PKG_VERSION`). + pub version: String, + /// The plugin instance itself (shared with handlers). + pub plugin: Arc, + /// Type-erased handlers paired with their hook names. + /// Mirrors `cpex_core::factory::PluginInstance.handlers`. + pub handlers: Vec<(String, Arc)>, +} + +impl PluginRegistration { + /// Convenience constructor — fills `abi_version` from the + /// compiled-against constant so plugin authors don't have to + /// remember to set it. + pub fn new( + name: impl Into, + version: impl Into, + plugin: Arc, + handlers: Vec<(String, Arc)>, + ) -> Self { + Self { + abi_version: ABI_VERSION, + name: name.into(), + version: version.into(), + plugin, + handlers, + } + } +} diff --git a/crates/cpex-dynamic-plugin/src/host.rs b/crates/cpex-dynamic-plugin/src/host.rs new file mode 100644 index 00000000..04e035e6 --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/host.rs @@ -0,0 +1,764 @@ +// Location: ./crates/cpex-dynamic-plugin/src/host.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Host-side: `DynamicPluginFactory` implements cpex-core's +// `PluginFactory` and is registered under a scheme (default `"lib"`) +// via `PluginManager::register_factory_scheme(...)`. Operators +// reference dynamic plugins with URL-shaped `kind:` strings: +// +// ```yaml +// plugins: +// - name: rate-limit +// kind: "lib:/opt/plugins/rate_limit.so#rate_limit_v1" +// hooks: [cmf.tool_pre_invoke] +// capabilities: [read_headers] +// config: +// max_per_second: 100 # plugin's OWN config; loader +// # concerns stay in `kind:` +// ``` +// +// # Flow +// +// 1. Parse `config.kind` as `:[#handler]`. +// 2. Validate `scheme` matches `self.scheme`. +// 3. `Library::new(path)` to dlopen, then `Box::leak` the Library +// so it survives until process exit (see "Why leak" below). +// 4. Bind to `ENTRY_POINT_SYMBOL`. +// 5. Serialize the `PluginConfig` to JSON. +// 6. Call the entry point with `ABI_VERSION` + config bytes + +// out-pointer. +// 7. Match on `EntryPointResult` → `PluginError::Config` for any +// error variant (with the variant name embedded for ops +// diagnostics). +// 8. On `Ok`: `Box::from_raw` the registration; extract +// `plugin` + `handlers`; optionally filter handlers to just +// the one named in the `#handler` fragment. +// 9. Build `PluginInstance`. +// +// # Why leak the library +// +// `Arc` and `Arc` hold vtable +// pointers into the cdylib's text section. If the library is +// unloaded (`Drop` on `Library` → `dlclose`) while ANY of those +// Arcs is still live, the next Arc operation jumps to unmapped +// memory and SIGSEGVs. The Arcs are cloned into the registry and +// can outlive any wrapper struct we'd hand them to, so the only +// safe path is to keep the library mapped for the process +// lifetime. +// +// Memory cost: each loaded cdylib's text section (typically a few +// hundred KB to a few MB) stays resident. Operators load plugins +// at startup and never unload — same model as Bevy and most Rust +// plugin frameworks. Hot-reload would need a reference-counted +// library wrapper coordinated with all derived Arcs; that's its +// own slice. + +use std::sync::Arc; + +use libloading::Library; + +use cpex_core::error::PluginError; +use cpex_core::factory::{PluginFactory, PluginInstance}; +use cpex_core::plugin::PluginConfig; + +use crate::abi::{ + EntryPointFn, EntryPointResult, ListFn, PluginManifest, PluginRegistration, ABI_VERSION, + ENTRY_POINT_SYMBOL, LIST_SYMBOL, +}; + +/// Loads Rust cdylib plugins at runtime. Registered under a scheme +/// (default `"lib"`) via `PluginManager::register_factory_scheme`. +/// Operators reference dynamic plugins with URL-shaped `kind:` +/// strings like `"lib:/path/to/plugin.so#handler"`. +pub struct DynamicPluginFactory { + scheme: String, +} + +impl DynamicPluginFactory { + /// Build with the default scheme `"lib"`. + pub fn new() -> Self { + Self { + scheme: "lib".to_string(), + } + } + + /// Override the default scheme. The factory must be registered + /// under the same scheme via `PluginManager::register_factory_scheme`. + pub fn with_scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } + + /// Returns the scheme this factory is configured for. + pub fn scheme(&self) -> &str { + &self.scheme + } +} + +impl Default for DynamicPluginFactory { + fn default() -> Self { + Self::new() + } +} + +/// Parsed kind: `:[?entry=][#handler]`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedKind { + /// Path to the cdylib file. + library_path: String, + /// Optional entry-point selector from the `?entry=` query + /// parameter. `None` means "use the default entry point + /// `cpex_plugin_create`" (single-plugin cdylib). `Some(name)` + /// means "look up `cpex_plugin_create_` instead" + /// (multi-plugin cdylib). + /// + /// Validated as a C identifier (`[a-zA-Z_][a-zA-Z0-9_]*`) in + /// `parse_kind` so we never construct a malformed symbol name. + entry: Option, + /// Optional handler name from the `#` fragment. `None` means + /// "use all handlers the registration returned." `Some(name)` + /// means "filter to just the handler registered under that + /// hook name in the registration." + handler: Option, +} + +/// Reject entry names that aren't valid C identifiers. Catches +/// malformed `?entry=` values at the URL-parse stage so we don't +/// construct invalid symbol names or pass weird bytes to dlsym. +/// +/// Accepts: starts with letter or underscore, followed by letters, +/// digits, or underscores. Same rule applies to the Rust ident +/// the macro accepts on the plugin side, so the two ends stay in +/// sync. +fn validate_entry_ident(entry: &str) -> Result<(), String> { + if entry.is_empty() { + return Err("entry name cannot be empty".to_string()); + } + let mut chars = entry.chars(); + let first = chars.next().expect("non-empty checked above"); + if !(first.is_ascii_alphabetic() || first == '_') { + return Err(format!( + "entry name '{entry}' must start with a letter or underscore (got '{first}')" + )); + } + for c in chars { + if !(c.is_ascii_alphanumeric() || c == '_') { + return Err(format!( + "entry name '{entry}' contains invalid character '{c}'; \ + only [a-zA-Z0-9_] allowed" + )); + } + } + Ok(()) +} + +/// Parse a `kind:` string into its scheme, path, optional entry, +/// and optional handler fragment. Returns an error message if the +/// shape is malformed (operator-facing diagnostic). +/// +/// URL component order: `:[?][#]`. +/// Currently the only recognized query parameter is `entry=`; +/// unknown parameters are rejected (fail-loud beats silently +/// ignoring operator typos). +/// +/// Examples: +/// - `lib:/opt/plugins/foo.so` → path only +/// - `lib:/opt/plugins/foo.so#bar` → path + handler "bar" +/// - `lib:/opt/plugins/foo.so?entry=baz` → path + entry "baz" +/// - `lib:/opt/plugins/multi.so?entry=baz#bar` → path + entry + handler +/// - `lib:/C:/plugins/foo.dll` → Windows path; preserved +/// - `lib:./relative.so` → relative path, +/// resolved by OS loader +fn parse_kind(kind: &str, expected_scheme: &str) -> Result { + let Some((scheme, rest)) = kind.split_once(':') else { + return Err(format!( + "kind '{kind}' missing scheme prefix; \ + expected '{expected_scheme}:[?entry=][#handler]'", + )); + }; + if scheme != expected_scheme { + return Err(format!( + "kind '{kind}' has scheme '{scheme}' but factory is registered for scheme '{expected_scheme}'", + )); + } + // Split the fragment off first (it comes last in URL order), + // then split query off the remaining (path + query) part. This + // ordering means a `?` inside a fragment (unusual but legal) + // stays in the fragment, and a `#` in the path is impossible + // because we'd already have consumed it as the fragment marker. + let (before_frag, handler) = match rest.split_once('#') { + Some((b, h)) => (b, Some(h.to_string())), + None => (rest, None), + }; + let (library_path, entry) = match before_frag.split_once('?') { + Some((path, query)) => { + let entry = parse_query_entry(kind, query)?; + (path.to_string(), entry) + } + None => (before_frag.to_string(), None), + }; + if library_path.is_empty() { + return Err(format!("kind '{kind}' has empty library path")); + } + if let Some(ref e) = entry { + validate_entry_ident(e).map_err(|why| format!("kind '{kind}': {why}"))?; + } + Ok(ParsedKind { + library_path, + entry, + handler, + }) +} + +/// Parse the query string of a kind URL. Only `entry=` is +/// recognized; multiple params would be ambiguous (which one wins?) +/// and unknown keys signal an operator typo we'd rather surface +/// than swallow. +fn parse_query_entry(kind: &str, query: &str) -> Result, String> { + if query.is_empty() { + return Ok(None); + } + // Reject `&` — we don't support multi-param queries yet, and a + // bare `&` is almost certainly a copy-paste mistake. + if query.contains('&') { + return Err(format!( + "kind '{kind}' has multi-parameter query '{query}'; \ + only a single 'entry=' parameter is supported" + )); + } + let Some((key, value)) = query.split_once('=') else { + return Err(format!( + "kind '{kind}' has malformed query '{query}'; expected 'entry='" + )); + }; + if key != "entry" { + return Err(format!( + "kind '{kind}' has unknown query parameter '{key}'; \ + only 'entry=' is recognized" + )); + } + if value.is_empty() { + return Err(format!( + "kind '{kind}' has empty 'entry=' value; \ + provide an entry name like 'entry=my_plugin'" + )); + } + Ok(Some(value.to_string())) +} + +/// Try to read the cdylib's optional plugin manifest. Returns +/// `Ok(None)` when the cdylib doesn't export the discovery symbol +/// (legacy single-plugin layout) — that's not an error. Returns +/// `Err` when the manifest IS present but its ABI version is wrong; +/// we shouldn't keep going in that case because the entries slice +/// could have a different layout than we expect. +/// +/// # Safety +/// +/// Caller guarantees `library` outlives any use of the returned +/// reference. In practice we leak the library in `create()`, so the +/// returned `'static` lifetime is honest: the manifest data lives +/// for the rest of the process. +unsafe fn read_manifest( + library: &'static Library, +) -> Result, String> { + let sym: libloading::Symbol<'_, ListFn> = match unsafe { library.get(LIST_SYMBOL) } { + Ok(s) => s, + Err(_) => return Ok(None), // No manifest exported — that's fine. + }; + let ptr = unsafe { sym() }; + if ptr.is_null() { + // Plugin exposed the symbol but returned null — treat as + // "no manifest" rather than an error. Plugin author can do + // this to disable discovery without removing the symbol. + return Ok(None); + } + let manifest: &'static PluginManifest = unsafe { &*ptr }; + if manifest.abi_version != ABI_VERSION { + return Err(format!( + "cdylib's plugin manifest reports ABI version {} but host expects {}", + manifest.abi_version, ABI_VERSION, + )); + } + Ok(Some(manifest)) +} + +impl PluginFactory for DynamicPluginFactory { + fn create( + &self, + config: &PluginConfig, + ) -> Result> { + // 1. Parse the kind string. + let parsed = parse_kind(&config.kind, &self.scheme).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (apl-dynamic-plugin): {}", + config.name, e + ), + }) + })?; + + // 2. dlopen + leak. After this point the library lives until + // process exit. `Box::leak` returns a &'static reference; + // we hold a raw pointer that we never reclaim. + // + // Safety: `Library::new` is unsafe because loading + // arbitrary code is inherently unsafe (the library could + // have init constructors that do anything). Operator + // chose the path; we trust them to know what they're + // loading. + let library: &'static Library = match unsafe { Library::new(&parsed.library_path) } + { + Ok(lib) => Box::leak(Box::new(lib)), + Err(e) => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': failed to dlopen '{}': {e}", + config.name, parsed.library_path, + ), + })); + } + }; + + // 3a. Read the optional plugin manifest. If the cdylib + // exposes one, we use it to: + // * Validate the operator's `?entry=` against the + // advertised entries. + // * Produce a "did you mean..." style error listing + // available entries when the operator gets it wrong. + // If the cdylib doesn't expose a manifest (single-plugin + // layout, or operator chose not to), we fall through to + // plain dlsym and surface its error verbatim. + let manifest = match unsafe { read_manifest(library) } { + Ok(m) => m, + Err(e) => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' has an incompatible manifest: {e}. \ + Rebuild the plugin against the same cpex-dynamic-plugin \ + version the host is using.", + config.name, parsed.library_path, + ), + })); + } + }; + + // 3b. If the operator specified `?entry=foo` AND the cdylib + // advertised a manifest, validate up-front that `foo` + // is in the manifest. This gives the friendliest error + // message ("available: [bar, baz]") before we even try + // dlsym. If the manifest is absent we just skip this + // check — the dlsym below will fail with a less helpful + // but still actionable error. + if let (Some(requested), Some(m)) = (&parsed.entry, manifest) { + if !m.entries.iter().any(|e| e.entry == requested.as_str()) { + let available: Vec<&str> = + m.entries.iter().map(|e| e.entry).collect(); + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' has no entry '{}'. \ + Available entries: [{}]", + config.name, + parsed.library_path, + requested, + available.join(", "), + ), + })); + } + } + + // 3c. Build the symbol name. Default = `cpex_plugin_create` + // (single-plugin macro); with `?entry=foo` it becomes + // `cpex_plugin_create_foo` (multi-plugin macro). The + // entry name has already been validated as a C + // identifier in `parse_kind`, so we can safely concat + // bytes without escaping. + let symbol_name: Vec = match &parsed.entry { + None => ENTRY_POINT_SYMBOL.to_vec(), + Some(e) => { + let mut s = Vec::with_capacity(b"cpex_plugin_create_".len() + e.len()); + s.extend_from_slice(b"cpex_plugin_create_"); + s.extend_from_slice(e.as_bytes()); + s + } + }; + + // 3d. Bind to the entry-point symbol. + // + // Safety: the cast to `EntryPointFn` is unchecked — if + // the symbol exists with a different signature, calls + // will silently misbehave. Mitigation: the ABI version + // handshake (step 6) catches mismatched plugins. + let entry: libloading::Symbol = match unsafe { + library.get::(&symbol_name) + } { + Ok(sym) => sym, + Err(e) => { + let symbol_display = std::str::from_utf8(&symbol_name) + .unwrap_or(""); + let hint = match &parsed.entry { + None => "did you use the cpex_dynamic_plugin! macro?".to_string(), + Some(entry_name) => match manifest { + Some(m) => { + let available: Vec<&str> = + m.entries.iter().map(|e| e.entry).collect(); + format!( + "available entries per the cdylib's manifest: [{}]", + available.join(", "), + ) + } + None => format!( + "cdylib does not expose a manifest, so the host can't \ + list available entries — check the plugin's documentation. \ + You requested entry '{entry_name}'.", + ), + }, + }; + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' does not export '{}' ({}): {e}", + config.name, + parsed.library_path, + symbol_display, + hint, + ), + })); + } + }; + + // 4. Serialize the PluginConfig the plugin will deserialize. + let config_bytes = serde_json::to_vec(config).map_err(|e| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}': failed to serialize PluginConfig for plugin entry point: {e}", + config.name, + ), + }) + })?; + + // 5. Call the entry point. Out-pointer is what the plugin + // writes its `Box::into_raw` registration through. + let mut out_registration: *mut PluginRegistration = std::ptr::null_mut(); + let result = unsafe { + entry( + ABI_VERSION, + config_bytes.as_ptr(), + config_bytes.len(), + &mut out_registration as *mut *mut PluginRegistration, + ) + }; + + // 6. Translate the entry-point result. + match result { + EntryPointResult::Ok => {} + EntryPointResult::AbiMismatch => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' was compiled against a different \ + cpex-dynamic-plugin ABI version than the host (host: {}). \ + Rebuild the plugin against the same cpex-core / \ + cpex-dynamic-plugin versions the host is using.", + config.name, parsed.library_path, ABI_VERSION, + ), + })); + } + EntryPointResult::ConfigParseError => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' rejected its PluginConfig — \ + likely a structural mismatch between operator's YAML \ + and the plugin's expected config schema", + config.name, parsed.library_path, + ), + })); + } + EntryPointResult::InitializationError => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' failed to initialize (the \ + plugin's create closure returned an error — check the \ + cdylib's logs / stderr for details)", + config.name, parsed.library_path, + ), + })); + } + EntryPointResult::Panic => { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' panicked during construction. \ + Caught at the FFI boundary; check the cdylib's logs / \ + stderr for the panic message and backtrace", + config.name, parsed.library_path, + ), + })); + } + } + + if out_registration.is_null() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' returned EntryPointResult::Ok but \ + left out_registration null — plugin's cpex_plugin_create \ + implementation is buggy", + config.name, parsed.library_path, + ), + })); + } + + // 7. Take ownership of the registration. + // Safety: plugin wrote a valid `Box::into_raw` pointer + // per the ABI contract; we reclaim it here. Same + // allocator on both sides (system) per the spec. + let registration: PluginRegistration = + *unsafe { Box::from_raw(out_registration) }; + + // 8. Optional handler filter: if the kind had a `#handler` + // fragment, keep only the matching one. + let handlers = match parsed.handler { + None => registration.handlers, + Some(wanted) => { + let mut filtered: Vec<(String, Arc)> = + registration + .handlers + .into_iter() + .filter(|(name, _)| name == &wanted) + .collect(); + if filtered.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' returned no handler named '{}' \ + (the `#{}` fragment in the kind selected a handler that \ + the plugin didn't register)", + config.name, parsed.library_path, wanted, wanted, + ), + })); + } + // Reorder so the named handler is first (deterministic). + filtered.sort_by(|a, b| a.0.cmp(&b.0)); + filtered + } + }; + + if handlers.is_empty() { + return Err(Box::new(PluginError::Config { + message: format!( + "plugin '{}': cdylib '{}' returned a PluginRegistration with \ + zero handlers — plugin must register at least one", + config.name, parsed.library_path, + ), + })); + } + + // 9. Convert to PluginInstance shape. + // PluginInstance.handlers uses `&'static str` for the + // hook name; we transmute via `Box::leak(name.into_boxed_str())`. + // The handler-name strings are tiny and we already + // accepted the library leak, so adding string leaks is + // proportionate. + let leaked_handlers: Vec<(&'static str, Arc)> = + handlers + .into_iter() + .map(|(name, handler)| { + let leaked: &'static str = + Box::leak(name.into_boxed_str()); + (leaked, handler) + }) + .collect(); + + tracing::info!( + plugin_name = %config.name, + library = %parsed.library_path, + entry = parsed.entry.as_deref().unwrap_or(""), + plugin_reported_name = %registration.name, + plugin_reported_version = %registration.version, + handler_count = leaked_handlers.len(), + "loaded dynamic plugin", + ); + + Ok(PluginInstance { + plugin: registration.plugin, + handlers: leaked_handlers, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_kind_simple() { + let parsed = parse_kind("lib:/opt/plugins/foo.so", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/plugins/foo.so"); + assert_eq!(parsed.entry, None); + assert_eq!(parsed.handler, None); + } + + #[test] + fn parse_kind_with_handler_fragment() { + let parsed = + parse_kind("lib:/opt/plugins/foo.so#my_handler", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/plugins/foo.so"); + assert_eq!(parsed.entry, None); + assert_eq!(parsed.handler.as_deref(), Some("my_handler")); + } + + #[test] + fn parse_kind_with_relative_path() { + let parsed = parse_kind("lib:./plugins/foo.so", "lib").unwrap(); + assert_eq!(parsed.library_path, "./plugins/foo.so"); + } + + #[test] + fn parse_kind_windows_path() { + // Windows drive-letter colon should pass through — we split + // on the FIRST colon only. + let parsed = parse_kind("lib:/C:/plugins/foo.dll", "lib").unwrap(); + assert_eq!(parsed.library_path, "/C:/plugins/foo.dll"); + } + + #[test] + fn parse_kind_wrong_scheme_errors() { + let err = parse_kind("wasm:/opt/foo.wasm", "lib").unwrap_err(); + assert!(err.contains("scheme 'wasm'")); + assert!(err.contains("registered for scheme 'lib'")); + } + + #[test] + fn parse_kind_missing_scheme_errors() { + let err = parse_kind("/opt/foo.so", "lib").unwrap_err(); + assert!(err.contains("missing scheme prefix")); + } + + #[test] + fn parse_kind_empty_path_errors() { + let err = parse_kind("lib:", "lib").unwrap_err(); + assert!(err.contains("empty library path")); + } + + #[test] + fn parse_kind_empty_handler_fragment_treated_as_empty_string() { + // `lib:/foo.so#` → handler = Some("") — unusual but + // we let the create() filter step catch it via the + // "no handler named ''" error path. + let parsed = parse_kind("lib:/foo.so#", "lib").unwrap(); + assert_eq!(parsed.handler.as_deref(), Some("")); + } + + // ----- ?entry= query-string parsing ----- + + #[test] + fn parse_kind_with_entry_query() { + let parsed = parse_kind("lib:/opt/multi.so?entry=foo", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/multi.so"); + assert_eq!(parsed.entry.as_deref(), Some("foo")); + assert_eq!(parsed.handler, None); + } + + #[test] + fn parse_kind_with_entry_and_handler() { + // Full URL shape: path + query + fragment. + let parsed = + parse_kind("lib:/opt/multi.so?entry=foo#my_handler", "lib").unwrap(); + assert_eq!(parsed.library_path, "/opt/multi.so"); + assert_eq!(parsed.entry.as_deref(), Some("foo")); + assert_eq!(parsed.handler.as_deref(), Some("my_handler")); + } + + #[test] + fn parse_kind_entry_with_underscore_and_digits() { + // Valid C identifier characters all the way through. + let parsed = + parse_kind("lib:/opt/multi.so?entry=rate_limiter_v2", "lib").unwrap(); + assert_eq!(parsed.entry.as_deref(), Some("rate_limiter_v2")); + } + + #[test] + fn parse_kind_entry_starting_with_underscore() { + let parsed = parse_kind("lib:/opt/multi.so?entry=_private", "lib").unwrap(); + assert_eq!(parsed.entry.as_deref(), Some("_private")); + } + + #[test] + fn parse_kind_entry_starting_with_digit_errors() { + // C identifiers can't start with a digit. + let err = parse_kind("lib:/opt/multi.so?entry=1foo", "lib").unwrap_err(); + assert!(err.contains("must start with a letter or underscore")); + } + + #[test] + fn parse_kind_entry_with_invalid_char_errors() { + let err = + parse_kind("lib:/opt/multi.so?entry=foo-bar", "lib").unwrap_err(); + assert!(err.contains("invalid character")); + } + + #[test] + fn parse_kind_empty_entry_value_errors() { + let err = parse_kind("lib:/opt/multi.so?entry=", "lib").unwrap_err(); + assert!(err.contains("empty 'entry=' value")); + } + + #[test] + fn parse_kind_unknown_query_param_errors() { + let err = + parse_kind("lib:/opt/multi.so?other=value", "lib").unwrap_err(); + assert!(err.contains("unknown query parameter 'other'")); + } + + #[test] + fn parse_kind_multi_param_query_errors() { + // `&` separator is rejected — only one param supported. + let err = + parse_kind("lib:/opt/multi.so?entry=foo&extra=bar", "lib").unwrap_err(); + assert!(err.contains("multi-parameter query")); + } + + #[test] + fn parse_kind_malformed_query_errors() { + let err = parse_kind("lib:/opt/multi.so?noequalssign", "lib").unwrap_err(); + assert!(err.contains("malformed query")); + } + + #[test] + fn parse_kind_empty_query_string_treated_as_no_entry() { + // Trailing `?` with no content — we treat it as no entry + // rather than an error, since the operator's intent is + // clear (just a stray character). + let parsed = parse_kind("lib:/opt/multi.so?", "lib").unwrap(); + assert_eq!(parsed.entry, None); + } + + // ----- validate_entry_ident ----- + + #[test] + fn validate_entry_ident_accepts_valid_names() { + assert!(validate_entry_ident("foo").is_ok()); + assert!(validate_entry_ident("foo_bar").is_ok()); + assert!(validate_entry_ident("_private").is_ok()); + assert!(validate_entry_ident("a").is_ok()); + assert!(validate_entry_ident("rate_limiter_v2").is_ok()); + } + + #[test] + fn validate_entry_ident_rejects_empty() { + assert!(validate_entry_ident("").is_err()); + } + + #[test] + fn validate_entry_ident_rejects_leading_digit() { + assert!(validate_entry_ident("1foo").is_err()); + assert!(validate_entry_ident("123").is_err()); + } + + #[test] + fn validate_entry_ident_rejects_special_chars() { + assert!(validate_entry_ident("foo-bar").is_err()); + assert!(validate_entry_ident("foo.bar").is_err()); + assert!(validate_entry_ident("foo bar").is_err()); + assert!(validate_entry_ident("foo!").is_err()); + assert!(validate_entry_ident("foo$bar").is_err()); + } +} diff --git a/crates/cpex-dynamic-plugin/src/lib.rs b/crates/cpex-dynamic-plugin/src/lib.rs new file mode 100644 index 00000000..49ee2186 --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/lib.rs @@ -0,0 +1,55 @@ +// Location: ./crates/cpex-dynamic-plugin/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// cpex-dynamic-plugin — load Rust cdylib plugins at runtime. +// +// See `docs/specs/cpex-rust-spec.md` §17 for the architecture. +// +// # Module layout +// +// * `abi` — shared types crossing the dlopen boundary +// (entry-point fn signature, registration struct, +// version constants). Always available. +// * `plugin` — helpers for plugin authors writing a `cdylib`: +// the `cpex_dynamic_plugin!` macro that generates +// the `extern "C"` entry point, helpers to build a +// `PluginRegistration`. Always available. +// * `host` — `DynamicPluginFactory` + `libloading`-backed +// loader. Behind the `host` feature flag — plugin- +// only builds don't pay for libloading. +// +// # ABI versioning +// +// `abi::ABI_VERSION` is bumped whenever the entry-point signature, +// the `PluginRegistration` layout, or the underlying +// `AnyHookHandler` trait changes shape. Host loads a plugin → host +// asks plugin which ABI version it was compiled against → if +// mismatch, host refuses to load and returns a clear error. Same- +// version-only Rust ABI is the load-bearing constraint; this +// runtime check makes mismatches loud instead of UB. + +pub mod abi; +pub mod plugin; + +#[cfg(feature = "host")] +pub mod host; + +pub use abi::{ + EntryPointFn, EntryPointResult, ListFn, PluginManifest, PluginManifestEntry, + PluginRegistration, ABI_VERSION, ENTRY_POINT_SYMBOL, LIST_SYMBOL, +}; +pub use plugin::{dispatch_create, CreateFn}; + +#[cfg(feature = "host")] +pub use host::DynamicPluginFactory; + +// `paste` is re-exported under a hidden module so the +// `cpex_dynamic_plugins!` macro can reference it as +// `$crate::__macro_support::paste::paste!`. Plugin authors don't +// touch this directly — they just use the macro. +#[doc(hidden)] +pub mod __macro_support { + pub use paste; +} diff --git a/crates/cpex-dynamic-plugin/src/plugin.rs b/crates/cpex-dynamic-plugin/src/plugin.rs new file mode 100644 index 00000000..f42d88ee --- /dev/null +++ b/crates/cpex-dynamic-plugin/src/plugin.rs @@ -0,0 +1,450 @@ +// Location: ./crates/cpex-dynamic-plugin/src/plugin.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin-author helpers for writing a Rust `cdylib` plugin. +// +// Plugin authors write a normal Rust struct implementing `Plugin` +// + `HookHandler` (per the in-tree plugin recipe), then use +// the [`cpex_dynamic_plugin!`] macro to generate the `extern "C"` +// entry point. The macro handles: +// +// * ABI-version handshake (rejects mismatched hosts loudly). +// * Config deserialization from the raw bytes the host passes. +// * `catch_unwind` around user code so a panic doesn't unwind +// across the `extern "C"` boundary (which would be UB). +// * Allocating + transferring ownership of `PluginRegistration`. +// +// Plugin authors never write unsafe FFI by hand. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use cpex_core::plugin::PluginConfig; + +use crate::abi::{EntryPointResult, PluginRegistration, ABI_VERSION}; + +/// The closure plugin authors hand to [`dispatch_create`] / +/// [`cpex_dynamic_plugin!`]. Receives the deserialized +/// `PluginConfig`, returns either a populated +/// `PluginRegistration` (success) or a string (initialization +/// error — wraps as [`EntryPointResult::InitializationError`]). +pub type CreateFn = + fn(PluginConfig) -> Result; + +/// Helper called from the plugin's generated `cpex_plugin_create` +/// function. Plugin authors should NOT call this directly — use +/// the [`cpex_dynamic_plugin!`] macro, which generates the +/// correct unsafe glue. +/// +/// # Safety +/// +/// * `plugin_config_json` / `plugin_config_len` must describe a +/// valid byte slice (UTF-8 isn't required at this layer — +/// `serde_json` will report a parse error if it isn't). +/// * `out_registration` must be non-null and writable. +/// +/// On every error variant, `*out_registration` is left untouched. +/// On `Ok`, `*out_registration` is set to a `Box::into_raw` pointer +/// the host takes ownership of. +pub unsafe fn dispatch_create( + host_abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut PluginRegistration, + create: CreateFn, +) -> EntryPointResult { + if host_abi_version != ABI_VERSION { + return EntryPointResult::AbiMismatch; + } + + // Materialize the config bytes into a borrowed slice. The + // host owns the storage; the plugin only reads. + let config_bytes = if plugin_config_json.is_null() || plugin_config_len == 0 { + &[][..] + } else { + // Safety: caller guarantees a valid byte range. Empty case + // handled above so we never construct a slice from null. + unsafe { std::slice::from_raw_parts(plugin_config_json, plugin_config_len) } + }; + + let config: PluginConfig = match serde_json::from_slice(config_bytes) { + Ok(c) => c, + Err(_) => return EntryPointResult::ConfigParseError, + }; + + // catch_unwind so a panic in user code doesn't propagate + // across the FFI boundary. AssertUnwindSafe because we don't + // care about state-after-panic — the plugin's create() is + // single-shot and any partial state is dropped here. + let result = catch_unwind(AssertUnwindSafe(|| create(config))); + + let registration = match result { + Ok(Ok(r)) => r, + Ok(Err(_)) => return EntryPointResult::InitializationError, + Err(_) => return EntryPointResult::Panic, + }; + + // Transfer ownership to the host. Box::into_raw produces a + // raw pointer the host's `Box::from_raw` reclaims. + let boxed = Box::new(registration); + let ptr = Box::into_raw(boxed); + // Safety: caller guarantees out_registration is writable. + unsafe { + *out_registration = ptr; + } + EntryPointResult::Ok +} + +/// Generate the `#[no_mangle] pub extern "C" fn cpex_plugin_create` +/// entry point for a Rust `cdylib` plugin. +/// +/// # Usage +/// +/// ```rust,ignore +/// use cpex_core::{hooks::adapter::TypedHandlerAdapter, plugin::{Plugin, PluginConfig}, hooks::trait_def::HookHandler, cmf::CmfHook}; +/// use cpex_dynamic_plugin::{cpex_dynamic_plugin, PluginRegistration}; +/// use std::sync::Arc; +/// +/// struct MyPlugin { cfg: PluginConfig } +/// // ... impl Plugin + HookHandler for MyPlugin ... +/// +/// cpex_dynamic_plugin! { +/// |cfg: PluginConfig| -> Result { +/// let plugin = Arc::new(MyPlugin { cfg }); +/// let adapter = Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); +/// Ok(PluginRegistration::new( +/// "my-plugin", +/// env!("CARGO_PKG_VERSION"), +/// plugin as Arc, +/// vec![("cmf.tool_pre_invoke".to_string(), adapter as Arc)], +/// )) +/// } +/// } +/// ``` +/// +/// The macro expands to a single `#[no_mangle] pub extern "C" fn +/// cpex_plugin_create(...)` that delegates to [`dispatch_create`]. +/// All unsafe-FFI plumbing is hidden. +#[macro_export] +macro_rules! cpex_dynamic_plugin { + ($create:expr) => { + /// Plugin entry point. Host's `DynamicPluginFactory` finds + /// this via `libloading::Library::get(b"cpex_plugin_create")`. + /// + /// Generated by `cpex_dynamic_plugin!`. Do not edit by hand. + #[no_mangle] + pub unsafe extern "C" fn cpex_plugin_create( + host_abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut $crate::abi::PluginRegistration, + ) -> $crate::abi::EntryPointResult { + // Cast the user closure to the function-pointer type + // `dispatch_create` expects. Plugin authors write + // `|cfg: PluginConfig| -> Result { ... }`. + let create_fn: $crate::plugin::CreateFn = $create; + unsafe { + $crate::plugin::dispatch_create( + host_abi_version, + plugin_config_json, + plugin_config_len, + out_registration, + create_fn, + ) + } + } + }; +} + +/// Generate entry points for MULTIPLE plugins packaged in one +/// cdylib, plus the optional `cpex_plugin_list` discovery symbol. +/// +/// Use this when you want to ship several distinct plugins inside +/// one shared object file. For single-plugin cdylibs, the simpler +/// [`cpex_dynamic_plugin!`] macro is the right tool — it emits +/// `cpex_plugin_create` (no entry selector, no manifest needed). +/// +/// # Usage +/// +/// ```rust,ignore +/// use cpex_core::plugin::{Plugin, PluginConfig}; +/// use cpex_dynamic_plugin::{cpex_dynamic_plugins, PluginRegistration}; +/// +/// fn build_rate_limiter(cfg: PluginConfig) -> Result { +/// // ... build and return PluginRegistration ... +/// # unimplemented!() +/// } +/// +/// fn build_audit(cfg: PluginConfig) -> Result { +/// // ... build and return PluginRegistration ... +/// # unimplemented!() +/// } +/// +/// cpex_dynamic_plugins! { +/// rate_limiter => { +/// name: "Rate Limiter", +/// version: "1.0.0", +/// description: "Token-bucket rate limiter", +/// create: build_rate_limiter, +/// }, +/// audit => { +/// name: "Audit Logger", +/// version: "0.5.0", +/// description: "Writes hook events to disk", +/// create: build_audit, +/// }, +/// } +/// ``` +/// +/// # Operator side +/// +/// Each entry becomes addressable from YAML via `?entry=`: +/// +/// ```yaml +/// plugins: +/// - name: edge-rate-limit +/// kind: "lib:/opt/plugins/multi.so?entry=rate_limiter" +/// hooks: [cmf.tool_pre_invoke] +/// config: +/// max_per_second: 100 +/// - name: audit-trail +/// kind: "lib:/opt/plugins/multi.so?entry=audit" +/// hooks: [cmf.tool_post_invoke] +/// config: +/// log_path: /var/log/cpex-audit.log +/// ``` +/// +/// # What the macro generates +/// +/// * One `#[no_mangle] pub unsafe extern "C" fn +/// cpex_plugin_create_(...)` per entry, each wrapping the +/// supplied `create` function via `dispatch_create` (same ABI +/// glue as the single-plugin macro). +/// * A `static` [`PluginManifest`](crate::abi::PluginManifest) +/// listing all entries. +/// * A `#[no_mangle] pub unsafe extern "C" fn cpex_plugin_list()` +/// returning a pointer to that manifest. The host uses this to +/// (a) validate the operator's `?entry=` against the available +/// entries and (b) produce friendly errors when the requested +/// entry doesn't exist. +/// +/// # Entry naming +/// +/// The entry name (the ident before `=>`) MUST be a valid Rust +/// identifier — that's what the macro requires, and it's also a +/// valid C identifier for the generated symbol. The same name +/// appears verbatim in: +/// +/// * The exported symbol: `cpex_plugin_create_`. +/// * The manifest's `entry` field (as a string). +/// * The operator's `?entry=` URL. +#[macro_export] +macro_rules! cpex_dynamic_plugins { + ( $( $entry:ident => { + name: $name:literal, + version: $version:literal, + description: $desc:literal, + create: $create:expr $(,)? + } ),+ $(,)? ) => { + $( + $crate::__macro_support::paste::paste! { + /// One entry point of a multi-plugin cdylib. Host's + /// `DynamicPluginFactory` finds this via + /// `libloading::Library::get(b"cpex_plugin_create_")` + /// when the operator's kind URL contains + /// `?entry=`. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + #[no_mangle] + pub unsafe extern "C" fn []( + host_abi_version: u32, + plugin_config_json: *const u8, + plugin_config_len: usize, + out_registration: *mut *mut $crate::abi::PluginRegistration, + ) -> $crate::abi::EntryPointResult { + let create_fn: $crate::plugin::CreateFn = $create; + unsafe { + $crate::plugin::dispatch_create( + host_abi_version, + plugin_config_json, + plugin_config_len, + out_registration, + create_fn, + ) + } + } + } + )+ + + /// The cdylib's plugin manifest. Compile-time constant + /// referenced by the generated `cpex_plugin_list` symbol. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + static __CPEX_PLUGIN_MANIFEST_ENTRIES: &[$crate::abi::PluginManifestEntry] = &[ + $( + $crate::abi::PluginManifestEntry { + entry: stringify!($entry), + name: $name, + version: $version, + description: $desc, + }, + )+ + ]; + + /// The full manifest, referenced by `cpex_plugin_list`. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + static __CPEX_PLUGIN_MANIFEST: $crate::abi::PluginManifest = $crate::abi::PluginManifest { + abi_version: $crate::abi::ABI_VERSION, + entries: __CPEX_PLUGIN_MANIFEST_ENTRIES, + }; + + /// Discovery symbol. Optional in the ABI; emitted only when + /// the multi-plugin macro is used. Host's + /// `DynamicPluginFactory` looks for this via + /// `libloading::Library::get(b"cpex_plugin_list")` and uses + /// the result to validate `?entry=` against available + /// entries. + /// + /// Generated by `cpex_dynamic_plugins!`. Do not edit by hand. + #[no_mangle] + pub unsafe extern "C" fn cpex_plugin_list() -> *const $crate::abi::PluginManifest { + &__CPEX_PLUGIN_MANIFEST as *const $crate::abi::PluginManifest + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use cpex_core::plugin::{Plugin, PluginConfig}; + + /// A bare-minimum plugin used for testing `dispatch_create`. + /// Doesn't register any hook handlers; just verifies that the + /// flow (abi check, config parse, catch_unwind, registration + /// allocation) works end-to-end without a real cdylib build. + #[derive(Debug)] + struct StubPlugin { + cfg: PluginConfig, + } + + #[async_trait::async_trait] + impl Plugin for StubPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + } + + fn stub_create(cfg: PluginConfig) -> Result { + let plugin = Arc::new(StubPlugin { cfg }); + Ok(PluginRegistration::new( + "stub-plugin", + "0.0.1", + plugin as Arc, + Vec::new(), + )) + } + + fn stub_create_failing(_cfg: PluginConfig) -> Result { + Err("simulated init failure".to_string()) + } + + fn stub_create_panicking(_cfg: PluginConfig) -> Result { + panic!("simulated panic in plugin init"); + } + + fn run_dispatch( + host_abi_version: u32, + config_bytes: &[u8], + create: CreateFn, + ) -> (EntryPointResult, *mut PluginRegistration) { + let mut out: *mut PluginRegistration = std::ptr::null_mut(); + let result = unsafe { + dispatch_create( + host_abi_version, + config_bytes.as_ptr(), + config_bytes.len(), + &mut out as *mut *mut PluginRegistration, + create, + ) + }; + (result, out) + } + + /// Helper: build the minimal serialized PluginConfig the + /// dispatch layer expects. + fn minimal_config_bytes() -> Vec { + let cfg = PluginConfig { + name: "test".to_string(), + kind: "lib:/dev/null".to_string(), + ..Default::default() + }; + serde_json::to_vec(&cfg).expect("PluginConfig serializes") + } + + #[test] + fn happy_path_returns_ok_and_populates_out() { + let bytes = minimal_config_bytes(); + let (result, out) = run_dispatch(ABI_VERSION, &bytes, stub_create); + assert_eq!(result, EntryPointResult::Ok); + assert!(!out.is_null()); + // Take ownership and verify the registration fields. + let boxed = unsafe { Box::from_raw(out) }; + assert_eq!(boxed.abi_version, ABI_VERSION); + assert_eq!(boxed.name, "stub-plugin"); + assert_eq!(boxed.version, "0.0.1"); + assert!(boxed.handlers.is_empty()); + } + + #[test] + fn abi_mismatch_short_circuits_before_user_code() { + // host_abi_version != ABI_VERSION → dispatch returns + // AbiMismatch without ever touching the config or + // invoking the create closure. + let bytes = minimal_config_bytes(); + let (result, out) = + run_dispatch(ABI_VERSION.wrapping_add(1), &bytes, stub_create); + assert_eq!(result, EntryPointResult::AbiMismatch); + assert!(out.is_null(), "out must be untouched on AbiMismatch"); + } + + #[test] + fn config_parse_error_returns_config_parse_error() { + let bytes = b"this isn't json"; + let (result, out) = run_dispatch(ABI_VERSION, bytes, stub_create); + assert_eq!(result, EntryPointResult::ConfigParseError); + assert!(out.is_null()); + } + + #[test] + fn empty_config_is_treated_as_parse_error() { + // An empty config-bytes range deserializes to error + // (`serde_json::from_slice(&[])` fails). Plugin authors + // who want to support "no config" should send `{}`. + let (result, _) = run_dispatch(ABI_VERSION, &[], stub_create); + assert_eq!(result, EntryPointResult::ConfigParseError); + } + + #[test] + fn init_failure_returns_initialization_error() { + let bytes = minimal_config_bytes(); + let (result, out) = run_dispatch(ABI_VERSION, &bytes, stub_create_failing); + assert_eq!(result, EntryPointResult::InitializationError); + assert!(out.is_null()); + } + + #[test] + fn panic_in_user_code_is_caught_and_reported() { + // catch_unwind catches the panic; dispatch returns + // Panic. Critical for safety — an unwinding panic across + // extern "C" is undefined behavior, so the host must + // never see one. + let bytes = minimal_config_bytes(); + let (result, out) = run_dispatch(ABI_VERSION, &bytes, stub_create_panicking); + assert_eq!(result, EntryPointResult::Panic); + assert!(out.is_null()); + } +} diff --git a/crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs b/crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs new file mode 100644 index 00000000..31954f8b --- /dev/null +++ b/crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs @@ -0,0 +1,824 @@ +// Location: ./crates/cpex-dynamic-plugin/tests/dlopen_e2e.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end test for `DynamicPluginFactory` against a real cdylib. +// +// Exercises the full slice: the example plugin (compiled as a +// cdylib by cargo via the dev-dep edge) → `DynamicPluginFactory` +// dlopens it → registration handshake → `PluginInstance` +// construction → invoke via `PluginManager` → assert outcome. +// +// This is the load-bearing "the unsafe glue actually works" +// test. The unit tests in `host.rs` cover the kind-string parser +// in isolation; this test wires through libloading + the entry +// point + Box::from_raw + handler dispatch end-to-end. +// +// # Why the file requires `--features host` +// +// `DynamicPluginFactory` lives behind the `host` feature flag in +// cpex-dynamic-plugin. The integration test is automatically +// included when running `cargo test --features host`. Plain +// `cargo test` (default features only) skips this file's tests +// because the `DynamicPluginFactory` symbol isn't visible. + +#![cfg(feature = "host")] + +use std::path::PathBuf; +use std::sync::Arc; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::Extensions; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; + +use cpex_dynamic_plugin::DynamicPluginFactory; + +/// Name of the allow-gate example plugin crate. Cargo turns +/// hyphens into underscores when forming the artifact filename. +const EXAMPLE_CRATE: &str = "cpex_dynamic_plugin_example"; + +/// Name of the multi-handler example plugin crate. Registers two +/// handlers: pre-invoke allow + post-invoke deny. +const MULTI_HANDLER_CRATE: &str = "cpex_dynamic_plugin_multi_handler_example"; + +/// Name of the multi-plugin example crate. Packages TWO distinct +/// plugins (allow + deny) under `?entry=allow` and `?entry=deny`. +const MULTI_PLUGIN_CRATE: &str = "cpex_dynamic_plugin_multi_plugin_example"; + +/// Locate a cdylib in the workspace target directory by crate +/// name. Uses `CARGO_MANIFEST_DIR` (the cpex-dynamic-plugin +/// crate's dir, set by cargo at test build time) and walks up to +/// the workspace root. Profile defaults to `debug`; tests run +/// `cargo test` which is the debug profile. +fn cdylib_path(crate_name: &str) -> PathBuf { + // CARGO_MANIFEST_DIR = /crates/cpex-dynamic-plugin + // Walk up two levels to get to . + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = crate_dir + .parent() // crates/ + .and_then(|p| p.parent()) // / + .expect("workspace root reachable from CARGO_MANIFEST_DIR") + .to_path_buf(); + + // Profile: `cargo test --release` would put it in `release/`, + // but the default `cargo test` is `debug/`. Detect via the + // `PROFILE` env var if set, else default debug. + let profile = option_env!("PROFILE").unwrap_or("debug"); + + // Filename: `lib.dylib` on macOS, `lib.so` on + // Linux, `.dll` on Windows. `DLL_PREFIX` is "lib" on + // unix and "" on windows; `DLL_SUFFIX` is the right thing + // per-OS. + let filename = format!( + "{prefix}{crate_name}{suffix}", + prefix = std::env::consts::DLL_PREFIX, + crate_name = crate_name, + suffix = std::env::consts::DLL_SUFFIX, + ); + + workspace_root + .join("target") + .join(profile) + .join(filename) +} + +/// Build the `kind:` string for a workspace cdylib. The dev-dep +/// edge from cpex-dynamic-plugin to the example plugins guarantees +/// cargo has built the cdylib before this test runs. +fn cdylib_kind(crate_name: &str) -> String { + let path = cdylib_path(crate_name); + assert!( + path.exists(), + "plugin cdylib not found at {} — \ + the dev-dependency on {} should have triggered the build. \ + Try `cargo test -p cpex-dynamic-plugin --features host`", + path.display(), + crate_name, + ); + format!("lib:{}", path.display()) +} + +fn example_kind() -> String { + cdylib_kind(EXAMPLE_CRATE) +} + +/// Build a `PluginConfig` referencing the example plugin via the +/// URL-shaped kind, with the operator's own plugin config +/// (a no-op `{}` for the example). +fn example_plugin_config() -> PluginConfig { + PluginConfig { + name: "example".into(), + kind: example_kind(), + hooks: vec!["cmf.tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(serde_json::json!({})), + ..Default::default() + } +} + +#[tokio::test] +async fn factory_loads_example_cdylib_and_executor_invokes_it() { + // 1. Set up a manager + register the dynamic-plugin factory + // under scheme "lib". + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + // 2. Build the plugin config that points at the example cdylib + // and load it via the standard factory-driven path. We use + // register_handler under the hood via a small helper rather + // than going through load_config_yaml — that path needs a + // full YAML; we already have a typed PluginConfig. + // + // The factory's `create()` is what dlopens the library, + // binds to cpex_plugin_create, calls it, and produces a + // PluginInstance. We then hand that PluginInstance's + // handlers to the manager via register_raw. + let cfg = example_plugin_config(); + + // The most direct path that exercises the factory: ask the + // manager to load a config containing the plugin. We build a + // minimal YAML and feed it. + let yaml = format!( + r#" +plugins: + - name: {name} + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + name = cfg.name, + kind = cfg.kind, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("load_config_yaml should succeed against the example cdylib"); + mgr.initialize().await.unwrap(); + + // 3. Dispatch a CMF message through the manager. The example + // plugin's handler is `PluginResult::allow()` — pipeline + // should continue. + let payload = MessagePayload { + message: Message::text(Role::User, "hello dynamic plugin"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + + assert!( + result.continue_processing, + "dynamic plugin's allow-gate should let the pipeline continue: \ + violation = {:?}", + result.violation, + ); + + // The example plugin doesn't mutate payload or extensions, but + // the executor returns the (possibly Box'd) original payload + // via `modified_payload`. Just confirm it's there. + assert!(result.modified_payload.is_some()); +} + +#[tokio::test] +async fn factory_reports_friendly_error_when_library_missing() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let yaml = r#" +plugins: + - name: missing + kind: "lib:/dev/null/definitely-not-a-real-cdylib.dylib" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {} +"#; + let parsed = cpex_core::config::parse_config(yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("missing cdylib should fail config load"); + let msg = format!("{err}"); + assert!( + msg.contains("dlopen") || msg.contains("failed to") || msg.to_lowercase().contains("not"), + "expected a dlopen-related error message, got: {msg}", + ); +} + +#[tokio::test] +async fn factory_rejects_wrong_scheme_in_kind() { + // The factory is registered for scheme "lib"; a kind starting + // with "wasm:" can't reach the factory at all — the registry's + // get() returns None and the manager reports "no factory". + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let yaml = r#" +plugins: + - name: wrong-scheme + kind: "wasm:/path/to/foo.wasm" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {} +"#; + let parsed = cpex_core::config::parse_config(yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("unregistered scheme should fail config load"); + let msg = format!("{err}"); + assert!( + msg.contains("no factory") || msg.contains("wasm"), + "expected a no-factory diagnostic, got: {msg}", + ); +} + +// -------------------------------------------------------------------- +// Multi-handler + multi-plugin tests. +// +// These tests exercise two aspects of the loader that the single- +// handler happy path can't cover: +// +// 1. A cdylib that registers MORE THAN ONE handler with distinct +// hook names. We need to confirm the host wires each handler +// to its declared hook (not collapsed onto one) AND that the +// `#handler` URL fragment correctly filters down to a single +// handler when the operator wants only one. +// 2. TWO DIFFERENT cdylibs loaded simultaneously by the same +// PluginManager. We need to confirm that two `Box::leak`ed +// Library handles + two separate registrations don't step on +// each other (separate vtables, separate Arcs, separate +// handler maps). +// +// The multi-handler example registers: +// * cmf.tool_pre_invoke → AllowOnPre → continue_processing=true +// * cmf.tool_post_invoke → DenyOnPost → continue_processing=false +// violation.code = +// "test.multi_handler.post_deny" +// +// The verdict + violation code is the test's signal for "which +// handler ran". +// -------------------------------------------------------------------- + +/// Multi-handler cdylib loaded WITHOUT a fragment should register +/// both handlers, each under its declared hook name. Invoking +/// `cmf.tool_pre_invoke` should hit the allow path; invoking +/// `cmf.tool_post_invoke` should hit the deny path with the +/// distinctive violation code. +#[tokio::test] +async fn multi_handler_no_fragment_registers_both() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + // Note: `hooks:` declares BOTH hook names so the registry binds + // both handlers. The cdylib produces a PluginRegistration with + // two `(hook_name, handler)` pairs; the host filters by `hooks:` + // and by URL fragment. With no fragment and both hooks listed, + // both handlers are wired. + let kind = cdylib_kind(MULTI_HANDLER_CRATE); + let yaml = format!( + r#" +plugins: + - name: multi + kind: "{kind}" + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed).expect("multi-handler cdylib loads"); + mgr.initialize().await.unwrap(); + + // Pre-invoke → allow. + let pre_payload = MessagePayload { + message: Message::text(Role::User, "pre"), + }; + let (pre_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + pre_payload, + Extensions::default(), + None, + ) + .await; + assert!( + pre_result.continue_processing, + "AllowOnPre should allow pre-invoke; got violation={:?}", + pre_result.violation, + ); + + // Post-invoke → deny with the distinctive code. This proves the + // post-handler ran (not the pre-handler bound to the wrong hook). + let post_payload = MessagePayload { + message: Message::text(Role::User, "post"), + }; + let (post_result, _bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + Extensions::default(), + None, + ) + .await; + assert!( + !post_result.continue_processing, + "DenyOnPost should deny post-invoke", + ); + let violation = post_result + .violation + .expect("deny should carry a violation"); + assert_eq!( + violation.code, "test.multi_handler.post_deny", + "violation code identifies the post-handler as the one that fired", + ); +} + +/// With a `#cmf.tool_pre_invoke` fragment in the kind, the host +/// should filter the cdylib's registered handlers down to just the +/// pre-invoke one. Even if the operator lists `cmf.tool_post_invoke` +/// in `hooks:`, no handler should be wired there because the +/// fragment filtered the post-handler out of the registration. +#[tokio::test] +async fn multi_handler_with_fragment_filters_to_one() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!( + "{}#cmf.tool_pre_invoke", + cdylib_kind(MULTI_HANDLER_CRATE), + ); + // Only list the pre hook — the fragment already filtered out + // the post handler, so listing post here would just fail the + // load with "no handler for hook". + let yaml = format!( + r#" +plugins: + - name: multi-filtered + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("fragment-filtered cdylib loads"); + mgr.initialize().await.unwrap(); + + // Pre still allows. + let payload = MessagePayload { + message: Message::text(Role::User, "pre"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + result.continue_processing, + "pre handler is the only one present and should allow", + ); + + // Post should have NO handler — invoking the hook is a no-op + // (no plugins subscribed). Manager returns a continue verdict + // by default. Confirm we don't accidentally see the post-deny + // violation that would mean the fragment filter failed. + let post_payload = MessagePayload { + message: Message::text(Role::User, "post"), + }; + let (post_result, _bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + Extensions::default(), + None, + ) + .await; + assert!( + post_result.continue_processing, + "with no post handler wired, the post hook should be a no-op", + ); + assert!( + post_result.violation.is_none(), + "fragment filter failed: post-deny handler fired anyway. \ + violation = {:?}", + post_result.violation, + ); +} + +/// Two distinct cdylibs loaded into the SAME PluginManager. Each +/// is built independently, each is `Box::leak`ed by the host, and +/// both contribute to the hook pipelines without interfering with +/// each other. +/// +/// We load: +/// * `cpex-dynamic-plugin-example` → allow on pre +/// * `cpex-dynamic-plugin-multi-handler-example` → allow on pre + +/// deny on post +/// +/// On `cmf.tool_pre_invoke` both plugins fire and both allow, so +/// the pipeline continues. On `cmf.tool_post_invoke` only the +/// multi-handler plugin is wired (the example plugin doesn't +/// subscribe to it) and we get the deny verdict. This proves the +/// two libraries coexist without symbol clashes or shared state. +#[tokio::test] +async fn multiple_dynamic_plugins_coexist_in_one_manager() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let single_kind = cdylib_kind(EXAMPLE_CRATE); + let multi_kind = cdylib_kind(MULTI_HANDLER_CRATE); + let yaml = format!( + r#" +plugins: + - name: single + kind: "{single_kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} + - name: multi + kind: "{multi_kind}" + hooks: [cmf.tool_pre_invoke, cmf.tool_post_invoke] + mode: sequential + priority: 20 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("two distinct cdylibs load into one manager"); + mgr.initialize().await.unwrap(); + + // Both plugins subscribe to pre-invoke. Both allow → pipeline + // continues. If either Library got unloaded prematurely (drop- + // order hazard), invoking through the vtable would segfault + // here — the test reaching the assert means both libraries are + // still mapped. + let pre_payload = MessagePayload { + message: Message::text(Role::User, "pre"), + }; + let (pre_result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + pre_payload, + Extensions::default(), + None, + ) + .await; + assert!( + pre_result.continue_processing, + "both pre-invoke handlers should allow; got violation={:?}", + pre_result.violation, + ); + + // Only multi subscribes to post-invoke, and it denies. The fact + // that the single plugin's library hasn't trampled multi's + // registration is what we're confirming. + let post_payload = MessagePayload { + message: Message::text(Role::User, "post"), + }; + let (post_result, _bg) = mgr + .invoke_named::( + "cmf.tool_post_invoke", + post_payload, + Extensions::default(), + None, + ) + .await; + assert!( + !post_result.continue_processing, + "multi's post-deny handler should fire even with a second \ + cdylib also loaded", + ); + let violation = post_result + .violation + .expect("deny should carry a violation"); + assert_eq!(violation.code, "test.multi_handler.post_deny"); +} + +// -------------------------------------------------------------------- +// Multi-plugin-per-cdylib tests (slice DL-3). +// +// These exercise the `?entry=` URL parameter and the optional +// `cpex_plugin_list` discovery symbol. The multi-plugin example +// cdylib packages TWO distinct plugins: +// +// * `?entry=allow` → allow-gate plugin → continue_processing=true +// * `?entry=deny` → deny-gate plugin → continue_processing=false, +// violation.code="test.multi_plugin.deny" +// +// Crucially, the multi-plugin cdylib does NOT export the default +// `cpex_plugin_create` symbol — operators MUST select an entry. That +// gives the tests a way to confirm the host's symbol resolution is +// keyed off `?entry=` rather than always falling back to the default. +// -------------------------------------------------------------------- + +/// `?entry=allow` selects the allow-gate plugin from the multi- +/// plugin cdylib. Verdict is "allow," and the plugin instance +/// reports its plugin-author-set name `allow-gate`. +#[tokio::test] +async fn multi_plugin_entry_allow_loads_allow_gate() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!("{}?entry=allow", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: multi-allow + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("multi-plugin cdylib loads with ?entry=allow"); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "allow test"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + result.continue_processing, + "?entry=allow should select the allow-gate plugin", + ); +} + +/// `?entry=deny` selects the deny-gate plugin from the SAME cdylib. +/// Verdict is "deny" with the distinctive violation code, proving +/// the host routed to a different entry-point function than the +/// previous test (not just running the same plugin twice). +#[tokio::test] +async fn multi_plugin_entry_deny_loads_deny_gate() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!("{}?entry=deny", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: multi-deny + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("multi-plugin cdylib loads with ?entry=deny"); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "deny test"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + !result.continue_processing, + "?entry=deny should select the deny-gate plugin", + ); + let violation = result.violation.expect("deny should carry a violation"); + assert_eq!( + violation.code, "test.multi_plugin.deny", + "violation code identifies the deny entry as the one that ran", + ); +} + +/// BOTH entries of the SAME cdylib loaded into one PluginManager +/// under different operator-facing names. The cdylib is dlopen'd +/// twice (or once with refcount 2; the OS dedupes) but the host's +/// PluginInstance map has two distinct entries. Pipeline aggregates +/// to a deny because the deny-gate fires. +#[tokio::test] +async fn multi_plugin_both_entries_coexist() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let allow_kind = format!("{}?entry=allow", cdylib_kind(MULTI_PLUGIN_CRATE)); + let deny_kind = format!("{}?entry=deny", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: gate-allow + kind: "{allow_kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} + - name: gate-deny + kind: "{deny_kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 20 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("both entries of the multi-plugin cdylib load"); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "two-entry test"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + // Pipeline: allow-gate runs (priority 10) → allows → deny-gate + // runs (priority 20) → denies → pipeline result is the deny. + // The fact that the deny VIOLATION CODE shows up proves that + // the second plugin instance is the deny entry, not a second + // copy of the allow entry. + assert!( + !result.continue_processing, + "deny-gate (priority 20) should produce a deny verdict", + ); + let violation = result.violation.expect("deny should carry a violation"); + assert_eq!(violation.code, "test.multi_plugin.deny"); +} + +/// `?entry=nonexistent` should be rejected at load time with a +/// friendly diagnostic listing the available entries from the +/// cdylib's manifest. This is the load-bearing test for the +/// `cpex_plugin_list` discovery symbol — if it weren't being read, +/// the error would just be a raw dlsym "symbol not found." +#[tokio::test] +async fn multi_plugin_unknown_entry_lists_available_entries() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let kind = format!("{}?entry=nonexistent", cdylib_kind(MULTI_PLUGIN_CRATE)); + let yaml = format!( + r#" +plugins: + - name: bogus + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("unknown entry should fail config load"); + let msg = format!("{err}"); + assert!( + msg.contains("no entry 'nonexistent'"), + "expected diagnostic to mention the requested entry; got: {msg}", + ); + assert!( + msg.contains("allow") && msg.contains("deny"), + "expected diagnostic to list available entries [allow, deny]; got: {msg}", + ); +} + +/// A multi-plugin cdylib without `?entry=` should fail because it +/// doesn't export the default `cpex_plugin_create` symbol. The +/// error message tells the operator what's missing — and since the +/// manifest IS available, the "no symbol" path can pivot to "did +/// you mean ?entry=allow or ?entry=deny?" via the hint. +/// +/// We assert the error mentions the missing default symbol; the +/// "did you use the macro?" hint applies here since `parsed.entry` +/// is None, which the host treats as the single-plugin case. +#[tokio::test] +async fn multi_plugin_without_entry_fails_with_helpful_error() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + // Note: no `?entry=` in the kind URL. + let kind = cdylib_kind(MULTI_PLUGIN_CRATE); + let yaml = format!( + r#" +plugins: + - name: no-entry + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + let err = mgr + .load_config(parsed) + .expect_err("multi-plugin cdylib without ?entry= should fail"); + let msg = format!("{err}"); + assert!( + msg.contains("cpex_plugin_create"), + "expected diagnostic to mention the missing default symbol; got: {msg}", + ); +} + +/// Sanity check that the single-plugin cdylib still works after the +/// host gained `?entry=` support. Same code as the original happy- +/// path test but kept distinct so a regression in single-plugin +/// behavior is easy to identify. +#[tokio::test] +async fn single_plugin_path_still_works_with_no_entry() { + let mgr = Arc::new(PluginManager::default()); + mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); + + let cfg = example_plugin_config(); + let yaml = format!( + r#" +plugins: + - name: {name} + kind: "{kind}" + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 10 + on_error: fail + config: {{}} +"#, + name = cfg.name, + kind = cfg.kind, + ); + let parsed = cpex_core::config::parse_config(&yaml) + .expect("YAML parses into CpexConfig"); + mgr.load_config(parsed) + .expect("single-plugin cdylib still loads with no ?entry="); + mgr.initialize().await.unwrap(); + + let payload = MessagePayload { + message: Message::text(Role::User, "single-plugin compat"), + }; + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload, + Extensions::default(), + None, + ) + .await; + assert!( + result.continue_processing, + "single-plugin allow-gate still wired correctly", + ); +} diff --git a/docs/specs/cpex-rust-spec.md b/docs/specs/cpex-rust-spec.md index eeb2be29..9599fb4e 100644 --- a/docs/specs/cpex-rust-spec.md +++ b/docs/specs/cpex-rust-spec.md @@ -1349,9 +1349,9 @@ cargo run --example cmf_capabilities_demo -p cpex-core cargo test --workspace ``` -## 17. Dynamic Plugin Loading (Design Note) +## 17. Dynamic Plugin Loading -> **Status:** the C ABI path described below is shipped today (cpex-ffi, the Go integration). The Rust `cdylib` path is design-only — see §18 row "Native (`dlopen`) plugin loader." +> **Status:** both transports are shipped. The C ABI path lives in `cpex-ffi` (used by the Go integration). The Rust `cdylib` path lives in `cpex-dynamic-plugin` — the architecture below applies to both; §17.5–17.8 cover the cdylib-specific surface that shipped. The framework is built so dynamic plugins (loaded at runtime from a `.so` / `.dylib` / `.dll`) work without changing the typed plugin-author API. The architecture deliberately separates two layers: @@ -1370,7 +1370,7 @@ The typed `HookHandler` is non-object-safe (because of `impl Future` return-p | Strategy | Status | What crosses the boundary | |---|---|---| | **C ABI via cpex-ffi** | Shipped (Go integration) | `extern "C"` functions, opaque manager handles, MessagePack-encoded payloads. Plugins never touch `HookHandler` directly — they implement whatever the FFI shim exposes. See [cpex-go-spec.md](./cpex-go-spec.md). | -| **Rust `cdylib` via `dlopen`** | Not implemented | A `cdylib` exports a registration entry point that returns `Arc` (or a vec of named handlers). Host loads via `libloading` and registers via `PluginManager::register_raw`. | +| **Rust `cdylib` via `dlopen`** | Shipped in `cpex-dynamic-plugin` | A `cdylib` exports a registration entry point that returns a `PluginRegistration` containing an `Arc` plus a `Vec<(hook_name, Arc)>`. Host loads via `libloading`, registers via `DynamicPluginFactory` (scheme: `lib`). See §17.5–17.8. | ### 17.2 Async stays end-to-end @@ -1394,15 +1394,120 @@ Independent of which transport you pick: - **No nested `block_on`.** A dynamic plugin must never `block_on` inside `handle` — the future is already running on a tokio task and nested blocking will panic. Same rule as in-tree plugins, but easier to forget when the plugin lives in someone else's repo. - **Panic isolation.** The host wraps every `AnyHookHandler::invoke` call in `catch_unwind`. cpex-ffi already does this at the C boundary; a Rust `cdylib` host would do the same at the registration shim. -Specific to the Rust `cdylib` path: +Specific to the Rust `cdylib` path (`cpex-dynamic-plugin`): -- **Rust ABI instability.** Plugin and host must be compiled with the same compiler version *and* same dependency versions. Different versions = UB. Mitigations: pin both, ship the host crate as a `=` version requirement, or use the `abi_stable` crate (gives a C-compatible vtable at the cost of an extra layer). -- **Allocator boundaries.** A `Box`/`Arc` allocated by the plugin must be dropped by the same allocator. The simplest path is for both sides to use the system allocator; otherwise the plugin must expose a free function the host calls on drop. -- **Symbol visibility.** The plugin's registration entry point must be `#[no_mangle] pub extern "C"` so `dlsym` can find it. Everything else can stay regular Rust. +- **Same-version-only Rust ABI.** Plugin and host must be compiled with the same compiler version *and* same dependency versions. Different versions = UB. The shipped mitigation is a runtime `ABI_VERSION: u32` handshake at the entry point: the host passes its version, the plugin compares against its own compiled-against constant, mismatches return `EntryPointResult::AbiMismatch` before any unsafe access. Possible future extensions: + - A per-version plugin build container that ships with each CPEX release, so plugin authors get a guaranteed-matching toolchain via `cargo cpex plugin build` without thinking about versions. + - The `abi_stable` crate, which gives structurally-validated stable vtables at the cost of `R*`-wrapped types everywhere on the boundary and per-call wrapper overhead. Reserved for a future where third-party plugin authors distribute binaries independently of host releases. +- **Allocator boundaries.** A `Box`/`Arc` allocated by the plugin must be dropped by the same allocator. Both sides use `std::alloc::System` by default; plugin authors must not override `#[global_allocator]`. +- **Symbol visibility.** Entry points are emitted by the `cpex_dynamic_plugin!` / `cpex_dynamic_plugins!` macros as `#[no_mangle] pub unsafe extern "C"` so `dlsym` can find them. Authors never write the unsafe FFI by hand. +- **Library lifetime.** `Arc` vtables point into the cdylib's text section; unloading the library while any Arc is live would SIGSEGV. The host `Box::leak`s the `libloading::Library` handle so loaded plugins stay mapped for the rest of the process. Hot reload would require refcounting the library alongside all derived `Arc`s — explicitly out of scope. +- **Panic across `extern "C"`.** Unwinding a panic across `extern "C"` is UB. The macros wrap the user's `create` closure in `catch_unwind` and return `EntryPointResult::Panic` on catch. Handler invocations rely on the same panic-isolation pattern as in-tree plugins. ### 17.4 Why this works without changing the typed API -The handler-collapse work in §6.2 (single async `HookHandler` trait) is orthogonal to dynamic loading. AFIT lives at the typed layer (inside the plugin's own binary); the module boundary lives at the type-erased layer. They don't collide. Plugin authors writing native, FFI, or hypothetical-cdylib plugins all write the same `async fn handle(...)` against the same `HookHandler` trait — only the registration shim changes between transports. +The handler-collapse work in §6.2 (single async `HookHandler` trait) is orthogonal to dynamic loading. AFIT lives at the typed layer (inside the plugin's own binary); the module boundary lives at the type-erased layer. They don't collide. Plugin authors writing native, FFI, or cdylib plugins all write the same `async fn handle(...)` against the same `HookHandler` trait — only the registration shim changes between transports. + +### 17.5 URL-shaped `kind` and factory scheme dispatch + +Operators reference dynamic plugins via a URL-shaped `kind:` string: + +``` +:[?entry=][#handler] +``` + +Components: + +- **`scheme`** — selects which factory loads the plugin. Default for `cpex-dynamic-plugin` is `lib`; other future schemes (e.g., `wasm:`) can register independently. +- **`path`** — filesystem path to the cdylib (absolute or relative; Windows drive letters supported via "first colon only" splitting). +- **`?entry=`** — optional. Names a specific entry point inside a multi-plugin cdylib (§17.6). Validated as a C identifier (`[a-zA-Z_][a-zA-Z0-9_]*`) before any symbol lookup. +- **`#handler`** — optional. Filters a multi-handler registration down to a single named handler. + +Worked examples: + +```yaml +plugins: + - name: my-plugin + kind: "lib:/opt/plugins/foo.so" # single plugin + + - name: identity + kind: "lib:/opt/plugins/auth.so#identity.resolve" # multi-handler, pick one + + - name: rate-limit + kind: "lib:/opt/plugins/multi.so?entry=rate_limiter" # multi-plugin + + - name: audit-post + kind: "lib:/opt/plugins/multi.so?entry=audit#cmf.tool_post_invoke" +``` + +The factory dispatch lives in `cpex_core::factory::PluginFactoryRegistry`: exact-`kind` matches win first, then `split_once(':')` falls back to a scheme registry. Hosts wire a dynamic-plugin factory via: + +```rust +mgr.register_factory_scheme("lib", Box::new(DynamicPluginFactory::new())); +``` + +Loader concerns (path, entry, handler filter) live in the kind URL by design — the plugin's `config:` block stays purely the plugin's own settings. + +### 17.6 Single-plugin vs multi-plugin cdylibs + +Two plugin-author macros, chosen per cdylib based on what you're shipping: + +| Macro | Symbol(s) | Manifest | When | +|---|---|---|---| +| `cpex_dynamic_plugin!` | `cpex_plugin_create` | None | One plugin per cdylib. `?entry=` not used. | +| `cpex_dynamic_plugins!` | `cpex_plugin_create_` per entry | `cpex_plugin_list` discovery symbol | Several distinct plugins packaged in one cdylib. Operator selects via `?entry=`. | + +Both macros generate the unsafe FFI glue: ABI-version handshake, config deserialization, `catch_unwind`, ownership transfer of the `PluginRegistration` via `Box::into_raw` / `Box::from_raw` (system allocator both sides). + +A `PluginRegistration` carries: + +```rust +pub struct PluginRegistration { + pub abi_version: u32, + pub name: String, // plugin author-set; for diagnostics + pub version: String, + pub plugin: Arc, // primary plugin handle + pub handlers: Vec<(String, Arc)>, // hook_name → handler +} +``` + +Multi-handler is orthogonal to multi-plugin: each entry of a multi-plugin cdylib can itself register multiple handlers, addressable via the `#handler` URL fragment. + +### 17.7 Discovery: the optional `cpex_plugin_list` symbol + +Multi-plugin cdylibs export an optional discovery symbol so the host can validate `?entry=` up-front and produce friendly diagnostics: + +```rust +pub const LIST_SYMBOL: &[u8] = b"cpex_plugin_list"; +pub type ListFn = unsafe extern "C" fn() -> *const PluginManifest; + +pub struct PluginManifest { + pub abi_version: u32, + pub entries: &'static [PluginManifestEntry], +} + +pub struct PluginManifestEntry { + pub entry: &'static str, // the ?entry= value; matches cpex_plugin_create_ + pub name: &'static str, // human-readable display name + pub version: &'static str, + pub description: &'static str, +} +``` + +All manifest data is `&'static` — baked into the cdylib's read-only memory at compile time. Nothing for the host to free, nothing for the plugin to reallocate. The macro emits the manifest as a `static` const. + +When the host sees `?entry=foo` and the manifest is present, it validates `foo` against `entries[].entry`. Unknown entries get `"no entry 'foo'. Available: [bar, baz]"`. When the manifest is absent (legacy single-plugin layout or operator opted out), the host falls through to plain `dlsym` and surfaces the raw "symbol not found" error. + +For hard-breaking changes to the manifest layout itself, the convention is to bump the symbol name (e.g., `cpex_plugin_list_v2`) rather than relying on `abi_version` alone — same idea as `dlsym` symbol versioning. + +### 17.8 Feature gating: plugin vs host roles + +`cpex-dynamic-plugin` serves two audiences from one crate: + +- **Plugin authors** depend with default features. They get `abi`, `plugin` (the macros), but NOT `libloading`. Plugin-side builds stay light. +- **Hosts** depend with `--features host`. That pulls `libloading` and exposes `DynamicPluginFactory`. + +Keeping both roles in one crate eliminates the two-crate version-skew class of bugs (plugin and host always see the same ABI types because they're in the same package). ## 18. Gaps and Unimplemented Features @@ -1418,7 +1523,7 @@ The handler-collapse work in §6.2 (single async `HookHandler` trait) is orth | Isolated (subprocess) plugins | `framework/isolated/` | Not yet implemented | | PDP (AuthZen/OPA) integration | `framework/pdp/` | Not yet implemented | | WASM plugin loader | `cpex-hosts::wasm` (planned) | Not yet implemented | -| Native (`dlopen`) plugin loader | `cpex-hosts::native` (planned) | Not yet implemented | +| Native (`dlopen`) plugin loader | `cpex-hosts::native` (planned) | Shipped in `cpex-dynamic-plugin` (see §17.5–17.8) | | `retry_delay_ms` in `PipelineResult` | `models.py` | Not implemented | The `cpex_core::plugin::Plugin` trait doc-comment mentions `cpex-hosts::{wasm,python,native}` host crates that would bridge to non-Rust plugin runtimes. None exist yet — this is a design intent placeholder, not shipped functionality.