Skip to main content

unparser_shared/
ffi.rs

1//! Thread-local last-error storage and the panic guard, for a C ABI that returns
2//! sentinels rather than error values.
3//!
4//! # The shape this supports
5//!
6//! A C entry point cannot return a Rust `Result`, so the family's libraries return null
7//! or a sentinel and record *why* separately, to be read by `<lib>_last_error()` and
8//! `<lib>_last_error_kind()`. Three things have to hold for that to be trustworthy:
9//!
10//! - **Message and kind move together.** A message paired with a stale kind is worse than
11//!   no kind at all, because it looks authoritative. [`LastErrorSlot::set`] writes both.
12//! - **Nothing unwinds across the boundary.** Unwinding out of `extern "C"` is undefined
13//!   behaviour, so every entry point wraps its body — see [`catch`].
14//! - **Classification survives to the boundary.** Rendering an error to a string early
15//!   discards the reason. Closures therefore carry [`FfiError`], not `String`.
16//!
17//! # Why the slot is yours, not ours
18//!
19//! [`LastErrorSlot`] is a type you place in your own `thread_local!`, rather than storage
20//! this crate owns. That is deliberate and load-bearing.
21//!
22//! Each library ships as its own cdylib, so today each has its own statics. But this crate
23//! is linked into all of them, and a Rust binary depending on two of them would share one
24//! copy — at which point a single shared static would make `unpdf_last_error()` return
25//! whatever the preceding `undoc` call recorded. Isolation cannot rest on how consumers
26//! happen to link; declaring the slot in the consuming crate makes it structural.
27//!
28//! ```
29//! use unparser_shared::ffi::LastErrorSlot;
30//!
31//! thread_local! {
32//!     static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() };
33//! }
34//!
35//! unparser_shared::export_last_error_abi!(LAST_ERROR, demo_last_error, demo_last_error_kind);
36//! ```
37
38use std::cell::{Cell, RefCell};
39use std::ffi::{c_char, c_int, CStr, CString};
40use std::panic::{catch_unwind, UnwindSafe};
41use std::ptr;
42
43use crate::kind;
44
45/// A failure on its way out of an entry point: why it failed, and what to say about it.
46///
47/// Carried instead of a rendered string so the classification reaches the boundary. The
48/// message is a `String` because it is about to be copied into a `CString` anyway.
49pub type FfiError = (c_int, String);
50
51/// The last failure recorded on this thread, and its classification.
52///
53/// Place one in a `thread_local!` in your own crate — see the [module docs](self) for why
54/// this crate does not own it.
55#[derive(Debug, Default)]
56pub struct LastErrorSlot {
57    message: RefCell<Option<CString>>,
58    kind: Cell<c_int>,
59}
60
61impl LastErrorSlot {
62    /// An empty slot: no message, kind [`kind::NONE`].
63    ///
64    /// `const` so it can be used in `thread_local! { static X: _ = const { .. } }`, which
65    /// avoids the lazy-initialisation check on every access.
66    pub const fn new() -> Self {
67        Self {
68            message: RefCell::new(None),
69            kind: Cell::new(kind::NONE),
70        }
71    }
72
73    /// Record a failure, replacing whatever was there.
74    ///
75    /// A message holding an interior NUL cannot become a C string; the kind is still
76    /// recorded and the message is dropped, so the caller gets a classification and a null
77    /// message rather than a classification paired with the *previous* message.
78    pub fn set(&self, kind: c_int, message: &str) {
79        // Written first: if the conversion fails, the stale message must already be gone.
80        *self.message.borrow_mut() = CString::new(message).ok();
81        self.kind.set(kind);
82    }
83
84    /// Record a failure from an [`FfiError`].
85    pub fn set_error(&self, error: &FfiError) {
86        self.set(error.0, &error.1);
87    }
88
89    /// Forget any recorded failure. Call on entry to an operation that succeeded, so a
90    /// caller polling the slot does not see an old failure attributed to a new call.
91    pub fn clear(&self) {
92        *self.message.borrow_mut() = None;
93        self.kind.set(kind::NONE);
94    }
95
96    /// The recorded message as a C string, or null when none is recorded.
97    ///
98    /// # Safety of the returned pointer
99    ///
100    /// Borrowed from thread-local storage: valid until the next call that writes this slot
101    /// on the same thread. Callers across the ABI must copy it, not retain it. The
102    /// pointer is produced inside the borrow and no reference escapes, so the value cannot
103    /// be freed while a caller is still inside the same call.
104    pub fn message_ptr(&self) -> *const c_char {
105        self.message
106            .borrow()
107            .as_ref()
108            .map(|message| message.as_ptr())
109            .unwrap_or(ptr::null())
110    }
111
112    /// The recorded classification, or [`kind::NONE`] when the last call succeeded.
113    pub fn kind(&self) -> c_int {
114        self.kind.get()
115    }
116}
117
118/// Run `body`, turning a panic into [`kind::PANIC`] instead of unwinding.
119///
120/// Use at every `extern "C"` entry point. A panic is a bug in the library rather than a
121/// classified failure of the caller's input, so it is reported as such rather than being
122/// dressed up as a document problem.
123///
124/// The panic payload is not forwarded: a panic message is an implementation detail and
125/// often not `'static`. What the caller needs is that it happened.
126///
127/// ```
128/// # use unparser_shared::{ffi, kind};
129/// let ok: Result<i32, ffi::FfiError> = ffi::catch(|| Ok(7));
130/// assert_eq!(ok.unwrap(), 7);
131///
132/// let boom: Result<i32, ffi::FfiError> = ffi::catch(|| panic!("bug"));
133/// assert_eq!(boom.unwrap_err().0, kind::PANIC);
134/// ```
135pub fn catch<T, F>(body: F) -> Result<T, FfiError>
136where
137    F: FnOnce() -> Result<T, FfiError> + UnwindSafe,
138{
139    match catch_unwind(body) {
140        Ok(result) => result,
141        Err(_) => Err((
142            kind::PANIC,
143            "a panic was caught at the ABI boundary".to_string(),
144        )),
145    }
146}
147
148/// A null or non-UTF-8 argument.
149///
150/// ```
151/// # use unparser_shared::{ffi, kind};
152/// assert_eq!(ffi::invalid_argument("path was null").0, kind::INVALID_ARGUMENT);
153/// ```
154pub fn invalid_argument(message: impl Into<String>) -> FfiError {
155    (kind::INVALID_ARGUMENT, message.into())
156}
157
158/// A result that cannot cross the ABI because it holds an interior NUL byte.
159///
160/// The wording is fixed so the family reports this identically; consumers have been seen
161/// matching on it before `*_last_error_kind` existed.
162pub fn invalid_output() -> FfiError {
163    (
164        kind::INVALID_OUTPUT,
165        "output contains null byte".to_string(),
166    )
167}
168
169/// Read a non-null C string argument as `&str`, classifying a non-UTF-8 one.
170///
171/// Separate from [`with_c_str!`](crate::with_c_str) because an entry point with an
172/// out-parameter has to reject a null pointer *before* it starts producing a result — its
173/// failure path writes through that pointer — so it does its own null check and needs only
174/// the conversion. Everywhere else, [`with_c_str!`](crate::with_c_str) does both.
175///
176/// # Safety
177///
178/// `ptr` must be non-null and point to a NUL-terminated string that stays valid for `'a`.
179/// The lifetime is unbounded, as with [`std::ffi::CStr::from_ptr`]: nothing here can check
180/// how long the caller's buffer lives.
181///
182/// ```
183/// # use std::ffi::CString;
184/// let path = CString::new("document.pdf").unwrap();
185/// let read = unsafe { unparser_shared::ffi::c_str_utf8(path.as_ptr()) };
186/// assert_eq!(read.unwrap(), "document.pdf");
187/// ```
188pub unsafe fn c_str_utf8<'a>(ptr: *const c_char) -> Result<&'a str, FfiError> {
189    unsafe { CStr::from_ptr(ptr) }
190        .to_str()
191        .map_err(|error| invalid_argument(error.to_string()))
192}
193
194/// Declare `<name>_last_error` and `<name>_last_error_kind` over a [`LastErrorSlot`].
195///
196/// The function names are passed in full rather than derived from a prefix, because
197/// `macro_rules!` cannot concatenate identifiers and an exported symbol is not worth a
198/// proc-macro dependency. Spelling them out also means the symbol appears literally in
199/// the source, which is what someone greps for.
200///
201/// ```
202/// use unparser_shared::ffi::LastErrorSlot;
203///
204/// thread_local! {
205///     static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() };
206/// }
207///
208/// unparser_shared::export_last_error_abi!(LAST_ERROR, mylib_last_error, mylib_last_error_kind);
209/// ```
210#[macro_export]
211macro_rules! export_last_error_abi {
212    ($slot:path, $message_fn:ident, $kind_fn:ident) => {
213        /// The message for the last failure on this thread, or null if none.
214        ///
215        /// # Safety
216        ///
217        /// The pointer is borrowed from thread-local storage and is valid until the next
218        /// call into this library on the same thread. Copy it; do not retain it.
219        #[no_mangle]
220        pub extern "C" fn $message_fn() -> *const ::std::ffi::c_char {
221            $slot.with(|slot| $crate::ffi::LastErrorSlot::message_ptr(slot))
222        }
223
224        /// Why the last call on this thread failed, or `0` if it succeeded.
225        ///
226        /// A new reason takes a new number and existing numbers are never reused, so an
227        /// unrecognised value should be treated as a generic failure rather than an error.
228        #[no_mangle]
229        pub extern "C" fn $kind_fn() -> ::std::ffi::c_int {
230            $slot.with(|slot| $crate::ffi::LastErrorSlot::kind(slot))
231        }
232    };
233}
234
235/// Assert that an error-kind enum's discriminants are what they were.
236///
237/// The discriminants are a public ABI contract, so a reordering that a compiler is happy
238/// with is a breaking change for every consumer. This turns that into a test failure.
239///
240/// ```
241/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
242/// #[repr(i32)]
243/// pub enum ErrorKind {
244///     Other = 1,
245///     Io = 2,
246/// }
247///
248/// unparser_shared::assert_stable_kinds! {
249///     ErrorKind, discriminants_are_stable,
250///     Other = 1,
251///     Io = 2,
252/// }
253/// ```
254#[macro_export]
255macro_rules! assert_stable_kinds {
256    ($enum:ty, $test_name:ident, $($variant:ident = $value:expr),+ $(,)?) => {
257        #[test]
258        fn $test_name() {
259            $(
260                assert_eq!(
261                    <$enum>::$variant as ::std::ffi::c_int,
262                    $value as ::std::ffi::c_int,
263                    concat!(
264                        stringify!($variant),
265                        " changed value. Discriminants are a public ABI contract: a new \
266                         reason takes a new number, existing ones are never renumbered."
267                    )
268                );
269            )+
270        }
271    };
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::ffi::CStr;
278
279    thread_local! {
280        static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() };
281    }
282
283    export_last_error_abi!(LAST_ERROR, test_last_error, test_last_error_kind);
284
285    fn recorded_message() -> Option<String> {
286        let ptr = test_last_error();
287        if ptr.is_null() {
288            None
289        } else {
290            // Safety: the pointer came from the slot and is not retained past this call.
291            Some(
292                unsafe { CStr::from_ptr(ptr) }
293                    .to_string_lossy()
294                    .into_owned(),
295            )
296        }
297    }
298
299    #[test]
300    fn an_untouched_slot_reads_as_success() {
301        LAST_ERROR.with(|slot| slot.clear());
302        assert_eq!(test_last_error_kind(), kind::NONE);
303        assert_eq!(recorded_message(), None);
304    }
305
306    #[test]
307    fn setting_and_clearing_moves_message_and_kind_together() {
308        LAST_ERROR.with(|slot| slot.set(kind::IO, "file not found"));
309        assert_eq!(test_last_error_kind(), kind::IO);
310        assert_eq!(recorded_message().as_deref(), Some("file not found"));
311
312        LAST_ERROR.with(|slot| slot.clear());
313        assert_eq!(test_last_error_kind(), kind::NONE);
314        assert_eq!(recorded_message(), None);
315    }
316
317    /// The failure mode this ordering exists for: a message that cannot become a C string
318    /// must not leave the *previous* message standing next to the new kind, which would
319    /// read as an authoritative explanation of the wrong failure.
320    #[test]
321    fn a_message_holding_a_nul_does_not_leave_the_previous_one_behind() {
322        LAST_ERROR.with(|slot| slot.set(kind::IO, "first failure"));
323        LAST_ERROR.with(|slot| slot.set(kind::PANIC, "second\0failure"));
324
325        assert_eq!(test_last_error_kind(), kind::PANIC);
326        assert_eq!(recorded_message(), None, "the stale message must be gone");
327    }
328
329    #[test]
330    fn set_error_records_both_halves() {
331        LAST_ERROR.with(|slot| slot.set_error(&invalid_output()));
332        assert_eq!(test_last_error_kind(), kind::INVALID_OUTPUT);
333        assert_eq!(
334            recorded_message().as_deref(),
335            Some("output contains null byte")
336        );
337        LAST_ERROR.with(|slot| slot.clear());
338    }
339
340    /// Isolation is the reason the slot lives in the consumer. Asserted so that a future
341    /// refactor moving storage into this crate fails here rather than in production.
342    #[test]
343    fn a_slot_is_per_thread() {
344        LAST_ERROR.with(|slot| slot.set(kind::IO, "main thread"));
345
346        std::thread::spawn(|| {
347            assert_eq!(
348                test_last_error_kind(),
349                kind::NONE,
350                "another thread must start clean"
351            );
352        })
353        .join()
354        .expect("thread should not panic");
355
356        assert_eq!(
357            test_last_error_kind(),
358            kind::IO,
359            "this thread keeps its own"
360        );
361        LAST_ERROR.with(|slot| slot.clear());
362    }
363
364    #[test]
365    fn catch_passes_success_and_classified_failure_through() {
366        assert_eq!(catch(|| Ok(41 + 1)).unwrap(), 42);
367
368        let err = catch::<(), _>(|| Err(invalid_argument("path was null"))).unwrap_err();
369        assert_eq!(err.0, kind::INVALID_ARGUMENT);
370        assert_eq!(err.1, "path was null");
371    }
372
373    #[test]
374    fn catch_turns_a_panic_into_a_boundary_reason() {
375        let previous = std::panic::take_hook();
376        std::panic::set_hook(Box::new(|_| {}));
377        let err = catch::<(), _>(|| panic!("deliberate")).unwrap_err();
378        std::panic::set_hook(previous);
379
380        assert_eq!(err.0, kind::PANIC);
381        assert!(kind::is_boundary(err.0));
382        assert!(
383            !err.1.contains("deliberate"),
384            "the panic payload is an implementation detail: {}",
385            err.1
386        );
387    }
388}