1use 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
29struct CFunction {
31 manifest: FunctionManifest,
32 name: CString,
34 invoke: cabi::InvokeFn,
35 free: cabi::FreeFn,
36 _library: &'static Library,
40}
41
42unsafe 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 let Ok(input) = CString::new(input.as_str()) else {
61 return RResult::RErr(RString::from("input contains a NUL byte"));
62 };
63
64 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 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 _ => 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 fn take_string(&self, ptr: *mut c_char) -> Option<String> {
111 if ptr.is_null() {
112 return None;
113 }
114 let owned = unsafe { CStr::from_ptr(ptr) }
117 .to_string_lossy()
118 .into_owned();
119 unsafe { (self.free)(ptr) };
121 Some(owned)
122 }
123}
124
125struct Bridge<'a, 'b> {
127 host: &'a HostApi_TO<'b, abi_stable::std_types::RBox<()>>,
128}
129
130unsafe fn bridge<'a>(ctx: *mut c_void) -> Option<&'a Bridge<'a, 'a>> {
136 (ctx as *const Bridge<'a, 'a>).as_ref()
137}
138
139fn to_c(s: &str) -> *mut c_char {
145 CString::new(s).unwrap_or_default().into_raw()
146}
147
148fn 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 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 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 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 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 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 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 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
234extern "C" fn host_emit(ctx: *mut c_void, chunk: *const c_char) -> i32 {
238 let delivered = catch_unwind(std::panic::AssertUnwindSafe(|| {
239 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
255fn 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 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 _ => 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 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 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 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 drop(unsafe { CString::from_raw(string) });
316}
317
318unsafe 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
329pub fn load(path: &Path) -> Result<Option<Vec<BoxedFunction>>, String> {
335 let library = match unsafe { Library::new(path) } {
338 Ok(l) => l,
339 Err(e) => return Err(format!("cannot open library: {e}")),
340 };
341
342 let version = unsafe { library.get::<cabi::AbiVersionFn>(cabi::SYM_ABI_VERSION) };
346 let Ok(version) = version else {
347 return Ok(None);
348 };
349 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 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 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 let library: &'static Library = Box::leak(Box::new(library));
392
393 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
424fn 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 #[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 #[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 #[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 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 assert_eq!(
505 entry(r#"{"name":"h"}"#).unwrap().access(),
506 FunctionAccess::Private
507 );
508 }
509
510 #[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 #[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 #[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 assert!(load(&path).is_err());
571
572 std::fs::remove_dir_all(&dir).unwrap();
573 }
574}