Skip to main content

ballistics_engine/bridge/
ffi.rs

1//! C ABI for the JSON command bridge — the entire mobile-facing surface.
2//!
3//! Three symbols. Ownership rules (also documented in the shipped header):
4//!
5//! - The caller owns the input buffer; the engine never retains it past the call.
6//! - Every pointer returned by `ballistics_bridge_call` / `ballistics_bridge_call_n`
7//!   is heap-allocated by the engine and must be released exactly once with
8//!   `ballistics_bridge_free`. Freeing NULL is a no-op.
9//! - The calls NEVER return NULL: every failure mode — invalid UTF-8, bad JSON,
10//!   unknown command, command failure, internal panic — is an in-band
11//!   `{"ok":false,...}` envelope.
12//! - Calls are independent and thread-safe; there is no shared mutable state.
13
14use std::ffi::{c_char, CStr, CString};
15
16use super::{bridge_call, BridgeErrorCode, BRIDGE_API_VERSION};
17
18fn respond(json: String) -> *mut c_char {
19    // A JSON string can legally contain no interior NULs (serde_json escapes
20    // control characters), so this only fails on memory exhaustion-class bugs;
21    // fall back to a static-shaped minimal envelope built from a clean literal.
22    CString::new(json)
23        .unwrap_or_else(|_| {
24            CString::new(format!(
25                r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"error":{{"code":"internal_error","message":"response contained an interior NUL"}}}}"#
26            ))
27            .expect("fallback envelope contains no NUL")
28        })
29        .into_raw()
30}
31
32fn error_envelope(code: BridgeErrorCode, message: &str) -> String {
33    // Reuse the bridge's own serializer by round-tripping through it would drag
34    // private helpers into the ABI layer; a literal is simpler and stable.
35    let code = serde_json::to_string(&code).unwrap_or_else(|_| "\"internal_error\"".into());
36    format!(
37        r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"error":{{"code":{code},"message":"{message}"}}}}"#
38    )
39}
40
41/// Process one bridge request (NUL-terminated UTF-8 JSON envelope).
42///
43/// # Safety
44/// `request_json` must be NULL or a valid NUL-terminated C string. The returned
45/// pointer must be freed with [`ballistics_bridge_free`] exactly once.
46#[no_mangle]
47pub unsafe extern "C" fn ballistics_bridge_call(request_json: *const c_char) -> *mut c_char {
48    if request_json.is_null() {
49        return respond(error_envelope(
50            BridgeErrorCode::InvalidJson,
51            "request pointer is NULL",
52        ));
53    }
54    let bytes = CStr::from_ptr(request_json).to_bytes();
55    call_with_bytes(bytes)
56}
57
58/// Length-explicit variant for callers whose buffers are not NUL-terminated.
59///
60/// # Safety
61/// `request` must be NULL or point to at least `len` readable bytes. The returned
62/// pointer must be freed with [`ballistics_bridge_free`] exactly once.
63#[no_mangle]
64pub unsafe extern "C" fn ballistics_bridge_call_n(
65    request: *const u8,
66    len: usize,
67) -> *mut c_char {
68    if request.is_null() {
69        return respond(error_envelope(
70            BridgeErrorCode::InvalidJson,
71            "request pointer is NULL",
72        ));
73    }
74    let bytes = std::slice::from_raw_parts(request, len);
75    call_with_bytes(bytes)
76}
77
78fn call_with_bytes(bytes: &[u8]) -> *mut c_char {
79    match std::str::from_utf8(bytes) {
80        Ok(text) => respond(bridge_call(text)),
81        Err(_) => respond(error_envelope(
82            BridgeErrorCode::InvalidJson,
83            "request is not valid UTF-8",
84        )),
85    }
86}
87
88/// Release a response produced by the bridge calls. NULL is a no-op.
89///
90/// # Safety
91/// `response` must be NULL or a pointer previously returned by
92/// [`ballistics_bridge_call`] / [`ballistics_bridge_call_n`] that has not already
93/// been freed.
94#[no_mangle]
95pub unsafe extern "C" fn ballistics_bridge_free(response: *mut c_char) {
96    if !response.is_null() {
97        drop(CString::from_raw(response));
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::ffi::CString;
105
106    fn roundtrip(request: &str) -> serde_json::Value {
107        let c_request = CString::new(request).unwrap();
108        let raw = unsafe { ballistics_bridge_call(c_request.as_ptr()) };
109        assert!(!raw.is_null(), "bridge must never return NULL");
110        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
111        unsafe { ballistics_bridge_free(raw) };
112        serde_json::from_str(&text).expect("bridge output is JSON")
113    }
114
115    #[test]
116    fn c_abi_smoke_meta_version() {
117        let out = roundtrip(r#"{"api_version":1,"command":"meta.version"}"#);
118        assert_eq!(out["ok"], true);
119        assert_eq!(out["result"]["engine_version"], env!("CARGO_PKG_VERSION"));
120    }
121
122    #[test]
123    fn null_request_is_an_envelope() {
124        let raw = unsafe { ballistics_bridge_call(std::ptr::null()) };
125        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
126        unsafe { ballistics_bridge_free(raw) };
127        let out: serde_json::Value = serde_json::from_str(&text).unwrap();
128        assert_eq!(out["ok"], false);
129        assert_eq!(out["error"]["code"], "invalid_json");
130    }
131
132    #[test]
133    fn length_variant_handles_non_terminated_buffers() {
134        let payload = br#"{"api_version":1,"command":"meta.version"}"#;
135        let raw = unsafe { ballistics_bridge_call_n(payload.as_ptr(), payload.len()) };
136        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
137        unsafe { ballistics_bridge_free(raw) };
138        let out: serde_json::Value = serde_json::from_str(&text).unwrap();
139        assert_eq!(out["ok"], true);
140    }
141
142    #[test]
143    fn invalid_utf8_is_an_envelope() {
144        let payload = [0xFFu8, 0xFE, 0x00];
145        let raw = unsafe { ballistics_bridge_call_n(payload.as_ptr(), payload.len()) };
146        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
147        unsafe { ballistics_bridge_free(raw) };
148        let out: serde_json::Value = serde_json::from_str(&text).unwrap();
149        assert_eq!(out["error"]["code"], "invalid_json");
150    }
151
152    #[test]
153    fn free_null_is_a_no_op() {
154        unsafe { ballistics_bridge_free(std::ptr::null_mut()) };
155    }
156}