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::{Inbox, MqttConfig, MqttTransport, PublishOptions, QualityOfService, Tls, Will};
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/// A message the broker publishes on the client's behalf if its connection ends without a
51/// disconnect: the network dropped, the power failed, or the keep-alive ran out.
52///
53/// `topic` is a borrowed null-terminated UTF-8 string with no wildcard, and `payload`
54/// points at `payload_len` bytes, or is null when `payload_len` is 0.
55#[repr(C)]
56pub struct PamojaMqttWill {
57    /// The topic the broker publishes it to.
58    pub topic: *const c_char,
59    /// What it publishes.
60    pub payload: *const u8,
61    /// How many bytes `payload` holds.
62    pub payload_len: usize,
63    /// The quality of service it is published at.
64    pub qos: PamojaQos,
65    /// Whether the broker retains it for clients that subscribe later.
66    pub retain: bool,
67}
68
69/// How a connection is secured with TLS, conventionally on port 8883.
70///
71/// Each PEM is a borrowed run of bytes with its length. A `ca_pem_len` of 0 trusts the
72/// system's certificate authorities; a client certificate and its key are both given or
73/// both left at 0.
74#[repr(C)]
75pub struct PamojaMqttTls {
76    /// The certificate authorities to trust, as PEM.
77    pub ca_pem: *const u8,
78    /// How many bytes `ca_pem` holds, or 0 to trust the system's authorities.
79    pub ca_pem_len: usize,
80    /// A client certificate to present, as PEM.
81    pub certificate_pem: *const u8,
82    /// How many bytes `certificate_pem` holds, or 0 for none.
83    pub certificate_pem_len: usize,
84    /// The client certificate's private key, as PEM.
85    pub key_pem: *const u8,
86    /// How many bytes `key_pem` holds, or 0 for none.
87    pub key_pem_len: usize,
88}
89
90/// Connection settings for an MQTT client.
91///
92/// `client_id` and `host` are borrowed null-terminated UTF-8 strings. A
93/// `keep_alive_secs`, `capacity`, or `max_packet_size` of `0` selects the core
94/// default. `username`, `password`, `will`, and `tls` are each null when unused.
95#[repr(C)]
96pub struct PamojaMqttConfig {
97    /// The MQTT client identifier presented to the broker.
98    pub client_id: *const c_char,
99    /// The broker hostname or IP address.
100    pub host: *const c_char,
101    /// The broker TCP port, conventionally 1883 for plaintext MQTT.
102    pub port: u16,
103    /// Keep-alive interval in seconds, or 0 for the default of 30.
104    pub keep_alive_secs: u32,
105    /// Bound on outstanding client requests, or 0 for the default of 64.
106    pub capacity: u32,
107    /// Default quality of service for publishes and subscriptions.
108    pub qos: PamojaQos,
109    /// The largest packet the connection sends or accepts, in bytes, or 0 for the
110    /// default of 10,240.
111    pub max_packet_size: u32,
112    /// The name to sign in to the broker with, or null.
113    pub username: *const c_char,
114    /// The password to sign in with, which needs a username, or null.
115    pub password: *const c_char,
116    /// A message the broker publishes if the connection ends without a goodbye, or null.
117    pub will: *const PamojaMqttWill,
118    /// TLS settings, or null for plain TCP.
119    pub tls: *const PamojaMqttTls,
120}
121
122/// An opaque handle to an MQTT client transport.
123pub struct PamojaMqttClient {
124    inner: Arc<Mutex<MqttTransport>>,
125    inbox: Inbox,
126}
127
128/// An opaque handle to a message received from a subscribed topic.
129pub struct PamojaMqttMessage {
130    topic: CString,
131    payload: Vec<u8>,
132}
133
134/// Creates a disconnected MQTT client from the given settings.
135///
136/// # Returns
137///
138/// A heap-allocated client handle the caller owns and must release with
139/// [`pamoja_mqtt_client_free`], or null on failure with the reason available from
140/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
141///
142/// # Safety
143///
144/// `config` must point to a valid [`PamojaMqttConfig`] whose `client_id` and
145/// `host` are valid null-terminated UTF-8 strings for the duration of the call.
146#[no_mangle]
147pub unsafe extern "C" fn pamoja_mqtt_client_new(
148    config: *const PamojaMqttConfig,
149) -> *mut PamojaMqttClient {
150    if config.is_null() {
151        set_last_error("config must not be null".to_owned());
152        return ptr::null_mut();
153    }
154    let Some(settings) = mqtt_settings(config) else {
155        return ptr::null_mut();
156    };
157
158    let transport = MqttTransport::new(settings);
159    let client = PamojaMqttClient {
160        inbox: transport.inbox(),
161        inner: Arc::new(Mutex::new(transport)),
162    };
163    Box::into_raw(Box::new(client))
164}
165
166/// Reads the broker settings a config describes.
167///
168/// Shared with the composable transport handle, so a client and a ladder rung
169/// read the same fields the same way.
170///
171/// # Safety
172///
173/// `config` must point to a valid [`PamojaMqttConfig`] whose `client_id` and
174/// `host` are valid null-terminated UTF-8 strings for the duration of the call,
175/// or be null.
176pub(crate) unsafe fn mqtt_settings(config: *const PamojaMqttConfig) -> Option<MqttConfig> {
177    if config.is_null() {
178        set_last_error("config must not be null".to_owned());
179        return None;
180    }
181    let config = &*config;
182    let client_id = read_str(config.client_id, "client_id")?;
183    let host = read_str(config.host, "host")?;
184
185    let mut settings = MqttConfig::new(client_id, host, config.port);
186    if config.keep_alive_secs != 0 {
187        settings = settings.keep_alive(Duration::from_secs(u64::from(config.keep_alive_secs)));
188    }
189    if config.capacity != 0 {
190        settings = settings.capacity(config.capacity as usize);
191    }
192    if config.max_packet_size != 0 {
193        settings = settings.max_packet_size(config.max_packet_size as usize);
194    }
195    match (config.username.is_null(), config.password.is_null()) {
196        (false, _) => {
197            let username = read_str(config.username, "username")?;
198            let password = if config.password.is_null() {
199                ""
200            } else {
201                read_str(config.password, "password")?
202            };
203            settings = settings.credentials(username, password);
204        }
205        (true, false) => {
206            set_last_error("a password needs a username: MQTT sends no password alone".to_owned());
207            return None;
208        }
209        (true, true) => {}
210    }
211    if let Some(will) = config.will.as_ref() {
212        let topic = read_str(will.topic, "will topic")?;
213        let payload = read_bytes(will.payload, will.payload_len).ok()?;
214        let mut built = Will::new(topic, payload).qos(will.qos.into());
215        if will.retain {
216            built = built.retained();
217        }
218        settings = settings.last_will(built);
219    }
220    if let Some(tls) = config.tls.as_ref() {
221        let mut built = if tls.ca_pem_len == 0 {
222            Tls::system_roots()
223        } else {
224            Tls::with_ca_pem(read_bytes(tls.ca_pem, tls.ca_pem_len).ok()?)
225        };
226        match (tls.certificate_pem_len, tls.key_pem_len) {
227            (0, 0) => {}
228            (0, _) | (_, 0) => {
229                set_last_error("a client certificate and its key come together".to_owned());
230                return None;
231            }
232            (certificate_len, key_len) => {
233                built = built.client_certificate(
234                    read_bytes(tls.certificate_pem, certificate_len).ok()?,
235                    read_bytes(tls.key_pem, key_len).ok()?,
236                );
237            }
238        }
239        settings = settings.tls(built);
240    }
241    Some(settings.qos(config.qos.into()))
242}
243
244/// Connects to the broker and starts the background event loop.
245///
246/// # Returns
247///
248/// [`PamojaStatus::Ok`] once connected, or an error status whose message is
249/// available from [`pamoja_last_error_message`](crate::pamoja_last_error_message).
250///
251/// # Safety
252///
253/// `client` must be a non-null handle returned by [`pamoja_mqtt_client_new`] and
254/// not yet freed.
255#[no_mangle]
256pub unsafe extern "C" fn pamoja_mqtt_client_connect(client: *mut PamojaMqttClient) -> PamojaStatus {
257    let Some(client) = client_handle(client) else {
258        return PamojaStatus::InvalidArgument;
259    };
260    let inner = Arc::clone(&client.inner);
261    run(async move { inner.lock().await.connect().await })
262}
263
264/// Publishes a payload to a topic.
265///
266/// # Returns
267///
268/// [`PamojaStatus::Ok`] once the payload is handed to the transport, or an error
269/// status.
270///
271/// # Safety
272///
273/// `client` must be a live handle from [`pamoja_mqtt_client_new`]; `topic` must be
274/// a valid null-terminated UTF-8 string; and `payload` must point to at least
275/// `payload_len` bytes, or be null when `payload_len` is 0.
276#[no_mangle]
277pub unsafe extern "C" fn pamoja_mqtt_client_publish(
278    client: *mut PamojaMqttClient,
279    topic: *const c_char,
280    payload: *const u8,
281    payload_len: usize,
282) -> PamojaStatus {
283    let Some(client) = client_handle(client) else {
284        return PamojaStatus::InvalidArgument;
285    };
286    let Some(topic) = read_str(topic, "topic") else {
287        return PamojaStatus::InvalidArgument;
288    };
289    let payload = match read_bytes(payload, payload_len) {
290        Ok(payload) => payload,
291        Err(status) => return status,
292    };
293    let topic = topic.to_owned();
294    let inner = Arc::clone(&client.inner);
295    run(async move { inner.lock().await.send(&topic, &payload).await })
296}
297
298/// Publishes a payload to a topic with options of its own, optionally waiting for the
299/// broker to acknowledge it.
300///
301/// # Arguments
302///
303/// * `client` - the client.
304/// * `topic` - the destination topic, with no wildcard.
305/// * `payload` - the message.
306/// * `payload_len` - how many bytes `payload` holds.
307/// * `qos` - the quality of service for this message.
308/// * `retain` - whether the broker keeps it for clients that subscribe later; an empty
309///   retained message clears the one the broker holds.
310/// * `confirmed` - whether to return only once the broker acknowledges the message: its
311///   `PUBACK` at QoS 1, its `PUBCOMP` at QoS 2, and once the connection has taken it at
312///   QoS 0, where MQTT acknowledges nothing.
313///
314/// # Returns
315///
316/// [`PamojaStatus::Ok`] once the message is queued, or acknowledged when `confirmed` is
317/// set, or an error status. A confirmed publish whose connection ends first fails with
318/// [`PamojaStatus::Transport`], when the message may or may not have arrived.
319///
320/// # Safety
321///
322/// `client` must be a live handle from [`pamoja_mqtt_client_new`]; `topic` must be a valid
323/// null-terminated UTF-8 string; and `payload` must point to at least `payload_len` bytes,
324/// or be null when `payload_len` is 0.
325#[no_mangle]
326pub unsafe extern "C" fn pamoja_mqtt_client_publish_with(
327    client: *mut PamojaMqttClient,
328    topic: *const c_char,
329    payload: *const u8,
330    payload_len: usize,
331    qos: PamojaQos,
332    retain: bool,
333    confirmed: bool,
334) -> PamojaStatus {
335    let Some(client) = client_handle(client) else {
336        return PamojaStatus::InvalidArgument;
337    };
338    let Some(topic) = read_str(topic, "topic") else {
339        return PamojaStatus::InvalidArgument;
340    };
341    let payload = match read_bytes(payload, payload_len) {
342        Ok(payload) => payload,
343        Err(status) => return status,
344    };
345    let mut options = PublishOptions::new().qos(qos.into());
346    if retain {
347        options = options.retained();
348    }
349    let topic = topic.to_owned();
350    let inner = Arc::clone(&client.inner);
351    run(async move {
352        let delivery = inner
353            .lock()
354            .await
355            .publish(&topic, &payload, options)
356            .await?;
357        if confirmed {
358            delivery.confirmed().await
359        } else {
360            Ok(())
361        }
362    })
363}
364
365/// Subscribes to a topic filter.
366///
367/// # Returns
368///
369/// [`PamojaStatus::Ok`] once the subscription is registered, or an error status.
370///
371/// # Safety
372///
373/// `client` must be a live handle from [`pamoja_mqtt_client_new`] and `topic` a
374/// valid null-terminated UTF-8 string.
375#[no_mangle]
376pub unsafe extern "C" fn pamoja_mqtt_client_subscribe(
377    client: *mut PamojaMqttClient,
378    topic: *const c_char,
379) -> PamojaStatus {
380    let Some(client) = client_handle(client) else {
381        return PamojaStatus::InvalidArgument;
382    };
383    let Some(topic) = read_str(topic, "topic") else {
384        return PamojaStatus::InvalidArgument;
385    };
386    let topic = topic.to_owned();
387    let inner = Arc::clone(&client.inner);
388    run(async move { inner.lock().await.subscribe(&topic).await })
389}
390
391/// Awaits the next message from any subscribed topic.
392///
393/// On success `*out_message` is set to a new message handle the caller owns and
394/// must release with [`pamoja_mqtt_message_free`], or to null once the connection
395/// has ended and no further messages will arrive.
396///
397/// # Returns
398///
399/// [`PamojaStatus::Ok`] on success (including end of stream), or an error status:
400/// [`PamojaStatus::Transport`] once, with the reason as the last error message,
401/// when the connection ended on its own.
402///
403/// # Safety
404///
405/// `client` must be a live handle from [`pamoja_mqtt_client_new`] and
406/// `out_message` must point to a writable `*mut PamojaMqttMessage`.
407#[no_mangle]
408pub unsafe extern "C" fn pamoja_mqtt_client_recv(
409    client: *mut PamojaMqttClient,
410    out_message: *mut *mut PamojaMqttMessage,
411) -> PamojaStatus {
412    if out_message.is_null() {
413        set_last_error("out_message must not be null".to_owned());
414        return PamojaStatus::InvalidArgument;
415    }
416    *out_message = ptr::null_mut();
417    let Some(client) = client_handle(client) else {
418        return PamojaStatus::InvalidArgument;
419    };
420    let inbox = client.inbox.clone();
421
422    match catch_unwind(AssertUnwindSafe(|| {
423        runtime().block_on(async move { inbox.recv().await })
424    })) {
425        Ok(Ok(Some(message))) => {
426            let boxed = Box::new(PamojaMqttMessage {
427                topic: CString::new(message.topic)
428                    .unwrap_or_else(|_| CString::new("").expect("static")),
429                payload: message.payload,
430            });
431            *out_message = Box::into_raw(boxed);
432            PamojaStatus::Ok
433        }
434        Ok(Ok(None)) => PamojaStatus::Ok,
435        Ok(Err(error)) => {
436            set_last_error(error.to_string());
437            PamojaStatus::from_error(&error)
438        }
439        Err(_) => {
440            set_last_error("panic at the FFI boundary".to_owned());
441            PamojaStatus::Panic
442        }
443    }
444}
445
446/// Waits a limited time for the next message from any subscribed topic.
447///
448/// Running out of time loses nothing: a message that arrives afterwards waits for
449/// the next receive. This is the call to use rather than abandoning a receive that
450/// is still waiting, which would take that message instead.
451///
452/// # Arguments
453///
454/// * `client` - the client.
455/// * `timeout_ms` - how long to wait, in milliseconds.
456/// * `out_message` - receives a message handle the caller releases with
457///   [`pamoja_mqtt_message_free`], or null when the time ran out or the
458///   connection has ended.
459/// * `out_timed_out` - receives whether the time ran out before a message arrived.
460///
461/// # Returns
462///
463/// [`PamojaStatus::Ok`] with a message, with the time run out, or with null once
464/// the connection has ended, or an error status.
465///
466/// # Safety
467///
468/// `client` must be a live handle from [`pamoja_mqtt_client_new`], and
469/// `out_message` and `out_timed_out` must be writable.
470#[no_mangle]
471pub unsafe extern "C" fn pamoja_mqtt_client_recv_within(
472    client: *mut PamojaMqttClient,
473    timeout_ms: u64,
474    out_message: *mut *mut PamojaMqttMessage,
475    out_timed_out: *mut bool,
476) -> PamojaStatus {
477    if out_message.is_null() || out_timed_out.is_null() {
478        set_last_error("out_message and out_timed_out must not be null".to_owned());
479        return PamojaStatus::InvalidArgument;
480    }
481    *out_message = ptr::null_mut();
482    *out_timed_out = false;
483    let Some(client) = client_handle(client) else {
484        return PamojaStatus::InvalidArgument;
485    };
486    let inbox = client.inbox.clone();
487    let limit = Duration::from_millis(timeout_ms);
488
489    match catch_unwind(AssertUnwindSafe(|| {
490        runtime().block_on(async move { tokio::time::timeout(limit, inbox.recv()).await })
491    })) {
492        Ok(Err(_)) => {
493            *out_timed_out = true;
494            PamojaStatus::Ok
495        }
496        Ok(Ok(Ok(Some(message)))) => {
497            *out_message = Box::into_raw(Box::new(PamojaMqttMessage {
498                topic: CString::new(message.topic)
499                    .unwrap_or_else(|_| CString::new("").expect("static")),
500                payload: message.payload,
501            }));
502            PamojaStatus::Ok
503        }
504        Ok(Ok(Ok(None))) => PamojaStatus::Ok,
505        Ok(Ok(Err(error))) => {
506            set_last_error(error.to_string());
507            PamojaStatus::from_error(&error)
508        }
509        Err(_) => {
510            set_last_error("panic at the FFI boundary".to_owned());
511            PamojaStatus::Panic
512        }
513    }
514}
515
516/// Reports whether the client currently holds an active connection.
517///
518/// # Returns
519///
520/// `true` while connected. Returns `false` for a null handle or if the check
521/// panics.
522///
523/// # Safety
524///
525/// `client` must be a live handle from [`pamoja_mqtt_client_new`], or null.
526#[no_mangle]
527pub unsafe extern "C" fn pamoja_mqtt_client_is_connected(client: *mut PamojaMqttClient) -> bool {
528    let Some(client) = client_handle(client) else {
529        return false;
530    };
531    let inner = Arc::clone(&client.inner);
532    catch_unwind(AssertUnwindSafe(|| {
533        runtime().block_on(async move { inner.lock().await.is_connected() })
534    }))
535    .unwrap_or(false)
536}
537
538/// Closes the connection and stops the background event loop.
539///
540/// # Returns
541///
542/// [`PamojaStatus::Ok`] once the client has disconnected.
543///
544/// # Safety
545///
546/// `client` must be a live handle from [`pamoja_mqtt_client_new`].
547#[no_mangle]
548pub unsafe extern "C" fn pamoja_mqtt_client_disconnect(
549    client: *mut PamojaMqttClient,
550) -> PamojaStatus {
551    let Some(client) = client_handle(client) else {
552        return PamojaStatus::InvalidArgument;
553    };
554    let inner = Arc::clone(&client.inner);
555    run(async move { inner.lock().await.disconnect().await })
556}
557
558/// Releases an MQTT client handle.
559///
560/// Passing null is a no-op.
561///
562/// # Safety
563///
564/// `client` must be a handle from [`pamoja_mqtt_client_new`] that has not already
565/// been freed, or null. After this call the handle must not be used again.
566#[no_mangle]
567pub unsafe extern "C" fn pamoja_mqtt_client_free(client: *mut PamojaMqttClient) {
568    if !client.is_null() {
569        drop(Box::from_raw(client));
570    }
571}
572
573/// Returns the topic a message was published to.
574///
575/// # Returns
576///
577/// A pointer to a null-terminated UTF-8 string valid until the message is freed,
578/// or null if `message` is null.
579///
580/// # Safety
581///
582/// `message` must be a live handle from [`pamoja_mqtt_client_recv`], or null.
583#[no_mangle]
584pub unsafe extern "C" fn pamoja_mqtt_message_topic(
585    message: *const PamojaMqttMessage,
586) -> *const c_char {
587    if message.is_null() {
588        return ptr::null();
589    }
590    (*message).topic.as_ptr()
591}
592
593/// Returns a pointer to a message's payload bytes.
594///
595/// Use [`pamoja_mqtt_message_payload_len`] for the length. The pointer is valid
596/// until the message is freed.
597///
598/// # Returns
599///
600/// A pointer to the payload bytes, or null if `message` is null.
601///
602/// # Safety
603///
604/// `message` must be a live handle from [`pamoja_mqtt_client_recv`], or null.
605#[no_mangle]
606pub unsafe extern "C" fn pamoja_mqtt_message_payload(
607    message: *const PamojaMqttMessage,
608) -> *const u8 {
609    if message.is_null() {
610        return ptr::null();
611    }
612    (*message).payload.as_ptr()
613}
614
615/// Returns the length in bytes of a message's payload.
616///
617/// # Returns
618///
619/// The payload length, or 0 if `message` is null.
620///
621/// # Safety
622///
623/// `message` must be a live handle from [`pamoja_mqtt_client_recv`], or null.
624#[no_mangle]
625pub unsafe extern "C" fn pamoja_mqtt_message_payload_len(
626    message: *const PamojaMqttMessage,
627) -> usize {
628    if message.is_null() {
629        return 0;
630    }
631    (*message).payload.len()
632}
633
634/// Releases a message handle.
635///
636/// Passing null is a no-op.
637///
638/// # Safety
639///
640/// `message` must be a handle from [`pamoja_mqtt_client_recv`] that has not
641/// already been freed, or null. After this call the handle must not be used again.
642#[no_mangle]
643pub unsafe extern "C" fn pamoja_mqtt_message_free(message: *mut PamojaMqttMessage) {
644    if !message.is_null() {
645        drop(Box::from_raw(message));
646    }
647}
648
649/// Runs a unit-returning async operation to completion on the shared runtime.
650///
651/// Panics are caught so they never unwind across the C boundary; a caught panic
652/// is reported as [`PamojaStatus::Panic`].
653fn run<F>(future: F) -> PamojaStatus
654where
655    F: Future<Output = Result<(), Error>>,
656{
657    match catch_unwind(AssertUnwindSafe(|| runtime().block_on(future))) {
658        Ok(Ok(())) => PamojaStatus::Ok,
659        Ok(Err(error)) => {
660            set_last_error(error.to_string());
661            PamojaStatus::from_error(&error)
662        }
663        Err(_) => {
664            set_last_error("panic at the FFI boundary".to_owned());
665            PamojaStatus::Panic
666        }
667    }
668}
669
670/// Borrows a client handle, recording an error and returning `None` if it is null.
671///
672/// # Safety
673///
674/// `client` must be a live handle from [`pamoja_mqtt_client_new`], or null.
675unsafe fn client_handle<'a>(client: *mut PamojaMqttClient) -> Option<&'a PamojaMqttClient> {
676    if client.is_null() {
677        set_last_error("client must not be null".to_owned());
678        None
679    } else {
680        Some(&*client)
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn qos_maps_to_core_levels() {
690        assert_eq!(
691            QualityOfService::from(PamojaQos::AtMostOnce),
692            QualityOfService::AtMostOnce
693        );
694        assert_eq!(
695            QualityOfService::from(PamojaQos::AtLeastOnce),
696            QualityOfService::AtLeastOnce
697        );
698        assert_eq!(
699            QualityOfService::from(PamojaQos::ExactlyOnce),
700            QualityOfService::ExactlyOnce
701        );
702    }
703
704    #[test]
705    fn read_bytes_treats_zero_length_as_empty() {
706        // Safety: a null pointer is allowed when the length is zero.
707        let bytes = unsafe { read_bytes(ptr::null(), 0) }.expect("empty payload");
708        assert!(bytes.is_empty());
709    }
710
711    #[test]
712    fn a_password_without_a_username_is_refused() {
713        let client_id = CString::new("lonely").expect("no null byte");
714        let host = CString::new("localhost").expect("no null byte");
715        let password = CString::new("hunter2").expect("no null byte");
716        let config = PamojaMqttConfig {
717            client_id: client_id.as_ptr(),
718            host: host.as_ptr(),
719            port: 1883,
720            keep_alive_secs: 0,
721            capacity: 0,
722            qos: PamojaQos::AtLeastOnce,
723            max_packet_size: 0,
724            username: ptr::null(),
725            password: password.as_ptr(),
726            will: ptr::null(),
727            tls: ptr::null(),
728        };
729        // Safety: the config and its borrowed strings are valid for the call.
730        let client = unsafe { pamoja_mqtt_client_new(&config) };
731        assert!(client.is_null());
732    }
733
734    #[test]
735    fn new_with_null_config_returns_null() {
736        // Safety: passing null is explicitly handled by the constructor.
737        let client = unsafe { pamoja_mqtt_client_new(ptr::null()) };
738        assert!(client.is_null());
739    }
740
741    #[test]
742    fn calls_on_a_null_client_are_rejected() {
743        // Safety: every entry point tolerates a null handle without dereferencing it.
744        let status =
745            unsafe { pamoja_mqtt_client_publish(ptr::null_mut(), ptr::null(), ptr::null(), 0) };
746        assert_eq!(status, PamojaStatus::InvalidArgument);
747        // Freeing null is a documented no-op.
748        unsafe { pamoja_mqtt_client_free(ptr::null_mut()) };
749    }
750
751    #[test]
752    fn a_null_topic_is_rejected_before_any_network_use() {
753        let client_id = CString::new("audit").expect("no null byte");
754        let host = CString::new("localhost").expect("no null byte");
755        let config = PamojaMqttConfig {
756            client_id: client_id.as_ptr(),
757            host: host.as_ptr(),
758            port: 1883,
759            keep_alive_secs: 0,
760            capacity: 0,
761            qos: PamojaQos::AtMostOnce,
762            max_packet_size: 0,
763            username: ptr::null(),
764            password: ptr::null(),
765            will: ptr::null(),
766            tls: ptr::null(),
767        };
768        // Safety: the config and its borrowed strings are valid for the call.
769        let client = unsafe { pamoja_mqtt_client_new(&config) };
770        assert!(!client.is_null());
771        // A null topic is caught before any connection is attempted.
772        let status = unsafe { pamoja_mqtt_client_publish(client, ptr::null(), ptr::null(), 0) };
773        assert_eq!(status, PamojaStatus::InvalidArgument);
774        // Safety: the handle came from client_new and has not been freed.
775        unsafe { pamoja_mqtt_client_free(client) };
776    }
777}