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 {}