Skip to main content

pamoja_ffi/
codec.rs

1//! The C ABI for wire formats and metered-link packing.
2//!
3//! These functions wrap [`pamoja_codec`] for callers that reach the SDK through
4//! the flat C boundary. The [`Codec`](pamoja_codec::Codec) trait is generic over
5//! the value being carried and so cannot cross a C ABI; what crosses instead is
6//! the concrete work a caller with an untyped payload actually needs. Converting
7//! a document between JSON and CBOR, and packing a batch of readings small enough
8//! for a metered link.
9//!
10//! Encoded output is bytes and comes back as a [`PamojaBuffer`]. Decoded output is
11//! a typed series, so it comes back as [`PamojaSamples`] (`int64`) or
12//! [`PamojaReadings`] (`float`) rather than bytes the caller would have to
13//! reinterpret.
14
15use std::panic::{catch_unwind, AssertUnwindSafe};
16use std::ptr;
17
18use pamoja_codec::{cbor_to_json, decode_deltas, encode_deltas, json_to_cbor, Quantizer};
19use pamoja_core::Result as CoreResult;
20
21use crate::{read_bytes, set_last_error, PamojaBuffer, PamojaStatus};
22
23/// An opaque handle to a decoded series of integer samples.
24///
25/// Read it with [`pamoja_samples_data`] and [`pamoja_samples_len`], then release
26/// it with [`pamoja_samples_free`].
27pub struct PamojaSamples {
28    samples: Vec<i64>,
29}
30
31/// An opaque handle to a decoded series of float readings.
32///
33/// Read it with [`pamoja_readings_data`] and [`pamoja_readings_len`], then
34/// release it with [`pamoja_readings_free`].
35pub struct PamojaReadings {
36    readings: Vec<f32>,
37}
38
39/// Converts a JSON document into its CBOR encoding.
40///
41/// # Returns
42///
43/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
44/// the caller must release with
45/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or an error status whose
46/// message is available from
47/// [`pamoja_last_error_message`](crate::pamoja_last_error_message).
48///
49/// # Safety
50///
51/// `json` must point to at least `json_len` readable bytes, or be null when
52/// `json_len` is 0, and `out_buffer` must point to a writable
53/// `*mut PamojaBuffer`.
54#[no_mangle]
55pub unsafe extern "C" fn pamoja_codec_json_to_cbor(
56    json: *const u8,
57    json_len: usize,
58    out_buffer: *mut *mut PamojaBuffer,
59) -> PamojaStatus {
60    transcode(json, json_len, out_buffer, json_to_cbor)
61}
62
63/// Converts a CBOR document into its JSON encoding.
64///
65/// # Returns
66///
67/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
68/// the caller must release with
69/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or an error status.
70///
71/// # Safety
72///
73/// `cbor` must point to at least `cbor_len` readable bytes, or be null when
74/// `cbor_len` is 0, and `out_buffer` must point to a writable
75/// `*mut PamojaBuffer`.
76#[no_mangle]
77pub unsafe extern "C" fn pamoja_codec_cbor_to_json(
78    cbor: *const u8,
79    cbor_len: usize,
80    out_buffer: *mut *mut PamojaBuffer,
81) -> PamojaStatus {
82    transcode(cbor, cbor_len, out_buffer, cbor_to_json)
83}
84
85/// Delta-encodes a series of integer samples into a compact buffer.
86///
87/// # Returns
88///
89/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
90/// the caller must release with
91/// [`pamoja_buffer_free`](crate::pamoja_buffer_free).
92///
93/// # Safety
94///
95/// `samples` must point to at least `count` readable `int64` values, or be null
96/// when `count` is 0, and `out_buffer` must point to a writable
97/// `*mut PamojaBuffer`.
98#[no_mangle]
99pub unsafe extern "C" fn pamoja_codec_encode_deltas(
100    samples: *const i64,
101    count: usize,
102    out_buffer: *mut *mut PamojaBuffer,
103) -> PamojaStatus {
104    let out_buffer = match out_slot(out_buffer, "out_buffer") {
105        Ok(slot) => slot,
106        Err(status) => return status,
107    };
108    let samples = match read_slice(samples, count, "samples") {
109        Ok(samples) => samples,
110        Err(status) => return status,
111    };
112    match catch_unwind(AssertUnwindSafe(|| encode_deltas(&samples))) {
113        Ok(bytes) => {
114            *out_buffer = PamojaBuffer::into_raw(bytes);
115            PamojaStatus::Ok
116        }
117        Err(_) => panicked(),
118    }
119}
120
121/// Decodes a delta-encoded buffer back into its integer samples.
122///
123/// # Returns
124///
125/// [`PamojaStatus::Ok`] on success, with `*out_samples` set to a new handle the
126/// caller must release with [`pamoja_samples_free`], or
127/// [`PamojaStatus::Codec`] if the buffer is malformed.
128///
129/// # Safety
130///
131/// `bytes` must point to at least `bytes_len` readable bytes, or be null when
132/// `bytes_len` is 0, and `out_samples` must point to a writable
133/// `*mut PamojaSamples`.
134#[no_mangle]
135pub unsafe extern "C" fn pamoja_codec_decode_deltas(
136    bytes: *const u8,
137    bytes_len: usize,
138    out_samples: *mut *mut PamojaSamples,
139) -> PamojaStatus {
140    let out_samples = match out_slot(out_samples, "out_samples") {
141        Ok(slot) => slot,
142        Err(status) => return status,
143    };
144    let bytes = match read_bytes(bytes, bytes_len) {
145        Ok(bytes) => bytes,
146        Err(status) => return status,
147    };
148    match catch_unwind(AssertUnwindSafe(|| decode_deltas(&bytes))) {
149        Ok(Ok(samples)) => {
150            *out_samples = Box::into_raw(Box::new(PamojaSamples { samples }));
151            PamojaStatus::Ok
152        }
153        Ok(Err(error)) => failed(&error),
154        Err(_) => panicked(),
155    }
156}
157
158/// Quantizes and delta-encodes a batch of float readings.
159///
160/// # Returns
161///
162/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
163/// the caller must release with
164/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or
165/// [`PamojaStatus::InvalidArgument`] if `scale` is not positive and finite.
166///
167/// # Safety
168///
169/// `readings` must point to at least `count` readable `float` values, or be null
170/// when `count` is 0, and `out_buffer` must point to a writable
171/// `*mut PamojaBuffer`.
172#[no_mangle]
173pub unsafe extern "C" fn pamoja_codec_quantizer_encode(
174    scale: f32,
175    readings: *const f32,
176    count: usize,
177    out_buffer: *mut *mut PamojaBuffer,
178) -> PamojaStatus {
179    let out_buffer = match out_slot(out_buffer, "out_buffer") {
180        Ok(slot) => slot,
181        Err(status) => return status,
182    };
183    if let Err(status) = check_scale(scale) {
184        return status;
185    }
186    let readings = match read_slice(readings, count, "readings") {
187        Ok(readings) => readings,
188        Err(status) => return status,
189    };
190    match catch_unwind(AssertUnwindSafe(|| Quantizer::new(scale).encode(&readings))) {
191        Ok(bytes) => {
192            *out_buffer = PamojaBuffer::into_raw(bytes);
193            PamojaStatus::Ok
194        }
195        Err(_) => panicked(),
196    }
197}
198
199/// Decodes a quantized batch back into float readings.
200///
201/// The readings come back to within the precision `scale` selected, which must be
202/// the same scale the batch was encoded with.
203///
204/// # Returns
205///
206/// [`PamojaStatus::Ok`] on success, with `*out_readings` set to a new handle the
207/// caller must release with [`pamoja_readings_free`], or
208/// [`PamojaStatus::Codec`] if the buffer is malformed.
209///
210/// # Safety
211///
212/// `bytes` must point to at least `bytes_len` readable bytes, or be null when
213/// `bytes_len` is 0, and `out_readings` must point to a writable
214/// `*mut PamojaReadings`.
215#[no_mangle]
216pub unsafe extern "C" fn pamoja_codec_quantizer_decode(
217    scale: f32,
218    bytes: *const u8,
219    bytes_len: usize,
220    out_readings: *mut *mut PamojaReadings,
221) -> PamojaStatus {
222    let out_readings = match out_slot(out_readings, "out_readings") {
223        Ok(slot) => slot,
224        Err(status) => return status,
225    };
226    if let Err(status) = check_scale(scale) {
227        return status;
228    }
229    let bytes = match read_bytes(bytes, bytes_len) {
230        Ok(bytes) => bytes,
231        Err(status) => return status,
232    };
233    match catch_unwind(AssertUnwindSafe(|| Quantizer::new(scale).decode(&bytes))) {
234        Ok(Ok(readings)) => {
235            *out_readings = Box::into_raw(Box::new(PamojaReadings { readings }));
236            PamojaStatus::Ok
237        }
238        Ok(Err(error)) => failed(&error),
239        Err(_) => panicked(),
240    }
241}
242
243/// Returns a pointer to a decoded series of integer samples.
244///
245/// Use [`pamoja_samples_len`] for the count. The pointer is valid until the
246/// handle is freed.
247///
248/// # Returns
249///
250/// A pointer to the samples, or null if `samples` is null.
251///
252/// # Safety
253///
254/// `samples` must be a live handle from [`pamoja_codec_decode_deltas`], or null.
255#[no_mangle]
256pub unsafe extern "C" fn pamoja_samples_data(samples: *const PamojaSamples) -> *const i64 {
257    if samples.is_null() {
258        return ptr::null();
259    }
260    (*samples).samples.as_ptr()
261}
262
263/// Returns the number of integer samples in a decoded series.
264///
265/// # Returns
266///
267/// The count, or 0 if `samples` is null.
268///
269/// # Safety
270///
271/// `samples` must be a live handle from [`pamoja_codec_decode_deltas`], or null.
272#[no_mangle]
273pub unsafe extern "C" fn pamoja_samples_len(samples: *const PamojaSamples) -> usize {
274    if samples.is_null() {
275        return 0;
276    }
277    (*samples).samples.len()
278}
279
280/// Releases a decoded sample series.
281///
282/// Passing null is a no-op.
283///
284/// # Safety
285///
286/// `samples` must be a handle from [`pamoja_codec_decode_deltas`] that has not
287/// already been freed, or null. After this call it must not be used again.
288#[no_mangle]
289pub unsafe extern "C" fn pamoja_samples_free(samples: *mut PamojaSamples) {
290    if !samples.is_null() {
291        drop(Box::from_raw(samples));
292    }
293}
294
295/// Returns a pointer to a decoded series of float readings.
296///
297/// Use [`pamoja_readings_len`] for the count. The pointer is valid until the
298/// handle is freed.
299///
300/// # Returns
301///
302/// A pointer to the readings, or null if `readings` is null.
303///
304/// # Safety
305///
306/// `readings` must be a live handle from [`pamoja_codec_quantizer_decode`], or
307/// null.
308#[no_mangle]
309pub unsafe extern "C" fn pamoja_readings_data(readings: *const PamojaReadings) -> *const f32 {
310    if readings.is_null() {
311        return ptr::null();
312    }
313    (*readings).readings.as_ptr()
314}
315
316/// Returns the number of float readings in a decoded series.
317///
318/// # Returns
319///
320/// The count, or 0 if `readings` is null.
321///
322/// # Safety
323///
324/// `readings` must be a live handle from [`pamoja_codec_quantizer_decode`], or
325/// null.
326#[no_mangle]
327pub unsafe extern "C" fn pamoja_readings_len(readings: *const PamojaReadings) -> usize {
328    if readings.is_null() {
329        return 0;
330    }
331    (*readings).readings.len()
332}
333
334/// Releases a decoded reading series.
335///
336/// Passing null is a no-op.
337///
338/// # Safety
339///
340/// `readings` must be a handle from [`pamoja_codec_quantizer_decode`] that has
341/// not already been freed, or null. After this call it must not be used again.
342#[no_mangle]
343pub unsafe extern "C" fn pamoja_readings_free(readings: *mut PamojaReadings) {
344    if !readings.is_null() {
345        drop(Box::from_raw(readings));
346    }
347}
348
349/// Runs one of the document conversions, wiring input and output to the boundary.
350///
351/// # Safety
352///
353/// `input` must point to at least `input_len` readable bytes, or be null when
354/// `input_len` is 0, and `out_buffer` must point to a writable
355/// `*mut PamojaBuffer`.
356unsafe fn transcode(
357    input: *const u8,
358    input_len: usize,
359    out_buffer: *mut *mut PamojaBuffer,
360    convert: fn(&[u8]) -> CoreResult<Vec<u8>>,
361) -> PamojaStatus {
362    let out_buffer = match out_slot(out_buffer, "out_buffer") {
363        Ok(slot) => slot,
364        Err(status) => return status,
365    };
366    let input = match read_bytes(input, input_len) {
367        Ok(input) => input,
368        Err(status) => return status,
369    };
370    match catch_unwind(AssertUnwindSafe(|| convert(&input))) {
371        Ok(Ok(bytes)) => {
372            *out_buffer = PamojaBuffer::into_raw(bytes);
373            PamojaStatus::Ok
374        }
375        Ok(Err(error)) => failed(&error),
376        Err(_) => panicked(),
377    }
378}
379
380/// Rejects a null out-pointer and borrows the slot it names, cleared.
381///
382/// Clearing first means a caller that ignores the status never reads a stale
383/// handle out of its own variable. Returning a reference rather than writing
384/// through the raw pointer later keeps the null check and the write together, so
385/// the write site carries no raw dereference of its own.
386///
387/// # Safety
388///
389/// `out` must be null or point to a writable `*mut T` that outlives the call.
390unsafe fn out_slot<'a, T>(out: *mut *mut T, name: &str) -> Result<&'a mut *mut T, PamojaStatus> {
391    if out.is_null() {
392        set_last_error(format!("{name} must not be null"));
393        return Err(PamojaStatus::InvalidArgument);
394    }
395    let slot = &mut *out;
396    *slot = ptr::null_mut();
397    Ok(slot)
398}
399
400/// Copies a borrowed array of `count` values, treating a zero count as empty.
401///
402/// # Safety
403///
404/// When `count` is non-zero, `ptr` must point to at least `count` readable `T`
405/// values.
406unsafe fn read_slice<T: Copy>(
407    ptr: *const T,
408    count: usize,
409    name: &str,
410) -> Result<Vec<T>, PamojaStatus> {
411    if count == 0 {
412        Ok(Vec::new())
413    } else if ptr.is_null() {
414        set_last_error(format!(
415            "{name} must not be null when its count is non-zero"
416        ));
417        Err(PamojaStatus::InvalidArgument)
418    } else {
419        Ok(std::slice::from_raw_parts(ptr, count).to_vec())
420    }
421}
422
423/// Rejects a scale that would make quantizing meaningless or produce infinities.
424fn check_scale(scale: f32) -> Result<(), PamojaStatus> {
425    if scale.is_finite() && scale > 0.0 {
426        Ok(())
427    } else {
428        set_last_error("scale must be positive and finite".to_owned());
429        Err(PamojaStatus::InvalidArgument)
430    }
431}
432
433/// Records a core error and maps it onto its status.
434fn failed(error: &pamoja_core::Error) -> PamojaStatus {
435    set_last_error(error.to_string());
436    PamojaStatus::from_error(error)
437}
438
439/// Records a caught panic and reports it as [`PamojaStatus::Panic`].
440fn panicked() -> PamojaStatus {
441    set_last_error("panic at the FFI boundary".to_owned());
442    PamojaStatus::Panic
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::{pamoja_buffer_data, pamoja_buffer_free, pamoja_buffer_len};
449
450    /// Copies a buffer handle's bytes out and releases the handle.
451    ///
452    /// # Safety
453    ///
454    /// `buffer` must be a live handle that has not already been freed.
455    unsafe fn take(buffer: *mut PamojaBuffer) -> Vec<u8> {
456        let bytes =
457            std::slice::from_raw_parts(pamoja_buffer_data(buffer), pamoja_buffer_len(buffer))
458                .to_vec();
459        pamoja_buffer_free(buffer);
460        bytes
461    }
462
463    #[test]
464    fn a_document_round_trips_through_cbor() {
465        let json = br#"{"c":21.5}"#;
466        let mut cbor = ptr::null_mut();
467        let mut back = ptr::null_mut();
468
469        // Safety: the inputs are valid slices and the out-pointers are writable.
470        unsafe {
471            assert_eq!(
472                pamoja_codec_json_to_cbor(json.as_ptr(), json.len(), &mut cbor),
473                PamojaStatus::Ok
474            );
475            let cbor_bytes = take(cbor);
476            assert!(cbor_bytes.len() < json.len());
477            assert_eq!(
478                pamoja_codec_cbor_to_json(cbor_bytes.as_ptr(), cbor_bytes.len(), &mut back),
479                PamojaStatus::Ok
480            );
481            assert_eq!(take(back), json);
482        }
483    }
484
485    #[test]
486    fn invalid_json_reports_a_codec_status() {
487        let json = b"not json";
488        let mut out = ptr::null_mut();
489        // Safety: the input is a valid slice and the out-pointer is writable.
490        let status = unsafe { pamoja_codec_json_to_cbor(json.as_ptr(), json.len(), &mut out) };
491        assert_eq!(status, PamojaStatus::Codec);
492        assert!(out.is_null());
493    }
494
495    #[test]
496    fn samples_round_trip_through_delta_encoding() {
497        let samples = [10i64, 11, 13, 12, 900];
498        let mut encoded = ptr::null_mut();
499        let mut decoded = ptr::null_mut();
500
501        // Safety: the inputs are valid slices and the out-pointers are writable.
502        unsafe {
503            assert_eq!(
504                pamoja_codec_encode_deltas(samples.as_ptr(), samples.len(), &mut encoded),
505                PamojaStatus::Ok
506            );
507            let bytes = take(encoded);
508            assert_eq!(
509                pamoja_codec_decode_deltas(bytes.as_ptr(), bytes.len(), &mut decoded),
510                PamojaStatus::Ok
511            );
512            let restored = std::slice::from_raw_parts(
513                pamoja_samples_data(decoded),
514                pamoja_samples_len(decoded),
515            )
516            .to_vec();
517            pamoja_samples_free(decoded);
518            assert_eq!(restored, samples);
519        }
520    }
521
522    #[test]
523    fn readings_round_trip_to_within_the_quantizer_precision() {
524        let readings = [20.0f32, 20.1, 20.2, 20.3];
525        let mut encoded = ptr::null_mut();
526        let mut decoded = ptr::null_mut();
527
528        // Safety: the inputs are valid slices and the out-pointers are writable.
529        unsafe {
530            assert_eq!(
531                pamoja_codec_quantizer_encode(
532                    100.0,
533                    readings.as_ptr(),
534                    readings.len(),
535                    &mut encoded
536                ),
537                PamojaStatus::Ok
538            );
539            let bytes = take(encoded);
540            assert!(bytes.len() < readings.len() * 4);
541            assert_eq!(
542                pamoja_codec_quantizer_decode(100.0, bytes.as_ptr(), bytes.len(), &mut decoded),
543                PamojaStatus::Ok
544            );
545            let restored = std::slice::from_raw_parts(
546                pamoja_readings_data(decoded),
547                pamoja_readings_len(decoded),
548            )
549            .to_vec();
550            pamoja_readings_free(decoded);
551            for (got, want) in restored.iter().zip(readings.iter()) {
552                assert!((got - want).abs() < 0.05);
553            }
554        }
555    }
556
557    #[test]
558    fn a_non_positive_scale_is_rejected() {
559        let readings = [1.0f32];
560        let mut out = ptr::null_mut();
561        // Safety: the input is a valid slice and the out-pointer is writable.
562        let status = unsafe {
563            pamoja_codec_quantizer_encode(0.0, readings.as_ptr(), readings.len(), &mut out)
564        };
565        assert_eq!(status, PamojaStatus::InvalidArgument);
566        assert!(out.is_null());
567    }
568
569    #[test]
570    fn a_null_out_pointer_is_rejected() {
571        let json = br#"{}"#;
572        // Safety: passing a null out-pointer is explicitly handled.
573        let status =
574            unsafe { pamoja_codec_json_to_cbor(json.as_ptr(), json.len(), ptr::null_mut()) };
575        assert_eq!(status, PamojaStatus::InvalidArgument);
576    }
577
578    #[test]
579    fn an_empty_series_encodes_and_decodes_as_empty() {
580        let mut encoded = ptr::null_mut();
581        let mut decoded = ptr::null_mut();
582        // Safety: a null data pointer is allowed when the count is zero.
583        unsafe {
584            assert_eq!(
585                pamoja_codec_encode_deltas(ptr::null(), 0, &mut encoded),
586                PamojaStatus::Ok
587            );
588            let bytes = take(encoded);
589            assert_eq!(
590                pamoja_codec_decode_deltas(bytes.as_ptr(), bytes.len(), &mut decoded),
591                PamojaStatus::Ok
592            );
593            assert_eq!(pamoja_samples_len(decoded), 0);
594            pamoja_samples_free(decoded);
595        }
596    }
597}