Skip to main content

pamoja_ffi/
serial.rs

1//! The C ABI for serial-line packet framing.
2//!
3//! These functions wrap [`pamoja_serial`] for callers that reach the SDK through
4//! the flat C boundary. Both framings are here in full: the one-shot
5//! encode and decode of a complete frame, and the streaming decoders that
6//! reassemble frames from the arbitrary chunks a UART actually delivers.
7//!
8//! The Rust decoders take one byte at a time. Crossing the boundary per byte
9//! would cost more than the decoding does, so what is exposed here is a
10//! chunk-at-a-time `feed` that runs the same per-byte loop natively and hands
11//! back every frame the chunk completed. A chunk that carries a corrupt frame
12//! does not fail the call, because the frames around it are still good; the
13//! decoder discards the corrupt one and counts it, and the count is readable
14//! with the `*_discarded` calls.
15
16use std::panic::{catch_unwind, AssertUnwindSafe};
17use std::ptr;
18
19use pamoja_serial::{cobs, slip, SerialError};
20
21use crate::{read_bytes, set_last_error, PamojaBuffer, PamojaStatus};
22
23/// The largest payload, in bytes, that a streaming decoder will reassemble.
24///
25/// The Rust decoders are generic over their capacity, which cannot cross a C
26/// ABI, so the decoders here are built at one documented size. It covers the
27/// 1500-byte maximum a serial link conventionally carries, with room to spare;
28/// a frame longer than this is discarded rather than truncated. A caller who
29/// needs a different bound has the Rust crate.
30pub const PAMOJA_SERIAL_FRAME_MAX: usize = 2048;
31
32/// An opaque handle to the frames one call to a streaming decoder completed.
33///
34/// Read it with [`pamoja_frames_count`], [`pamoja_frames_data`], and
35/// [`pamoja_frames_len`], then release it with [`pamoja_frames_free`].
36pub struct PamojaFrames {
37    frames: Vec<Vec<u8>>,
38}
39
40/// An opaque handle to a streaming SLIP decoder.
41pub struct PamojaSlipDecoder {
42    inner: slip::SlipDecoder<PAMOJA_SERIAL_FRAME_MAX>,
43    discarded: u64,
44}
45
46/// An opaque handle to a streaming COBS decoder.
47pub struct PamojaCobsDecoder {
48    inner: cobs::CobsDecoder<PAMOJA_SERIAL_FRAME_MAX>,
49    discarded: u64,
50}
51
52/// Frames a payload as a SLIP packet (RFC 1055).
53///
54/// # Returns
55///
56/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
57/// the caller must release with
58/// [`pamoja_buffer_free`](crate::pamoja_buffer_free).
59///
60/// # Safety
61///
62/// `payload` must point to at least `payload_len` readable bytes, or be null when
63/// `payload_len` is 0, and `out_buffer` must point to a writable
64/// `*mut PamojaBuffer`.
65#[no_mangle]
66pub unsafe extern "C" fn pamoja_serial_slip_encode(
67    payload: *const u8,
68    payload_len: usize,
69    out_buffer: *mut *mut PamojaBuffer,
70) -> PamojaStatus {
71    frame(
72        payload,
73        payload_len,
74        out_buffer,
75        slip::max_encoded_len,
76        slip::encode,
77    )
78}
79
80/// Reads the payload back out of a SLIP frame.
81///
82/// # Returns
83///
84/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
85/// the caller must release with
86/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or
87/// [`PamojaStatus::Codec`] if the frame is corrupt.
88///
89/// # Safety
90///
91/// `frame` must point to at least `frame_len` readable bytes, or be null when
92/// `frame_len` is 0, and `out_buffer` must point to a writable
93/// `*mut PamojaBuffer`.
94#[no_mangle]
95pub unsafe extern "C" fn pamoja_serial_slip_decode(
96    frame: *const u8,
97    frame_len: usize,
98    out_buffer: *mut *mut PamojaBuffer,
99) -> PamojaStatus {
100    unframe(frame, frame_len, out_buffer, slip::decode)
101}
102
103/// Frames a payload as a COBS packet, terminated by its zero delimiter.
104///
105/// # Returns
106///
107/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
108/// the caller must release with
109/// [`pamoja_buffer_free`](crate::pamoja_buffer_free).
110///
111/// # Safety
112///
113/// `payload` must point to at least `payload_len` readable bytes, or be null when
114/// `payload_len` is 0, and `out_buffer` must point to a writable
115/// `*mut PamojaBuffer`.
116#[no_mangle]
117pub unsafe extern "C" fn pamoja_serial_cobs_encode(
118    payload: *const u8,
119    payload_len: usize,
120    out_buffer: *mut *mut PamojaBuffer,
121) -> PamojaStatus {
122    frame(
123        payload,
124        payload_len,
125        out_buffer,
126        cobs::max_encoded_len,
127        cobs::encode,
128    )
129}
130
131/// Reads the payload back out of a COBS frame.
132///
133/// # Returns
134///
135/// [`PamojaStatus::Ok`] on success, with `*out_buffer` set to a new buffer handle
136/// the caller must release with
137/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or
138/// [`PamojaStatus::Codec`] if the frame is corrupt.
139///
140/// # Safety
141///
142/// `frame` must point to at least `frame_len` readable bytes, or be null when
143/// `frame_len` is 0, and `out_buffer` must point to a writable
144/// `*mut PamojaBuffer`.
145#[no_mangle]
146pub unsafe extern "C" fn pamoja_serial_cobs_decode(
147    frame: *const u8,
148    frame_len: usize,
149    out_buffer: *mut *mut PamojaBuffer,
150) -> PamojaStatus {
151    unframe(frame, frame_len, out_buffer, cobs::decode)
152}
153
154/// Returns the largest SLIP frame a payload of `payload_len` bytes can produce.
155///
156/// # Returns
157///
158/// The worst-case encoded length, which is every byte escaped plus the delimiter.
159#[no_mangle]
160pub extern "C" fn pamoja_serial_slip_max_encoded_len(payload_len: usize) -> usize {
161    slip::max_encoded_len(payload_len)
162}
163
164/// Returns the largest COBS frame a payload of `payload_len` bytes can produce.
165///
166/// # Returns
167///
168/// The worst-case encoded length, one overhead byte per 254 plus the delimiter.
169#[no_mangle]
170pub extern "C" fn pamoja_serial_cobs_max_encoded_len(payload_len: usize) -> usize {
171    cobs::max_encoded_len(payload_len)
172}
173
174/// Creates a streaming SLIP decoder.
175///
176/// # Returns
177///
178/// A new decoder the caller must release with [`pamoja_slip_decoder_free`].
179#[no_mangle]
180pub extern "C" fn pamoja_slip_decoder_new() -> *mut PamojaSlipDecoder {
181    Box::into_raw(Box::new(PamojaSlipDecoder {
182        inner: slip::SlipDecoder::new(),
183        discarded: 0,
184    }))
185}
186
187/// Feeds a chunk of the byte stream to a SLIP decoder.
188///
189/// # Returns
190///
191/// [`PamojaStatus::Ok`] on success, with `*out_frames` set to a new handle
192/// holding every frame this chunk completed, in order, which the caller must
193/// release with [`pamoja_frames_free`]. A chunk that completes no frame yields
194/// an empty handle rather than an error.
195///
196/// # Safety
197///
198/// `decoder` must be a live handle from [`pamoja_slip_decoder_new`], `bytes` must
199/// point to at least `bytes_len` readable bytes or be null when `bytes_len` is 0,
200/// and `out_frames` must point to a writable `*mut PamojaFrames`.
201#[no_mangle]
202pub unsafe extern "C" fn pamoja_slip_decoder_feed(
203    decoder: *mut PamojaSlipDecoder,
204    bytes: *const u8,
205    bytes_len: usize,
206    out_frames: *mut *mut PamojaFrames,
207) -> PamojaStatus {
208    let out_frames = match out_slot(out_frames, "out_frames") {
209        Ok(slot) => slot,
210        Err(status) => return status,
211    };
212    if decoder.is_null() {
213        set_last_error("decoder must not be null".to_owned());
214        return PamojaStatus::InvalidArgument;
215    }
216    let bytes = match read_bytes(bytes, bytes_len) {
217        Ok(bytes) => bytes,
218        Err(status) => return status,
219    };
220    let decoder = &mut *decoder;
221    match catch_unwind(AssertUnwindSafe(|| {
222        let mut frames = Vec::new();
223        for &byte in &bytes {
224            match decoder.inner.push(byte) {
225                Ok(Some(frame)) => frames.push(frame.to_vec()),
226                Ok(None) => {}
227                Err(_) => decoder.discarded += 1,
228            }
229        }
230        frames
231    })) {
232        Ok(frames) => {
233            *out_frames = Box::into_raw(Box::new(PamojaFrames { frames }));
234            PamojaStatus::Ok
235        }
236        Err(_) => panicked(),
237    }
238}
239
240/// Returns how many corrupt frames a SLIP decoder has discarded.
241///
242/// # Returns
243///
244/// The running count, or 0 if `decoder` is null.
245///
246/// # Safety
247///
248/// `decoder` must be a live handle from [`pamoja_slip_decoder_new`], or null.
249#[no_mangle]
250pub unsafe extern "C" fn pamoja_slip_decoder_discarded(decoder: *const PamojaSlipDecoder) -> u64 {
251    if decoder.is_null() {
252        return 0;
253    }
254    (*decoder).discarded
255}
256
257/// Discards any partly assembled frame, returning a SLIP decoder to its initial state.
258///
259/// Passing null is a no-op.
260///
261/// # Safety
262///
263/// `decoder` must be a live handle from [`pamoja_slip_decoder_new`], or null.
264#[no_mangle]
265pub unsafe extern "C" fn pamoja_slip_decoder_reset(decoder: *mut PamojaSlipDecoder) {
266    if !decoder.is_null() {
267        (*decoder).inner.reset();
268    }
269}
270
271/// Releases a SLIP decoder handle.
272///
273/// Passing null is a no-op.
274///
275/// # Safety
276///
277/// `decoder` must be a handle from [`pamoja_slip_decoder_new`] that has not
278/// already been freed, or null. After this call it must not be used again.
279#[no_mangle]
280pub unsafe extern "C" fn pamoja_slip_decoder_free(decoder: *mut PamojaSlipDecoder) {
281    if !decoder.is_null() {
282        drop(Box::from_raw(decoder));
283    }
284}
285
286/// Creates a streaming COBS decoder.
287///
288/// # Returns
289///
290/// A new decoder the caller must release with [`pamoja_cobs_decoder_free`].
291#[no_mangle]
292pub extern "C" fn pamoja_cobs_decoder_new() -> *mut PamojaCobsDecoder {
293    Box::into_raw(Box::new(PamojaCobsDecoder {
294        inner: cobs::CobsDecoder::new(),
295        discarded: 0,
296    }))
297}
298
299/// Feeds a chunk of the byte stream to a COBS decoder.
300///
301/// # Returns
302///
303/// [`PamojaStatus::Ok`] on success, with `*out_frames` set to a new handle
304/// holding every frame this chunk completed, in order, which the caller must
305/// release with [`pamoja_frames_free`].
306///
307/// # Safety
308///
309/// `decoder` must be a live handle from [`pamoja_cobs_decoder_new`], `bytes` must
310/// point to at least `bytes_len` readable bytes or be null when `bytes_len` is 0,
311/// and `out_frames` must point to a writable `*mut PamojaFrames`.
312#[no_mangle]
313pub unsafe extern "C" fn pamoja_cobs_decoder_feed(
314    decoder: *mut PamojaCobsDecoder,
315    bytes: *const u8,
316    bytes_len: usize,
317    out_frames: *mut *mut PamojaFrames,
318) -> PamojaStatus {
319    let out_frames = match out_slot(out_frames, "out_frames") {
320        Ok(slot) => slot,
321        Err(status) => return status,
322    };
323    if decoder.is_null() {
324        set_last_error("decoder must not be null".to_owned());
325        return PamojaStatus::InvalidArgument;
326    }
327    let bytes = match read_bytes(bytes, bytes_len) {
328        Ok(bytes) => bytes,
329        Err(status) => return status,
330    };
331    let decoder = &mut *decoder;
332    match catch_unwind(AssertUnwindSafe(|| {
333        let mut frames = Vec::new();
334        for &byte in &bytes {
335            match decoder.inner.push(byte) {
336                Ok(Some(frame)) => frames.push(frame.to_vec()),
337                Ok(None) => {}
338                Err(_) => decoder.discarded += 1,
339            }
340        }
341        frames
342    })) {
343        Ok(frames) => {
344            *out_frames = Box::into_raw(Box::new(PamojaFrames { frames }));
345            PamojaStatus::Ok
346        }
347        Err(_) => panicked(),
348    }
349}
350
351/// Returns how many corrupt frames a COBS decoder has discarded.
352///
353/// # Returns
354///
355/// The running count, or 0 if `decoder` is null.
356///
357/// # Safety
358///
359/// `decoder` must be a live handle from [`pamoja_cobs_decoder_new`], or null.
360#[no_mangle]
361pub unsafe extern "C" fn pamoja_cobs_decoder_discarded(decoder: *const PamojaCobsDecoder) -> u64 {
362    if decoder.is_null() {
363        return 0;
364    }
365    (*decoder).discarded
366}
367
368/// Discards any partly assembled frame, returning a COBS decoder to its initial state.
369///
370/// Passing null is a no-op.
371///
372/// # Safety
373///
374/// `decoder` must be a live handle from [`pamoja_cobs_decoder_new`], or null.
375#[no_mangle]
376pub unsafe extern "C" fn pamoja_cobs_decoder_reset(decoder: *mut PamojaCobsDecoder) {
377    if !decoder.is_null() {
378        (*decoder).inner.reset();
379    }
380}
381
382/// Releases a COBS decoder handle.
383///
384/// Passing null is a no-op.
385///
386/// # Safety
387///
388/// `decoder` must be a handle from [`pamoja_cobs_decoder_new`] that has not
389/// already been freed, or null. After this call it must not be used again.
390#[no_mangle]
391pub unsafe extern "C" fn pamoja_cobs_decoder_free(decoder: *mut PamojaCobsDecoder) {
392    if !decoder.is_null() {
393        drop(Box::from_raw(decoder));
394    }
395}
396
397/// Returns how many frames a decoder call produced.
398///
399/// # Returns
400///
401/// The count, or 0 if `frames` is null.
402///
403/// # Safety
404///
405/// `frames` must be a live handle from a decoder `feed` call, or null.
406#[no_mangle]
407pub unsafe extern "C" fn pamoja_frames_count(frames: *const PamojaFrames) -> usize {
408    if frames.is_null() {
409        return 0;
410    }
411    (*frames).frames.len()
412}
413
414/// Returns a pointer to one frame's payload bytes.
415///
416/// Use [`pamoja_frames_len`] for its length. The pointer is valid until the
417/// handle is freed.
418///
419/// # Returns
420///
421/// A pointer to the payload, or null if `frames` is null or `index` is out of
422/// range.
423///
424/// # Safety
425///
426/// `frames` must be a live handle from a decoder `feed` call, or null.
427#[no_mangle]
428pub unsafe extern "C" fn pamoja_frames_data(
429    frames: *const PamojaFrames,
430    index: usize,
431) -> *const u8 {
432    if frames.is_null() {
433        return ptr::null();
434    }
435    let frames = &*frames;
436    match frames.frames.get(index) {
437        Some(frame) => frame.as_ptr(),
438        None => ptr::null(),
439    }
440}
441
442/// Returns the length in bytes of one frame's payload.
443///
444/// # Returns
445///
446/// The length, or 0 if `frames` is null or `index` is out of range.
447///
448/// # Safety
449///
450/// `frames` must be a live handle from a decoder `feed` call, or null.
451#[no_mangle]
452pub unsafe extern "C" fn pamoja_frames_len(frames: *const PamojaFrames, index: usize) -> usize {
453    if frames.is_null() {
454        return 0;
455    }
456    let frames = &*frames;
457    frames.frames.get(index).map_or(0, Vec::len)
458}
459
460/// Releases a decoded frame set.
461///
462/// Passing null is a no-op.
463///
464/// # Safety
465///
466/// `frames` must be a handle from a decoder `feed` call that has not already been
467/// freed, or null. After this call it must not be used again.
468#[no_mangle]
469pub unsafe extern "C" fn pamoja_frames_free(frames: *mut PamojaFrames) {
470    if !frames.is_null() {
471        drop(Box::from_raw(frames));
472    }
473}
474
475/// Runs one of the encoders into a buffer sized by its own worst case.
476///
477/// # Safety
478///
479/// `payload` must point to at least `payload_len` readable bytes, or be null when
480/// `payload_len` is 0, and `out_buffer` must point to a writable
481/// `*mut PamojaBuffer`.
482unsafe fn frame(
483    payload: *const u8,
484    payload_len: usize,
485    out_buffer: *mut *mut PamojaBuffer,
486    bound: fn(usize) -> usize,
487    encode: fn(&[u8], &mut [u8]) -> Result<usize, SerialError>,
488) -> PamojaStatus {
489    let out_buffer = match out_slot(out_buffer, "out_buffer") {
490        Ok(slot) => slot,
491        Err(status) => return status,
492    };
493    let payload = match read_bytes(payload, payload_len) {
494        Ok(payload) => payload,
495        Err(status) => return status,
496    };
497    match catch_unwind(AssertUnwindSafe(|| {
498        let mut out = vec![0u8; bound(payload.len())];
499        encode(&payload, &mut out).map(|written| {
500            out.truncate(written);
501            out
502        })
503    })) {
504        Ok(Ok(bytes)) => {
505            *out_buffer = PamojaBuffer::into_raw(bytes);
506            PamojaStatus::Ok
507        }
508        Ok(Err(error)) => failed(error),
509        Err(_) => panicked(),
510    }
511}
512
513/// Runs one of the decoders into a buffer no smaller than the frame it reads.
514///
515/// A decoded payload is never longer than the frame that carried it, so the
516/// frame's own length is a sound bound for the output.
517///
518/// # Safety
519///
520/// `frame` must point to at least `frame_len` readable bytes, or be null when
521/// `frame_len` is 0, and `out_buffer` must point to a writable
522/// `*mut PamojaBuffer`.
523unsafe fn unframe(
524    frame: *const u8,
525    frame_len: usize,
526    out_buffer: *mut *mut PamojaBuffer,
527    decode: fn(&[u8], &mut [u8]) -> Result<usize, SerialError>,
528) -> PamojaStatus {
529    let out_buffer = match out_slot(out_buffer, "out_buffer") {
530        Ok(slot) => slot,
531        Err(status) => return status,
532    };
533    let frame = match read_bytes(frame, frame_len) {
534        Ok(frame) => frame,
535        Err(status) => return status,
536    };
537    match catch_unwind(AssertUnwindSafe(|| {
538        let mut out = vec![0u8; frame.len()];
539        decode(&frame, &mut out).map(|written| {
540            out.truncate(written);
541            out
542        })
543    })) {
544        Ok(Ok(bytes)) => {
545            *out_buffer = PamojaBuffer::into_raw(bytes);
546            PamojaStatus::Ok
547        }
548        Ok(Err(error)) => failed(error),
549        Err(_) => panicked(),
550    }
551}
552
553/// Rejects a null out-pointer and borrows the slot it names, cleared.
554///
555/// # Safety
556///
557/// `out` must be null or point to a writable `*mut T` that outlives the call.
558unsafe fn out_slot<'a, T>(out: *mut *mut T, name: &str) -> Result<&'a mut *mut T, PamojaStatus> {
559    if out.is_null() {
560        set_last_error(format!("{name} must not be null"));
561        return Err(PamojaStatus::InvalidArgument);
562    }
563    let slot = &mut *out;
564    *slot = ptr::null_mut();
565    Ok(slot)
566}
567
568/// Records a framing error and maps it onto its status.
569fn failed(error: SerialError) -> PamojaStatus {
570    set_last_error(error.to_string());
571    match error {
572        SerialError::BufferTooSmall => PamojaStatus::InvalidArgument,
573        SerialError::InvalidEscape | SerialError::TruncatedFrame => PamojaStatus::Codec,
574    }
575}
576
577/// Records a caught panic and reports it as [`PamojaStatus::Panic`].
578fn panicked() -> PamojaStatus {
579    set_last_error("panic at the FFI boundary".to_owned());
580    PamojaStatus::Panic
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::{pamoja_buffer_data, pamoja_buffer_free, pamoja_buffer_len};
587
588    /// Copies a buffer handle's bytes out and releases the handle.
589    ///
590    /// # Safety
591    ///
592    /// `buffer` must be a live handle that has not already been freed.
593    unsafe fn take(buffer: *mut PamojaBuffer) -> Vec<u8> {
594        let bytes =
595            std::slice::from_raw_parts(pamoja_buffer_data(buffer), pamoja_buffer_len(buffer))
596                .to_vec();
597        pamoja_buffer_free(buffer);
598        bytes
599    }
600
601    /// Copies every frame out of a frame set and releases the handle.
602    ///
603    /// # Safety
604    ///
605    /// `frames` must be a live handle that has not already been freed.
606    unsafe fn drain(frames: *mut PamojaFrames) -> Vec<Vec<u8>> {
607        let collected = (0..pamoja_frames_count(frames))
608            .map(|index| {
609                std::slice::from_raw_parts(
610                    pamoja_frames_data(frames, index),
611                    pamoja_frames_len(frames, index),
612                )
613                .to_vec()
614            })
615            .collect();
616        pamoja_frames_free(frames);
617        collected
618    }
619
620    #[test]
621    fn a_payload_round_trips_through_slip() {
622        let payload = b"gps:37.42,-122.08\xc0\xdb";
623        let mut framed = ptr::null_mut();
624        let mut back = ptr::null_mut();
625
626        // Safety: the inputs are valid slices and the out-pointers are writable.
627        unsafe {
628            assert_eq!(
629                pamoja_serial_slip_encode(payload.as_ptr(), payload.len(), &mut framed),
630                PamojaStatus::Ok
631            );
632            let frame = take(framed);
633            assert_eq!(
634                pamoja_serial_slip_decode(frame.as_ptr(), frame.len(), &mut back),
635                PamojaStatus::Ok
636            );
637            assert_eq!(take(back), payload);
638        }
639    }
640
641    #[test]
642    fn a_payload_round_trips_through_cobs() {
643        let payload = b"\x11\x22\x00\x33";
644        let mut framed = ptr::null_mut();
645        let mut back = ptr::null_mut();
646
647        // Safety: the inputs are valid slices and the out-pointers are writable.
648        unsafe {
649            assert_eq!(
650                pamoja_serial_cobs_encode(payload.as_ptr(), payload.len(), &mut framed),
651                PamojaStatus::Ok
652            );
653            let frame = take(framed);
654            assert_eq!(frame[frame.len() - 1], 0x00, "the frame delimiter");
655            assert_eq!(
656                pamoja_serial_cobs_decode(frame.as_ptr(), frame.len(), &mut back),
657                PamojaStatus::Ok
658            );
659            assert_eq!(take(back), payload);
660        }
661    }
662
663    #[test]
664    fn a_corrupt_frame_reports_a_codec_status() {
665        // An escape byte followed by neither escaped marker.
666        let frame = [0xDBu8, 0x01, 0xC0];
667        let mut out = ptr::null_mut();
668        // Safety: the input is a valid slice and the out-pointer is writable.
669        let status = unsafe { pamoja_serial_slip_decode(frame.as_ptr(), frame.len(), &mut out) };
670        assert_eq!(status, PamojaStatus::Codec);
671        assert!(out.is_null());
672    }
673
674    #[test]
675    fn a_stream_split_across_chunks_still_yields_whole_frames() {
676        let decoder = pamoja_slip_decoder_new();
677        let stream = [b'o', b'k', 0xC0, b'g', b'o', 0xC0];
678        let mut collected = Vec::new();
679
680        // Safety: the decoder is live and each chunk is a valid slice.
681        unsafe {
682            for chunk in stream.chunks(2) {
683                let mut frames = ptr::null_mut();
684                assert_eq!(
685                    pamoja_slip_decoder_feed(decoder, chunk.as_ptr(), chunk.len(), &mut frames),
686                    PamojaStatus::Ok
687                );
688                collected.extend(drain(frames));
689            }
690            assert_eq!(pamoja_slip_decoder_discarded(decoder), 0);
691            pamoja_slip_decoder_free(decoder);
692        }
693
694        assert_eq!(collected, vec![b"ok".to_vec(), b"go".to_vec()]);
695    }
696
697    #[test]
698    fn a_corrupt_frame_mid_stream_is_counted_and_the_rest_survive() {
699        let decoder = pamoja_slip_decoder_new();
700        // A good frame, then an escape truncated by the delimiter, then a good frame.
701        let stream = [b'o', b'k', 0xC0, 0xDB, 0xC0, b'g', b'o', 0xC0];
702        let mut frames = ptr::null_mut();
703
704        // Safety: the decoder is live, the input is a valid slice, and the
705        // out-pointer is writable.
706        let collected = unsafe {
707            assert_eq!(
708                pamoja_slip_decoder_feed(decoder, stream.as_ptr(), stream.len(), &mut frames),
709                PamojaStatus::Ok
710            );
711            let collected = drain(frames);
712            assert_eq!(pamoja_slip_decoder_discarded(decoder), 1);
713            pamoja_slip_decoder_free(decoder);
714            collected
715        };
716
717        assert_eq!(
718            collected,
719            vec![b"ok".to_vec(), b"go".to_vec()],
720            "the frames either side of the corrupt one are still delivered"
721        );
722    }
723
724    #[test]
725    fn a_cobs_stream_reassembles_across_chunks() {
726        let decoder = pamoja_cobs_decoder_new();
727        let stream = [0x03, 0x11, 0x22, 0x02, 0x33, 0x00];
728        let mut collected = Vec::new();
729
730        // Safety: the decoder is live and each chunk is a valid slice.
731        unsafe {
732            for chunk in stream.chunks(4) {
733                let mut frames = ptr::null_mut();
734                assert_eq!(
735                    pamoja_cobs_decoder_feed(decoder, chunk.as_ptr(), chunk.len(), &mut frames),
736                    PamojaStatus::Ok
737                );
738                collected.extend(drain(frames));
739            }
740            pamoja_cobs_decoder_reset(decoder);
741            pamoja_cobs_decoder_free(decoder);
742        }
743
744        assert_eq!(collected, vec![vec![0x11, 0x22, 0x00, 0x33]]);
745    }
746
747    #[test]
748    fn a_null_decoder_is_rejected() {
749        let mut frames = ptr::null_mut();
750        // Safety: passing a null decoder is explicitly handled.
751        let status =
752            unsafe { pamoja_slip_decoder_feed(ptr::null_mut(), ptr::null(), 0, &mut frames) };
753        assert_eq!(status, PamojaStatus::InvalidArgument);
754        assert!(frames.is_null());
755    }
756
757    #[test]
758    fn the_worst_case_bounds_are_reported() {
759        assert_eq!(
760            pamoja_serial_slip_max_encoded_len(4),
761            slip::max_encoded_len(4)
762        );
763        assert_eq!(
764            pamoja_serial_cobs_max_encoded_len(4),
765            cobs::max_encoded_len(4)
766        );
767    }
768}