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//!
23//! # Choosing what the library carries
24//!
25//! Every capability is a cargo feature, all on by default, so a host that builds
26//! this crate itself gets the same "compile only what you use" property a Rust
27//! consumer has, and it is worth using. The seven capabilities that need an async
28//! runtime (`mqtt`, `coap`, `loopback`, `sync`, `ladder`, `bus`, and `sim`) carry
29//! Tokio and the network stacks with them, and are about half the compiled
30//! library on their own: dropping just those, keeping every other capability,
31//! took a release build from 2.07 MB to 1.01 MB. A host that only decodes
32//! protocol bytes can go much further, to 0.41 MB:
33//!
34//! ```sh
35//! cargo build --release -p pamoja-ffi --no-default-features \
36//!   --features "modbus,can,serial,gpio,sensors,codec,security"
37//! ```
38//!
39//! Those figures are one `x86_64-pc-windows-msvc` build and will differ per
40//! platform; `cargo xtask builds` reports the sizes on the machine at hand. The
41//! Node, Python, and .NET packages ship the full default build regardless,
42//! because their package managers cannot express the choice per consumer.
43
44// This crate is the FFI boundary, so raw-pointer work is its entire purpose; the
45// workspace `unsafe_code = "warn"` lint is therefore allowed here. Safety is kept
46// reviewable by confining every `unsafe` block to this crate.
47#![allow(unsafe_code)]
48
49use std::cell::RefCell;
50use std::ffi::{c_char, CString};
51use std::ptr;
52use std::sync::OnceLock;
53
54use pamoja_core::Error;
55
56// The capability modules are public so every item the C ABI exports, including
57// the buffer-size constants a caller sizes an array from, stays reachable from
58// the crate root. A constant referenced only from the generated header reads as
59// dead code otherwise.
60#[cfg(feature = "actuators")]
61pub mod actuators;
62#[cfg(feature = "audit")]
63pub mod audit;
64#[cfg(feature = "bus")]
65pub mod bus;
66#[cfg(feature = "can")]
67pub mod can;
68#[cfg(feature = "coap")]
69pub mod coap;
70#[cfg(feature = "codec")]
71pub mod codec;
72#[cfg(feature = "gpio")]
73pub mod gpio;
74#[cfg(feature = "kit")]
75pub mod kit;
76#[cfg(feature = "ladder")]
77pub mod ladder;
78#[cfg(feature = "loopback")]
79pub mod loopback;
80#[cfg(feature = "lora")]
81pub mod lora;
82#[cfg(feature = "lora")]
83pub mod lora_region;
84#[cfg(feature = "lorawan")]
85pub mod lorawan;
86#[cfg(feature = "mavlink")]
87pub mod mavlink;
88#[cfg(feature = "mavlink")]
89pub mod mavlink_protocol;
90#[cfg(feature = "mavlink")]
91pub mod mavlink_schema;
92#[cfg(feature = "mesh")]
93pub mod mesh;
94#[cfg(feature = "modbus")]
95pub mod modbus;
96#[cfg(feature = "mqtt")]
97pub mod mqtt;
98#[cfg(feature = "power")]
99pub mod power;
100#[cfg(feature = "profile")]
101pub mod profile;
102#[cfg(feature = "ros2")]
103pub mod ros2;
104#[cfg(feature = "routing")]
105pub mod routing;
106#[cfg(feature = "security")]
107pub mod security;
108#[cfg(feature = "sensors")]
109pub mod sensors;
110#[cfg(feature = "serial")]
111pub mod serial;
112#[cfg(feature = "session")]
113pub mod session;
114#[cfg(feature = "sim")]
115pub mod sim;
116#[cfg(feature = "sync")]
117pub mod sync;
118#[cfg(feature = "telemetry")]
119pub mod telemetry;
120#[cfg(feature = "runtime")]
121pub mod transport;
122#[cfg(feature = "update")]
123pub mod update;
124#[cfg(feature = "zenoh")]
125pub mod zenoh;
126
127/// The result of a fallible pamoja call.
128///
129/// A return of [`PamojaStatus::Ok`] means success; any other value indicates a
130/// failure whose description is available from [`pamoja_last_error_message`] on
131/// the same thread.
132#[repr(C)]
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum PamojaStatus {
135    /// The call succeeded.
136    Ok = 0,
137    /// A transport-level failure while connecting, sending, or receiving.
138    Transport = 1,
139    /// A device or peripheral input/output operation failed.
140    Io = 2,
141    /// A payload could not be encoded or decoded.
142    Codec = 3,
143    /// The operation targeted a resource that is closed or disconnected.
144    Closed = 4,
145    /// The requested capability is not compiled into this build.
146    Unsupported = 5,
147    /// An argument was null or otherwise invalid, for example non-UTF-8 text.
148    InvalidArgument = 6,
149    /// A failure that does not map onto a more specific status.
150    Other = 7,
151    /// A Rust panic was caught at the boundary; the call had no effect.
152    Panic = 8,
153    /// A security check failed, such as an invalid identity or a bad signature.
154    Auth = 9,
155}
156
157impl PamojaStatus {
158    /// Maps a core [`Error`] onto the matching status code.
159    ///
160    /// # Arguments
161    ///
162    /// * `error` - the error returned by a core or capability call.
163    ///
164    /// # Returns
165    ///
166    /// The [`PamojaStatus`] that classifies `error`.
167    pub(crate) fn from_error(error: &Error) -> Self {
168        match error {
169            Error::Transport(_) => Self::Transport,
170            Error::Io(_) => Self::Io,
171            Error::Codec(_) => Self::Codec,
172            Error::Closed => Self::Closed,
173            Error::Auth(_) => Self::Auth,
174            Error::Unsupported(_) => Self::Unsupported,
175            _ => Self::Other,
176        }
177    }
178}
179
180thread_local! {
181    /// The most recent error message produced on this thread.
182    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
183}
184
185/// Records `message` as the calling thread's most recent error.
186///
187/// # Arguments
188///
189/// * `message` - the human-readable description to expose through
190///   [`pamoja_last_error_message`]. Any interior null byte is replaced with a
191///   generic message so the value always stores cleanly as a C string.
192pub(crate) fn set_last_error(message: String) {
193    let value =
194        CString::new(message).unwrap_or_else(|_| CString::new("pamoja error").expect("static"));
195    LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(value));
196}
197
198/// Returns the calling thread's most recent error message, or null if none.
199///
200/// # Returns
201///
202/// A pointer to a null-terminated UTF-8 string owned by the library, valid until
203/// the next failing call on the same thread, or null if no error has been
204/// recorded. The caller must not free it and should copy it before making another
205/// pamoja call on this thread.
206#[no_mangle]
207pub extern "C" fn pamoja_last_error_message() -> *const c_char {
208    LAST_ERROR.with(|slot| match &*slot.borrow() {
209        Some(value) => value.as_ptr(),
210        None => ptr::null(),
211    })
212}
213
214/// Copies a borrowed byte buffer, treating a zero length as an empty payload.
215///
216/// # Safety
217///
218/// When `len` is non-zero, `ptr` must point to at least `len` readable bytes.
219#[cfg(any(
220    feature = "audit",
221    feature = "can",
222    feature = "codec",
223    feature = "lora",
224    feature = "lorawan",
225    feature = "mavlink",
226    feature = "mesh",
227    feature = "modbus",
228    feature = "mqtt",
229    feature = "ros2",
230    feature = "runtime",
231    feature = "security",
232    feature = "sensors",
233    feature = "serial",
234    feature = "session",
235    feature = "update"
236))]
237pub(crate) unsafe fn read_bytes(ptr: *const u8, len: usize) -> Result<Vec<u8>, PamojaStatus> {
238    if len == 0 {
239        Ok(Vec::new())
240    } else if ptr.is_null() {
241        set_last_error("payload must not be null when its length is non-zero".to_owned());
242        Err(PamojaStatus::InvalidArgument)
243    } else {
244        Ok(std::slice::from_raw_parts(ptr, len).to_vec())
245    }
246}
247
248/// An opaque handle to a byte buffer owned by the caller.
249///
250/// Calls that produce a variable-length result hand back one of these rather than
251/// writing into a caller buffer, so the caller never has to guess a size. Read it
252/// with [`pamoja_buffer_data`] and [`pamoja_buffer_len`], then release it with
253/// [`pamoja_buffer_free`].
254#[cfg(any(
255    feature = "audit",
256    feature = "bus",
257    feature = "codec",
258    feature = "lorawan",
259    feature = "modbus",
260    feature = "ros2",
261    feature = "security",
262    feature = "serial",
263    feature = "sync",
264    feature = "update"
265))]
266pub struct PamojaBuffer {
267    bytes: Vec<u8>,
268}
269
270#[cfg(any(
271    feature = "audit",
272    feature = "bus",
273    feature = "codec",
274    feature = "lorawan",
275    feature = "modbus",
276    feature = "ros2",
277    feature = "security",
278    feature = "serial",
279    feature = "sync",
280    feature = "update"
281))]
282impl PamojaBuffer {
283    /// Wraps owned bytes in a heap-allocated handle for the caller to own.
284    ///
285    /// # Arguments
286    ///
287    /// * `bytes` - the buffer contents to hand across the boundary.
288    ///
289    /// # Returns
290    ///
291    /// A raw handle the caller must release with [`pamoja_buffer_free`].
292    pub(crate) fn into_raw(bytes: Vec<u8>) -> *mut Self {
293        Box::into_raw(Box::new(Self { bytes }))
294    }
295}
296
297/// Returns a pointer to a buffer's bytes.
298///
299/// Use [`pamoja_buffer_len`] for the length. The pointer is valid until the
300/// buffer is freed.
301///
302/// # Returns
303///
304/// A pointer to the bytes, or null if `buffer` is null.
305///
306/// # Safety
307///
308/// `buffer` must be a live handle from a pamoja call that produced one, or null.
309#[cfg(any(
310    feature = "audit",
311    feature = "bus",
312    feature = "codec",
313    feature = "lorawan",
314    feature = "modbus",
315    feature = "ros2",
316    feature = "security",
317    feature = "serial",
318    feature = "sync",
319    feature = "update"
320))]
321#[no_mangle]
322pub unsafe extern "C" fn pamoja_buffer_data(buffer: *const PamojaBuffer) -> *const u8 {
323    if buffer.is_null() {
324        return ptr::null();
325    }
326    (*buffer).bytes.as_ptr()
327}
328
329/// Returns the length in bytes of a buffer.
330///
331/// # Returns
332///
333/// The length, or 0 if `buffer` is null.
334///
335/// # Safety
336///
337/// `buffer` must be a live handle from a pamoja call that produced one, or null.
338#[cfg(any(
339    feature = "audit",
340    feature = "bus",
341    feature = "codec",
342    feature = "lorawan",
343    feature = "modbus",
344    feature = "ros2",
345    feature = "security",
346    feature = "serial",
347    feature = "sync",
348    feature = "update"
349))]
350#[no_mangle]
351pub unsafe extern "C" fn pamoja_buffer_len(buffer: *const PamojaBuffer) -> usize {
352    if buffer.is_null() {
353        return 0;
354    }
355    (*buffer).bytes.len()
356}
357
358/// Releases a buffer handle.
359///
360/// Passing null is a no-op.
361///
362/// # Safety
363///
364/// `buffer` must be a handle from a pamoja call that produced one and that has
365/// not already been freed, or null. After this call it must not be used again.
366#[cfg(any(
367    feature = "audit",
368    feature = "bus",
369    feature = "codec",
370    feature = "lorawan",
371    feature = "modbus",
372    feature = "ros2",
373    feature = "security",
374    feature = "serial",
375    feature = "sync",
376    feature = "update"
377))]
378#[no_mangle]
379pub unsafe extern "C" fn pamoja_buffer_free(buffer: *mut PamojaBuffer) {
380    if !buffer.is_null() {
381        drop(Box::from_raw(buffer));
382    }
383}
384
385/// An owned, null-terminated UTF-8 string produced by the library.
386///
387/// Some calls build a string rather than borrowing one that already lives inside
388/// a handle: a canonical key expression, a DDS topic name, a profile serialized
389/// to JSON. Those return this, and the caller releases it with
390/// [`pamoja_string_free`].
391#[cfg(any(
392    feature = "lora",
393    feature = "mavlink",
394    feature = "profile",
395    feature = "ros2",
396    feature = "zenoh"
397))]
398pub struct PamojaString {
399    text: CString,
400}
401
402#[cfg(any(
403    feature = "lora",
404    feature = "mavlink",
405    feature = "profile",
406    feature = "ros2",
407    feature = "zenoh"
408))]
409impl PamojaString {
410    /// Wraps an owned string in a heap-allocated handle for the caller to own.
411    ///
412    /// # Arguments
413    ///
414    /// * `text` - the string to hand across the boundary.
415    ///
416    /// # Returns
417    ///
418    /// A raw handle the caller must release with [`pamoja_string_free`], or null
419    /// if `text` contains an interior null byte.
420    pub(crate) fn into_raw(text: String) -> *mut Self {
421        match CString::new(text) {
422            Ok(text) => Box::into_raw(Box::new(Self { text })),
423            Err(_) => {
424                set_last_error("the string contains an interior null byte".to_owned());
425                ptr::null_mut()
426            }
427        }
428    }
429}
430
431/// Returns a pointer to a string's bytes.
432///
433/// The pointer is valid until the string is freed.
434///
435/// # Returns
436///
437/// A null-terminated UTF-8 string, or null if `string` is null.
438///
439/// # Safety
440///
441/// `string` must be a live handle from a call that produced one, or null. After
442/// [`pamoja_string_free`] it must not be used again.
443#[cfg(any(
444    feature = "lora",
445    feature = "mavlink",
446    feature = "profile",
447    feature = "ros2",
448    feature = "zenoh"
449))]
450#[no_mangle]
451pub unsafe extern "C" fn pamoja_string_data(string: *const PamojaString) -> *const c_char {
452    if string.is_null() {
453        return ptr::null();
454    }
455    (*string).text.as_ptr()
456}
457
458/// Returns the length in bytes of a string, excluding its null terminator.
459///
460/// # Returns
461///
462/// The byte length, or 0 if `string` is null.
463///
464/// # Safety
465///
466/// `string` must be a live handle from a call that produced one, or null.
467#[cfg(any(
468    feature = "lora",
469    feature = "mavlink",
470    feature = "profile",
471    feature = "ros2",
472    feature = "zenoh"
473))]
474#[no_mangle]
475pub unsafe extern "C" fn pamoja_string_len(string: *const PamojaString) -> usize {
476    if string.is_null() {
477        return 0;
478    }
479    (*string).text.as_bytes().len()
480}
481
482/// Releases a string handle.
483///
484/// Passing null is a no-op.
485///
486/// # Safety
487///
488/// `string` must be a handle from a call that produced one and that has not
489/// already been freed, or null. After this call it must not be used again.
490#[cfg(any(
491    feature = "lora",
492    feature = "mavlink",
493    feature = "profile",
494    feature = "ros2",
495    feature = "zenoh"
496))]
497#[no_mangle]
498pub unsafe extern "C" fn pamoja_string_free(string: *mut PamojaString) {
499    if !string.is_null() {
500        drop(Box::from_raw(string));
501    }
502}
503
504/// Borrows a C string argument as `&str`, recording an error on null or non-UTF-8.
505///
506/// # Safety
507///
508/// `ptr` must be a valid null-terminated string for the duration of the call, or
509/// null.
510#[cfg(any(
511    feature = "coap",
512    feature = "ladder",
513    feature = "lora",
514    feature = "mavlink",
515    feature = "mqtt",
516    feature = "profile",
517    feature = "ros2",
518    feature = "runtime",
519    feature = "sync",
520    feature = "zenoh"
521))]
522pub(crate) unsafe fn read_str<'a>(ptr: *const c_char, name: &str) -> Option<&'a str> {
523    if ptr.is_null() {
524        set_last_error(format!("{name} must not be null"));
525        return None;
526    }
527    match std::ffi::CStr::from_ptr(ptr).to_str() {
528        Ok(value) => Some(value),
529        Err(_) => {
530            set_last_error(format!("{name} must be valid UTF-8"));
531            None
532        }
533    }
534}
535
536/// The process-wide runtime that drives every blocking async call.
537#[cfg(feature = "runtime")]
538static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
539
540/// Returns the shared Tokio runtime, building it on first use.
541///
542/// A multi-threaded runtime is required because several transports spawn a
543/// background event loop that has to keep running after a `block_on` returns.
544/// Every async capability shares this one executor, so a process that uses two
545/// of them does not carry two runtimes.
546#[cfg(feature = "runtime")]
547pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
548    RUNTIME.get_or_init(|| {
549        tokio::runtime::Builder::new_multi_thread()
550            .enable_all()
551            .build()
552            .expect("build the pamoja tokio runtime")
553    })
554}
555
556/// The version of the native pamoja library.
557static VERSION: OnceLock<CString> = OnceLock::new();
558
559/// Returns the version string of the native pamoja library.
560///
561/// # Returns
562///
563/// A pointer to a static null-terminated UTF-8 string owned by the library. The
564/// caller must not free it; it is valid for the lifetime of the process.
565#[no_mangle]
566pub extern "C" fn pamoja_version() -> *const c_char {
567    VERSION
568        .get_or_init(|| CString::new(env!("CARGO_PKG_VERSION")).expect("version has no null byte"))
569        .as_ptr()
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn status_maps_each_error_variant() {
578        assert!(matches!(
579            PamojaStatus::from_error(&Error::Transport("x".into())),
580            PamojaStatus::Transport
581        ));
582        assert!(matches!(
583            PamojaStatus::from_error(&Error::Io("x".into())),
584            PamojaStatus::Io
585        ));
586        assert!(matches!(
587            PamojaStatus::from_error(&Error::Codec("x".into())),
588            PamojaStatus::Codec
589        ));
590        assert!(matches!(
591            PamojaStatus::from_error(&Error::Closed),
592            PamojaStatus::Closed
593        ));
594        assert!(matches!(
595            PamojaStatus::from_error(&Error::Unsupported("mqtt")),
596            PamojaStatus::Unsupported
597        ));
598    }
599
600    #[test]
601    fn version_is_a_non_empty_c_string() {
602        let ptr = pamoja_version();
603        assert!(!ptr.is_null());
604        // Safety: `pamoja_version` returns a valid static C string.
605        let version = unsafe { std::ffi::CStr::from_ptr(ptr) };
606        assert_eq!(version.to_str().expect("utf-8"), env!("CARGO_PKG_VERSION"));
607    }
608
609    #[test]
610    fn last_error_round_trips_on_this_thread() {
611        set_last_error("transport error: boom".to_owned());
612        let ptr = pamoja_last_error_message();
613        assert!(!ptr.is_null());
614        // Safety: a message was just recorded on this thread.
615        let message = unsafe { std::ffi::CStr::from_ptr(ptr) };
616        assert_eq!(message.to_str().expect("utf-8"), "transport error: boom");
617    }
618}