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