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),
165/// [`PamojaStatus::InvalidArgument`] if `scale` is not positive and finite, or
166/// [`PamojaStatus::Codec`] if a reading is not a number, is infinite, or is too
167/// large for the scale; the format has no way to carry a missing reading.
168///
169/// # Safety
170///
171/// `readings` must point to at least `count` readable `float` values, or be null
172/// when `count` is 0, and `out_buffer` must point to a writable
173/// `*mut PamojaBuffer`.
174#[no_mangle]
175pub unsafe extern "C" fn pamoja_codec_quantizer_encode(
176    scale: f32,
177    readings: *const f32,
178    count: usize,
179    out_buffer: *mut *mut PamojaBuffer,
180) -> PamojaStatus {
181    let out_buffer = match out_slot(out_buffer, "out_buffer") {
182        Ok(slot) => slot,
183        Err(status) => return status,
184    };
185    if let Err(status) = check_scale(scale) {
186        return status;
187    }
188    let readings = match read_slice(readings, count, "readings") {
189        Ok(readings) => readings,
190        Err(status) => return status,
191    };
192    match catch_unwind(AssertUnwindSafe(|| Quantizer::new(scale).encode(&readings))) {
193        Ok(Ok(bytes)) => {
194            *out_buffer = PamojaBuffer::into_raw(bytes);
195            PamojaStatus::Ok
196        }
197        Ok(Err(error)) => failed(&error),
198        Err(_) => panicked(),
199    }
200}
201
202/// Decodes a quantized batch back into float readings.
203///
204/// The readings come back to within the precision `scale` selected, which must be
205/// the same scale the batch was encoded with.
206///
207/// # Returns
208///
209/// [`PamojaStatus::Ok`] on success, with `*out_readings` set to a new handle the
210/// caller must release with [`pamoja_readings_free`], or
211/// [`PamojaStatus::Codec`] if the buffer is malformed.
212///
213/// # Safety
214///
215/// `bytes` must point to at least `bytes_len` readable bytes, or be null when
216/// `bytes_len` is 0, and `out_readings` must point to a writable
217/// `*mut PamojaReadings`.
218#[no_mangle]
219pub unsafe extern "C" fn pamoja_codec_quantizer_decode(
220    scale: f32,
221    bytes: *const u8,
222    bytes_len: usize,
223    out_readings: *mut *mut PamojaReadings,
224) -> PamojaStatus {
225    let out_readings = match out_slot(out_readings, "out_readings") {
226        Ok(slot) => slot,
227        Err(status) => return status,
228    };
229    if let Err(status) = check_scale(scale) {
230        return status;
231    }
232    let bytes = match read_bytes(bytes, bytes_len) {
233        Ok(bytes) => bytes,
234        Err(status) => return status,
235    };
236    match catch_unwind(AssertUnwindSafe(|| Quantizer::new(scale).decode(&bytes))) {
237        Ok(Ok(readings)) => {
238            *out_readings = Box::into_raw(Box::new(PamojaReadings { readings }));
239            PamojaStatus::Ok
240        }
241        Ok(Err(error)) => failed(&error),
242        Err(_) => panicked(),
243    }
244}
245
246/// Returns a pointer to a decoded series of integer samples.
247///
248/// Use [`pamoja_samples_len`] for the count. The pointer is valid until the
249/// handle is freed.
250///
251/// # Returns
252///
253/// A pointer to the samples, or null if `samples` is null.
254///
255/// # Safety
256///
257/// `samples` must be a live handle from [`pamoja_codec_decode_deltas`], or null.
258#[no_mangle]
259pub unsafe extern "C" fn pamoja_samples_data(samples: *const PamojaSamples) -> *const i64 {
260    if samples.is_null() {
261        return ptr::null();
262    }
263    (*samples).samples.as_ptr()
264}
265
266/// Returns the number of integer samples in a decoded series.
267///
268/// # Returns
269///
270/// The count, or 0 if `samples` is null.
271///
272/// # Safety
273///
274/// `samples` must be a live handle from [`pamoja_codec_decode_deltas`], or null.
275#[no_mangle]
276pub unsafe extern "C" fn pamoja_samples_len(samples: *const PamojaSamples) -> usize {
277    if samples.is_null() {
278        return 0;
279    }
280    (*samples).samples.len()
281}
282
283/// Releases a decoded sample series.
284///
285/// Passing null is a no-op.
286///
287/// # Safety
288///
289/// `samples` must be a handle from [`pamoja_codec_decode_deltas`] that has not
290/// already been freed, or null. After this call it must not be used again.
291#[no_mangle]
292pub unsafe extern "C" fn pamoja_samples_free(samples: *mut PamojaSamples) {
293    if !samples.is_null() {
294        drop(Box::from_raw(samples));
295    }
296}
297
298/// Returns a pointer to a decoded series of float readings.
299///
300/// Use [`pamoja_readings_len`] for the count. The pointer is valid until the
301/// handle is freed.
302///
303/// # Returns
304///
305/// A pointer to the readings, or null if `readings` is null.
306///
307/// # Safety
308///
309/// `readings` must be a live handle from [`pamoja_codec_quantizer_decode`], or
310/// null.
311#[no_mangle]
312pub unsafe extern "C" fn pamoja_readings_data(readings: *const PamojaReadings) -> *const f32 {
313    if readings.is_null() {
314        return ptr::null();
315    }
316    (*readings).readings.as_ptr()
317}
318
319/// Returns the number of float readings in a decoded series.
320///
321/// # Returns
322///
323/// The count, or 0 if `readings` is null.
324///
325/// # Safety
326///
327/// `readings` must be a live handle from [`pamoja_codec_quantizer_decode`], or
328/// null.
329#[no_mangle]
330pub unsafe extern "C" fn pamoja_readings_len(readings: *const PamojaReadings) -> usize {
331    if readings.is_null() {
332        return 0;
333    }
334    (*readings).readings.len()
335}
336
337/// Releases a decoded reading series.
338///
339/// Passing null is a no-op.
340///
341/// # Safety
342///
343/// `readings` must be a handle from [`pamoja_codec_quantizer_decode`] that has
344/// not already been freed, or null. After this call it must not be used again.
345#[no_mangle]
346pub unsafe extern "C" fn pamoja_readings_free(readings: *mut PamojaReadings) {
347    if !readings.is_null() {
348        drop(Box::from_raw(readings));
349    }
350}
351
352/// Runs one of the document conversions, wiring input and output to the boundary.
353///
354/// # Safety
355///
356/// `input` must point to at least `input_len` readable bytes, or be null when
357/// `input_len` is 0, and `out_buffer` must point to a writable
358/// `*mut PamojaBuffer`.
359unsafe fn transcode(
360    input: *const u8,
361    input_len: usize,
362    out_buffer: *mut *mut PamojaBuffer,
363    convert: fn(&[u8]) -> CoreResult<Vec<u8>>,
364) -> PamojaStatus {
365    let out_buffer = match out_slot(out_buffer, "out_buffer") {
366        Ok(slot) => slot,
367        Err(status) => return status,
368    };
369    let input = match read_bytes(input, input_len) {
370        Ok(input) => input,
371        Err(status) => return status,
372    };
373    match catch_unwind(AssertUnwindSafe(|| convert(&input))) {
374        Ok(Ok(bytes)) => {
375            *out_buffer = PamojaBuffer::into_raw(bytes);
376            PamojaStatus::Ok
377        }
378        Ok(Err(error)) => failed(&error),
379        Err(_) => panicked(),
380    }
381}
382
383/// Rejects a null out-pointer and borrows the slot it names, cleared.
384///
385/// Clearing first means a caller that ignores the status never reads a stale
386/// handle out of its own variable. Returning a reference rather than writing
387/// through the raw pointer later keeps the null check and the write together, so
388/// the write site carries no raw dereference of its own.
389///
390/// # Safety
391///
392/// `out` must be null or point to a writable `*mut T` that outlives the call.
393unsafe fn out_slot<'a, T>(out: *mut *mut T, name: &str) -> Result<&'a mut *mut T, PamojaStatus> {
394    if out.is_null() {
395        set_last_error(format!("{name} must not be null"));
396        return Err(PamojaStatus::InvalidArgument);
397    }
398    let slot = &mut *out;
399    *slot = ptr::null_mut();
400    Ok(slot)
401}
402
403/// Copies a borrowed array of `count` values, treating a zero count as empty.
404///
405/// # Safety
406///
407/// When `count` is non-zero, `ptr` must point to at least `count` readable `T`
408/// values.
409unsafe fn read_slice<T: Copy>(
410    ptr: *const T,
411    count: usize,
412    name: &str,
413) -> Result<Vec<T>, PamojaStatus> {
414    if count == 0 {
415        Ok(Vec::new())
416    } else if ptr.is_null() {
417        set_last_error(format!(
418            "{name} must not be null when its count is non-zero"
419        ));
420        Err(PamojaStatus::InvalidArgument)
421    } else {
422        Ok(std::slice::from_raw_parts(ptr, count).to_vec())
423    }
424}
425
426/// Rejects a scale that would make quantizing meaningless or produce infinities.
427fn check_scale(scale: f32) -> Result<(), PamojaStatus> {
428    if scale.is_finite() && scale > 0.0 {
429        Ok(())
430    } else {
431        set_last_error(format!(
432            "a quantizer's scale must be a positive, finite number, not {scale}"
433        ));
434        Err(PamojaStatus::InvalidArgument)
435    }
436}
437
438/// Records a core error and maps it onto its status.
439fn failed(error: &pamoja_core::Error) -> PamojaStatus {
440    set_last_error(error.to_string());
441    PamojaStatus::from_error(error)
442}
443
444/// Records a caught panic and reports it as [`PamojaStatus::Panic`].
445fn panicked() -> PamojaStatus {
446    set_last_error("panic at the FFI boundary".to_owned());
447    PamojaStatus::Panic
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::{pamoja_buffer_data, pamoja_buffer_free, pamoja_buffer_len};
454
455    /// Copies a buffer handle's bytes out and releases the handle.
456    ///
457    /// # Safety
458    ///
459    /// `buffer` must be a live handle that has not already been freed.
460    unsafe fn take(buffer: *mut PamojaBuffer) -> Vec<u8> {
461        let bytes =
462            std::slice::from_raw_parts(pamoja_buffer_data(buffer), pamoja_buffer_len(buffer))
463                .to_vec();
464        pamoja_buffer_free(buffer);
465        bytes
466    }
467
468    #[test]
469    fn a_document_round_trips_through_cbor() {
470        let json = br#"{"c":21.5}"#;
471        let mut cbor = ptr::null_mut();
472        let mut back = ptr::null_mut();
473
474        // Safety: the inputs are valid slices and the out-pointers are writable.
475        unsafe {
476            assert_eq!(
477                pamoja_codec_json_to_cbor(json.as_ptr(), json.len(), &mut cbor),
478                PamojaStatus::Ok
479            );
480            let cbor_bytes = take(cbor);
481            assert!(cbor_bytes.len() < json.len());
482            assert_eq!(
483                pamoja_codec_cbor_to_json(cbor_bytes.as_ptr(), cbor_bytes.len(), &mut back),
484                PamojaStatus::Ok
485            );
486            assert_eq!(take(back), json);
487        }
488    }
489
490    #[test]
491    fn invalid_json_reports_a_codec_status() {
492        let json = b"not json";
493        let mut out = ptr::null_mut();
494        // Safety: the input is a valid slice and the out-pointer is writable.
495        let status = unsafe { pamoja_codec_json_to_cbor(json.as_ptr(), json.len(), &mut out) };
496        assert_eq!(status, PamojaStatus::Codec);
497        assert!(out.is_null());
498    }
499
500    #[test]
501    fn samples_round_trip_through_delta_encoding() {
502        let samples = [10i64, 11, 13, 12, 900];
503        let mut encoded = ptr::null_mut();
504        let mut decoded = ptr::null_mut();
505
506        // Safety: the inputs are valid slices and the out-pointers are writable.
507        unsafe {
508            assert_eq!(
509                pamoja_codec_encode_deltas(samples.as_ptr(), samples.len(), &mut encoded),
510                PamojaStatus::Ok
511            );
512            let bytes = take(encoded);
513            assert_eq!(
514                pamoja_codec_decode_deltas(bytes.as_ptr(), bytes.len(), &mut decoded),
515                PamojaStatus::Ok
516            );
517            let restored = std::slice::from_raw_parts(
518                pamoja_samples_data(decoded),
519                pamoja_samples_len(decoded),
520            )
521            .to_vec();
522            pamoja_samples_free(decoded);
523            assert_eq!(restored, samples);
524        }
525    }
526
527    #[test]
528    fn readings_round_trip_to_within_the_quantizer_precision() {
529        let readings = [20.0f32, 20.1, 20.2, 20.3];
530        let mut encoded = ptr::null_mut();
531        let mut decoded = ptr::null_mut();
532
533        // Safety: the inputs are valid slices and the out-pointers are writable.
534        unsafe {
535            assert_eq!(
536                pamoja_codec_quantizer_encode(
537                    100.0,
538                    readings.as_ptr(),
539                    readings.len(),
540                    &mut encoded
541                ),
542                PamojaStatus::Ok
543            );
544            let bytes = take(encoded);
545            assert!(bytes.len() < readings.len() * 4);
546            assert_eq!(
547                pamoja_codec_quantizer_decode(100.0, bytes.as_ptr(), bytes.len(), &mut decoded),
548                PamojaStatus::Ok
549            );
550            let restored = std::slice::from_raw_parts(
551                pamoja_readings_data(decoded),
552                pamoja_readings_len(decoded),
553            )
554            .to_vec();
555            pamoja_readings_free(decoded);
556            for (got, want) in restored.iter().zip(readings.iter()) {
557                assert!((got - want).abs() < 0.05);
558            }
559        }
560    }
561
562    #[test]
563    fn a_non_positive_scale_is_rejected() {
564        let readings = [1.0f32];
565        let mut out = ptr::null_mut();
566        // Safety: the input is a valid slice and the out-pointer is writable.
567        let status = unsafe {
568            pamoja_codec_quantizer_encode(0.0, readings.as_ptr(), readings.len(), &mut out)
569        };
570        assert_eq!(status, PamojaStatus::InvalidArgument);
571        assert!(out.is_null());
572    }
573
574    #[test]
575    fn a_null_out_pointer_is_rejected() {
576        let json = br#"{}"#;
577        // Safety: passing a null out-pointer is explicitly handled.
578        let status =
579            unsafe { pamoja_codec_json_to_cbor(json.as_ptr(), json.len(), ptr::null_mut()) };
580        assert_eq!(status, PamojaStatus::InvalidArgument);
581    }
582
583    #[test]
584    fn an_empty_series_encodes_and_decodes_as_empty() {
585        let mut encoded = ptr::null_mut();
586        let mut decoded = ptr::null_mut();
587        // Safety: a null data pointer is allowed when the count is zero.
588        unsafe {
589            assert_eq!(
590                pamoja_codec_encode_deltas(ptr::null(), 0, &mut encoded),
591                PamojaStatus::Ok
592            );
593            let bytes = take(encoded);
594            assert_eq!(
595                pamoja_codec_decode_deltas(bytes.as_ptr(), bytes.len(), &mut decoded),
596                PamojaStatus::Ok
597            );
598            assert_eq!(pamoja_samples_len(decoded), 0);
599            pamoja_samples_free(decoded);
600        }
601    }
602}