Skip to main content

pamoja_ffi/
mqtt.rs

1//! The C ABI for the MQTT transport.
2//!
3//! These functions wrap [`pamoja_mqtt`] for callers that reach the SDK through
4//! the flat C boundary. Because that boundary has no async support, the crate
5//! owns a single multi-threaded Tokio runtime and each call blocks on it until
6//! the underlying async operation completes; a host that wants concurrency runs
7//! these calls on its own threads. The shared transport sits behind an async
8//! mutex, mirroring the Node and Python bindings so behavior matches across
9//! languages.
10
11use std::ffi::{c_char, CString};
12use std::future::Future;
13use std::panic::{catch_unwind, AssertUnwindSafe};
14use std::ptr;
15use std::sync::Arc;
16use std::time::Duration;
17
18use tokio::sync::Mutex;
19
20use pamoja_core::{Error, Transport};
21use pamoja_mqtt::{MqttConfig, MqttTransport, QualityOfService};
22
23use crate::{read_bytes, read_str, runtime, set_last_error, PamojaStatus};
24
25/// MQTT delivery guarantee, mirroring the protocol's quality-of-service levels.
26// The shared `Once` suffix is the protocol's own vocabulary; renaming would
27// distort the C ABI, so the variant-name lint is allowed here.
28#[allow(clippy::enum_variant_names)]
29#[repr(C)]
30#[derive(Clone, Copy)]
31pub enum PamojaQos {
32    /// Fire and forget; the broker does not acknowledge delivery.
33    AtMostOnce = 0,
34    /// Delivered at least once and acknowledged.
35    AtLeastOnce = 1,
36    /// Delivered exactly once via a four-step handshake.
37    ExactlyOnce = 2,
38}
39
40impl From<PamojaQos> for QualityOfService {
41    fn from(value: PamojaQos) -> Self {
42        match value {
43            PamojaQos::AtMostOnce => QualityOfService::AtMostOnce,
44            PamojaQos::AtLeastOnce => QualityOfService::AtLeastOnce,
45            PamojaQos::ExactlyOnce => QualityOfService::ExactlyOnce,
46        }
47    }
48}
49
50/// Connection settings for an MQTT client.
51///
52/// `client_id` and `host` are borrowed null-terminated UTF-8 strings. A
53/// `keep_alive_secs` or `capacity` of `0` selects the core default.
54#[repr(C)]
55pub struct PamojaMqttConfig {
56    /// The MQTT client identifier presented to the broker.
57    pub client_id: *const c_char,
58    /// The broker hostname or IP address.
59    pub host: *const c_char,
60    /// The broker TCP port, conventionally 1883 for plaintext MQTT.
61    pub port: u16,
62    /// Keep-alive interval in seconds, or 0 for the default of 30.
63    pub keep_alive_secs: u32,
64    /// Bound on outstanding client requests, or 0 for the default of 64.
65    pub capacity: u32,
66    /// Default quality of service for publishes and subscriptions.
67    pub qos: PamojaQos,
68}
69
70/// An opaque handle to an MQTT client transport.
71pub struct PamojaMqttClient {
72    inner: Arc<Mutex<MqttTransport>>,
73}
74
75/// An opaque handle to a message received from a subscribed topic.
76pub struct PamojaMqttMessage {
77    topic: CString,
78    payload: Vec<u8>,
79}
80
81/// Creates a disconnected MQTT client from the given settings.
82///
83/// # Returns
84///
85/// A heap-allocated client handle the caller owns and must release with
86/// [`pamoja_mqtt_client_free`], or null on failure with the reason available from
87/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
88///
89/// # Safety
90///
91/// `config` must point to a valid [`PamojaMqttConfig`] whose `client_id` and
92/// `host` are valid null-terminated UTF-8 strings for the duration of the call.
93#[no_mangle]
94pub unsafe extern "C" fn pamoja_mqtt_client_new(
95    config: *const PamojaMqttConfig,
96) -> *mut PamojaMqttClient {
97    if config.is_null() {
98        set_last_error("config must not be null".to_owned());
99        return ptr::null_mut();
100    }
101    let Some(settings) = mqtt_settings(config) else {
102        return ptr::null_mut();
103    };
104
105    let client = PamojaMqttClient {
106        inner: Arc::new(Mutex::new(MqttTransport::new(settings))),
107    };
108    Box::into_raw(Box::new(client))
109}
110
111/// Reads the broker settings a config describes.
112///
113/// Shared with the composable transport handle, so a client and a ladder rung
114/// read the same fields the same way.
115///
116/// # Safety
117///
118/// `config` must point to a valid [`PamojaMqttConfig`] whose `client_id` and
119/// `host` are valid null-terminated UTF-8 strings for the duration of the call,
120/// or be null.
121pub(crate) unsafe fn mqtt_settings(config: *const PamojaMqttConfig) -> Option<MqttConfig> {
122    if config.is_null() {
123        set_last_error("config must not be null".to_owned());
124        return None;
125    }
126    let config = &*config;
127    let client_id = read_str(config.client_id, "client_id")?;
128    let host = read_str(config.host, "host")?;
129
130    let mut settings = MqttConfig::new(client_id, host, config.port);
131    if config.keep_alive_secs != 0 {
132        settings = settings.keep_alive(Duration::from_secs(u64::from(config.keep_alive_secs)));
133    }
134    if config.capacity != 0 {
135        settings = settings.capacity(config.capacity as usize);
136    }
137    Some(settings.qos(config.qos.into()))
138}
139
140/// Connects to the broker and starts the background event loop.
141///
142/// # Returns
143///
144/// [`PamojaStatus::Ok`] once connected, or an error status whose message is
145/// available from [`pamoja_last_error_message`](crate::pamoja_last_error_message).
146///
147/// # Safety
148///
149/// `client` must be a non-null handle returned by [`pamoja_mqtt_client_new`] and
150/// not yet freed.
151#[no_mangle]
152pub unsafe extern "C" fn pamoja_mqtt_client_connect(client: *mut PamojaMqttClient) -> PamojaStatus {
153    let Some(client) = client_handle(client) else {
154        return PamojaStatus::InvalidArgument;
155    };
156    let inner = Arc::clone(&client.inner);
157    run(async move { inner.lock().await.connect().await })
158}
159
160/// Publishes a payload to a topic.
161///
162/// # Returns
163///
164/// [`PamojaStatus::Ok`] once the payload is handed to the transport, or an error
165/// status.
166///
167/// # Safety
168///
169/// `client` must be a live handle from [`pamoja_mqtt_client_new`]; `topic` must be
170/// a valid null-terminated UTF-8 string; and `payload` must point to at least
171/// `payload_len` bytes, or be null when `payload_len` is 0.
172#[no_mangle]
173pub unsafe extern "C" fn pamoja_mqtt_client_publish(
174    client: *mut PamojaMqttClient,
175    topic: *const c_char,
176    payload: *const u8,
177    payload_len: usize,
178) -> PamojaStatus {
179    let Some(client) = client_handle(client) else {
180        return PamojaStatus::InvalidArgument;
181    };
182    let Some(topic) = read_str(topic, "topic") else {
183        return PamojaStatus::InvalidArgument;
184    };
185    let payload = match read_bytes(payload, payload_len) {
186        Ok(payload) => payload,
187        Err(status) => return status,
188    };
189    let topic = topic.to_owned();
190    let inner = Arc::clone(&client.inner);
191    run(async move { inner.lock().await.send(&topic, &payload).await })
192}
193
194/// Subscribes to a topic filter.
195///
196/// # Returns
197///
198/// [`PamojaStatus::Ok`] once the subscription is registered, or an error status.
199///
200/// # Safety
201///
202/// `client` must be a live handle from [`pamoja_mqtt_client_new`] and `topic` a
203/// valid null-terminated UTF-8 string.
204#[no_mangle]
205pub unsafe extern "C" fn pamoja_mqtt_client_subscribe(
206    client: *mut PamojaMqttClient,
207    topic: *const c_char,
208) -> PamojaStatus {
209    let Some(client) = client_handle(client) else {
210        return PamojaStatus::InvalidArgument;
211    };
212    let Some(topic) = read_str(topic, "topic") else {
213        return PamojaStatus::InvalidArgument;
214    };
215    let topic = topic.to_owned();
216    let inner = Arc::clone(&client.inner);
217    run(async move { inner.lock().await.subscribe(&topic).await })
218}
219
220/// Awaits the next message from any subscribed topic.
221///
222/// On success `*out_message` is set to a new message handle the caller owns and
223/// must release with [`pamoja_mqtt_message_free`], or to null once the connection
224/// has ended and no further messages will arrive.
225///
226/// # Returns
227///
228/// [`PamojaStatus::Ok`] on success (including end of stream), or an error status.
229///
230/// # Safety
231///
232/// `client` must be a live handle from [`pamoja_mqtt_client_new`] and
233/// `out_message` must point to a writable `*mut PamojaMqttMessage`.
234#[no_mangle]
235pub unsafe extern "C" fn pamoja_mqtt_client_recv(
236    client: *mut PamojaMqttClient,
237    out_message: *mut *mut PamojaMqttMessage,
238) -> PamojaStatus {
239    if out_message.is_null() {
240        set_last_error("out_message must not be null".to_owned());
241        return PamojaStatus::InvalidArgument;
242    }
243    *out_message = ptr::null_mut();
244    let Some(client) = client_handle(client) else {
245        return PamojaStatus::InvalidArgument;
246    };
247    let inner = Arc::clone(&client.inner);
248
249    match catch_unwind(AssertUnwindSafe(|| {
250        runtime().block_on(async move { inner.lock().await.recv().await })
251    })) {
252        Ok(Ok(Some(message))) => {
253            let boxed = Box::new(PamojaMqttMessage {
254                topic: CString::new(message.topic)
255                    .unwrap_or_else(|_| CString::new("").expect("static")),
256                payload: message.payload,
257            });
258            *out_message = Box::into_raw(boxed);
259            PamojaStatus::Ok
260        }
261        Ok(Ok(None)) => PamojaStatus::Ok,
262        Ok(Err(error)) => {
263            set_last_error(error.to_string());
264            PamojaStatus::from_error(&error)
265        }
266        Err(_) => {
267            set_last_error("panic at the FFI boundary".to_owned());
268            PamojaStatus::Panic
269        }
270    }
271}
272
273/// Reports whether the client currently holds an active connection.
274///
275/// # Returns
276///
277/// `true` while connected. Returns `false` for a null handle or if the check
278/// panics.
279///
280/// # Safety
281///
282/// `client` must be a live handle from [`pamoja_mqtt_client_new`], or null.
283#[no_mangle]
284pub unsafe extern "C" fn pamoja_mqtt_client_is_connected(client: *mut PamojaMqttClient) -> bool {
285    let Some(client) = client_handle(client) else {
286        return false;
287    };
288    let inner = Arc::clone(&client.inner);
289    catch_unwind(AssertUnwindSafe(|| {
290        runtime().block_on(async move { inner.lock().await.is_connected() })
291    }))
292    .unwrap_or(false)
293}
294
295/// Closes the connection and stops the background event loop.
296///
297/// # Returns
298///
299/// [`PamojaStatus::Ok`] once the client has disconnected.
300///
301/// # Safety
302///
303/// `client` must be a live handle from [`pamoja_mqtt_client_new`].
304#[no_mangle]
305pub unsafe extern "C" fn pamoja_mqtt_client_disconnect(
306    client: *mut PamojaMqttClient,
307) -> PamojaStatus {
308    let Some(client) = client_handle(client) else {
309        return PamojaStatus::InvalidArgument;
310    };
311    let inner = Arc::clone(&client.inner);
312    run(async move { inner.lock().await.disconnect().await })
313}
314
315/// Releases an MQTT client handle.
316///
317/// Passing null is a no-op.
318///
319/// # Safety
320///
321/// `client` must be a handle from [`pamoja_mqtt_client_new`] that has not already
322/// been freed, or null. After this call the handle must not be used again.
323#[no_mangle]
324pub unsafe extern "C" fn pamoja_mqtt_client_free(client: *mut PamojaMqttClient) {
325    if !client.is_null() {
326        drop(Box::from_raw(client));
327    }
328}
329
330/// Returns the topic a message was published to.
331///
332/// # Returns
333///
334/// A pointer to a null-terminated UTF-8 string valid until the message is freed,
335/// or null if `message` is null.
336///
337/// # Safety
338///
339/// `message` must be a live handle from [`pamoja_mqtt_client_recv`], or null.
340#[no_mangle]
341pub unsafe extern "C" fn pamoja_mqtt_message_topic(
342    message: *const PamojaMqttMessage,
343) -> *const c_char {
344    if message.is_null() {
345        return ptr::null();
346    }
347    (*message).topic.as_ptr()
348}
349
350/// Returns a pointer to a message's payload bytes.
351///
352/// Use [`pamoja_mqtt_message_payload_len`] for the length. The pointer is valid
353/// until the message is freed.
354///
355/// # Returns
356///
357/// A pointer to the payload bytes, or null if `message` is null.
358///
359/// # Safety
360///
361/// `message` must be a live handle from [`pamoja_mqtt_client_recv`], or null.
362#[no_mangle]
363pub unsafe extern "C" fn pamoja_mqtt_message_payload(
364    message: *const PamojaMqttMessage,
365) -> *const u8 {
366    if message.is_null() {
367        return ptr::null();
368    }
369    (*message).payload.as_ptr()
370}
371
372/// Returns the length in bytes of a message's payload.
373///
374/// # Returns
375///
376/// The payload length, or 0 if `message` is null.
377///
378/// # Safety
379///
380/// `message` must be a live handle from [`pamoja_mqtt_client_recv`], or null.
381#[no_mangle]
382pub unsafe extern "C" fn pamoja_mqtt_message_payload_len(
383    message: *const PamojaMqttMessage,
384) -> usize {
385    if message.is_null() {
386        return 0;
387    }
388    (*message).payload.len()
389}
390
391/// Releases a message handle.
392///
393/// Passing null is a no-op.
394///
395/// # Safety
396///
397/// `message` must be a handle from [`pamoja_mqtt_client_recv`] that has not
398/// already been freed, or null. After this call the handle must not be used again.
399#[no_mangle]
400pub unsafe extern "C" fn pamoja_mqtt_message_free(message: *mut PamojaMqttMessage) {
401    if !message.is_null() {
402        drop(Box::from_raw(message));
403    }
404}
405
406/// Runs a unit-returning async operation to completion on the shared runtime.
407///
408/// Panics are caught so they never unwind across the C boundary; a caught panic
409/// is reported as [`PamojaStatus::Panic`].
410fn run<F>(future: F) -> PamojaStatus
411where
412    F: Future<Output = Result<(), Error>>,
413{
414    match catch_unwind(AssertUnwindSafe(|| runtime().block_on(future))) {
415        Ok(Ok(())) => PamojaStatus::Ok,
416        Ok(Err(error)) => {
417            set_last_error(error.to_string());
418            PamojaStatus::from_error(&error)
419        }
420        Err(_) => {
421            set_last_error("panic at the FFI boundary".to_owned());
422            PamojaStatus::Panic
423        }
424    }
425}
426
427/// Borrows a client handle, recording an error and returning `None` if it is null.
428///
429/// # Safety
430///
431/// `client` must be a live handle from [`pamoja_mqtt_client_new`], or null.
432unsafe fn client_handle<'a>(client: *mut PamojaMqttClient) -> Option<&'a PamojaMqttClient> {
433    if client.is_null() {
434        set_last_error("client must not be null".to_owned());
435        None
436    } else {
437        Some(&*client)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn qos_maps_to_core_levels() {
447        assert_eq!(
448            QualityOfService::from(PamojaQos::AtMostOnce),
449            QualityOfService::AtMostOnce
450        );
451        assert_eq!(
452            QualityOfService::from(PamojaQos::AtLeastOnce),
453            QualityOfService::AtLeastOnce
454        );
455        assert_eq!(
456            QualityOfService::from(PamojaQos::ExactlyOnce),
457            QualityOfService::ExactlyOnce
458        );
459    }
460
461    #[test]
462    fn read_bytes_treats_zero_length_as_empty() {
463        // Safety: a null pointer is allowed when the length is zero.
464        let bytes = unsafe { read_bytes(ptr::null(), 0) }.expect("empty payload");
465        assert!(bytes.is_empty());
466    }
467
468    #[test]
469    fn new_with_null_config_returns_null() {
470        // Safety: passing null is explicitly handled by the constructor.
471        let client = unsafe { pamoja_mqtt_client_new(ptr::null()) };
472        assert!(client.is_null());
473    }
474
475    #[test]
476    fn calls_on_a_null_client_are_rejected() {
477        // Safety: every entry point tolerates a null handle without dereferencing it.
478        let status =
479            unsafe { pamoja_mqtt_client_publish(ptr::null_mut(), ptr::null(), ptr::null(), 0) };
480        assert_eq!(status, PamojaStatus::InvalidArgument);
481        // Freeing null is a documented no-op.
482        unsafe { pamoja_mqtt_client_free(ptr::null_mut()) };
483    }
484
485    #[test]
486    fn a_null_topic_is_rejected_before_any_network_use() {
487        let client_id = CString::new("audit").expect("no null byte");
488        let host = CString::new("localhost").expect("no null byte");
489        let config = PamojaMqttConfig {
490            client_id: client_id.as_ptr(),
491            host: host.as_ptr(),
492            port: 1883,
493            keep_alive_secs: 0,
494            capacity: 0,
495            qos: PamojaQos::AtMostOnce,
496        };
497        // Safety: the config and its borrowed strings are valid for the call.
498        let client = unsafe { pamoja_mqtt_client_new(&config) };
499        assert!(!client.is_null());
500        // A null topic is caught before any connection is attempted.
501        let status = unsafe { pamoja_mqtt_client_publish(client, ptr::null(), ptr::null(), 0) };
502        assert_eq!(status, PamojaStatus::InvalidArgument);
503        // Safety: the handle came from client_new and has not been freed.
504        unsafe { pamoja_mqtt_client_free(client) };
505    }
506}