Skip to main content

ffi_bridge/
errors.rs

1//! # errors — Error taxonomy and FFI result type
2//!
3//! Provides [`FfiErrorCode`] (C-ABI enum), [`FfiError`] (rich Rust error),
4//! [`FfiResult`] (C-ABI result wrapper), and the
5//! [`catch_panic`] utility that prevents panics from crossing the FFI boundary.
6//!
7//! ## Key principle
8//!
9//! **Panics must never cross the FFI boundary.** Undefined behaviour results if
10//! a Rust panic unwinds into C or Go. Every `extern "C"` function in this crate
11//! wraps its body in [`catch_panic`].
12
13use crate::memory::{FfiBuffer, FfiString};
14
15// ─── FfiErrorCode ─────────────────────────────────────────────────────────────
16
17/// Numeric error codes that cross the FFI boundary.
18///
19/// Matches the `FfiErrorCode` enum in `shared/ffi.h`.
20/// All values are stable — codes are never renumbered or removed.
21#[repr(i32)]
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FfiErrorCode {
24    Ok = 0,
25    NullPointer = 1,
26    BufferTooSmall = 2,
27    InvalidUtf8 = 3,
28    Serialization = 4,
29    Panic = 5,
30    Timeout = 6,
31    NotFound = 7,
32    LockPoisoned = 8,
33    Unknown = 99,
34}
35
36// ─── FfiError ─────────────────────────────────────────────────────────────────
37
38/// Rich Rust-side error type that can be converted to an [`FfiErrorCode`] + message.
39#[derive(Debug)]
40pub enum FfiError {
41    NullPointer,
42    BufferTooSmall { needed: usize, available: usize },
43    InvalidUtf8(String),
44    Serialization(String),
45    Panic(String),
46    Timeout,
47    NotFound(String),
48    LockPoisoned,
49    Unknown(String),
50}
51
52impl FfiError {
53    /// Map to the corresponding [`FfiErrorCode`].
54    pub fn code(&self) -> FfiErrorCode {
55        match self {
56            Self::NullPointer => FfiErrorCode::NullPointer,
57            Self::BufferTooSmall { .. } => FfiErrorCode::BufferTooSmall,
58            Self::InvalidUtf8(_) => FfiErrorCode::InvalidUtf8,
59            Self::Serialization(_) => FfiErrorCode::Serialization,
60            Self::Panic(_) => FfiErrorCode::Panic,
61            Self::Timeout => FfiErrorCode::Timeout,
62            Self::NotFound(_) => FfiErrorCode::NotFound,
63            Self::LockPoisoned => FfiErrorCode::LockPoisoned,
64            Self::Unknown(_) => FfiErrorCode::Unknown,
65        }
66    }
67
68    /// Build a human-readable error message.
69    pub fn message(&self) -> String {
70        match self {
71            Self::NullPointer => "null pointer received".into(),
72            Self::BufferTooSmall { needed, available } => {
73                format!("buffer too small: need {needed} bytes, have {available}")
74            }
75            Self::InvalidUtf8(ctx) => format!("invalid UTF-8 in {ctx}"),
76            Self::Serialization(detail) => format!("serialization error: {detail}"),
77            Self::Panic(msg) => format!("panic caught at FFI boundary: {msg}"),
78            Self::Timeout => "operation timed out".into(),
79            Self::NotFound(name) => format!("not found: {name}"),
80            Self::LockPoisoned => "mutex lock is poisoned".into(),
81            Self::Unknown(detail) => format!("unknown error: {detail}"),
82        }
83    }
84}
85
86impl std::fmt::Display for FfiError {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "{}", self.message())
89    }
90}
91
92impl std::error::Error for FfiError {}
93
94// ─── FfiResult ────────────────────────────────────────────────────────────────
95
96/// C-ABI result type.
97///
98/// On success: `error_code == FfiErrorCode::Ok`, `error_message` is empty,
99/// `payload` contains the result bytes.
100///
101/// On error: `error_code != 0`, `error_message` carries a UTF-8 string,
102/// `payload` is zeroed.
103///
104/// **Always** call [`ffi_result_free`] when you are done with the result,
105/// regardless of `error_code`.
106#[repr(C)]
107pub struct FfiResult {
108    pub error_code: FfiErrorCode,
109    pub error_message: FfiString,
110    pub payload: FfiBuffer,
111}
112
113impl FfiResult {
114    /// Construct a successful result carrying `payload`.
115    pub fn ok(payload: FfiBuffer) -> Self {
116        FfiResult {
117            error_code: FfiErrorCode::Ok,
118            error_message: FfiString::null(),
119            payload,
120        }
121    }
122
123    /// Construct an error result.
124    pub fn err(error: FfiError) -> Self {
125        let msg = FfiString::new(&error.message());
126        FfiResult {
127            error_code: error.code(),
128            error_message: msg,
129            payload: FfiBuffer::null(),
130        }
131    }
132
133    /// Returns `true` if this result represents success.
134    #[inline]
135    pub fn is_ok(&self) -> bool {
136        self.error_code == FfiErrorCode::Ok
137    }
138}
139
140/// Free an [`FfiResult`] and all heap memory it owns.
141///
142/// **Must** be called exactly once per `FfiResult` received from this crate.
143///
144/// If you take ownership of `payload` before this call, zero `result.payload`
145/// first to prevent a double-free.
146///
147/// **Exported as:** `ffi_result_free`
148#[no_mangle]
149pub extern "C" fn ffi_result_free(result: FfiResult) {
150    // SAFETY: caller guarantees single-ownership.
151    unsafe {
152        result.error_message.dealloc();
153        result.payload.dealloc();
154    }
155}
156
157// ─── catch_panic ──────────────────────────────────────────────────────────────
158
159/// Run `f` and convert any Rust panic into an [`FfiResult`] error.
160///
161/// This is the **critical safety wrapper** used by every `extern "C"` function
162/// in this crate. A panic unwind crossing the FFI boundary is undefined behaviour;
163/// `catch_panic` ensures it never happens.
164///
165/// # Example
166///
167/// ```rust,ignore
168/// #[no_mangle]
169/// pub extern "C" fn my_ffi_fn(buf: FfiBuffer) -> FfiResult {
170///     catch_panic(|| {
171///         // ... do work, return Result<FfiBuffer, FfiError>
172///         Ok(buf)
173///     })
174/// }
175/// ```
176pub fn catch_panic<F>(f: F) -> FfiResult
177where
178    F: FnOnce() -> Result<FfiBuffer, FfiError> + std::panic::UnwindSafe,
179{
180    match std::panic::catch_unwind(f) {
181        Ok(Ok(buf)) => FfiResult::ok(buf),
182        Ok(Err(e)) => FfiResult::err(e),
183        Err(panic_payload) => {
184            let msg = panic_payload
185                .downcast_ref::<&str>()
186                .copied()
187                .or_else(|| panic_payload.downcast_ref::<String>().map(|s| s.as_str()))
188                .unwrap_or("unknown panic payload");
189            FfiResult::err(FfiError::Panic(msg.to_string()))
190        }
191    }
192}
193
194// ─── Tests ────────────────────────────────────────────────────────────────────
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn ffi_result_ok_is_ok() {
202        let buf = FfiBuffer::new(8);
203        let result = FfiResult::ok(buf);
204        assert!(result.is_ok());
205        assert_eq!(result.error_code, FfiErrorCode::Ok);
206        ffi_result_free(result);
207    }
208
209    #[test]
210    fn ffi_result_err_carries_code_and_message() {
211        let result = FfiResult::err(FfiError::NullPointer);
212        assert!(!result.is_ok());
213        assert_eq!(result.error_code, FfiErrorCode::NullPointer);
214        let msg = unsafe { result.error_message.as_str() };
215        assert!(msg.contains("null pointer"));
216        ffi_result_free(result);
217    }
218
219    #[test]
220    fn catch_panic_ok_path() {
221        let result = catch_panic(|| Ok(FfiBuffer::new(4)));
222        assert!(result.is_ok());
223        ffi_result_free(result);
224    }
225
226    #[test]
227    fn catch_panic_err_path() {
228        let result = catch_panic(|| Err::<FfiBuffer, _>(FfiError::Timeout));
229        assert_eq!(result.error_code, FfiErrorCode::Timeout);
230        ffi_result_free(result);
231    }
232
233    #[test]
234    fn catch_panic_catches_panic() {
235        let result = catch_panic(|| {
236            panic!("intentional test panic");
237        });
238        assert_eq!(result.error_code, FfiErrorCode::Panic);
239        let msg = unsafe { result.error_message.as_str() };
240        assert!(msg.contains("intentional test panic"));
241        ffi_result_free(result);
242    }
243
244    #[test]
245    fn ffi_error_buffer_too_small_message() {
246        let e = FfiError::BufferTooSmall {
247            needed: 100,
248            available: 10,
249        };
250        assert!(e.message().contains("100"));
251        assert!(e.message().contains("10"));
252    }
253}