1use 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#[allow(clippy::enum_variant_names)]
29#[repr(C)]
30#[derive(Clone, Copy)]
31pub enum PamojaQos {
32 AtMostOnce = 0,
34 AtLeastOnce = 1,
36 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#[repr(C)]
56pub struct PamojaMqttWill {
57 pub topic: *const c_char,
59 pub payload: *const u8,
61 pub payload_len: usize,
63 pub qos: PamojaQos,
65 pub retain: bool,
67}
68
69#[repr(C)]
75pub struct PamojaMqttTls {
76 pub ca_pem: *const u8,
78 pub ca_pem_len: usize,
80 pub certificate_pem: *const u8,
82 pub certificate_pem_len: usize,
84 pub key_pem: *const u8,
86 pub key_pem_len: usize,
88}
89
90#[repr(C)]
96pub struct PamojaMqttConfig {
97 pub client_id: *const c_char,
99 pub host: *const c_char,
101 pub port: u16,
103 pub keep_alive_secs: u32,
105 pub capacity: u32,
107 pub qos: PamojaQos,
109 pub max_packet_size: u32,
112 pub username: *const c_char,
114 pub password: *const c_char,
116 pub will: *const PamojaMqttWill,
118 pub tls: *const PamojaMqttTls,
120}
121
122pub struct PamojaMqttClient {
124 inner: Arc<Mutex<MqttTransport>>,
125 inbox: Inbox,
126}
127
128pub struct PamojaMqttMessage {
130 topic: CString,
131 payload: Vec<u8>,
132}
133
134#[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
166pub(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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
649fn 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
670unsafe 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 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 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 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 let status =
745 unsafe { pamoja_mqtt_client_publish(ptr::null_mut(), ptr::null(), ptr::null(), 0) };
746 assert_eq!(status, PamojaStatus::InvalidArgument);
747 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 let client = unsafe { pamoja_mqtt_client_new(&config) };
770 assert!(!client.is_null());
771 let status = unsafe { pamoja_mqtt_client_publish(client, ptr::null(), ptr::null(), 0) };
773 assert_eq!(status, PamojaStatus::InvalidArgument);
774 unsafe { pamoja_mqtt_client_free(client) };
776 }
777}