Skip to main content

telepath_wire/
framing.rs

1//! Framing layer for the Telepath wire protocol.
2//!
3//! Both directions terminate frames with a `0x00` byte. The encoding
4//! between frame bytes differs by direction:
5//!
6//! - Downstream (Host → Target): COBS
7//! - Upstream   (Target → Host): rzCOBS
8//!
9//! [`FrameAccumulator`] is framing-agnostic — it discovers boundaries by
10//! splitting on `0x00` and does not interpret the bytes between.
11//!
12//! # Framing-crate replacement policy
13//!
14//! Both COBS and rzCOBS are core wire infrastructure on the critical
15//! path for every packet. The current implementations are thin wrappers
16//! around the external `cobs` and `rzcobs` crates, but the stability
17//! contract is the wrapper API exposed by this module:
18//!
19//! - [`cobs_encode`] / [`cobs_decode`] — downstream
20//! - [`rzcobs_encode`] / [`rzcobs_decode`] — upstream
21//!
22//! Both algorithm pairs are interchangeable with an in-tree implementation
23//! provided the wrapper signatures and [`crate::WireError`] mapping are
24//! preserved. Replacement may be triggered (symmetrically for either) by
25//! any of:
26//!
27//! 1. The upstream crate fails to build against a Rust edition / MSRV we
28//!    need.
29//! 2. A correctness or performance bug is identified and no upstream fix
30//!    lands within 30 days.
31//! 3. Optimizations specific to Telepath are wanted (e.g. exploiting the
32//!    known [`crate::MAX_PAYLOAD_SIZE`] = 256 bound, or fusing the encode
33//!    pass with postcard serialization).
34//!
35//! Reference materials for an in-tree rewrite:
36//!
37//! - COBS: Cheshire & Baker 1999; worst-case overhead `ceil(n / 254)`
38//!   bytes, surfaced via `cobs::max_encoding_length`.
39//! - rzCOBS: <https://github.com/Dirbaio/rzcobs#algorithm> (7-byte
40//!   chunks; bitmap control byte; literal runs); worst-case overhead
41//!   surfaced via [`max_rzcobs_encoding_length`].
42//!
43//! Unit tests in `mod tests` cover embedded zeros, long literal runs,
44//! max-payload boundaries, and malformed input for **both** algorithms;
45//! any in-tree replacement MUST pass them unchanged.
46
47use crate::WireError;
48
49/// Maximum encoded/framed frame size including the `0x00` frame delimiter.
50///
51/// Sized to accommodate a fully-serialized `Request` or `Response` with a
52/// maximum-length payload, plus framing overhead. For `MAX_PAYLOAD_SIZE = 256`,
53/// a full serialized `Request` is at most ~264 bytes.
54///
55/// - COBS worst-case: `ceil(264 / 254)` ≈ 2 bytes overhead + 1 byte delimiter
56///   → 267 bytes total.
57/// - rzCOBS worst-case: `264 + ceil(264 / 7) + 1` = 264 + 38 + 1 = 303 bytes
58///   (per `max_rzcobs_encoding_length`) + 1 byte delimiter → 304 bytes total.
59///
60/// We round up to 512 to give headroom and match the RTT buffer size.
61pub const MAX_FRAME_SIZE: usize = 512;
62
63/// Worst-case rzCOBS-encoded length for a payload of `n` bytes, **excluding**
64/// the trailing `0x00` frame delimiter.
65///
66/// Per Dirbaio's analysis: at most `ceil(n / 7)` control bytes are emitted,
67/// plus `n` payload bytes, plus 1 for the final end-marker.
68pub const fn max_rzcobs_encoding_length(n: usize) -> usize {
69    n + n.div_ceil(7) + 1
70}
71
72/// COBS-encode `data` into `dst`, appending a `0x00` frame delimiter.
73///
74/// Returns the total bytes written to `dst` (encoded bytes + 1 for the
75/// delimiter). `dst` must be at least `cobs::max_encoding_length(data.len()) + 1`
76/// bytes long; [`MAX_FRAME_SIZE`] is always sufficient for any valid packet.
77pub fn cobs_encode(data: &[u8], dst: &mut [u8]) -> Result<usize, WireError> {
78    let min_len = cobs::max_encoding_length(data.len()) + 1;
79    if dst.len() < min_len {
80        return Err(WireError::PayloadTooLarge);
81    }
82    let n = cobs::encode(data, dst);
83    dst[n] = 0x00;
84    Ok(n + 1)
85}
86
87/// COBS-decode `src` (without the `0x00` delimiter) into `dst`.
88///
89/// Returns the number of decoded bytes written to `dst`.
90pub fn cobs_decode(src: &[u8], dst: &mut [u8]) -> Result<usize, WireError> {
91    cobs::decode(src, dst)
92        .map(|report| report.frame_size())
93        .map_err(|_| WireError::FramingError)
94}
95
96// ---------------------------------------------------------------------------
97// rzCOBS helpers
98// ---------------------------------------------------------------------------
99
100/// A `&mut [u8]`-backed writer that implements the `rzcobs::Write` custom
101/// trait. Fails with `()` when the slice is exhausted.
102struct SliceWriter<'a> {
103    dst: &'a mut [u8],
104    pos: usize,
105}
106
107impl rzcobs::Write for SliceWriter<'_> {
108    type Error = ();
109
110    fn write(&mut self, byte: u8) -> Result<(), ()> {
111        if self.pos >= self.dst.len() {
112            return Err(());
113        }
114        self.dst[self.pos] = byte;
115        self.pos += 1;
116        Ok(())
117    }
118}
119
120/// rzCOBS-encode `data` into `dst`, appending a `0x00` frame delimiter.
121///
122/// Returns the total bytes written to `dst` (encoded bytes + 1 for the
123/// delimiter). `dst` must be at least
124/// `max_rzcobs_encoding_length(data.len()) + 1` bytes long;
125/// [`MAX_FRAME_SIZE`] is always sufficient for any valid packet.
126pub fn rzcobs_encode(data: &[u8], dst: &mut [u8]) -> Result<usize, WireError> {
127    let min_len = max_rzcobs_encoding_length(data.len()) + 1;
128    if dst.len() < min_len {
129        return Err(WireError::PayloadTooLarge);
130    }
131    // `Encoder::new` takes ownership of the writer; retrieve it via
132    // `enc.writer()` after encoding is complete.
133    let writer = SliceWriter { dst, pos: 0 };
134    let mut enc = rzcobs::Encoder::new(writer);
135    for &b in data {
136        enc.write(b).map_err(|_| WireError::PayloadTooLarge)?;
137    }
138    enc.end().map_err(|_| WireError::PayloadTooLarge)?;
139    // The pre-check guarantees n < dst.len(), so the delimiter write is safe.
140    let n = enc.writer().pos;
141    enc.writer().dst[n] = 0x00;
142    Ok(n + 1)
143}
144
145/// rzCOBS-decode `src` (without the `0x00` delimiter) into `dst`.
146///
147/// Returns the number of decoded bytes written to `dst`.
148///
149/// # Trailing-zero padding
150///
151/// The rzCOBS algorithm works on 7-byte chunks. The last chunk is
152/// zero-padded to 7 bytes, so the returned length may be up to 6 bytes
153/// **larger** than the original data length. Callers that know the
154/// exact original length should trim; callers passing the result to
155/// `postcard::from_bytes` can ignore this because postcard silently
156/// discards trailing bytes.
157///
158/// The `dst` buffer must be at least `MAX_FRAME_SIZE` bytes to
159/// accommodate the worst-case padded output.
160///
161/// # In-tree implementation
162///
163/// The `rzcobs` crate v0.1.x does not provide a `no_std` decode
164/// function, so this implementation follows the same algorithm directly.
165/// The algorithm invariants are covered by the tests in `mod tests`.
166pub fn rzcobs_decode(src: &[u8], dst: &mut [u8]) -> Result<usize, WireError> {
167    let mut out_pos = 0usize;
168    let mut it = src.iter().rev().copied();
169    while let Some(x) = it.next() {
170        match x {
171            0x00 => return Err(WireError::FramingError),
172            0x01..=0x7F => {
173                for i in 0..7usize {
174                    if out_pos >= dst.len() {
175                        return Err(WireError::FramingError);
176                    }
177                    if x & (1 << (6 - i)) == 0 {
178                        dst[out_pos] = it.next().ok_or(WireError::FramingError)?;
179                    } else {
180                        dst[out_pos] = 0x00;
181                    }
182                    out_pos += 1;
183                }
184            }
185            0x80..=0xFE => {
186                let n = usize::from(x & 0x7F) + 7;
187                if out_pos >= dst.len() {
188                    return Err(WireError::FramingError);
189                }
190                dst[out_pos] = 0x00;
191                out_pos += 1;
192                for _ in 0..n {
193                    if out_pos >= dst.len() {
194                        return Err(WireError::FramingError);
195                    }
196                    dst[out_pos] = it.next().ok_or(WireError::FramingError)?;
197                    out_pos += 1;
198                }
199            }
200            0xFF => {
201                for _ in 0..134usize {
202                    if out_pos >= dst.len() {
203                        return Err(WireError::FramingError);
204                    }
205                    dst[out_pos] = it.next().ok_or(WireError::FramingError)?;
206                    out_pos += 1;
207                }
208            }
209        }
210    }
211    dst[..out_pos].reverse();
212    Ok(out_pos)
213}
214
215// ---------------------------------------------------------------------------
216// FrameAccumulator
217// ---------------------------------------------------------------------------
218
219/// Byte-by-byte frame accumulator for COBS-framed streams.
220///
221/// Feed raw bytes from the transport via [`Self::feed`]. When a `0x00`
222/// delimiter is received, [`Self::frame`] returns the raw encoded frame
223/// bytes ready for decoding.
224///
225/// `N` is the internal buffer capacity. Frames that exceed `N` bytes cause
226/// the accumulator to discard the current frame and set an overflow flag;
227/// [`Self::frame`] returns `None` until [`Self::reset`] is called.
228pub struct FrameAccumulator<const N: usize> {
229    buf: [u8; N],
230    len: usize,
231    overflow: bool,
232}
233
234impl<const N: usize> FrameAccumulator<N> {
235    /// Create a new, empty accumulator.
236    pub const fn new() -> Self {
237        Self {
238            buf: [0u8; N],
239            len: 0,
240            overflow: false,
241        }
242    }
243
244    /// Feed one byte into the accumulator.
245    ///
246    /// Returns `true` when a complete frame has been received (i.e., a `0x00`
247    /// delimiter was just observed). Call [`Self::frame`] to get the encoded
248    /// bytes, then [`Self::reset`] before feeding more data.
249    pub fn feed(&mut self, byte: u8) -> bool {
250        if byte == 0x00 {
251            // Frame delimiter — signal frame completion regardless of overflow.
252            return true;
253        }
254        if self.len >= N {
255            self.overflow = true;
256            self.len = 0;
257            return false;
258        }
259        self.buf[self.len] = byte;
260        self.len += 1;
261        false
262    }
263
264    /// Return the accumulated encoded frame bytes.
265    ///
266    /// Returns `None` if no complete frame is available (overflow or empty
267    /// accumulator).
268    pub fn frame(&self) -> Option<&[u8]> {
269        if self.overflow || self.len == 0 {
270            None
271        } else {
272            Some(&self.buf[..self.len])
273        }
274    }
275
276    /// Reset the accumulator, discarding any partial frame.
277    pub fn reset(&mut self) {
278        self.len = 0;
279        self.overflow = false;
280    }
281}
282
283impl<const N: usize> Default for FrameAccumulator<N> {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Tests
291// ---------------------------------------------------------------------------
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    // ---- COBS ---------------------------------------------------------------
298
299    #[test]
300    fn cobs_encode_decode_roundtrip() {
301        let data = b"hello telepath";
302        let mut encoded = [0u8; 64];
303        let n = cobs_encode(data, &mut encoded).unwrap();
304        assert_eq!(encoded[n - 1], 0x00);
305        for &b in &encoded[..n - 1] {
306            assert_ne!(b, 0x00);
307        }
308        let mut decoded = [0u8; 64];
309        let m = cobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
310        assert_eq!(&decoded[..m], data);
311    }
312
313    #[test]
314    fn cobs_with_embedded_zeros() {
315        let data = [0x00u8, 0x42, 0x00, 0xFF, 0x00];
316        let mut encoded = [0u8; 32];
317        let n = cobs_encode(&data, &mut encoded).unwrap();
318        assert_eq!(encoded[n - 1], 0x00);
319        let mut decoded = [0u8; 32];
320        let m = cobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
321        assert_eq!(&decoded[..m], &data);
322    }
323
324    #[test]
325    fn cobs_long_run_no_zeros() {
326        let data = [0x42u8; 300];
327        let mut encoded = [0u8; 512];
328        let n = cobs_encode(&data, &mut encoded).unwrap();
329        assert_eq!(encoded[n - 1], 0x00);
330        let mut decoded = [0u8; 512];
331        let m = cobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
332        assert_eq!(&decoded[..m], &data[..]);
333    }
334
335    #[test]
336    fn cobs_max_payload_boundary() {
337        let data = [0xABu8; crate::MAX_PAYLOAD_SIZE];
338        let mut encoded = [0u8; MAX_FRAME_SIZE];
339        let n = cobs_encode(&data, &mut encoded).unwrap();
340        assert!(n <= MAX_FRAME_SIZE);
341        let mut decoded = [0u8; MAX_FRAME_SIZE];
342        let m = cobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
343        assert_eq!(&decoded[..m], &data[..]);
344    }
345
346    #[test]
347    fn cobs_encode_overflow_returns_error() {
348        let data = b"hello";
349        let mut tiny = [0u8; 1];
350        assert!(matches!(
351            cobs_encode(data, &mut tiny),
352            Err(WireError::PayloadTooLarge)
353        ));
354    }
355
356    #[test]
357    fn cobs_decode_malformed_returns_error() {
358        // A single 0x00 overhead byte (run-length of 0) is invalid in COBS.
359        let bad = [0x00u8];
360        let mut dst = [0u8; 16];
361        assert!(matches!(
362            cobs_decode(&bad, &mut dst),
363            Err(WireError::FramingError)
364        ));
365    }
366
367    // ---- FrameAccumulator ---------------------------------------------------
368
369    #[test]
370    fn accumulator_basic() {
371        let mut acc: FrameAccumulator<64> = FrameAccumulator::new();
372        let data = b"ping";
373        let mut encoded = [0u8; 16];
374        let n = cobs_encode(data, &mut encoded).unwrap();
375        let mut complete = false;
376        for &b in &encoded[..n] {
377            complete = acc.feed(b);
378        }
379        assert!(complete);
380        let frame = acc.frame().unwrap();
381        let mut decoded = [0u8; 16];
382        let m = cobs_decode(frame, &mut decoded).unwrap();
383        assert_eq!(&decoded[..m], data);
384    }
385
386    #[test]
387    fn accumulator_reset_allows_second_frame() {
388        let mut acc: FrameAccumulator<64> = FrameAccumulator::new();
389        let data1 = b"first";
390        let data2 = b"second";
391        let mut enc = [0u8; 32];
392
393        let n = cobs_encode(data1, &mut enc).unwrap();
394        for &b in &enc[..n] {
395            acc.feed(b);
396        }
397        acc.reset();
398
399        let n = cobs_encode(data2, &mut enc).unwrap();
400        let mut complete = false;
401        for &b in &enc[..n] {
402            complete = acc.feed(b);
403        }
404        assert!(complete);
405        let frame = acc.frame().unwrap();
406        let mut decoded = [0u8; 32];
407        let m = cobs_decode(frame, &mut decoded).unwrap();
408        assert_eq!(&decoded[..m], data2);
409    }
410
411    #[test]
412    fn accumulator_overflow_returns_none() {
413        let mut acc: FrameAccumulator<4> = FrameAccumulator::new();
414        for _ in 0..5 {
415            acc.feed(0x42);
416        }
417        acc.feed(0x00);
418        assert!(acc.frame().is_none());
419    }
420
421    #[test]
422    fn max_frame_size_covers_max_payload() {
423        assert!(MAX_FRAME_SIZE >= crate::MAX_PAYLOAD_SIZE + 4);
424    }
425
426    // ---- rzCOBS -------------------------------------------------------------
427
428    #[test]
429    fn rzcobs_encode_decode_roundtrip() {
430        // "hello telepath" is 14 bytes = exactly 2 full 7-byte chunks,
431        // so there is no trailing-zero padding.
432        let data = b"hello telepath";
433        let mut encoded = [0u8; 64];
434        let n = rzcobs_encode(data, &mut encoded).unwrap();
435        assert_eq!(encoded[n - 1], 0x00);
436        let mut decoded = [0u8; 64];
437        let m = rzcobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
438        assert_eq!(&decoded[..data.len()], data.as_slice());
439        assert!(decoded[data.len()..m].iter().all(|&b| b == 0x00));
440    }
441
442    #[test]
443    fn rzcobs_with_embedded_zeros() {
444        // Decoded output is padded to 7-byte boundary; last bytes are zeros.
445        let data = [0x00u8, 0x42, 0x00, 0xFF, 0x00];
446        let mut encoded = [0u8; 32];
447        let n = rzcobs_encode(&data, &mut encoded).unwrap();
448        assert_eq!(encoded[n - 1], 0x00);
449        let mut decoded = [0u8; 32];
450        let m = rzcobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
451        assert_eq!(&decoded[..data.len()], &data);
452        assert!(decoded[data.len()..m].iter().all(|&b| b == 0x00));
453    }
454
455    #[test]
456    fn rzcobs_long_run_no_zeros() {
457        // 200 bytes → ceil(200/7) = 29 chunks → 203 decoded bytes.
458        let data = [0x42u8; 200];
459        let mut encoded = [0u8; 512];
460        let n = rzcobs_encode(&data, &mut encoded).unwrap();
461        assert_eq!(encoded[n - 1], 0x00);
462        let mut decoded = [0u8; 512];
463        let m = rzcobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
464        assert_eq!(&decoded[..data.len()], &data[..]);
465        assert!(decoded[data.len()..m].iter().all(|&b| b == 0x00));
466    }
467
468    #[test]
469    fn rzcobs_max_payload_boundary() {
470        // 256 bytes → ceil(256/7) = 37 chunks → 259 decoded bytes.
471        let data = [0xABu8; crate::MAX_PAYLOAD_SIZE];
472        let mut encoded = [0u8; MAX_FRAME_SIZE];
473        let n = rzcobs_encode(&data, &mut encoded).unwrap();
474        assert!(n <= MAX_FRAME_SIZE);
475        let mut decoded = [0u8; MAX_FRAME_SIZE];
476        let m = rzcobs_decode(&encoded[..n - 1], &mut decoded).unwrap();
477        assert_eq!(&decoded[..data.len()], &data[..]);
478        assert!(decoded[data.len()..m].iter().all(|&b| b == 0x00));
479    }
480
481    #[test]
482    fn rzcobs_encode_overflow_returns_error() {
483        let data = b"hello";
484        let mut tiny = [0u8; 1];
485        assert!(matches!(
486            rzcobs_encode(data, &mut tiny),
487            Err(WireError::PayloadTooLarge)
488        ));
489    }
490
491    #[test]
492    fn rzcobs_decode_malformed_returns_error() {
493        // A single 0x01 byte is an invalid rzCOBS frame (incomplete chunk).
494        let bad = [0x01u8];
495        let mut dst = [0u8; 16];
496        assert!(matches!(
497            rzcobs_decode(&bad, &mut dst),
498            Err(WireError::FramingError)
499        ));
500    }
501
502    #[test]
503    fn rzcobs_max_frame_size_covers_max_payload() {
504        assert!(MAX_FRAME_SIZE >= max_rzcobs_encoding_length(crate::MAX_PAYLOAD_SIZE) + 1);
505    }
506}