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//!
14//! Android callers want `ballistics_bridge_call_n` over a `ByteArray`, not
15//! `ballistics_bridge_call` over a `jstring`: JNI's string conversions speak modified UTF-8,
16//! which `call_with_bytes` below refuses. `docs/ANDROID_JNI_BRIDGE.md` has the worked example
17//! and the reasoning; `tests::a_request_in_jni_modified_utf8_is_refused` pins the behaviour it
18//! argues from.
19
20use std::ffi::{c_char, CStr, CString};
21
22use super::{bridge_call, BridgeErrorCode, BRIDGE_API_VERSION};
23
24fn respond(json: String) -> *mut c_char {
25    // A JSON string can legally contain no interior NULs (serde_json escapes
26    // control characters), so this only fails on memory exhaustion-class bugs;
27    // fall back to a static-shaped minimal envelope built from a clean literal.
28    CString::new(json)
29        .unwrap_or_else(|_| {
30            CString::new(format!(
31                r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"error":{{"code":"internal_error","message":"response contained an interior NUL"}}}}"#
32            ))
33            .expect("fallback envelope contains no NUL")
34        })
35        .into_raw()
36}
37
38fn error_envelope(code: BridgeErrorCode, message: &str) -> String {
39    // Reuse the bridge's own serializer by round-tripping through it would drag
40    // private helpers into the ABI layer; a literal is simpler and stable.
41    let code = serde_json::to_string(&code).unwrap_or_else(|_| "\"internal_error\"".into());
42    format!(
43        r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"error":{{"code":{code},"message":"{message}"}}}}"#
44    )
45}
46
47/// Process one bridge request (NUL-terminated UTF-8 JSON envelope).
48///
49/// # Safety
50/// `request_json` must be NULL or a valid NUL-terminated C string. The returned
51/// pointer must be freed with [`ballistics_bridge_free`] exactly once.
52#[no_mangle]
53pub unsafe extern "C" fn ballistics_bridge_call(request_json: *const c_char) -> *mut c_char {
54    if request_json.is_null() {
55        return respond(error_envelope(
56            BridgeErrorCode::InvalidJson,
57            "request pointer is NULL",
58        ));
59    }
60    let bytes = CStr::from_ptr(request_json).to_bytes();
61    call_with_bytes(bytes)
62}
63
64/// Length-explicit variant for callers whose buffers are not NUL-terminated.
65///
66/// # Safety
67/// `request` must be NULL or point to at least `len` readable bytes. The returned
68/// pointer must be freed with [`ballistics_bridge_free`] exactly once.
69#[no_mangle]
70pub unsafe extern "C" fn ballistics_bridge_call_n(request: *const u8, len: usize) -> *mut c_char {
71    if request.is_null() {
72        return respond(error_envelope(
73            BridgeErrorCode::InvalidJson,
74            "request pointer is NULL",
75        ));
76    }
77    let bytes = std::slice::from_raw_parts(request, len);
78    call_with_bytes(bytes)
79}
80
81fn call_with_bytes(bytes: &[u8]) -> *mut c_char {
82    match std::str::from_utf8(bytes) {
83        Ok(text) => respond(bridge_call(text)),
84        Err(_) => respond(error_envelope(
85            BridgeErrorCode::InvalidJson,
86            "request is not valid UTF-8",
87        )),
88    }
89}
90
91/// Release a response produced by the bridge calls. NULL is a no-op.
92///
93/// # Safety
94/// `response` must be NULL or a pointer previously returned by
95/// [`ballistics_bridge_call`] / [`ballistics_bridge_call_n`] that has not already
96/// been freed.
97#[no_mangle]
98pub unsafe extern "C" fn ballistics_bridge_free(response: *mut c_char) {
99    if !response.is_null() {
100        drop(CString::from_raw(response));
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::ffi::CString;
108
109    fn roundtrip(request: &str) -> serde_json::Value {
110        let c_request = CString::new(request).unwrap();
111        let raw = unsafe { ballistics_bridge_call(c_request.as_ptr()) };
112        assert!(!raw.is_null(), "bridge must never return NULL");
113        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
114        unsafe { ballistics_bridge_free(raw) };
115        serde_json::from_str(&text).expect("bridge output is JSON")
116    }
117
118    #[test]
119    fn c_abi_smoke_meta_version() {
120        let out = roundtrip(r#"{"api_version":1,"command":"meta.version"}"#);
121        assert_eq!(out["ok"], true);
122        assert_eq!(out["result"]["engine_version"], env!("CARGO_PKG_VERSION"));
123    }
124
125    #[test]
126    fn null_request_is_an_envelope() {
127        let raw = unsafe { ballistics_bridge_call(std::ptr::null()) };
128        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
129        unsafe { ballistics_bridge_free(raw) };
130        let out: serde_json::Value = serde_json::from_str(&text).unwrap();
131        assert_eq!(out["ok"], false);
132        assert_eq!(out["error"]["code"], "invalid_json");
133    }
134
135    #[test]
136    fn length_variant_handles_non_terminated_buffers() {
137        let payload = br#"{"api_version":1,"command":"meta.version"}"#;
138        let raw = unsafe { ballistics_bridge_call_n(payload.as_ptr(), payload.len()) };
139        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
140        unsafe { ballistics_bridge_free(raw) };
141        let out: serde_json::Value = serde_json::from_str(&text).unwrap();
142        assert_eq!(out["ok"], true);
143    }
144
145    #[test]
146    fn invalid_utf8_is_an_envelope() {
147        let payload = [0xFFu8, 0xFE, 0x00];
148        let raw = unsafe { ballistics_bridge_call_n(payload.as_ptr(), payload.len()) };
149        let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
150        unsafe { ballistics_bridge_free(raw) };
151        let out: serde_json::Value = serde_json::from_str(&text).unwrap();
152        assert_eq!(out["error"]["code"], "invalid_json");
153    }
154
155    #[test]
156    fn free_null_is_a_no_op() {
157        unsafe { ballistics_bridge_free(std::ptr::null_mut()) };
158    }
159
160    /// Encode `text` the way JNI's `GetStringUTFChars` and `GetStringUTFRegion` do: MODIFIED
161    /// UTF-8, which is UTF-8 applied to UTF-16 code units rather than to scalar values. A
162    /// supplementary character therefore comes out as its two surrogates, each in the 3-byte
163    /// form (six bytes, where UTF-8 uses four), and U+0000 comes out as `C0 80`.
164    fn modified_utf8(text: &str) -> Vec<u8> {
165        let mut out = Vec::new();
166        for unit in text.encode_utf16() {
167            match unit {
168                0 => out.extend_from_slice(&[0xC0, 0x80]),
169                0x0001..=0x007F => out.push(unit as u8),
170                0x0080..=0x07FF => {
171                    out.push(0xC0 | (unit >> 6) as u8);
172                    out.push(0x80 | (unit & 0x3F) as u8);
173                }
174                _ => {
175                    out.push(0xE0 | (unit >> 12) as u8);
176                    out.push(0x80 | ((unit >> 6) & 0x3F) as u8);
177                    out.push(0x80 | (unit & 0x3F) as u8);
178                }
179            }
180        }
181        out
182    }
183
184    /// MBA-1543: the bridge refuses JNI's modified UTF-8, which is what makes
185    /// `ballistics_bridge_call_n` over a `ByteArray` the Android entry point rather than a
186    /// stylistic preference. `docs/ANDROID_JNI_BRIDGE.md` argues from exactly this behaviour,
187    /// so it is driven here rather than asserted there.
188    ///
189    /// The discriminator is the error CODE, not the fact of an error: the command in this
190    /// request does not exist either way, so getting as far as `unknown_command` proves the
191    /// bytes were decoded, and `invalid_json` proves they were not.
192    #[test]
193    fn a_request_in_jni_modified_utf8_is_refused() {
194        // U+1F3AF is supplementary, which is where the two encodings diverge.
195        let request = r#"{"api_version":1,"command":"no.such.command.🎯"}"#;
196        let standard = request.as_bytes();
197        let modified = modified_utf8(request);
198        assert_ne!(
199            modified.as_slice(),
200            standard,
201            "the fixture must actually exercise the divergence"
202        );
203
204        let decoded = |bytes: &[u8]| {
205            let raw = unsafe { ballistics_bridge_call_n(bytes.as_ptr(), bytes.len()) };
206            let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
207            unsafe { ballistics_bridge_free(raw) };
208            serde_json::from_str::<serde_json::Value>(&text).unwrap()
209        };
210
211        let out = decoded(standard);
212        assert_eq!(out["error"]["code"], "unknown_command", "{out}");
213
214        let out = decoded(&modified);
215        assert_eq!(out["error"]["code"], "invalid_json", "{out}");
216        assert_eq!(
217            out["error"]["message"], "request is not valid UTF-8",
218            "{out}"
219        );
220    }
221
222    /// The scope of that refusal, so the docs do not overstate it: the two encodings agree on
223    /// everything from U+0001 to U+FFFF, so ordinary accented text goes through JNI's own
224    /// conversion unharmed and the defect hides until someone types outside the BMP.
225    #[test]
226    fn modified_utf8_and_utf8_agree_below_the_supplementary_planes() {
227        for text in ["ascii", "München", "Ω", "中文", "\u{FFFF}"] {
228            assert_eq!(
229                modified_utf8(text),
230                text.as_bytes(),
231                "{text} must encode identically in both"
232            );
233        }
234        for text in ["🎯", "\u{10000}", "\u{10FFFF}"] {
235            assert_ne!(modified_utf8(text), text.as_bytes(), "{text} must diverge");
236        }
237        // And the other member of the divergence: an interior NUL, which modified UTF-8 exists
238        // to keep out of the byte stream in the first place.
239        assert_eq!(modified_utf8("\0"), vec![0xC0, 0x80]);
240    }
241}