Skip to main content

pamoja_ffi/
lib.rs

1//! The curated C ABI surface for the pamoja SDK.
2//!
3//! This crate exposes a small, hand-written `extern "C"` API over
4//! [`pamoja_core`] and the capability crates so that languages without a native
5//! Rust bridge - C, C++, and C#/.NET through P/Invoke - can drive the SDK. It is
6//! deliberately the project's single auditable `unsafe` boundary: every raw
7//! pointer is dereferenced here and nowhere else.
8//!
9//! The committed header `include/pamoja.h` is generated from this source by
10//! `cbindgen` (see `build.rs`) and is drift-checked in CI, so the C contract can
11//! never fall behind the Rust surface.
12//!
13//! # Conventions
14//!
15//! - Fallible calls return a [`PamojaStatus`] code. On any non-`Ok` result a
16//!   human-readable message is stored for the calling thread and can be read with
17//!   [`pamoja_last_error_message`].
18//! - Handles are opaque, heap-allocated, and owned by the caller, who must release
19//!   each with its matching `*_free` function.
20//! - All strings crossing the boundary are UTF-8. Inputs are borrowed for the
21//!   duration of the call; returned pointers document their own lifetime.
22//! - An enum the caller passes in, as an argument or as a field of a struct, must hold
23//!   one of the values the enum declares. The library reads it as one of those values,
24//!   and any other value is undefined behavior; the .NET binding refuses one before the
25//!   call.
26//!
27//! # Choosing what the library carries
28//!
29//! Every capability is a cargo feature, all on by default, so a host that builds
30//! this crate itself gets the same "compile only what you use" property a Rust
31//! consumer has, and it is worth using. The seven capabilities that need an async
32//! runtime (`mqtt`, `coap`, `loopback`, `sync`, `ladder`, `bus`, and `sim`) carry
33//! Tokio and the network stacks with them, and are about half the compiled
34//! library on their own: dropping just those, keeping every other capability,
35//! took a release build from 2.07 MB to 1.01 MB. A host that only decodes
36//! protocol bytes can go much further, to 0.41 MB:
37//!
38//! ```sh
39//! cargo build --release -p pamoja-ffi --no-default-features \
40//!   --features "modbus,can,serial,gpio,sensors,codec,security"
41//! ```
42//!
43//! Those figures are one `x86_64-pc-windows-msvc` build and will differ per
44//! platform; `cargo xtask builds` reports the sizes on the machine at hand. The
45//! Node, Python, and .NET packages ship the full default build regardless,
46//! because their package managers cannot express the choice per consumer.
47
48// This crate is the FFI boundary, so raw-pointer work is its entire purpose; the
49// workspace `unsafe_code = "warn"` lint is therefore allowed here. Safety is kept
50// reviewable by confining every `unsafe` block to this crate.
51#![allow(unsafe_code)]
52
53use std::cell::RefCell;
54use std::ffi::{c_char, CString};
55use std::ptr;
56use std::sync::OnceLock;
57
58use pamoja_core::Error;
59
60// The capability modules are public so every item the C ABI exports, including
61// the buffer-size constants a caller sizes an array from, stays reachable from
62// the crate root. A constant referenced only from the generated header reads as
63// dead code otherwise.
64#[cfg(feature = "actuators")]
65pub mod actuators;
66#[cfg(feature = "actuators")]
67pub mod actuators_driver;
68#[cfg(feature = "audit")]
69pub mod audit;
70#[cfg(feature = "bus")]
71pub mod bus;
72#[cfg(feature = "can")]
73pub mod can;
74#[cfg(feature = "can")]
75pub mod can_bus;
76#[cfg(feature = "coap")]
77pub mod coap;
78#[cfg(feature = "codec")]
79pub mod codec;
80#[cfg(feature = "gateway")]
81pub mod gateway;
82#[cfg(feature = "gateway")]
83pub mod gateway_chirpstack;
84#[cfg(feature = "gateway")]
85pub mod gateway_network;
86#[cfg(feature = "gateway")]
87pub mod gateway_station;
88#[cfg(feature = "gpio")]
89pub mod gpio;
90#[cfg(feature = "hal")]
91pub mod hal;
92#[cfg(feature = "runtime")]
93pub mod host;
94#[cfg(feature = "kit")]
95pub mod kit;
96#[cfg(feature = "ladder")]
97pub mod ladder;
98#[cfg(feature = "loopback")]
99pub mod loopback;
100#[cfg(feature = "lora")]
101pub mod lora;
102#[cfg(feature = "radios")]
103pub mod lora_radio;
104#[cfg(feature = "lora")]
105pub mod lora_region;
106#[cfg(feature = "lorawan")]
107pub mod lorawan;
108#[cfg(all(feature = "lora", feature = "lorawan"))]
109pub mod lorawan_device;
110#[cfg(feature = "lorawan")]
111pub mod lorawan_link;
112#[cfg(feature = "lorawan")]
113pub mod lorawan_mac;
114#[cfg(all(feature = "lora", feature = "lorawan"))]
115pub mod lorawan_packages;
116#[cfg(all(feature = "lora", feature = "lorawan"))]
117pub mod lorawan_relay;
118#[cfg(all(feature = "lora", feature = "lorawan"))]
119pub mod lorawan_relay_node;
120#[cfg(feature = "mavlink")]
121pub mod mavlink;
122#[cfg(feature = "mavlink")]
123pub mod mavlink_enums;
124#[cfg(feature = "mavlink")]
125pub mod mavlink_protocol;
126#[cfg(feature = "mavlink")]
127pub mod mavlink_schema;
128#[cfg(feature = "mesh")]
129pub mod mesh;
130#[cfg(feature = "modbus")]
131pub mod modbus;
132#[cfg(feature = "modbus")]
133pub mod modbus_client;
134#[cfg(feature = "kit")]
135pub mod motion;
136#[cfg(feature = "mqtt")]
137pub mod mqtt;
138#[cfg(feature = "hal")]
139pub mod port;
140#[cfg(feature = "power")]
141pub mod power;
142#[cfg(feature = "profile")]
143pub mod profile;
144#[cfg(feature = "radios")]
145pub mod radios;
146#[cfg(feature = "ros2")]
147pub mod ros2;
148#[cfg(feature = "routing")]
149pub mod routing;
150#[cfg(feature = "profile")]
151pub mod rules;
152#[cfg(feature = "security")]
153pub mod security;
154#[cfg(feature = "sensors")]
155pub mod sensors;
156#[cfg(feature = "sensors")]
157pub mod sensors_driver;
158#[cfg(feature = "serial")]
159pub mod serial;
160#[cfg(feature = "session")]
161pub mod session;
162#[cfg(feature = "sim")]
163pub mod sim;
164#[cfg(feature = "sync")]
165pub mod sync;
166#[cfg(feature = "telemetry")]
167pub mod telemetry;
168#[cfg(feature = "runtime")]
169pub mod transport;
170#[cfg(feature = "update")]
171pub mod update;
172#[cfg(feature = "zenoh")]
173pub mod zenoh;
174
175/// The result of a fallible pamoja call.
176///
177/// A return of [`PamojaStatus::Ok`] means success; any other value indicates a
178/// failure whose description is available from [`pamoja_last_error_message`] on
179/// the same thread.
180#[repr(C)]
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub enum PamojaStatus {
183    /// The call succeeded.
184    Ok = 0,
185    /// A transport-level failure while connecting, sending, or receiving.
186    Transport = 1,
187    /// A device or peripheral input/output operation failed.
188    Io = 2,
189    /// A payload could not be encoded or decoded.
190    Codec = 3,
191    /// The operation targeted a resource that is closed or disconnected.
192    Closed = 4,
193    /// The requested capability is not compiled into this build.
194    Unsupported = 5,
195    /// An argument was null or otherwise invalid, for example non-UTF-8 text.
196    InvalidArgument = 6,
197    /// A failure that does not map onto a more specific status.
198    Other = 7,
199    /// A Rust panic was caught at the boundary; the call had no effect.
200    Panic = 8,
201    /// A security check failed, such as an invalid identity or a bad signature.
202    Auth = 9,
203}
204
205impl PamojaStatus {
206    /// Maps a core [`Error`] onto the matching status code.
207    ///
208    /// # Arguments
209    ///
210    /// * `error` - the error returned by a core or capability call.
211    ///
212    /// # Returns
213    ///
214    /// The [`PamojaStatus`] that classifies `error`.
215    pub(crate) fn from_error(error: &Error) -> Self {
216        match error {
217            Error::Transport(_) => Self::Transport,
218            Error::Io(_) => Self::Io,
219            Error::Codec(_) => Self::Codec,
220            Error::Closed => Self::Closed,
221            Error::Auth(_) => Self::Auth,
222            Error::Unsupported(_) => Self::Unsupported,
223            _ => Self::Other,
224        }
225    }
226}
227
228thread_local! {
229    /// The most recent error message produced on this thread.
230    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
231}
232
233/// Records `message` as the calling thread's most recent error.
234///
235/// # Arguments
236///
237/// * `message` - the human-readable description to expose through
238///   [`pamoja_last_error_message`]. Any interior null byte is replaced with a
239///   generic message so the value always stores cleanly as a C string.
240pub(crate) fn set_last_error(message: String) {
241    let value =
242        CString::new(message).unwrap_or_else(|_| CString::new("pamoja error").expect("static"));
243    LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(value));
244}
245
246/// Takes the calling thread's most recent error message, leaving none recorded.
247#[cfg(feature = "runtime")]
248pub(crate) fn take_last_error() -> Option<String> {
249    LAST_ERROR
250        .with(|slot| slot.borrow_mut().take())
251        .and_then(|value| value.into_string().ok())
252}
253
254/// Returns the calling thread's most recent error message, or null if none.
255///
256/// # Returns
257///
258/// A pointer to a null-terminated UTF-8 string owned by the library, valid until
259/// the next failing call on the same thread, or null if no error has been
260/// recorded. The caller must not free it and should copy it before making another
261/// pamoja call on this thread.
262#[no_mangle]
263pub extern "C" fn pamoja_last_error_message() -> *const c_char {
264    LAST_ERROR.with(|slot| match &*slot.borrow() {
265        Some(value) => value.as_ptr(),
266        None => ptr::null(),
267    })
268}
269
270/// Copies a borrowed byte buffer, treating a zero length as an empty payload.
271///
272/// # Safety
273///
274/// When `len` is non-zero, `ptr` must point to at least `len` readable bytes.
275#[cfg(any(
276    feature = "audit",
277    feature = "can",
278    feature = "codec",
279    feature = "gateway",
280    feature = "hal",
281    feature = "lora",
282    feature = "lorawan",
283    feature = "mavlink",
284    feature = "mesh",
285    feature = "modbus",
286    feature = "mqtt",
287    feature = "ros2",
288    feature = "runtime",
289    feature = "security",
290    feature = "sensors",
291    feature = "serial",
292    feature = "session",
293    feature = "update"
294))]
295pub(crate) unsafe fn read_bytes(ptr: *const u8, len: usize) -> Result<Vec<u8>, PamojaStatus> {
296    if len == 0 {
297        Ok(Vec::new())
298    } else if ptr.is_null() {
299        set_last_error("payload must not be null when its length is non-zero".to_owned());
300        Err(PamojaStatus::InvalidArgument)
301    } else {
302        Ok(std::slice::from_raw_parts(ptr, len).to_vec())
303    }
304}
305
306/// An opaque handle to a byte buffer owned by the caller.
307///
308/// Calls that produce a variable-length result hand back one of these rather than
309/// writing into a caller buffer, so the caller never has to guess a size. Read it
310/// with [`pamoja_buffer_data`] and [`pamoja_buffer_len`], then release it with
311/// [`pamoja_buffer_free`].
312#[cfg(any(
313    feature = "audit",
314    feature = "bus",
315    feature = "codec",
316    feature = "gateway",
317    feature = "hal",
318    feature = "lorawan",
319    feature = "modbus",
320    feature = "radios",
321    feature = "ros2",
322    feature = "security",
323    feature = "serial",
324    feature = "sync",
325    feature = "update"
326))]
327pub struct PamojaBuffer {
328    bytes: Vec<u8>,
329}
330
331#[cfg(any(
332    feature = "audit",
333    feature = "bus",
334    feature = "codec",
335    feature = "gateway",
336    feature = "hal",
337    feature = "lorawan",
338    feature = "modbus",
339    feature = "radios",
340    feature = "ros2",
341    feature = "security",
342    feature = "serial",
343    feature = "sync",
344    feature = "update"
345))]
346impl PamojaBuffer {
347    /// Wraps owned bytes in a heap-allocated handle for the caller to own.
348    ///
349    /// # Arguments
350    ///
351    /// * `bytes` - the buffer contents to hand across the boundary.
352    ///
353    /// # Returns
354    ///
355    /// A raw handle the caller must release with [`pamoja_buffer_free`].
356    pub(crate) fn into_raw(bytes: Vec<u8>) -> *mut Self {
357        Box::into_raw(Box::new(Self { bytes }))
358    }
359}
360
361/// Returns a pointer to a buffer's bytes.
362///
363/// Use [`pamoja_buffer_len`] for the length. The pointer is valid until the
364/// buffer is freed.
365///
366/// # Returns
367///
368/// A pointer to the bytes, or null if `buffer` is null.
369///
370/// # Safety
371///
372/// `buffer` must be a live handle from a pamoja call that produced one, or null.
373#[cfg(any(
374    feature = "audit",
375    feature = "bus",
376    feature = "codec",
377    feature = "gateway",
378    feature = "hal",
379    feature = "lorawan",
380    feature = "modbus",
381    feature = "radios",
382    feature = "ros2",
383    feature = "security",
384    feature = "serial",
385    feature = "sync",
386    feature = "update"
387))]
388#[no_mangle]
389pub unsafe extern "C" fn pamoja_buffer_data(buffer: *const PamojaBuffer) -> *const u8 {
390    if buffer.is_null() {
391        return ptr::null();
392    }
393    (*buffer).bytes.as_ptr()
394}
395
396/// Returns the length in bytes of a buffer.
397///
398/// # Returns
399///
400/// The length, or 0 if `buffer` is null.
401///
402/// # Safety
403///
404/// `buffer` must be a live handle from a pamoja call that produced one, or null.
405#[cfg(any(
406    feature = "audit",
407    feature = "bus",
408    feature = "codec",
409    feature = "gateway",
410    feature = "hal",
411    feature = "lorawan",
412    feature = "modbus",
413    feature = "radios",
414    feature = "ros2",
415    feature = "security",
416    feature = "serial",
417    feature = "sync",
418    feature = "update"
419))]
420#[no_mangle]
421pub unsafe extern "C" fn pamoja_buffer_len(buffer: *const PamojaBuffer) -> usize {
422    if buffer.is_null() {
423        return 0;
424    }
425    (*buffer).bytes.len()
426}
427
428/// Releases a buffer handle.
429///
430/// Passing null is a no-op.
431///
432/// # Safety
433///
434/// `buffer` must be a handle from a pamoja call that produced one and that has
435/// not already been freed, or null. After this call it must not be used again.
436#[cfg(any(
437    feature = "audit",
438    feature = "bus",
439    feature = "codec",
440    feature = "gateway",
441    feature = "hal",
442    feature = "lorawan",
443    feature = "modbus",
444    feature = "radios",
445    feature = "ros2",
446    feature = "security",
447    feature = "serial",
448    feature = "sync",
449    feature = "update"
450))]
451#[no_mangle]
452pub unsafe extern "C" fn pamoja_buffer_free(buffer: *mut PamojaBuffer) {
453    if !buffer.is_null() {
454        drop(Box::from_raw(buffer));
455    }
456}
457
458/// An owned, null-terminated UTF-8 string produced by the library.
459///
460/// Some calls build a string rather than borrowing one that already lives inside
461/// a handle: a canonical key expression, a DDS topic name, a profile serialized
462/// to JSON. Those return this, and the caller releases it with
463/// [`pamoja_string_free`].
464#[cfg(any(
465    feature = "lora",
466    feature = "mavlink",
467    feature = "profile",
468    feature = "ros2",
469    feature = "sensors",
470    feature = "zenoh"
471))]
472pub struct PamojaString {
473    text: CString,
474}
475
476#[cfg(any(
477    feature = "lora",
478    feature = "mavlink",
479    feature = "profile",
480    feature = "ros2",
481    feature = "sensors",
482    feature = "zenoh"
483))]
484impl PamojaString {
485    /// Wraps an owned string in a heap-allocated handle for the caller to own.
486    ///
487    /// # Arguments
488    ///
489    /// * `text` - the string to hand across the boundary.
490    ///
491    /// # Returns
492    ///
493    /// A raw handle the caller must release with [`pamoja_string_free`], or null
494    /// if `text` contains an interior null byte.
495    pub(crate) fn into_raw(text: String) -> *mut Self {
496        match CString::new(text) {
497            Ok(text) => Box::into_raw(Box::new(Self { text })),
498            Err(_) => {
499                set_last_error("the string contains an interior null byte".to_owned());
500                ptr::null_mut()
501            }
502        }
503    }
504}
505
506/// Returns a pointer to a string's bytes.
507///
508/// The pointer is valid until the string is freed.
509///
510/// # Returns
511///
512/// A null-terminated UTF-8 string, or null if `string` is null.
513///
514/// # Safety
515///
516/// `string` must be a live handle from a call that produced one, or null. After
517/// [`pamoja_string_free`] it must not be used again.
518#[cfg(any(
519    feature = "lora",
520    feature = "mavlink",
521    feature = "profile",
522    feature = "ros2",
523    feature = "sensors",
524    feature = "zenoh"
525))]
526#[no_mangle]
527pub unsafe extern "C" fn pamoja_string_data(string: *const PamojaString) -> *const c_char {
528    if string.is_null() {
529        return ptr::null();
530    }
531    (*string).text.as_ptr()
532}
533
534/// Returns the length in bytes of a string, excluding its null terminator.
535///
536/// # Returns
537///
538/// The byte length, or 0 if `string` is null.
539///
540/// # Safety
541///
542/// `string` must be a live handle from a call that produced one, or null.
543#[cfg(any(
544    feature = "lora",
545    feature = "mavlink",
546    feature = "profile",
547    feature = "ros2",
548    feature = "sensors",
549    feature = "zenoh"
550))]
551#[no_mangle]
552pub unsafe extern "C" fn pamoja_string_len(string: *const PamojaString) -> usize {
553    if string.is_null() {
554        return 0;
555    }
556    (*string).text.as_bytes().len()
557}
558
559/// Releases a string handle.
560///
561/// Passing null is a no-op.
562///
563/// # Safety
564///
565/// `string` must be a handle from a call that produced one and that has not
566/// already been freed, or null. After this call it must not be used again.
567#[cfg(any(
568    feature = "lora",
569    feature = "mavlink",
570    feature = "profile",
571    feature = "ros2",
572    feature = "sensors",
573    feature = "zenoh"
574))]
575#[no_mangle]
576pub unsafe extern "C" fn pamoja_string_free(string: *mut PamojaString) {
577    if !string.is_null() {
578        drop(Box::from_raw(string));
579    }
580}
581
582/// Borrows a C string argument as `&str`, recording an error on null or non-UTF-8.
583///
584/// # Safety
585///
586/// `ptr` must be a valid null-terminated string for the duration of the call, or
587/// null.
588#[cfg(any(
589    feature = "coap",
590    feature = "gpio",
591    feature = "hal",
592    feature = "ladder",
593    feature = "lora",
594    feature = "mavlink",
595    feature = "mqtt",
596    feature = "profile",
597    feature = "radios",
598    feature = "ros2",
599    feature = "runtime",
600    feature = "sync",
601    feature = "zenoh"
602))]
603pub(crate) unsafe fn read_str<'a>(ptr: *const c_char, name: &str) -> Option<&'a str> {
604    if ptr.is_null() {
605        set_last_error(format!("{name} must not be null"));
606        return None;
607    }
608    match std::ffi::CStr::from_ptr(ptr).to_str() {
609        Ok(value) => Some(value),
610        Err(_) => {
611            set_last_error(format!("{name} must be valid UTF-8"));
612            None
613        }
614    }
615}
616
617/// The process-wide runtime that drives every blocking async call.
618#[cfg(feature = "runtime")]
619static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
620
621/// Returns the shared Tokio runtime, building it on first use.
622///
623/// A multi-threaded runtime is required because several transports spawn a
624/// background event loop that has to keep running after a `block_on` returns.
625/// Every async capability shares this one executor, so a process that uses two
626/// of them does not carry two runtimes.
627#[cfg(feature = "runtime")]
628pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
629    RUNTIME.get_or_init(|| {
630        tokio::runtime::Builder::new_multi_thread()
631            .enable_all()
632            .build()
633            .expect("build the pamoja tokio runtime")
634    })
635}
636
637/// The version of the native pamoja library.
638static VERSION: OnceLock<CString> = OnceLock::new();
639
640/// Returns the version string of the native pamoja library.
641///
642/// # Returns
643///
644/// A pointer to a static null-terminated UTF-8 string owned by the library. The
645/// caller must not free it; it is valid for the lifetime of the process.
646#[no_mangle]
647pub extern "C" fn pamoja_version() -> *const c_char {
648    VERSION
649        .get_or_init(|| CString::new(env!("CARGO_PKG_VERSION")).expect("version has no null byte"))
650        .as_ptr()
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    /// Every constant the crate exports reaches the header as a value C can use.
658    ///
659    /// cbindgen renders a constant's initializer as written: one that names another crate's
660    /// constant is dropped, and one that names an imported constant is emitted unresolved, so
661    /// the header stops compiling. Both go unnoticed until a C consumer includes it.
662    #[test]
663    fn every_exported_constant_reaches_the_header_as_a_value() {
664        let header = include_str!("../include/pamoja.h");
665        let mut defined = std::collections::HashMap::new();
666        for line in header.lines() {
667            if let Some(rest) = line.strip_prefix("#define PAMOJA_") {
668                let mut parts = rest.splitn(2, ' ');
669                let (Some(name), Some(value)) = (parts.next(), parts.next()) else {
670                    continue;
671                };
672                defined.insert(format!("PAMOJA_{name}"), value.trim().to_owned());
673            }
674        }
675
676        let mut missing = Vec::new();
677        let mut unresolved = Vec::new();
678        let sources = std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))
679            .expect("the crate's own sources");
680        for entry in sources.flatten() {
681            let path = entry.path();
682            if path.extension().is_none_or(|extension| extension != "rs") {
683                continue;
684            }
685            let source = std::fs::read_to_string(&path).expect("read a source");
686            for line in source.lines() {
687                let Some(rest) = line.trim_start().strip_prefix("pub const PAMOJA_") else {
688                    continue;
689                };
690                let Some(name) = rest.split(':').next() else {
691                    continue;
692                };
693                let name = format!("PAMOJA_{}", name.trim());
694                match defined.get(&name) {
695                    None => missing.push(name),
696                    Some(value) => {
697                        if value
698                            .chars()
699                            .next()
700                            .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
701                            && !value.starts_with("PAMOJA_")
702                        {
703                            unresolved.push(format!("{name} = {value}"));
704                        }
705                    }
706                }
707            }
708        }
709        assert!(missing.is_empty(), "dropped from the header: {missing:?}");
710        assert!(
711            unresolved.is_empty(),
712            "emitted as a name C cannot resolve: {unresolved:?}"
713        );
714    }
715
716    #[test]
717    fn status_maps_each_error_variant() {
718        assert!(matches!(
719            PamojaStatus::from_error(&Error::Transport("x".into())),
720            PamojaStatus::Transport
721        ));
722        assert!(matches!(
723            PamojaStatus::from_error(&Error::Io("x".into())),
724            PamojaStatus::Io
725        ));
726        assert!(matches!(
727            PamojaStatus::from_error(&Error::Codec("x".into())),
728            PamojaStatus::Codec
729        ));
730        assert!(matches!(
731            PamojaStatus::from_error(&Error::Closed),
732            PamojaStatus::Closed
733        ));
734        assert!(matches!(
735            PamojaStatus::from_error(&Error::Unsupported("mqtt")),
736            PamojaStatus::Unsupported
737        ));
738    }
739
740    #[test]
741    fn version_is_a_non_empty_c_string() {
742        let ptr = pamoja_version();
743        assert!(!ptr.is_null());
744        // Safety: `pamoja_version` returns a valid static C string.
745        let version = unsafe { std::ffi::CStr::from_ptr(ptr) };
746        assert_eq!(version.to_str().expect("utf-8"), env!("CARGO_PKG_VERSION"));
747    }
748
749    #[test]
750    fn last_error_round_trips_on_this_thread() {
751        set_last_error("transport error: boom".to_owned());
752        let ptr = pamoja_last_error_message();
753        assert!(!ptr.is_null());
754        // Safety: a message was just recorded on this thread.
755        let message = unsafe { std::ffi::CStr::from_ptr(ptr) };
756        assert_eq!(message.to_str().expect("utf-8"), "transport error: boom");
757    }
758}