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