diff --git a/Cargo.toml b/Cargo.toml index f707bf2..e147895 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "ucobs" version = "0.2.0" edition = "2021" -rust-version = "1.83" +rust-version = "1.93" license = "MIT" description = "μCOBS — Consistent Overhead Byte Stuffing. no_std, zero-alloc, fastest, most-tested COBS implementation." authors = ["Stephen Waits "] @@ -23,11 +23,11 @@ unsafe_code = "forbid" all-features = true [dev-dependencies] -cobs = "0.3" +cobs = "0.5" corncobs = "0.1" -criterion = { version = "0.5", features = ["html_reports"] } +criterion = { version = "0.8", features = ["html_reports"] } proptest = "1" -rand = "0.8" +rand = "0.10" [[bench]] name = "bench" diff --git a/benches/bench.rs b/benches/bench.rs index 4d9a132..e875a35 100644 --- a/benches/bench.rs +++ b/benches/bench.rs @@ -1,4 +1,6 @@ -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; // ── Test payloads ─────────────────────────────────────────────────── @@ -51,9 +53,11 @@ fn cobs_encode(data: &[u8]) -> Vec { } fn cobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { - // unwrap_or(0): the cobs crate returns Err(EmptyFrame) for empty input, - // which is valid COBS. We treat it as 0 decoded bytes for benchmarking. - cobs::decode(encoded, out).unwrap_or(0) + // The cobs crate returns DecodeReport on success or Err(EmptyFrame) + // for empty input. We extract parsed_size, treating errors as 0. + cobs::decode(encoded, out) + .map(|report| report.parsed_size()) + .unwrap_or(0) } // ── Benchmarks ────────────────────────────────────────────────────── diff --git a/proptest-regressions/lib.txt b/proptest-regressions/lib.txt new file mode 100644 index 0000000..6a736ee --- /dev/null +++ b/proptest-regressions/lib.txt @@ -0,0 +1,10 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 00411472de4d6cdb296c04bc52fd533354bc25ba1765807da8b7994868fcb987 # shrinks to len = 1 +cc 81ebf688f1dd5ea2a735756a7fb703b92c2a2033057ae91886577c6a63763cf5 # shrinks to data = [0] +cc 32d0d2d26e2be6ec66b432f1e9baee378777b1a00b959922cff7a5784292ec4b # shrinks to data = [0] +cc fe447e0e58f4c0b21f6e1b38a90bd33578c968967c956964a06013a4df977d68 # shrinks to data = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0] diff --git a/src/lib.rs b/src/lib.rs index e8f02f3..8d3cf0a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -184,14 +184,30 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option { let mut di = 0; loop { - // Scan for next zero (or 254-byte cap). let remaining = src.len() - si; let max_run = if remaining < 254 { remaining } else { 254 }; - let mut run = 0; - if max_run > 0 && src[si] != 0x00 { - while run < max_run && src[si + run] != 0x00 { - run += 1; + + // Create bounded source window (split_at is const since Rust 1.71). + let (_, src_tail) = src.split_at(si); + let (src_window, _) = src_tail.split_at(max_run); + + // Fast path: leading zero byte avoids scan loop overhead. + // Always continue — the next iteration emits the trailing group code byte. + if max_run > 0 && src_window[0] == 0x00 { + if di >= dest.len() { + return None; } + dest[di] = 0x01; + di += 1; + si += 1; + continue; + } + + // Phase 1: Scan for next zero byte. + // LLVM sees `run < src_window.len()` → eliminates bounds check. + let mut run = 0; + while run < src_window.len() && src_window[run] != 0x00 { + run += 1; } let full_block = run == 254; @@ -202,28 +218,24 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option { dest[di] = if full_block { 0xFF } else { (run + 1) as u8 }; di += 1; - // Copy the run. + // Phase 2: Copy via const copy_from_slice (Rust 1.93+) → memcpy. if run > 0 { if di + run > dest.len() { return None; } - let mut i = 0; - while i < run { - dest[di + i] = src[si + i]; - i += 1; - } + let (_, dest_tail) = dest.split_at_mut(di); + let (dest_chunk, _) = dest_tail.split_at_mut(run); + let (src_chunk, _) = src_window.split_at(run); + dest_chunk.copy_from_slice(src_chunk); di += run; } si += run; if full_block { - // 254 non-zero bytes emitted; no implicit zero. - // If input is exhausted exactly here, we're done. if si >= src.len() { break; } } else { - // Run ended at a zero or end-of-input. if si >= src.len() { break; } @@ -267,6 +279,26 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option { if code == 0 { return None; // unexpected zero in COBS data } + + if code == 1 { + // Batch path: count consecutive 0x01 codes, fill zeros in bulk. + let mut count = 1usize; + while si < src.len() && src[si] == 0x01 { + si += 1; + count += 1; + } + // Each 0x01 inserts an implicit zero, except the last at end-of-input. + let zeros = if si < src.len() { count } else { count - 1 }; + if zeros > 0 { + if di + zeros > dest.len() { + return None; + } + dest[di..di + zeros].fill(0); + di += zeros; + } + continue; + } + // Bulk-copy `code - 1` data bytes. let n = code - 1; if si + n > src.len() || di + n > dest.len() { @@ -276,7 +308,7 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option { di += n; si += n; // If code < 0xFF, an implicit zero follows (unless we're at the end). - if code < 0xFF && si < src.len() { + if code != 0xFF && si < src.len() { if di >= dest.len() { return None; } @@ -910,11 +942,11 @@ mod tests { #[test] fn interop_random_payloads() { - use rand::Rng; - let mut rng = rand::thread_rng(); + use rand::RngExt; + let mut rng = rand::rng(); for _ in 0..500 { - let len = rng.gen_range(0..=512); - let data: Vec = (0..len).map(|_| rng.gen()).collect(); + let len = rng.random_range(0..=512); + let data: Vec = (0..len).map(|_| rng.random()).collect(); cross_encode(&data); cross_decode(&data); }