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}