use std::ffi::{c_char, c_void, CStr, CString};
use std::panic::catch_unwind;
use std::path::Path;
use abi_stable::sabi_trait::TD_Opaque;
use abi_stable::std_types::{RResult, RStr, RString};
use apiplant_abi::c as cabi;
use apiplant_abi::{BoxedFunction, Function, FunctionManifest, HostApi_TO, LogLevel};
use libloading::{Library, Symbol};
use serde_json::Value;
struct CFunction {
manifest: FunctionManifest,
name: CString,
invoke: cabi::InvokeFn,
free: cabi::FreeFn,
_library: &'static Library,
}
unsafe impl Send for CFunction {}
unsafe impl Sync for CFunction {}
impl Function for CFunction {
fn manifest(&self) -> FunctionManifest {
self.manifest.clone()
}
fn invoke(
&self,
host: HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
input: RStr<'_>,
) -> RResult<RString, RString> {
let Ok(input) = CString::new(input.as_str()) else {
return RResult::RErr(RString::from("input contains a NUL byte"));
};
let mut bridge = Bridge { host: &host };
let c_host = cabi::Host {
ctx: &mut bridge as *mut Bridge<'_, '_> as *mut c_void,
query: Some(host_query),
log: Some(host_log),
config: Some(host_config),
principal_id: Some(host_principal_id),
hook: Some(host_hook),
free_string: Some(host_free_string),
send_email: Some(host_send_email),
cache: Some(host_cache),
payments: Some(host_payments),
ai: Some(host_ai),
emit: Some(host_emit),
publish: Some(host_publish),
};
let mut out: *mut c_char = std::ptr::null_mut();
let status =
unsafe { (self.invoke)(self.name.as_ptr(), input.as_ptr(), &c_host, &mut out) };
let message = self.take_string(out);
match status {
cabi::OK => RResult::ROk(RString::from(message.unwrap_or_default())),
cabi::ERR_REQUEST => RResult::RErr(RString::from(
message.unwrap_or_else(|| "function rejected the request".to_string()),
)),
_ => RResult::RErr(RString::from(format!(
"{}{}",
apiplant_abi::INTERNAL_ERROR_PREFIX,
message.unwrap_or_else(|| format!("function returned status {status}"))
))),
}
}
}
impl CFunction {
fn take_string(&self, ptr: *mut c_char) -> Option<String> {
if ptr.is_null() {
return None;
}
let owned = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { (self.free)(ptr) };
Some(owned)
}
}
struct Bridge<'a, 'b> {
host: &'a HostApi_TO<'b, abi_stable::std_types::RBox<()>>,
}
unsafe fn bridge<'a>(ctx: *mut c_void) -> Option<&'a Bridge<'a, 'a>> {
(ctx as *const Bridge<'a, 'a>).as_ref()
}
fn to_c(s: &str) -> *mut c_char {
CString::new(s).unwrap_or_default().into_raw()
}
fn guard_string<F: FnOnce() -> *mut c_char>(f: F) -> *mut c_char {
match catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(ptr) => ptr,
Err(_) => {
tracing::error!("panic in a host callback serving a C function");
std::ptr::null_mut()
}
}
}
extern "C" fn host_query(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
guard_string(|| {
let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
else {
return to_c(r#"{"error":"invalid query request"}"#);
};
match bridge.host.query(RStr::from_str(&request)) {
RResult::ROk(rows) => to_c(rows.as_str()),
RResult::RErr(e) => {
let body = serde_json::json!({ "error": e.as_str() });
to_c(&body.to_string())
}
}
})
}
extern "C" fn host_send_email(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
guard_string(|| {
let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
else {
return to_c(r#"{"error":"invalid email request"}"#);
};
in_band(bridge.host.send_email(RStr::from_str(&request)))
})
}
extern "C" fn host_payments(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
guard_string(|| {
let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
else {
return to_c(r#"{"error":"invalid payments request"}"#);
};
in_band(bridge.host.payments(RStr::from_str(&request)))
})
}
extern "C" fn host_cache(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
guard_string(|| {
let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
else {
return to_c(r#"{"error":"invalid cache request"}"#);
};
in_band(bridge.host.cache(RStr::from_str(&request)))
})
}
extern "C" fn host_ai(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
guard_string(|| {
let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
else {
return to_c(r#"{"error":"invalid ai request"}"#);
};
in_band(bridge.host.ai(RStr::from_str(&request)))
})
}
extern "C" fn host_publish(ctx: *mut c_void, request: *const c_char) -> *mut c_char {
guard_string(|| {
let (Some(bridge), Some(request)) = (unsafe { bridge(ctx) }, unsafe { cstr(request) })
else {
return to_c(r#"{"error":"invalid publish request"}"#);
};
in_band(bridge.host.publish(RStr::from_str(&request)))
})
}
extern "C" fn host_emit(ctx: *mut c_void, chunk: *const c_char) -> i32 {
let delivered = catch_unwind(std::panic::AssertUnwindSafe(|| {
let (Some(bridge), Some(chunk)) = (unsafe { bridge(ctx) }, unsafe { cstr(chunk) }) else {
return false;
};
bridge.host.emit(RStr::from_str(&chunk))
}));
match delivered {
Ok(true) => 1,
Ok(false) => 0,
Err(_) => {
tracing::error!("panic in a host callback serving a C function");
0
}
}
}
fn in_band(result: RResult<RString, RString>) -> *mut c_char {
match result {
RResult::ROk(reply) => to_c(reply.as_str()),
RResult::RErr(e) => to_c(&serde_json::json!({ "error": e.as_str() }).to_string()),
}
}
extern "C" fn host_log(ctx: *mut c_void, level: i32, message: *const c_char) {
let _ = catch_unwind(std::panic::AssertUnwindSafe(|| {
let (Some(bridge), Some(message)) = (unsafe { bridge(ctx) }, unsafe { cstr(message) })
else {
return;
};
let level = match level {
cabi::log_level::TRACE => LogLevel::Trace,
cabi::log_level::DEBUG => LogLevel::Debug,
cabi::log_level::WARN => LogLevel::Warn,
cabi::log_level::ERROR => LogLevel::Error,
_ => LogLevel::Info,
};
bridge.host.log(level, RStr::from_str(&message));
}));
}
extern "C" fn host_config(ctx: *mut c_void) -> *mut c_char {
guard_string(|| match unsafe { bridge(ctx) } {
Some(b) => to_c(b.host.config().as_str()),
None => to_c("{}"),
})
}
extern "C" fn host_principal_id(ctx: *mut c_void) -> *mut c_char {
guard_string(|| match unsafe { bridge(ctx) } {
Some(b) => to_c(b.host.principal_id().as_str()),
None => to_c(""),
})
}
extern "C" fn host_hook(ctx: *mut c_void) -> *mut c_char {
guard_string(|| match unsafe { bridge(ctx) } {
Some(b) => to_c(b.host.hook().as_str()),
None => to_c(""),
})
}
extern "C" fn host_free_string(_ctx: *mut c_void, string: *mut c_char) {
if string.is_null() {
return;
}
drop(unsafe { CString::from_raw(string) });
}
unsafe fn cstr(ptr: *const c_char) -> Option<String> {
if ptr.is_null() {
return None;
}
Some(CStr::from_ptr(ptr).to_string_lossy().into_owned())
}
pub fn load(path: &Path) -> Result<Option<Vec<BoxedFunction>>, String> {
let library = match unsafe { Library::new(path) } {
Ok(l) => l,
Err(e) => return Err(format!("cannot open library: {e}")),
};
let version = unsafe { library.get::<cabi::AbiVersionFn>(cabi::SYM_ABI_VERSION) };
let Ok(version) = version else {
return Ok(None);
};
let version = unsafe { version() };
if version != cabi::ABI_VERSION {
return Err(format!(
"library targets apiplant C ABI version {version}, this host speaks {}",
cabi::ABI_VERSION
));
}
let symbol = |name: &[u8]| -> Result<*const (), String> {
unsafe {
library
.get::<*const ()>(name)
.map(|s: Symbol<'_, *const ()>| *s)
.map_err(|e| {
format!(
"library exports `apiplant_abi_version` but not `{}`: {e}",
String::from_utf8_lossy(&name[..name.len() - 1])
)
})
}
};
let manifest_ptr = symbol(cabi::SYM_MANIFEST)?;
let invoke_ptr = symbol(cabi::SYM_INVOKE)?;
let free_ptr = symbol(cabi::SYM_FREE)?;
let (manifest_fn, invoke, free): (cabi::ManifestFn, cabi::InvokeFn, cabi::FreeFn) = unsafe {
(
std::mem::transmute::<*const (), cabi::ManifestFn>(manifest_ptr),
std::mem::transmute::<*const (), cabi::InvokeFn>(invoke_ptr),
std::mem::transmute::<*const (), cabi::FreeFn>(free_ptr),
)
};
let library: &'static Library = Box::leak(Box::new(library));
let json = unsafe { cstr(manifest_fn()) }
.ok_or_else(|| "`apiplant_manifest` returned NULL".to_string())?;
let entries: Vec<Value> = serde_json::from_str::<Value>(&json)
.map_err(|e| format!("`apiplant_manifest` is not valid JSON: {e}"))?
.as_array()
.cloned()
.ok_or_else(|| "`apiplant_manifest` must return a JSON array".to_string())?;
if entries.is_empty() {
return Err("`apiplant_manifest` returned an empty array".to_string());
}
let mut functions = Vec::with_capacity(entries.len());
for entry in &entries {
let manifest = parse_manifest(entry)?;
let name = CString::new(manifest.name.as_str())
.map_err(|_| "a function name contains a NUL byte".to_string())?;
functions.push(BoxedFunction::from_value(
CFunction {
manifest,
name,
invoke,
free,
_library: library,
},
TD_Opaque,
));
}
Ok(Some(functions))
}
fn parse_manifest(entry: &Value) -> Result<FunctionManifest, String> {
apiplant_abi::manifest_from_json(entry)
}
#[cfg(test)]
mod tests {
use super::*;
use apiplant_abi::{FunctionAccess, HttpMethod, Visibility};
fn entry(json: &str) -> Result<FunctionManifest, String> {
parse_manifest(&serde_json::from_str(json).unwrap())
}
#[test]
fn a_name_is_the_only_required_field() {
let m = entry(r#"{"name":"hello"}"#).unwrap();
assert_eq!(m.name.as_str(), "hello");
assert_eq!(m.version.as_str(), "0.0.0");
assert_eq!(m.method, HttpMethod::Post);
assert!(m.description.is_empty());
}
#[test]
fn a_missing_or_unreadable_name_is_an_error() {
assert!(entry(r#"{"description":"no name"}"#).is_err());
assert!(entry(r#"{"name":""}"#).is_err());
assert!(entry(r#"{"name":42}"#).is_err());
}
#[test]
fn visibility_defaults_to_private() {
assert_eq!(
entry(r#"{"name":"h"}"#).unwrap().visibility,
Visibility::Private
);
}
#[test]
fn visibility_uses_the_same_strings_as_resource_permissions() {
let vis = |v: &str| entry(&format!(r#"{{"name":"h","visibility":"{v}"}}"#)).unwrap();
assert_eq!(vis("public").visibility, Visibility::Public);
assert_eq!(vis("authenticated").visibility, Visibility::Authenticated);
assert_eq!(vis("private").visibility, Visibility::Private);
let gated = vis("role:admin");
assert_eq!(gated.visibility, Visibility::RoleGated);
assert_eq!(gated.role.as_str(), "admin");
}
#[test]
fn an_unknown_visibility_is_rejected() {
let err = entry(r#"{"name":"h","visibility":"pubic"}"#).unwrap_err();
assert!(err.contains("unknown permission"), "{err}");
assert!(entry(r#"{"name":"h","visibility":"role:"}"#).is_err());
}
#[test]
fn permission_is_read_and_outranks_visibility() {
let member = entry(r#"{"name":"h","permission":"member"}"#).unwrap();
assert_eq!(member.access(), FunctionAccess::Member);
assert_eq!(member.visibility, Visibility::Authenticated);
let both = entry(r#"{"name":"h","visibility":"public","permission":"role:ops"}"#).unwrap();
assert_eq!(both.access(), FunctionAccess::Role("ops".into()));
assert_eq!(both.visibility, Visibility::RoleGated);
assert_eq!(both.role.as_str(), "ops");
assert_eq!(
entry(r#"{"name":"h"}"#).unwrap().access(),
FunctionAccess::Private
);
}
#[test]
fn the_admin_block_survives_as_an_object_or_a_string() {
let inline = entry(r#"{"name":"h","admin":{"label":"Do it","order":2}}"#).unwrap();
let parsed: Value = serde_json::from_str(inline.admin.as_str()).unwrap();
assert_eq!(parsed["label"], "Do it");
assert_eq!(parsed["order"], 2);
let preserialised = entry(r#"{"name":"h","admin":"{\"label\":\"Do it\"}"}"#).unwrap();
assert_eq!(preserialised.admin.as_str(), r#"{"label":"Do it"}"#);
assert!(entry(r#"{"name":"h"}"#).unwrap().admin.is_empty());
}
#[test]
fn methods_are_case_insensitive_and_validated() {
let m = |v: &str| entry(&format!(r#"{{"name":"h","method":"{v}"}}"#));
assert_eq!(m("get").unwrap().method, HttpMethod::Get);
assert_eq!(m("Put").unwrap().method, HttpMethod::Put);
assert_eq!(m("DELETE").unwrap().method, HttpMethod::Delete);
let err = m("PATCH").unwrap_err();
assert!(err.contains("unsupported method"), "{err}");
}
#[test]
fn schemas_accept_an_object_or_a_string() {
let inline = entry(r#"{"name":"h","input_schema":{"type":"object"}}"#).unwrap();
assert_eq!(
serde_json::from_str::<Value>(inline.input_schema.as_str()).unwrap(),
serde_json::json!({"type":"object"})
);
let preserialised =
entry(r#"{"name":"h","input_schema":"{\"type\":\"string\"}"}"#).unwrap();
assert_eq!(
serde_json::from_str::<Value>(preserialised.input_schema.as_str()).unwrap(),
serde_json::json!({"type":"string"})
);
assert!(entry(r#"{"name":"h"}"#).unwrap().input_schema.is_empty());
assert!(entry(r#"{"name":"h","input_schema":null}"#)
.unwrap()
.input_schema
.is_empty());
}
#[test]
fn a_library_that_is_not_c_abi_is_not_an_error() {
let dir = std::env::temp_dir().join(format!("apiplant-cabi-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("libgarbage.so");
std::fs::write(&path, b"not an elf file").unwrap();
assert!(load(&path).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}