Skip to main content

haproxy_spoa_hub_plugin_api/
lib.rs

1//! Plugin API for haproxy-spoa-hub.
2//!
3//! # ABI model
4//!
5//! Plugins are loaded from `.so` files at runtime via `dlopen`. The
6//! interface between hub and plugin is a hand-rolled `#[repr(C)]`
7//! vtable defined in [`vtable::PluginVTable`], reached via the
8//! exported `get_plugin_vtable` symbol.
9//!
10//! See [`vtable`] for the load-bearing layout invariants and version
11//! evolution rules. In short: append-only, never reorder, never remove,
12//! version-gate every new field on the host. The matrix test in
13//! `crates/hub/tests/abi_matrix.rs` enforces these invariants by
14//! loading every published plugin version against the current hub.
15//!
16//! # Layout discipline for data types
17//!
18//! `PluginContext`, `SpoeMessage`, `ProcessingResult`, `Diagnostic`,
19//! `ConfigValue`, `SpoeValue`, `TxnVariable`, and `VarScope` are all
20//! `#[derive(StableAbi)]` which gives them `#[repr(C)]` layout.
21//! Existing fields MUST NOT be reordered or removed. Adding a field is
22//! a layout change that requires a coordinated plugin-api version bump
23//! (and is itself a separate concern from vtable evolution — there is
24//! no per-field version-gating on data types).
25//!
26//! `abi_stable` is pinned to `=0.11.3` in `Cargo.toml` so plugins and
27//! hub see byte-identical `RString` / `RVec` / etc. across the FFI
28//! boundary. Bumping that pin is a coordinated rollout, not a routine
29//! patch.
30
31#![allow(non_camel_case_types, non_local_definitions)]
32
33pub mod types;
34pub mod vtable;
35
36pub use abi_stable;
37pub use abi_stable::std_types::{
38    RBoxError, RHashMap, ROption, RResult, RStr, RString, RVec, Tuple2,
39};
40pub use types::{
41    ConfigValue, Diagnostic, DiagnosticSeverity, PluginContext, ProcessingResult, SpoeMessage,
42    SpoeValue, TxnVariable, VarScope,
43};
44pub use vtable::{
45    GET_PLUGIN_VTABLE_SYMBOL, GetPluginVTableFn, PLUGIN_API_VERSION, PLUGIN_API_VERSION_V1,
46    PluginVTable,
47};
48
49/// Define a plugin and emit the FFI surface (vtable + entry symbol).
50///
51/// Plugin authors write a regular `impl`-style block; the macro emits
52/// `extern "C"` thunks that bridge into it, the static `PluginVTable`,
53/// and the `get_plugin_vtable` symbol the hub looks up after `dlopen`.
54///
55/// `process` is automatically wrapped in `std::panic::catch_unwind` so
56/// a panic during request handling does not abort the hub process.
57///
58/// # Usage
59///
60/// ```rust,ignore
61/// use haproxy_spoa_hub_plugin_api::{ProcessingResult, SpoeMessage, SpoeValue, TxnVariable, define_plugin};
62///
63/// #[derive(Debug, Default)]
64/// struct MyPlugin;
65///
66/// define_plugin!(MyPlugin, {
67///     fn new() -> Self { MyPlugin }
68///
69///     fn init(&mut self, _: &PluginContext) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
70///         Ok(())
71///     }
72///
73///     fn name(&self) -> &str { "my-plugin" }
74///     fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
75///
76///     fn process(&self, _: &SpoeMessage)
77///         -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>>
78///     {
79///         Ok(ProcessingResult::single(TxnVariable::session(
80///             "result", SpoeValue::String("ok".into()),
81///         )))
82///     }
83/// });
84/// ```
85///
86/// `config_schema` and `validate` are optional. Add them at the end of
87/// the block in that order if you need them. Plugins that omit them
88/// get the default no-op behavior (no schema, empty diagnostics).
89///
90/// # Lifetime constraints
91///
92/// `name` and `version` MUST return `&'static str`. The macro emits an
93/// FFI thunk whose return type is `RStr<'static>`, so a `&str`
94/// borrowed from `&self` will not compile. In practice this means
95/// returning either a string literal or `env!("CARGO_PKG_VERSION")`.
96/// If you need to compute the name from instance fields, store it in
97/// a `&'static str` (e.g. via `Box::leak(...)` at construction time)
98/// or pre-register the strings as `const`s.
99///
100/// # Panic safety
101///
102/// Every author-supplied body is wrapped in `std::panic::catch_unwind`
103/// inside its FFI thunk. A panic surfaces as:
104/// - `process` / `init` / `create`: `RResult::RErr(PluginPanicError)`.
105/// - `validate`: a single `Diagnostic::error` entry returned to the
106///   host (not an abort — required for `--validate-socket` mode).
107/// - `config_schema`: treated as `RNone`.
108/// - `name` / `version`: returned as the literal `"<plugin-panic>"`.
109/// - `shutdown` / `destroy`: absorbed; memory may leak but the hub
110///   stays up.
111#[macro_export]
112macro_rules! define_plugin {
113    (
114        $plugin_ty:ty, {
115            fn new() -> Self $new_body:block
116
117            fn init(&mut self, $ctx_param:ident : &PluginContext $(,)?)
118                -> Result<(), Box<dyn std::error::Error + Send + Sync>> $init_body:block
119
120            fn name(&self) -> &str $name_body:block
121
122            fn version(&self) -> &str $version_body:block
123
124            fn process(
125                &self,
126                $msg_param:ident : &SpoeMessage $(,)?
127            ) -> Result<ProcessingResult, Box<dyn std::error::Error + Send + Sync>> $process_body:block
128
129            $(fn config_schema(&self) -> Option<&str> $schema_body:block)?
130
131            $(fn validate(&self, $vctx_param:ident : &PluginContext $(,)?) -> Vec<Diagnostic> $validate_body:block)?
132        }
133    ) => {
134        // Author-supplied bodies become inherent methods on the
135        // plugin type. The thunks below cast the opaque state pointer
136        // back to `&Self` (or `&mut Self` for init) and call them.
137        impl $plugin_ty {
138            #[allow(dead_code)]
139            fn __new() -> Self $new_body
140
141            #[allow(clippy::unnecessary_wraps, dead_code)]
142            fn __init(
143                &mut self,
144                $ctx_param: &$crate::PluginContext,
145            ) -> ::std::result::Result<(), ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>>
146                $init_body
147
148            #[allow(dead_code)]
149            fn __name(&self) -> &'static str $name_body
150
151            #[allow(dead_code)]
152            fn __version(&self) -> &'static str $version_body
153
154            #[allow(clippy::unnecessary_wraps, dead_code)]
155            fn __process(
156                &self,
157                $msg_param: &$crate::SpoeMessage,
158            ) -> ::std::result::Result<
159                $crate::ProcessingResult,
160                ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
161            > $process_body
162
163            $(
164                #[allow(clippy::unnecessary_wraps, dead_code)]
165                fn __config_schema(&self) -> ::std::option::Option<&'static str> $schema_body
166            )?
167
168            $(
169                #[allow(clippy::unnecessary_wraps, dead_code)]
170                fn __validate(
171                    &self,
172                    $vctx_param: &$crate::PluginContext,
173                ) -> ::std::vec::Vec<$crate::Diagnostic> $validate_body
174            )?
175        }
176
177        // FFI thunks. All `unsafe` operations are confined here; the
178        // plugin author's bodies above remain in safe Rust.
179        const _: () = {
180            use ::std::os::raw::c_void;
181            use ::std::panic::{AssertUnwindSafe, catch_unwind};
182
183            // Every thunk that calls into user-supplied code is wrapped
184            // in `catch_unwind`. Without this, a panic in any plugin
185            // method would unwind through `extern "C"` — which Rust
186            // defines as abort — taking the whole hub down. Each thunk
187            // has a sensible fallback for the catch case so the host
188            // observes a structured error rather than UB.
189
190            extern "C" fn create() -> $crate::RResult<*mut c_void, $crate::RBoxError> {
191                match catch_unwind(|| {
192                    let plugin: ::std::boxed::Box<$plugin_ty> =
193                        ::std::boxed::Box::new(<$plugin_ty>::__new());
194                    ::std::boxed::Box::into_raw(plugin).cast::<c_void>()
195                }) {
196                    Ok(raw) => $crate::RResult::ROk(raw),
197                    Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
198                        $crate::PluginPanicError,
199                    )),
200                }
201            }
202
203            extern "C" fn destroy(state: *mut c_void) {
204                if state.is_null() {
205                    return;
206                }
207                // SAFETY: state was produced by `create` via Box::into_raw
208                // on `Box<$plugin_ty>`. The hub guarantees one destroy per
209                // create. A panic in the plugin's Drop is absorbed; the
210                // alternative (abort) would lose every other live plugin.
211                let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
212                    ::std::mem::drop(::std::boxed::Box::from_raw(state.cast::<$plugin_ty>()));
213                }));
214            }
215
216            extern "C" fn init(
217                state: *mut c_void,
218                ctx: &$crate::PluginContext,
219            ) -> $crate::RResult<(), $crate::RBoxError> {
220                // SAFETY: state was produced by `create` and not yet
221                // destroyed. Hub holds an exclusive reference during init.
222                let plugin = unsafe { &mut *state.cast::<$plugin_ty>() };
223                match catch_unwind(AssertUnwindSafe(|| plugin.__init(ctx))) {
224                    Ok(Ok(())) => $crate::RResult::ROk(()),
225                    Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
226                    Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
227                        $crate::PluginPanicError,
228                    )),
229                }
230            }
231
232            extern "C" fn process(
233                state: *const c_void,
234                msg: &$crate::SpoeMessage,
235            ) -> $crate::RResult<$crate::ProcessingResult, $crate::RBoxError> {
236                // SAFETY: state is alive between init and destroy. Process
237                // takes &self so concurrent calls share a borrow — the
238                // plugin must use interior mutability for any mutable state.
239                let plugin = unsafe { &*state.cast::<$plugin_ty>() };
240                match catch_unwind(AssertUnwindSafe(|| plugin.__process(msg))) {
241                    Ok(Ok(result)) => $crate::RResult::ROk(result),
242                    Ok(Err(e)) => $crate::RResult::RErr($crate::RBoxError::from_box(e)),
243                    Err(_) => $crate::RResult::RErr($crate::RBoxError::new(
244                        $crate::PluginPanicError,
245                    )),
246                }
247            }
248
249            extern "C" fn name(state: *const c_void) -> $crate::RStr<'static> {
250                // SAFETY: state alive between init and destroy.
251                let plugin = unsafe { &*state.cast::<$plugin_ty>() };
252                match catch_unwind(AssertUnwindSafe(|| plugin.__name())) {
253                    Ok(s) => $crate::RStr::from(s),
254                    Err(_) => $crate::RStr::from("<plugin-panic>"),
255                }
256            }
257
258            extern "C" fn plugin_version(state: *const c_void) -> $crate::RStr<'static> {
259                // SAFETY: state alive between init and destroy.
260                let plugin = unsafe { &*state.cast::<$plugin_ty>() };
261                match catch_unwind(AssertUnwindSafe(|| plugin.__version())) {
262                    Ok(s) => $crate::RStr::from(s),
263                    Err(_) => $crate::RStr::from("<plugin-panic>"),
264                }
265            }
266
267            extern "C" fn shutdown(_state: *const c_void) {
268                // The original SpoePlugin trait's shutdown has an empty
269                // default body; the macro mirrors that. Plugins that need
270                // teardown logic do it in `destroy` (Drop on the boxed
271                // state), which is called once after the last use.
272                // Wrapped for forward-compat: if the macro grows a
273                // user-overridable shutdown body, the wrapper is already
274                // here.
275                let _ = catch_unwind(AssertUnwindSafe(|| {
276                    let _ = _state;
277                }));
278            }
279
280            extern "C" fn config_schema(_state: *const c_void) -> $crate::ROption<$crate::RString> {
281                // catch_unwind around the (potentially user-overridden)
282                // body. On panic, return RNone so the hub skips schema
283                // validation rather than aborting.
284                catch_unwind(AssertUnwindSafe(|| {
285                    $crate::__define_plugin_config_schema_thunk!(_state, $plugin_ty $(, $schema_body)?)
286                }))
287                .unwrap_or($crate::ROption::RNone)
288            }
289
290            extern "C" fn validate(
291                _state: *const c_void,
292                _ctx: &$crate::PluginContext,
293            ) -> $crate::RVec<$crate::Diagnostic> {
294                // catch_unwind around the (potentially user-overridden)
295                // body. On panic, return a single error Diagnostic so
296                // the hub surfaces a structured failure instead of
297                // aborting (critical for --validate-socket mode).
298                catch_unwind(AssertUnwindSafe(|| {
299                    $crate::__define_plugin_validate_thunk!(_state, _ctx, $plugin_ty $(, $validate_body)?)
300                }))
301                .unwrap_or_else(|_| {
302                    let mut diags = $crate::RVec::new();
303                    diags.push($crate::Diagnostic::error(
304                        0,
305                        0,
306                        "plugin's validate() panicked",
307                    ));
308                    diags
309                })
310            }
311
312            #[allow(non_upper_case_globals)]
313            static PLUGIN_VTABLE_INSTANCE: $crate::PluginVTable = $crate::PluginVTable {
314                api_version: $crate::PLUGIN_API_VERSION,
315                create,
316                destroy,
317                init,
318                process,
319                name,
320                plugin_version,
321                shutdown,
322                config_schema,
323                validate,
324            };
325
326            #[unsafe(no_mangle)]
327            pub extern "C" fn get_plugin_vtable() -> *const $crate::PluginVTable {
328                &PLUGIN_VTABLE_INSTANCE
329            }
330        };
331    };
332}
333
334/// Internal helper used by `define_plugin!` to expand the optional
335/// `config_schema` arm. With body → call the impl; without → return None.
336#[doc(hidden)]
337#[macro_export]
338macro_rules! __define_plugin_config_schema_thunk {
339    ($state:ident, $plugin_ty:ty) => {{
340        let _ = $state;
341        $crate::ROption::RNone
342    }};
343    ($state:ident, $plugin_ty:ty, $body:block) => {{
344        // SAFETY: state alive between init and destroy.
345        let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
346        match plugin.__config_schema() {
347            ::std::option::Option::Some(s) => $crate::ROption::RSome($crate::RString::from(s)),
348            ::std::option::Option::None => $crate::ROption::RNone,
349        }
350    }};
351}
352
353/// Internal helper used by `define_plugin!` to expand the optional
354/// `validate` arm. With body → call the impl; without → return empty.
355#[doc(hidden)]
356#[macro_export]
357macro_rules! __define_plugin_validate_thunk {
358    ($state:ident, $ctx:ident, $plugin_ty:ty) => {{
359        let _ = $state;
360        let _ = $ctx;
361        $crate::RVec::new()
362    }};
363    ($state:ident, $ctx:ident, $plugin_ty:ty, $body:block) => {{
364        // SAFETY: state alive between init and destroy.
365        let plugin = unsafe { &*$state.cast::<$plugin_ty>() };
366        $crate::RVec::from(plugin.__validate($ctx))
367    }};
368}
369
370/// Error raised when a plugin's `process` panics. Wrapped in `RBoxError`
371/// by the `define_plugin!` macro's panic-safety net.
372#[derive(Debug)]
373pub struct PluginPanicError;
374
375impl std::fmt::Display for PluginPanicError {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        write!(f, "plugin panicked during message processing")
378    }
379}
380
381impl std::error::Error for PluginPanicError {}