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}