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