Skip to main content

apiplant_server/
cabi.rs

1//! Loading function libraries that speak the [plain C ABI](apiplant_abi::c).
2//!
3//! The host tries the `abi_stable` root module first; a library without one is
4//! offered here. What comes back is an ordinary [`BoxedFunction`], so everything
5//! downstream — routing, visibility, OpenAPI, lifecycle hooks — treats a function
6//! written in C exactly like one written in Rust.
7//!
8//! Two rules govern everything below, and both exist because the other side of
9//! the boundary is not Rust:
10//!
11//! * **Nothing may unwind into C.** Every callback the host exposes is wrapped in
12//!   [`catch_unwind`], because a panic crossing an `extern "C"` frame aborts the
13//!   process instead of failing one request.
14//! * **Each side frees what it allocated.** The library and the host may not
15//!   share an allocator, so host strings go back through `free_string` and the
16//!   library's output comes back through its own `apiplant_free`.
17
18use std::ffi::{c_char, c_void, CStr, CString};
19use std::panic::catch_unwind;
20use std::path::Path;
21
22use abi_stable::sabi_trait::TD_Opaque;
23use abi_stable::std_types::{RResult, RStr, RString};
24use apiplant_abi::c as cabi;
25use apiplant_abi::{BoxedFunction, Function, FunctionManifest, HostApi_TO, LogLevel};
26use libloading::{Library, Symbol};
27use serde_json::Value;
28
29/// A function living in a C shared library, presented as an ABI function object.
30struct CFunction {
31    manifest: FunctionManifest,
32    /// The manifest name, as a C string, ready to pass to `invoke`.
33    name: CString,
34    invoke: cabi::InvokeFn,
35    free: cabi::FreeFn,
36    /// Keeps the library mapped for as long as any function from it exists.
37    /// Never dropped in practice — the registry lives for the process — but
38    /// holding it here is what makes that a guarantee rather than a hope.
39    _library: &'static Library,
40}
41
42// SAFETY: the library is leaked, `invoke`/`free` are plain code pointers into it,
43// and the ABI requires `apiplant_invoke` to be callable from several threads at
44// once — the same requirement the Rust `Function` trait states with `Send + Sync`.
45unsafe impl Send for CFunction {}
46unsafe impl Sync for CFunction {}
47
48impl Function for CFunction {
49    fn manifest(&self) -> FunctionManifest {
50        self.manifest.clone()
51    }
52
53    fn invoke(
54        &self,
55        host: HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
56        input: RStr<'_>,
57    ) -> RResult<RString, RString> {
58        // The input may legitimately contain interior NULs only if a client sent
59        // them; C cannot represent that, so reject it rather than truncate.
60        let Ok(input) = CString::new(input.as_str()) else {
61            return RResult::RErr(RString::from("input contains a NUL byte"));
62        };
63
64        // `bridge` is borrowed by the callbacks for exactly this call. It never
65        // escapes: `apiplant_invoke` returns before the borrow ends.
66        let mut bridge = Bridge { host: &host };
67        let c_host = cabi::Host {
68            ctx: &mut bridge as *mut Bridge<'_, '_> as *mut c_void,
69            query: Some(host_query),
70            log: Some(host_log),
71            config: Some(host_config),
72            principal_id: Some(host_principal_id),
73            hook: Some(host_hook),
74            free_string: Some(host_free_string),
75            send_email: Some(host_send_email),
76            cache: Some(host_cache),
77            payments: Some(host_payments),
78            ai: Some(host_ai),
79            emit: Some(host_emit),
80        };
81
82        let mut out: *mut c_char = std::ptr::null_mut();
83        // SAFETY: `invoke` came from this library's symbol table with the ABI's
84        // signature; the three pointers are valid for the duration of the call.
85        let status =
86            unsafe { (self.invoke)(self.name.as_ptr(), input.as_ptr(), &c_host, &mut out) };
87
88        let message = self.take_string(out);
89        match status {
90            cabi::OK => RResult::ROk(RString::from(message.unwrap_or_default())),
91            cabi::ERR_REQUEST => RResult::RErr(RString::from(
92                message.unwrap_or_else(|| "function rejected the request".to_string()),
93            )),
94            // Anything that isn't OK or ERR_REQUEST is the function's fault,
95            // including codes from a future ABI this host doesn't know.
96            _ => RResult::RErr(RString::from(format!(
97                "{}{}",
98                apiplant_abi::INTERNAL_ERROR_PREFIX,
99                message.unwrap_or_else(|| format!("function returned status {status}"))
100            ))),
101        }
102    }
103}
104
105impl CFunction {
106    /// Copy a string the library produced, then hand the original back for it to
107    /// free. Returns `None` for a null pointer, which is how a function signals
108    /// "no body" (and what we get if it forgot to set `*out` at all).
109    fn take_string(&self, ptr: *mut c_char) -> Option<String> {
110        if ptr.is_null() {
111            return None;
112        }
113        // SAFETY: non-null and, per the ABI, NUL-terminated and owned by the
114        // library until we return it to `apiplant_free` below.
115        let owned = unsafe { CStr::from_ptr(ptr) }
116            .to_string_lossy()
117            .into_owned();
118        // SAFETY: same pointer, handed straight back to its own allocator.
119        unsafe { (self.free)(ptr) };
120        Some(owned)
121    }
122}
123
124/// What `Host::ctx` points at: the Rust host API for one in-flight call.
125struct Bridge<'a, 'b> {
126    host: &'a HostApi_TO<'b, abi_stable::std_types::RBox<()>>,
127}
128
129/// Recover the bridge inside a callback.
130///
131/// # Safety
132/// `ctx` must be the pointer the host put in [`cabi::Host::ctx`], and the call
133/// must still be in progress.
134unsafe fn bridge<'a>(ctx: *mut c_void) -> Option<&'a Bridge<'a, 'a>> {
135    (ctx as *const Bridge<'a, 'a>).as_ref()
136}
137
138/// Hand a string to C. The callee returns it to [`host_free_string`].
139///
140/// A string with an interior NUL cannot be represented in C; that would mean the
141/// database returned one, so the empty string is the honest answer rather than a
142/// silent truncation at the NUL.
143fn to_c(s: &str) -> *mut c_char {
144    CString::new(s).unwrap_or_default().into_raw()
145}
146
147/// Wrap a callback body so a panic becomes a null pointer instead of an abort.
148fn guard_string<F: FnOnce() -> *mut c_char>(f: F) -> *mut c_char {
149    match catch_unwind(std::panic::AssertUnwindSafe(f)) {
150        Ok(ptr) => ptr,
151        Err(_) => {
152            tracing::error!("panic in a host callback serving a C function");
153            std::ptr::null_mut()
154        }
155    }
156}
157
158extern "C" fn host_query(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
159    guard_string(|| {
160        // SAFETY: `ctx` is the bridge for the call in progress; `request` is a
161        // NUL-terminated string the callee owns for the duration of this call.
162        let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
163        else {
164            return to_c(r#"{"error":"invalid query request"}"#);
165        };
166        match bridge.host.query(RStr::from_str(&request)) {
167            RResult::ROk(rows) => to_c(rows.as_str()),
168            // Reported in-band: the shape (object with "error") is what tells a
169            // failure apart from a result set. See `apiplant_abi::c::Host::query`.
170            RResult::RErr(e) => {
171                let body = serde_json::json!({ "error": e.as_str() });
172                to_c(&body.to_string())
173            }
174        }
175    })
176}
177
178extern "C" fn host_send_email(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
179    guard_string(|| {
180        // SAFETY: as in `host_query`.
181        let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
182        else {
183            return to_c(r#"{"error":"invalid email request"}"#);
184        };
185        in_band(bridge.host.send_email(RStr::from_str(&request)))
186    })
187}
188
189extern "C" fn host_payments(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
190    guard_string(|| {
191        // SAFETY: as in `host_query`.
192        let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
193        else {
194            return to_c(r#"{"error":"invalid payments request"}"#);
195        };
196        in_band(bridge.host.payments(RStr::from_str(&request)))
197    })
198}
199
200extern "C" fn host_cache(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
201    guard_string(|| {
202        // SAFETY: as in `host_query`.
203        let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
204        else {
205            return to_c(r#"{"error":"invalid cache request"}"#);
206        };
207        in_band(bridge.host.cache(RStr::from_str(&request)))
208    })
209}
210
211extern "C" fn host_ai(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
212    guard_string(|| {
213        // SAFETY: as in `host_query`.
214        let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
215        else {
216            return to_c(r#"{"error":"invalid ai request"}"#);
217        };
218        in_band(bridge.host.ai(RStr::from_str(&request)))
219    })
220}
221
222/// Unlike every other callback this one has nothing to allocate, so it reports
223/// through its return code: non-zero delivered, zero nobody listening. A panic
224/// is the latter — the chunk did not arrive.
225extern "C" fn host_emit(ctx: *mut c_void, chunk: *const c_char) -> i32 {
226    let delivered = catch_unwind(std::panic::AssertUnwindSafe(|| {
227        // SAFETY: as in `host_query`.
228        let (Some(bridge), Some(chunk)) = (unsafe { bridge(ctx) }, unsafe { cstr(chunk) }) else {
229            return false;
230        };
231        bridge.host.emit(RStr::from_str(&chunk))
232    }));
233    match delivered {
234        Ok(true) => 1,
235        Ok(false) => 0,
236        Err(_) => {
237            tracing::error!("panic in a host callback serving a C function");
238            0
239        }
240    }
241}
242
243/// Flatten a host result into the one string C gets back, reporting a failure
244/// as `{"error": …}` — the same convention `host_query` uses, and the reason
245/// these callbacks need no out-parameter.
246fn in_band(result: RResult<RString, RString>) -> *mut c_char {
247    match result {
248        RResult::ROk(reply) => to_c(reply.as_str()),
249        RResult::RErr(e) => to_c(&serde_json::json!({ "error": e.as_str() }).to_string()),
250    }
251}
252
253extern "C" fn host_log(ctx: *mut c_void, level: i32, message: *const c_char) {
254    let _ = catch_unwind(std::panic::AssertUnwindSafe(|| {
255        // SAFETY: as in `host_query`.
256        let (Some(bridge), Some(message)) = (unsafe { bridge(ctx) }, unsafe { cstr(message) })
257        else {
258            return;
259        };
260        let level = match level {
261            cabi::log_level::TRACE => LogLevel::Trace,
262            cabi::log_level::DEBUG => LogLevel::Debug,
263            cabi::log_level::WARN => LogLevel::Warn,
264            cabi::log_level::ERROR => LogLevel::Error,
265            // Includes INFO and any level from a future ABI.
266            _ => LogLevel::Info,
267        };
268        bridge.host.log(level, RStr::from_str(&message));
269    }));
270}
271
272extern "C" fn host_config(ctx: *mut c_void) -> *mut c_char {
273    // SAFETY: as in `host_query`.
274    guard_string(|| match unsafe { bridge(ctx) } {
275        Some(b) => to_c(b.host.config().as_str()),
276        None => to_c("{}"),
277    })
278}
279
280extern "C" fn host_principal_id(ctx: *mut c_void) -> *mut c_char {
281    // SAFETY: as in `host_query`.
282    guard_string(|| match unsafe { bridge(ctx) } {
283        Some(b) => to_c(b.host.principal_id().as_str()),
284        None => to_c(""),
285    })
286}
287
288extern "C" fn host_hook(ctx: *mut c_void) -> *mut c_char {
289    // SAFETY: as in `host_query`.
290    guard_string(|| match unsafe { bridge(ctx) } {
291        Some(b) => to_c(b.host.hook().as_str()),
292        None => to_c(""),
293    })
294}
295
296extern "C" fn host_free_string(_ctx: *mut c_void, string: *mut c_char) {
297    if string.is_null() {
298        return;
299    }
300    // SAFETY: every string the callbacks above return came from
301    // `CString::into_raw`, so this is the matching `from_raw`. A library that
302    // passes anything else violates the ABI.
303    drop(unsafe { CString::from_raw(string) });
304}
305
306/// Borrow a C string as UTF-8, lossily.
307///
308/// # Safety
309/// `ptr` must be null or a valid NUL-terminated string.
310unsafe fn cstr(ptr: *const c_char) -> Option<String> {
311    if ptr.is_null() {
312        return None;
313    }
314    Some(CStr::from_ptr(ptr).to_string_lossy().into_owned())
315}
316
317/// Try to load `path` as a C-ABI library, returning its functions.
318///
319/// `Ok(None)` means "this isn't a C-ABI library" — no `apiplant_abi_version`
320/// symbol — which lets the caller report the *original* `abi_stable` failure
321/// instead of a confusing second one. `Err` means it is one and is broken.
322pub fn load(path: &Path) -> Result<Option<Vec<BoxedFunction>>, String> {
323    // SAFETY: loading any shared library runs its initialisers; that is inherent
324    // to the feature and no more unsafe here than for the `abi_stable` path.
325    let library = match unsafe { Library::new(path) } {
326        Ok(l) => l,
327        Err(e) => return Err(format!("cannot open library: {e}")),
328    };
329
330    // Probe before committing: a missing version symbol just means this library
331    // speaks the other ABI.
332    // SAFETY: the symbol's type is asserted to match the ABI's `AbiVersionFn`.
333    let version = unsafe { library.get::<cabi::AbiVersionFn>(cabi::SYM_ABI_VERSION) };
334    let Ok(version) = version else {
335        return Ok(None);
336    };
337    // SAFETY: calling a function the library exported under the documented name.
338    let version = unsafe { version() };
339    if version != cabi::ABI_VERSION {
340        return Err(format!(
341            "library targets apiplant C ABI version {version}, this host speaks {}",
342            cabi::ABI_VERSION
343        ));
344    }
345
346    let symbol = |name: &[u8]| -> Result<*const (), String> {
347        // SAFETY: resolved as an untyped pointer and transmuted by the caller to
348        // the signature the ABI documents for that name.
349        unsafe {
350            library
351                .get::<*const ()>(name)
352                .map(|s: Symbol<'_, *const ()>| *s)
353                .map_err(|e| {
354                    format!(
355                        "library exports `apiplant_abi_version` but not `{}`: {e}",
356                        String::from_utf8_lossy(&name[..name.len() - 1])
357                    )
358                })
359        }
360    };
361
362    let manifest_ptr = symbol(cabi::SYM_MANIFEST)?;
363    let invoke_ptr = symbol(cabi::SYM_INVOKE)?;
364    let free_ptr = symbol(cabi::SYM_FREE)?;
365
366    // SAFETY: each pointer resolved from the documented symbol name, transmuted
367    // to the signature `apiplant_abi::c` specifies for it.
368    let (manifest_fn, invoke, free): (cabi::ManifestFn, cabi::InvokeFn, cabi::FreeFn) = unsafe {
369        (
370            std::mem::transmute::<*const (), cabi::ManifestFn>(manifest_ptr),
371            std::mem::transmute::<*const (), cabi::InvokeFn>(invoke_ptr),
372            std::mem::transmute::<*const (), cabi::FreeFn>(free_ptr),
373        )
374    };
375
376    // The manifest must outlive the borrow, and the library must stay mapped for
377    // as long as any function points into it. Leaking is how the `abi_stable`
378    // path does it too — nothing ever unloads a function library.
379    let library: &'static Library = Box::leak(Box::new(library));
380
381    // SAFETY: the ABI requires a static, NUL-terminated string here.
382    let json = unsafe { cstr(manifest_fn()) }
383        .ok_or_else(|| "`apiplant_manifest` returned NULL".to_string())?;
384    let entries: Vec<Value> = serde_json::from_str::<Value>(&json)
385        .map_err(|e| format!("`apiplant_manifest` is not valid JSON: {e}"))?
386        .as_array()
387        .cloned()
388        .ok_or_else(|| "`apiplant_manifest` must return a JSON array".to_string())?;
389    if entries.is_empty() {
390        return Err("`apiplant_manifest` returned an empty array".to_string());
391    }
392
393    let mut functions = Vec::with_capacity(entries.len());
394    for entry in &entries {
395        let manifest = parse_manifest(entry)?;
396        let name = CString::new(manifest.name.as_str())
397            .map_err(|_| "a function name contains a NUL byte".to_string())?;
398        functions.push(BoxedFunction::from_value(
399            CFunction {
400                manifest,
401                name,
402                invoke,
403                free,
404                _library: library,
405            },
406            TD_Opaque,
407        ));
408    }
409    Ok(Some(functions))
410}
411
412/// Build a [`FunctionManifest`] from one entry of `apiplant_manifest`'s array.
413///
414/// The shape is shared with every other manifest that arrives as JSON — see
415/// [`apiplant_abi::manifest_from_json`], which is where the field-by-field
416/// rules and their defaults live.
417fn parse_manifest(entry: &Value) -> Result<FunctionManifest, String> {
418    apiplant_abi::manifest_from_json(entry)
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use apiplant_abi::{FunctionAccess, HttpMethod, Visibility};
425
426    fn entry(json: &str) -> Result<FunctionManifest, String> {
427        parse_manifest(&serde_json::from_str(json).unwrap())
428    }
429
430    #[test]
431    fn a_name_is_the_only_required_field() {
432        let m = entry(r#"{"name":"hello"}"#).unwrap();
433        assert_eq!(m.name.as_str(), "hello");
434        assert_eq!(m.version.as_str(), "0.0.0");
435        assert_eq!(m.method, HttpMethod::Post);
436        assert!(m.description.is_empty());
437    }
438
439    #[test]
440    fn a_missing_or_unreadable_name_is_an_error() {
441        assert!(entry(r#"{"description":"no name"}"#).is_err());
442        assert!(entry(r#"{"name":""}"#).is_err());
443        assert!(entry(r#"{"name":42}"#).is_err());
444    }
445
446    /// The safe direction: a function that says nothing stays unreachable.
447    #[test]
448    fn visibility_defaults_to_private() {
449        assert_eq!(
450            entry(r#"{"name":"h"}"#).unwrap().visibility,
451            Visibility::Private
452        );
453    }
454
455    #[test]
456    fn visibility_uses_the_same_strings_as_resource_permissions() {
457        let vis = |v: &str| entry(&format!(r#"{{"name":"h","visibility":"{v}"}}"#)).unwrap();
458        assert_eq!(vis("public").visibility, Visibility::Public);
459        assert_eq!(vis("authenticated").visibility, Visibility::Authenticated);
460        assert_eq!(vis("private").visibility, Visibility::Private);
461
462        let gated = vis("role:admin");
463        assert_eq!(gated.visibility, Visibility::RoleGated);
464        assert_eq!(gated.role.as_str(), "admin");
465    }
466
467    /// A typo must not silently become `private` and leave the author wondering
468    /// why their endpoint 404s — unlike an *absent* field, which is a choice.
469    #[test]
470    fn an_unknown_visibility_is_rejected() {
471        let err = entry(r#"{"name":"h","visibility":"pubic"}"#).unwrap_err();
472        assert!(err.contains("unknown permission"), "{err}");
473        assert!(entry(r#"{"name":"h","visibility":"role:"}"#).is_err());
474    }
475
476    /// `permission` is the current key for the policy a C library declares, and
477    /// it may say things `visibility` never could.
478    #[test]
479    fn permission_is_read_and_outranks_visibility() {
480        let member = entry(r#"{"name":"h","permission":"member"}"#).unwrap();
481        assert_eq!(member.access(), FunctionAccess::Member);
482        // `member` has no Visibility of its own, so the legacy field carries the
483        // nearest thing rather than something wider.
484        assert_eq!(member.visibility, Visibility::Authenticated);
485
486        let both = entry(r#"{"name":"h","visibility":"public","permission":"role:ops"}"#).unwrap();
487        assert_eq!(both.access(), FunctionAccess::Role("ops".into()));
488        assert_eq!(both.visibility, Visibility::RoleGated);
489        assert_eq!(both.role.as_str(), "ops");
490
491        // Absent means private — a library that says nothing exposes nothing.
492        assert_eq!(
493            entry(r#"{"name":"h"}"#).unwrap().access(),
494            FunctionAccess::Private
495        );
496    }
497
498    /// The dashboard block is passed through verbatim; the admin generator, not
499    /// the loader, is what understands its shape.
500    #[test]
501    fn the_admin_block_survives_as_an_object_or_a_string() {
502        let inline = entry(r#"{"name":"h","admin":{"label":"Do it","order":2}}"#).unwrap();
503        let parsed: Value = serde_json::from_str(inline.admin.as_str()).unwrap();
504        assert_eq!(parsed["label"], "Do it");
505        assert_eq!(parsed["order"], 2);
506
507        let preserialised = entry(r#"{"name":"h","admin":"{\"label\":\"Do it\"}"}"#).unwrap();
508        assert_eq!(preserialised.admin.as_str(), r#"{"label":"Do it"}"#);
509
510        assert!(entry(r#"{"name":"h"}"#).unwrap().admin.is_empty());
511    }
512
513    #[test]
514    fn methods_are_case_insensitive_and_validated() {
515        let m = |v: &str| entry(&format!(r#"{{"name":"h","method":"{v}"}}"#));
516        assert_eq!(m("get").unwrap().method, HttpMethod::Get);
517        assert_eq!(m("Put").unwrap().method, HttpMethod::Put);
518        assert_eq!(m("DELETE").unwrap().method, HttpMethod::Delete);
519
520        let err = m("PATCH").unwrap_err();
521        assert!(err.contains("unsupported method"), "{err}");
522    }
523
524    /// Schemas are for the docs, and C has no derive to generate them — so both
525    /// an inline object and an already-serialised string have to work.
526    #[test]
527    fn schemas_accept_an_object_or_a_string() {
528        let inline = entry(r#"{"name":"h","input_schema":{"type":"object"}}"#).unwrap();
529        assert_eq!(
530            serde_json::from_str::<Value>(inline.input_schema.as_str()).unwrap(),
531            serde_json::json!({"type":"object"})
532        );
533
534        let preserialised =
535            entry(r#"{"name":"h","input_schema":"{\"type\":\"string\"}"}"#).unwrap();
536        assert_eq!(
537            serde_json::from_str::<Value>(preserialised.input_schema.as_str()).unwrap(),
538            serde_json::json!({"type":"string"})
539        );
540
541        assert!(entry(r#"{"name":"h"}"#).unwrap().input_schema.is_empty());
542        assert!(entry(r#"{"name":"h","input_schema":null}"#)
543            .unwrap()
544            .input_schema
545            .is_empty());
546    }
547
548    /// Not a C-ABI library and not a valid library at all both have to be
549    /// distinguishable from "loaded fine", or the caller reports the wrong error.
550    #[test]
551    fn a_library_that_is_not_c_abi_is_not_an_error() {
552        let dir = std::env::temp_dir().join(format!("apiplant-cabi-{}", std::process::id()));
553        std::fs::create_dir_all(&dir).unwrap();
554        let path = dir.join("libgarbage.so");
555        std::fs::write(&path, b"not an elf file").unwrap();
556
557        // Unopenable: a real error.
558        assert!(load(&path).is_err());
559
560        std::fs::remove_dir_all(&dir).unwrap();
561    }
562}