Skip to main content

darkbio_cobs/
lib.rs

1// cobs-rs: fast cobs encoder and decoder
2// Copyright 2025 Dark Bio AG. All rights reserved.
3
4// Pull in the README as the package doc
5#![doc = include_str!("../README.md")]
6// Build without the standard library unless the std feature asks for it
7#![cfg_attr(not(feature = "std"), no_std)]
8
9// The tests allocate, so they link the standard library in every configuration
10#[cfg(test)]
11extern crate std;
12
13/// Error types that can be returned from encoding.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
15pub enum EncodeError {
16    /// The output buffer holds `have` bytes but the worst case encoding of the
17    /// input needs `want`, as computed by [`encode_buffer`]. Nothing was
18    /// written. A smaller buffer is refused even if the actual encoding would
19    /// have fit.
20    #[error("buffer too small: have {have} bytes, want {want} bytes")]
21    BufferTooSmall { have: usize, want: usize },
22}
23
24/// Error types that can be returned from decoding.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
26pub enum DecodeError {
27    /// The input is empty. A COBS stream is never shorter than one byte, since
28    /// the empty payload encodes as a single `0x01`.
29    #[error("empty input")]
30    EmptyInput,
31
32    /// The output buffer holds `have` bytes but the worst case decoding of the
33    /// input needs `want`, as computed by [`decode_buffer`]. Nothing was
34    /// written. Inputs of a single byte skip this check, they never produce
35    /// output.
36    #[error("buffer too small: have {have} bytes, want {want} bytes")]
37    BufferTooSmall { have: usize, want: usize },
38
39    /// The chunk marker at input offset `at` is zero, which no COBS stream
40    /// contains. Both [`decode`] and [`decode_nonzero`] report it. The latter
41    /// keeps this one check to stay memory safe.
42    #[error("zero marker at position {at}")]
43    ZeroMarker { at: usize },
44
45    /// A chunk payload contains a zero byte at input offset `at`, which the
46    /// encoder never produces. Only [`decode`] reports it. [`decode_nonzero`]
47    /// trusts the caller and decodes such input to garbage instead.
48    #[error("zero byte in data at position {at}")]
49    ZeroBinary { at: usize },
50
51    /// The chunk marker at input offset `at` announces `marker - 1` payload
52    /// bytes, which run past the `len` bytes of input. Truncated frames end up
53    /// here.
54    #[error("chunk overflow at position {at}: chunk {marker} exceeds data length {len}")]
55    ChunkOverflow { at: usize, marker: u8, len: usize },
56}
57
58/// Computes the maximum size needed to COBS encode a blind input blob.
59#[inline]
60pub const fn encode_buffer(size: usize) -> usize {
61    size + size.div_ceil(254) + 1
62}
63
64/// Computes the maximum size needed to COBS decode a blind input data.
65#[inline]
66pub const fn decode_buffer(size: usize) -> usize {
67    if size == 0 {
68        // Zero length COBS is invalid. We could panic here, but that makes call
69        // sites brittle when parsing potentially malicious input. We could also
70        // return an error, but that makes the method so much uglier. Returning
71        // zero is safe however, because the caller can still alloc a zero-byte
72        // buffer and the decoder will error anyway.
73        return 0;
74    }
75    size - 1
76}
77
78/// Encodes an opaque data blob with COBS using 0 as the sentinel value. Returns
79/// the number of bytes the encoding took. Returns an error if the output buffer
80/// is too small.
81#[inline]
82pub fn encode(data: &[u8], encoded: &mut [u8]) -> Result<usize, EncodeError> {
83    let want = encode_buffer(data.len());
84    if encoded.len() < want {
85        return Err(EncodeError::BufferTooSmall {
86            have: encoded.len(),
87            want,
88        });
89    }
90    // The output was checked to hold the worst case encoding
91    Ok(unsafe { encode_unchecked(data, encoded) })
92}
93
94/// Encodes an opaque data blob with COBS using 0 as the sentinel value. Returns
95/// the number of bytes the encoding took.
96///
97/// # Safety
98/// The caller must ensure `encoded` has at least `encode_buffer(data.len())` bytes.
99#[inline]
100pub unsafe fn encode_unchecked(data: &[u8], encoded: &mut [u8]) -> usize {
101    // The empty blob is always encoded as 0x01
102    if data.is_empty() {
103        encoded[0] = 0x01;
104        return 1;
105    }
106    // Sanity check in debug builds that the user called it correctly
107    debug_assert!(encoded.len() >= encode_buffer(data.len()));
108
109    // Consume the input stream one zero delimited run at a time, copying whole
110    // chunks into the output instead of individual bytes
111    unsafe {
112        let mut input_pos = 0usize;
113        let mut output_pos = 0usize;
114
115        loop {
116            // Look up the next zero, skipping the scanner call for zero runs
117            let run = if *data.get_unchecked(input_pos) == 0 {
118                Some(0)
119            } else {
120                memchr::memchr(0, data.get_unchecked(input_pos..))
121            };
122            // Copy over all the full chunks preceding the zero or the end
123            let mut rem = run.unwrap_or(data.len() - input_pos);
124            while rem >= 254 {
125                *encoded.get_unchecked_mut(output_pos) = 0xff;
126                core::ptr::copy_nonoverlapping(
127                    data.as_ptr().add(input_pos),
128                    encoded.as_mut_ptr().add(output_pos + 1),
129                    254,
130                );
131                input_pos += 254;
132                output_pos += 255;
133                rem -= 254;
134            }
135            if run.is_some() {
136                // Copy over the partial chunk and consume the zero closing it
137                *encoded.get_unchecked_mut(output_pos) = rem as u8 + 1;
138                core::ptr::copy_nonoverlapping(
139                    data.as_ptr().add(input_pos),
140                    encoded.as_mut_ptr().add(output_pos + 1),
141                    rem,
142                );
143                input_pos += rem + 1;
144                output_pos += rem + 1;
145
146                // If the zero was the last byte, terminate with an empty chunk
147                if input_pos == data.len() {
148                    *encoded.get_unchecked_mut(output_pos) = 0x01;
149                    return output_pos + 1;
150                }
151            } else {
152                // Copy over any partial chunk at the tail. Data ending exactly
153                // on a chunk boundary was fully consumed by the full chunks.
154                if rem > 0 {
155                    *encoded.get_unchecked_mut(output_pos) = rem as u8 + 1;
156                    core::ptr::copy_nonoverlapping(
157                        data.as_ptr().add(input_pos),
158                        encoded.as_mut_ptr().add(output_pos + 1),
159                        rem,
160                    );
161                    output_pos += rem + 1;
162                }
163                return output_pos;
164            }
165        }
166    }
167}
168
169/// Decodes an opaque data blob with COBS using 0 as the sentinel value. Returns
170/// the number of bytes the decoding took. Returns an error if the output buffer
171/// is too small or if the input is malformed.
172#[inline]
173pub fn decode(data: &[u8], decoded: &mut [u8]) -> Result<usize, DecodeError> {
174    if data.is_empty() {
175        return Err(DecodeError::EmptyInput);
176    }
177    if data.len() > 1 {
178        let want = decode_buffer(data.len());
179        if decoded.len() < want {
180            return Err(DecodeError::BufferTooSmall {
181                have: decoded.len(),
182                want,
183            });
184        }
185    }
186    // The output was checked to hold the worst case decoding, a lone byte
187    // never produces any
188    unsafe { decode_unchecked(data, decoded) }
189}
190
191/// Decodes an opaque data blob with COBS using 0 as the sentinel value. Returns
192/// the number of bytes the decoding took.
193///
194/// # Safety
195/// The caller must ensure `decoded` has at least `decode_buffer(data.len())` bytes.
196#[inline]
197pub unsafe fn decode_unchecked(data: &[u8], decoded: &mut [u8]) -> Result<usize, DecodeError> {
198    // The empty blob is not a valid COBS encoding
199    if data.is_empty() {
200        return Err(DecodeError::EmptyInput);
201    }
202    // The empty text is always encoded as 0x01
203    if data.len() == 1 && data[0] == 0x01 {
204        return Ok(0);
205    }
206    // Sanity check in debug builds that the user called it correctly
207    debug_assert!(decoded.len() >= decode_buffer(data.len()));
208
209    // A valid COBS stream cannot contain any zero bytes, neither as chunk
210    // markers nor as chunk content, so a single scan up front can validate the
211    // entire input. Streams failing it are handed off to the byte by byte
212    // decoder to pinpoint the error. Clean streams skip all further checks.
213    if memchr::memchr(0, data).is_some() {
214        return decode_scalar(data, decoded);
215    }
216    decode_chunked::<false>(data, decoded)
217}
218
219/// Decodes an opaque data blob with COBS using 0 as the sentinel value,
220/// assuming the input contains no zero bytes, a guarantee usually provided by
221/// a zero delimited framing layer. Skipping the validation scan makes this
222/// faster than `decode`, but violating the assumption yields either a decode
223/// error or garbage output, never memory unsafety. Returns the number of bytes
224/// the decoding took. Returns an error if the output buffer is too small or if
225/// the input is malformed.
226#[inline]
227pub fn decode_nonzero(data: &[u8], decoded: &mut [u8]) -> Result<usize, DecodeError> {
228    if data.is_empty() {
229        return Err(DecodeError::EmptyInput);
230    }
231    if data.len() > 1 {
232        let want = decode_buffer(data.len());
233        if decoded.len() < want {
234            return Err(DecodeError::BufferTooSmall {
235                have: decoded.len(),
236                want,
237            });
238        }
239    }
240    // The output was checked to hold the worst case decoding, a lone byte
241    // never produces any
242    unsafe { decode_nonzero_unchecked(data, decoded) }
243}
244
245/// Decodes an opaque data blob with COBS using 0 as the sentinel value,
246/// assuming the input contains no zero bytes. Returns the number of bytes the
247/// decoding took.
248///
249/// # Safety
250/// The caller must ensure `decoded` has at least `decode_buffer(data.len())` bytes.
251#[inline]
252pub unsafe fn decode_nonzero_unchecked(
253    data: &[u8],
254    decoded: &mut [u8],
255) -> Result<usize, DecodeError> {
256    // The empty blob is not a valid COBS encoding
257    if data.is_empty() {
258        return Err(DecodeError::EmptyInput);
259    }
260    // The empty text is always encoded as 0x01
261    if data.len() == 1 && data[0] == 0x01 {
262        return Ok(0);
263    }
264    // Sanity check in debug builds that the user called it correctly
265    debug_assert!(decoded.len() >= decode_buffer(data.len()));
266
267    decode_chunked::<true>(data, decoded)
268}
269
270/// Decodes an opaque data blob with COBS one chunk at a time, copying whole
271/// chunks into the output instead of individual bytes. With `CHECKED` the
272/// chunk markers are verified to not be zero, without it the caller vouches
273/// that the input contains no zero bytes at all.
274///
275/// # Safety
276/// The caller must ensure `decoded` has at least `decode_buffer(data.len())`
277/// bytes and that `data` is not empty. Without `CHECKED`, the caller must also
278/// ensure that `data` contains no zero bytes.
279#[inline]
280fn decode_chunked<const CHECKED: bool>(
281    data: &[u8],
282    decoded: &mut [u8],
283) -> Result<usize, DecodeError> {
284    unsafe {
285        let mut input_pos = 0usize;
286        let mut output_pos = 0usize;
287
288        // Consume the bulk of the stream with fixed size copies per chunk. The
289        // copies intentionally cover a maximum size no matter the real one,
290        // making them straight inline copies without memcpy calls. Short
291        // chunks copy 16 bytes and anything longer the full 254, keeping the
292        // write amplification of tiny chunk streams in check. Garbage copied
293        // past a chunk is overwritten by the next chunk or falls beyond the
294        // length returned to the caller. The stream cannot end nor overflow
295        // within this loop, so the separator zero can also be written blindly,
296        // dropped again for full chunks by not advancing over it.
297        while input_pos + 255 < data.len() {
298            let marker = *data.get_unchecked(input_pos);
299            if CHECKED && marker == 0 {
300                return Err(DecodeError::ZeroMarker { at: input_pos });
301            }
302            let chunk = marker as usize - 1;
303            input_pos += 1;
304
305            core::ptr::copy_nonoverlapping(
306                data.as_ptr().add(input_pos),
307                decoded.as_mut_ptr().add(output_pos),
308                16,
309            );
310            if chunk > 16 {
311                core::ptr::copy_nonoverlapping(
312                    data.as_ptr().add(input_pos),
313                    decoded.as_mut_ptr().add(output_pos),
314                    254,
315                );
316            }
317            input_pos += chunk;
318            output_pos += chunk;
319
320            *decoded.get_unchecked_mut(output_pos) = 0;
321            output_pos += (marker != 0xff) as usize;
322        }
323        // Consume the stream tail one chunk at a time with exact copies
324        loop {
325            // Read the length marker and ensure the chunk fits the input
326            let marker = *data.get_unchecked(input_pos);
327            if CHECKED && marker == 0 {
328                return Err(DecodeError::ZeroMarker { at: input_pos });
329            }
330            let chunk = marker as usize - 1;
331            input_pos += 1;
332
333            if input_pos + chunk > data.len() {
334                return Err(DecodeError::ChunkOverflow {
335                    at: input_pos - 1,
336                    marker,
337                    len: data.len(),
338                });
339            }
340            // Copy over the entire chunk
341            core::ptr::copy_nonoverlapping(
342                data.as_ptr().add(input_pos),
343                decoded.as_mut_ptr().add(output_pos),
344                chunk,
345            );
346            input_pos += chunk;
347            output_pos += chunk;
348
349            // If the stream is done, so is the decoder
350            if input_pos == data.len() {
351                return Ok(output_pos);
352            }
353            // If we had a partial chunk, there must be a zero following
354            if marker != 0xff {
355                *decoded.get_unchecked_mut(output_pos) = 0;
356                output_pos += 1;
357            }
358        }
359    }
360}
361
362/// Decodes an opaque data blob with COBS one byte at a time. This is the path
363/// for streams known to contain zero bytes, walking the chunks to pinpoint
364/// whether a zero marker, a zero binary or an overflow triggers first.
365///
366/// # Safety
367/// The caller must ensure `decoded` has at least `decode_buffer(data.len())` bytes.
368#[cold]
369#[inline(never)]
370fn decode_scalar(data: &[u8], decoded: &mut [u8]) -> Result<usize, DecodeError> {
371    // Consume the input stream one chunk at a time
372    unsafe {
373        let mut output_pos = 0usize;
374        let mut i = 0usize;
375
376        while i < data.len() {
377            // Zero cannot be part of a COBS encoded stream
378            let marker = *data.get_unchecked(i);
379            if marker == 0 {
380                return Err(DecodeError::ZeroMarker { at: i });
381            }
382            i += 1;
383
384            // If the marker defines an overflowing chunk, abort
385            if i + (marker as usize) - 1 > data.len() {
386                return Err(DecodeError::ChunkOverflow {
387                    at: i - 1,
388                    marker,
389                    len: data.len(),
390                });
391            }
392            // Consume the entire chunk, ensuring there's no zero in it
393            for _ in 1..marker {
394                let b = *data.get_unchecked(i);
395                if b == 0 {
396                    return Err(DecodeError::ZeroBinary { at: i });
397                }
398                *decoded.get_unchecked_mut(output_pos) = b;
399                output_pos += 1;
400                i += 1;
401            }
402            // If we had a partial chunk, there must be a zero following
403            if i < data.len() && marker != 0xff {
404                *decoded.get_unchecked_mut(output_pos) = 0;
405                output_pos += 1;
406            }
407        }
408        Ok(output_pos)
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use std::vec;
416    use std::vec::Vec;
417
418    #[test]
419    fn test_roundtrip_empty() {
420        let data = [];
421        let mut enc_buf = [0u8; 1];
422        let len = encode(&data, &mut enc_buf).unwrap();
423        assert_eq!(len, 1);
424        assert_eq!(enc_buf[0], 0x01);
425
426        let mut dec_buf = [0u8; 0];
427        let dec_len = decode(&enc_buf[..len], &mut dec_buf).unwrap();
428        assert_eq!(dec_len, 0);
429    }
430
431    #[test]
432    fn test_roundtrip_no_zeros() {
433        let data = [1, 2, 3, 4, 5];
434        let mut enc_buf = [0u8; encode_buffer(5)];
435        let len = encode(&data, &mut enc_buf).unwrap();
436
437        let mut dec_buf = [0u8; decode_buffer(encode_buffer(5))];
438        let dec_len = decode(&enc_buf[..len], &mut dec_buf).unwrap();
439        assert_eq!(&dec_buf[..dec_len], &data);
440    }
441
442    #[test]
443    fn test_roundtrip_with_zeros() {
444        let data = [0, 1, 0, 2, 0, 0, 3];
445        let mut enc_buf = [0u8; encode_buffer(7)];
446        let len = encode(&data, &mut enc_buf).unwrap();
447
448        let mut dec_buf = [0u8; decode_buffer(encode_buffer(7))];
449        let dec_len = decode(&enc_buf[..len], &mut dec_buf).unwrap();
450        assert_eq!(&dec_buf[..dec_len], &data);
451    }
452
453    #[test]
454    fn test_roundtrip_254_nonzero() {
455        let data: Vec<u8> = (1..=254).collect();
456        let mut enc_buf = vec![0u8; encode_buffer(254)];
457        let len = encode(&data, &mut enc_buf).unwrap();
458
459        let mut dec_buf = vec![0u8; decode_buffer(enc_buf.len())];
460        let dec_len = decode(&enc_buf[..len], &mut dec_buf).unwrap();
461        assert_eq!(&dec_buf[..dec_len], &data[..]);
462    }
463
464    #[test]
465    fn test_roundtrip_255_nonzero() {
466        let data: Vec<u8> = (1..=254).chain(std::iter::once(1)).collect();
467        let mut enc_buf = vec![0u8; encode_buffer(255)];
468        let len = encode(&data, &mut enc_buf).unwrap();
469
470        let mut dec_buf = vec![0u8; decode_buffer(enc_buf.len())];
471        let dec_len = decode(&enc_buf[..len], &mut dec_buf).unwrap();
472        assert_eq!(&dec_buf[..dec_len], &data[..]);
473    }
474
475    #[test]
476    fn test_roundtrip_chunk_boundaries() {
477        let sizes: Vec<usize> = if cfg!(miri) {
478            (0..=64)
479                .chain([253, 254, 255, 256, 507, 508, 509, 510, 1021, 1024])
480                .collect()
481        } else {
482            (0..=515)
483                .chain([1021, 1024, 4093, 4096, 8191, 65536])
484                .collect()
485        };
486        for size in sizes {
487            for period in [1usize, 2, 3, 253, 254, 255, 256] {
488                for phase in [0, period - 1] {
489                    let data: Vec<u8> = (0..size)
490                        .map(|i| {
491                            if i % period == phase {
492                                0
493                            } else {
494                                (i % 251 + 1) as u8
495                            }
496                        })
497                        .collect();
498                    roundtrip_reference(&data);
499                }
500            }
501            let data: Vec<u8> = (0..size).map(|i| (i % 251 + 1) as u8).collect();
502            roundtrip_reference(&data);
503        }
504    }
505
506    /// Encodes and decodes a blob with both this crate and the reference cobs
507    /// crate, cross checking all the outputs against one another.
508    fn roundtrip_reference(data: &[u8]) {
509        let mut encoded = vec![0u8; encode_buffer(data.len())];
510        let encoded_len = encode(data, &mut encoded).unwrap();
511
512        let mut reference = vec![0u8; cobs::max_encoding_length(data.len())];
513        let reference_len = cobs::encode(data, &mut reference);
514        assert_eq!(&encoded[..encoded_len], &reference[..reference_len]);
515
516        let mut decoded = vec![0u8; decode_buffer(encoded_len)];
517        let decoded_len = decode(&encoded[..encoded_len], &mut decoded).unwrap();
518        assert_eq!(&decoded[..decoded_len], data);
519
520        let mut nonzero = vec![0u8; decode_buffer(encoded_len)];
521        let nonzero_len = decode_nonzero(&encoded[..encoded_len], &mut nonzero).unwrap();
522        assert_eq!(&nonzero[..nonzero_len], data);
523    }
524
525    #[test]
526    fn test_decode_malformed() {
527        let mut buffer = [0u8; 16];
528
529        assert_eq!(decode(&[], &mut buffer), Err(DecodeError::EmptyInput));
530        assert_eq!(
531            decode(&[0x00], &mut buffer),
532            Err(DecodeError::ZeroMarker { at: 0 })
533        );
534        assert_eq!(
535            decode(&[0x02, 0x41, 0x00], &mut buffer),
536            Err(DecodeError::ZeroMarker { at: 2 })
537        );
538        assert_eq!(
539            decode(&[0x02, 0x00], &mut buffer),
540            Err(DecodeError::ZeroBinary { at: 1 })
541        );
542        assert_eq!(
543            decode(&[0x03, 0x41, 0x00, 0x41], &mut buffer),
544            Err(DecodeError::ZeroBinary { at: 2 })
545        );
546        assert_eq!(
547            decode(&[0x03, 0x41], &mut buffer),
548            Err(DecodeError::ChunkOverflow {
549                at: 0,
550                marker: 3,
551                len: 2
552            })
553        );
554        assert_eq!(
555            decode(&[0x05, 0x41, 0x00, 0x41], &mut buffer),
556            Err(DecodeError::ChunkOverflow {
557                at: 0,
558                marker: 5,
559                len: 4
560            })
561        );
562    }
563
564    #[test]
565    fn test_decode_nonzero_malformed() {
566        let mut buffer = [0u8; 128];
567
568        assert_eq!(
569            decode_nonzero(&[], &mut buffer),
570            Err(DecodeError::EmptyInput)
571        );
572        assert_eq!(
573            decode_nonzero(&[0x03, 0x41], &mut buffer),
574            Err(DecodeError::ChunkOverflow {
575                at: 0,
576                marker: 3,
577                len: 2
578            })
579        );
580        // Zero free truncated streams long enough for the chunked decoder must
581        // error the same way as the scanning decoder
582        let mut long = Vec::new();
583        for _ in 0..26 {
584            long.extend_from_slice(&[0x03, 0x41, 0x42]);
585        }
586        long.extend_from_slice(&[0x05, 0x41]);
587        assert_eq!(
588            decode_nonzero(&long, &mut buffer),
589            decode(&long, &mut [0u8; 128])
590        );
591        // Feeding zeroes violates the contract, the result is unspecified but
592        // the call must remain memory safe
593        long[40] = 0;
594        let _ = decode_nonzero(&long, &mut buffer);
595    }
596
597    #[test]
598    fn test_buffer_too_small() {
599        let mut buffer = [0u8; 2];
600
601        assert_eq!(
602            encode(&[1, 2, 3], &mut buffer),
603            Err(EncodeError::BufferTooSmall {
604                have: 2,
605                want: encode_buffer(3)
606            })
607        );
608        assert_eq!(
609            decode(&[0x02, 0x41, 0x02, 0x42], &mut buffer),
610            Err(DecodeError::BufferTooSmall { have: 2, want: 3 })
611        );
612    }
613}