diff --git a/.gitignore b/.gitignore index 20b8bac..83067e9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /fuzz/corpus /fuzz/artifacts Cargo.lock +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md index ef7d9d3..96eea50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ 2. **Interoperable** — proven byte-for-byte compatibility with `corncobs`, `cobs` crates and Python `cobs` packages. 3. **Insanely tested** — canonical vectors, property-based tests (proptest), fuzz targets (cargo-fuzz), cross-crate interop, randomized payloads. If you think testing is done, you're 10% done. 4. **Fast** — benchmark against every competing implementation. Must be at least as fast, ideally fastest. Benchmarks are CI-enforced. -5. **Tiny** — `no_std`, zero-alloc, zero runtime dependencies. ~140 lines of implementation. Runs on 8-bit MCUs and servers alike. +5. **Tiny** — `no_std`, zero-alloc, zero runtime dependencies. ~120 lines of implementation. Runs on 8-bit MCUs and servers alike. 6. **Trustworthy** — small enough to audit by hand. No unsafe. No panics in release. Fuzz-hardened. ## Architecture diff --git a/Cargo.toml b/Cargo.toml index 26f4074..82af795 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "ucobs" version = "0.1.0" edition = "2021" +rust-version = "1.56" license = "MIT" description = "μCOBS — Consistent Overhead Byte Stuffing. no_std, zero-alloc, fastest, most-tested COBS implementation." authors = ["Stephen Waits "] @@ -15,7 +16,19 @@ categories = ["encoding", "no-std::no-alloc", "embedded"] [features] default = [] +[lints.rust] +unsafe_code = "forbid" + +[package.metadata.docs.rs] +all-features = true + [dev-dependencies] +cobs = "0.3" corncobs = "0.1" +criterion = { version = "0.5", features = ["html_reports"] } proptest = "1" rand = "0.8" + +[[bench]] +name = "bench" +harness = false diff --git a/README.md b/README.md index 11b17b5..02c6f11 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,70 @@ allowing `0x00` to serve as an unambiguous frame delimiter. Overhead is exactly - **Insanely tested** — canonical vectors, property-based tests, fuzz targets, cross-crate interop, randomized payloads, and more coming +## Performance + +Benchmarked against the two other `no_std` COBS crates using +[Criterion](https://github.com/bheisler/criterion.rs) across three payload +patterns: **all zeros** (worst case — every byte triggers a code emit), +**no zeros**, and **mixed** (0x00–0xFF cycling). Throughput in MB/s, higher +is better. + +### Encode + +| Payload | Pattern | μCOBS | `cobs` 0.3 | `corncobs` 0.1 | +|---------|---------|------:|-----------:|---------------:| +| 64 B | zeros | 595 | 748 | 180 | +| 64 B | mixed | 1,853 | 1,011 | 1,734 | +| 64 B | nonzero | 1,884 | 1,012 | 2,110 | +| 256 B | zeros | 654 | 817 | 184 | +| 256 B | mixed | 1,888 | 1,002 | 1,887 | +| 256 B | nonzero | 1,964 | 1,004 | 1,968 | +| 4096 B | zeros | 675 | 845 | 188 | +| 4096 B | mixed | 1,734 | 1,016 | 1,746 | +| 4096 B | nonzero | 1,775 | 1,020 | 2,245 | + +### Decode + +| Payload | Pattern | μCOBS | `cobs` 0.3 | `corncobs` 0.1 | +|---------|---------|------:|-----------:|---------------:| +| 64 B | zeros | 595 | 440 | 732 | +| 64 B | mixed | 7,041 | 504 | 7,991 | +| 64 B | nonzero | 9,900 | 502 | 9,807 | +| 256 B | zeros | 656 | 472 | 742 | +| 256 B | mixed |17,024 | 645 | 16,207 | +| 256 B | nonzero |18,456 | 647 | 19,549 | +| 4096 B | zeros | 675 | 483 | 768 | +| 4096 B | mixed |17,522 | 656 | 22,959 | +| 4096 B | nonzero |37,987 | 623 | 40,005 | + +### Code size + +Measured from `.text` section of release-optimized symbols (`encode` + `decode` +combined): + +| Crate | Code size | +|----------------|----------:| +| μCOBS | 541 B | +| `cobs` 0.3 | 537 B | +| `corncobs` 0.1 | 637 B | + +All three crates are tiny. The difference is negligible for any target. + +### Crate properties + +| Property | μCOBS | `cobs` 0.3 | `corncobs` 0.1 | +|----------------|:------:|:-----------------:|:--------------:| +| `no_std` | always | opt-in[^1] | always | +| Zero-alloc | yes | opt-in[^2] | yes | +| `unsafe`-free | yes | yes | yes | +| Implementation | ~120 LOC | ~790 LOC | ~645 LOC | + +[^1]: Requires disabling the default `std` feature. +[^2]: `alloc` is enabled by default via the `std` feature. + +> Measured on Intel N100, Rust 1.93.1, Linux 6.12. Run `cargo bench` to +> reproduce. Full Criterion reports are generated in `target/criterion/report/`. + ## Usage ```rust diff --git a/bench_python.py b/bench_python.py new file mode 100644 index 0000000..490d7c0 --- /dev/null +++ b/bench_python.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Benchmark Python cobs encode/decode vs ucobs (reference timing only). + +Requires: pip install cobs +""" + +import time + + +def payload_zeros(n: int) -> bytes: + return b"\x00" * n + + +def payload_nonzero(n: int) -> bytes: + return bytes((i % 255 + 1) for i in range(n)) + + +def payload_mixed(n: int) -> bytes: + return bytes(i % 256 for i in range(n)) + + +def bench(func, data, iterations=10_000): + start = time.perf_counter() + for _ in range(iterations): + func(data) + elapsed = time.perf_counter() - start + return elapsed + + +def main(): + try: + from cobs import cobs as cobs_mod + except ImportError: + print("ERROR: python cobs package not installed. Run: pip install cobs") + return + + sizes = [0, 1, 10, 64, 254, 255, 256, 1024, 4096] + payloads = [ + ("zeros", payload_zeros), + ("nonzero", payload_nonzero), + ("mixed", payload_mixed), + ] + iterations = 10_000 + + print(f"{'operation':<30} {'size':>6} {'iters':>7} {'total_ms':>10} {'per_op_us':>10} {'MB/s':>10}") + print("-" * 80) + + for name, make in payloads: + for size in sizes: + data = make(size) + + # Encode + elapsed = bench(cobs_mod.encode, data, iterations) + per_op_us = (elapsed / iterations) * 1_000_000 + mbps = (size * iterations / elapsed / 1_000_000) if elapsed > 0 and size > 0 else 0 + print(f"{'python-cobs encode/' + name:<30} {size:>6} {iterations:>7} {elapsed*1000:>10.2f} {per_op_us:>10.3f} {mbps:>10.1f}") + + # Decode + encoded = cobs_mod.encode(data) + elapsed = bench(cobs_mod.decode, encoded, iterations) + per_op_us = (elapsed / iterations) * 1_000_000 + mbps = (size * iterations / elapsed / 1_000_000) if elapsed > 0 and size > 0 else 0 + print(f"{'python-cobs decode/' + name:<30} {size:>6} {iterations:>7} {elapsed*1000:>10.2f} {per_op_us:>10.3f} {mbps:>10.1f}") + + +if __name__ == "__main__": + main() diff --git a/benches/bench.rs b/benches/bench.rs new file mode 100644 index 0000000..4d9a132 --- /dev/null +++ b/benches/bench.rs @@ -0,0 +1,149 @@ +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; + +// ── Test payloads ─────────────────────────────────────────────────── + +fn payload_zeros(len: usize) -> Vec { + vec![0u8; len] +} + +fn payload_nonzero(len: usize) -> Vec { + (0..len).map(|i| (i % 255 + 1) as u8).collect() +} + +fn payload_mixed(len: usize) -> Vec { + (0..len).map(|i| (i % 256) as u8).collect() +} + +// ── ucobs ─────────────────────────────────────────────────────────── + +fn ucobs_encode(data: &[u8]) -> Vec { + let mut buf = vec![0u8; ucobs::max_encoded_len(data.len()) + 1]; + let n = ucobs::encode(data, &mut buf).unwrap(); + buf.truncate(n); + buf +} + +fn ucobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { + ucobs::decode(encoded, out).unwrap() +} + +// ── corncobs ──────────────────────────────────────────────────────── + +fn corncobs_encode(data: &[u8]) -> Vec { + let mut buf = vec![0u8; corncobs::max_encoded_len(data.len())]; + let n = corncobs::encode_buf(data, &mut buf); + buf.truncate(n); + buf +} + +fn corncobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { + // corncobs expects trailing sentinel + corncobs::decode_buf(encoded, out).unwrap() +} + +// ── cobs crate ────────────────────────────────────────────────────── + +fn cobs_encode(data: &[u8]) -> Vec { + let mut buf = vec![0u8; cobs::max_encoding_length(data.len())]; + let n = cobs::encode(data, &mut buf); + buf.truncate(n); + buf +} + +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) +} + +// ── Benchmarks ────────────────────────────────────────────────────── + +const SIZES: &[usize] = &[0, 1, 10, 64, 254, 255, 256, 1024, 4096]; + +fn bench_encode(c: &mut Criterion) { + let payloads: Vec<(&str, Box Vec>)> = vec![ + ("zeros", Box::new(payload_zeros)), + ("nonzero", Box::new(payload_nonzero)), + ("mixed", Box::new(payload_mixed)), + ]; + + for (name, make) in &payloads { + let mut group = c.benchmark_group(format!("encode/{name}")); + for &size in SIZES { + let data = make(size); + group.throughput(Throughput::Bytes(size as u64)); + + group.bench_with_input(BenchmarkId::new("ucobs", size), &data, |b, d| { + let mut buf = vec![0u8; ucobs::max_encoded_len(d.len()) + 1]; + b.iter(|| { + black_box(ucobs::encode(black_box(d), &mut buf).unwrap()); + }); + }); + + group.bench_with_input(BenchmarkId::new("corncobs", size), &data, |b, d| { + let mut buf = vec![0u8; corncobs::max_encoded_len(d.len())]; + b.iter(|| { + black_box(corncobs::encode_buf(black_box(d), &mut buf)); + }); + }); + + group.bench_with_input(BenchmarkId::new("cobs", size), &data, |b, d| { + let mut buf = vec![0u8; cobs::max_encoding_length(d.len())]; + b.iter(|| { + black_box(cobs::encode(black_box(d), &mut buf)); + }); + }); + } + group.finish(); + } +} + +fn bench_decode(c: &mut Criterion) { + let payloads: Vec<(&str, Box Vec>)> = vec![ + ("zeros", Box::new(payload_zeros)), + ("nonzero", Box::new(payload_nonzero)), + ("mixed", Box::new(payload_mixed)), + ]; + + for (name, make) in &payloads { + let mut group = c.benchmark_group(format!("decode/{name}")); + for &size in SIZES { + let data = make(size); + let ucobs_enc = ucobs_encode(&data); + // corncobs expects sentinel in encoded form + let corncobs_enc = corncobs_encode(&data); + let cobs_enc = cobs_encode(&data); + + group.throughput(Throughput::Bytes(size as u64)); + + group.bench_with_input(BenchmarkId::new("ucobs", size), &ucobs_enc, |b, enc| { + let mut buf = vec![0u8; size + 1]; + b.iter(|| { + black_box(ucobs_decode(black_box(enc), &mut buf)); + }); + }); + + group.bench_with_input( + BenchmarkId::new("corncobs", size), + &corncobs_enc, + |b, enc| { + let mut buf = vec![0u8; size + 1]; + b.iter(|| { + black_box(corncobs_decode(black_box(enc), &mut buf)); + }); + }, + ); + + group.bench_with_input(BenchmarkId::new("cobs", size), &cobs_enc, |b, enc| { + let mut buf = vec![0u8; size + 1]; + b.iter(|| { + black_box(cobs_decode(black_box(enc), &mut buf)); + }); + }); + } + group.finish(); + } +} + +criterion_group!(benches, bench_encode, bench_decode); +criterion_main!(benches); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index c5271e8..e3e3dd3 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,3 +24,9 @@ name = "fuzz_roundtrip" path = "fuzz_targets/fuzz_roundtrip.rs" test = false doc = false + +[[bin]] +name = "fuzz_small_dest" +path = "fuzz_targets/fuzz_small_dest.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/fuzz_small_dest.rs b/fuzz/fuzz_targets/fuzz_small_dest.rs new file mode 100644 index 0000000..49393df --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_small_dest.rs @@ -0,0 +1,36 @@ +//! Fuzz target: encode and decode with undersized destination buffers. +//! Must never panic — should return None when dest is too small. +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { + return; + } + + // Split fuzz input: first byte selects dest size, rest is payload. + let dest_size = data[0] as usize; + let payload = &data[1..]; + + // Encode with a potentially too-small buffer. + let mut enc_buf = vec![0u8; dest_size]; + let enc_result = ucobs::encode(payload, &mut enc_buf); + + if let Some(enc_len) = enc_result { + let encoded = &enc_buf[..enc_len]; + + // Encoded output must never contain 0x00. + assert!(!encoded.contains(&0x00), "encoded contains sentinel"); + + // Decode with a potentially too-small buffer. + let mut dec_buf = vec![0u8; dest_size]; + let dec_result = ucobs::decode(encoded, &mut dec_buf); + + if let Some(dec_len) = dec_result { + assert_eq!( + &dec_buf[..dec_len], payload, + "round-trip mismatch with small buffers" + ); + } + } +}); diff --git a/justfile b/justfile new file mode 100644 index 0000000..01b2e2d --- /dev/null +++ b/justfile @@ -0,0 +1,64 @@ +# μCOBS development tasks + +# default: list all recipes +default: + @just --list + +# run ALL quality gates (tests, clippy, fmt check, doc, fuzz, miri) +test: test-unit test-clippy test-fmt test-doc test-fuzz test-miri + +# quick compile check (no tests) +check: + cargo check + +# unit + integration + proptest tests (via nextest for parallelism) +test-unit: + cargo nextest run + cargo test --doc + +# clippy with all warnings as errors +test-clippy: + cargo clippy -- -D warnings + +# check formatting +test-fmt: + cargo fmt -- --check + +# doc build (catches broken doc links) +test-doc: + cargo doc --no-deps + +# fuzz all targets (60s each, requires nightly + cargo-fuzz) +test-fuzz: + cd fuzz && cargo +nightly fuzz run fuzz_decode -- -max_total_time=60 + cd fuzz && cargo +nightly fuzz run fuzz_roundtrip -- -max_total_time=60 + cd fuzz && cargo +nightly fuzz run fuzz_small_dest -- -max_total_time=60 + +# run tests under miri (catches UB, requires nightly + miri component) +# proptest is excluded — its file-persistence layer calls getcwd which miri does not support +test-miri: + cargo +nightly miri test -- --skip proptests + +# run criterion benchmarks (ucobs vs cobs vs corncobs) +bench: + cargo bench + +# run python cobs benchmarks (requires: pip install cobs) +bench-python: + python3 bench_python.py + +# run all benchmarks (rust + python) +bench-all: bench bench-python + +# format code +fmt: + cargo fmt + +# build in release mode +build: + cargo build --release + +# clean all build artifacts +clean: + cargo clean + rm -rf fuzz/target fuzz/corpus fuzz/artifacts diff --git a/src/lib.rs b/src/lib.rs index 5faf985..5db4634 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,106 +6,101 @@ //! //! COBS transforms a byte sequence so that `0x00` never appears in the //! output, allowing `0x00` to be used as an unambiguous frame delimiter. -//! Overhead is exactly 1 byte per 254 input bytes (worst case). +//! Overhead is at most 1 byte per 254 input bytes plus 1; see +//! [`max_encoded_len`] for the exact formula. //! //! This crate is `no_std` and zero-alloc — suitable for embedded firmware. //! -//! # Examples +//! # Sentinel convention +//! +//! The encoder does **not** append a trailing `0x00` sentinel. The decoder +//! expects input **without** a trailing sentinel. Callers should append / +//! strip the sentinel themselves when framing for transport: //! //! ``` -//! let mut buf = [0u8; 16]; +//! let data = [0x11, 0x00, 0x33]; //! -//! // Encode -//! let n = ucobs::encode(&[0x11, 0x00, 0x33], &mut buf).unwrap(); -//! assert_eq!(&buf[..n], &[0x02, 0x11, 0x02, 0x33]); +//! // Encode — leave room for the sentinel byte +//! let mut frame = [0u8; 16]; +//! let n = ucobs::encode(&data, &mut frame).unwrap(); +//! frame[n] = 0x00; // append sentinel for framing //! -//! // Decode +//! // Decode — strip sentinel before decoding //! let mut out = [0u8; 16]; -//! let n = ucobs::decode(&[0x02, 0x11, 0x02, 0x33], &mut out).unwrap(); -//! assert_eq!(&out[..n], &[0x11, 0x00, 0x33]); +//! let m = ucobs::decode(&frame[..n], &mut out).unwrap(); +//! assert_eq!(&out[..m], &data); //! ``` #![no_std] +#![forbid(unsafe_code)] +#![warn(missing_docs)] /// Encode `src` into `dest` using COBS. Returns the number of bytes written, /// or `None` if `dest` is too small. /// -/// The output does NOT include a trailing `0x00` sentinel — the caller +/// The output does **not** include a trailing `0x00` sentinel — the caller /// should append it as a frame delimiter. +#[must_use] pub fn encode(src: &[u8], dest: &mut [u8]) -> Option { let mut si = 0; let mut di = 0; - let mut code_idx = di; - let mut need_final_code = true; - if di >= dest.len() && !src.is_empty() { - return None; - } - // Reserve space for the first code byte. - di += 1; - let mut code: u8 = 1; - - while si < src.len() { - if src[si] == 0x00 { - if code_idx >= dest.len() { - return None; - } - dest[code_idx] = code; - code_idx = di; - if di >= dest.len() { - return None; - } - di += 1; - code = 1; - si += 1; - need_final_code = true; + loop { + // Scan for next zero (or 254-byte cap). + let remaining = src.len() - si; + let max_run = if remaining < 254 { remaining } else { 254 }; + let run = if max_run > 0 && src[si] == 0x00 { + 0 } else { - if di >= dest.len() { - return None; - } - dest[di] = src[si]; - di += 1; - si += 1; - code += 1; - if code == 0xFF { - // Block full (254 data bytes) — emit code. - if code_idx >= dest.len() { - return None; - } - dest[code_idx] = code; + src[si..si + max_run] + .iter() + .position(|&b| b == 0x00) + .unwrap_or(max_run) + }; + let full_block = run == 254; - if si < src.len() { - // More input: start a new block. - code_idx = di; - if di >= dest.len() { - return None; - } - di += 1; - code = 1; - need_final_code = true; - } else { - // Input exhausted right at the block boundary. - need_final_code = false; - } - } - } - } - - if need_final_code { - if code_idx >= dest.len() { + // Write code byte. + if di >= dest.len() { return None; } - dest[code_idx] = code; + dest[di] = if full_block { 0xFF } else { (run + 1) as u8 }; + di += 1; + + // Bulk-copy the run. + if run > 0 { + if di + run > dest.len() { + return None; + } + dest[di..di + run].copy_from_slice(&src[si..si + run]); + 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; + } + si += 1; // skip the zero byte + } } Some(di) } /// Decode a COBS-encoded buffer. Returns the number of decoded bytes -/// written to `dest`, or `None` if the input is malformed. +/// written to `dest`, or `None` if the input is malformed or `dest` is +/// too small. /// -/// `src` should NOT include the trailing `0x00` frame sentinel — strip +/// `src` should **not** include the trailing `0x00` frame sentinel — strip /// it before calling this function. +#[must_use] pub fn decode(src: &[u8], dest: &mut [u8]) -> Option { let mut si = 0; let mut di = 0; @@ -115,14 +110,15 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option { if code == 0 { return None; // unexpected zero in COBS data } - // Copy `code - 1` data bytes verbatim. - for _ in 1..code { - if si >= src.len() || di >= dest.len() { + // Bulk-copy `code - 1` data bytes. + let n = code - 1; + if n > 0 { + if si + n > src.len() || di + n > dest.len() { return None; } - dest[di] = src[si]; - di += 1; - si += 1; + dest[di..di + n].copy_from_slice(&src[si..si + n]); + di += n; + si += n; } // If code < 0xFF, an implicit zero follows (unless we're at the end). if code < 0xFF && si < src.len() { @@ -137,6 +133,7 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option { } /// Maximum encoded size for a given source length (excluding sentinel). +#[must_use] pub const fn max_encoded_len(src_len: usize) -> usize { src_len + (src_len / 254) + 1 } @@ -182,10 +179,7 @@ mod tests { #[test] fn decode_zero_delimited() { - assert_eq!( - dec(&[0x01, 0x02, 0x11, 0x01]), - Some(vec![0x00, 0x11, 0x00]) - ); + assert_eq!(dec(&[0x01, 0x02, 0x11, 0x01]), Some(vec![0x00, 0x11, 0x00])); } #[test] @@ -280,6 +274,280 @@ mod tests { assert_eq!(dec(&[]), Some(vec![])); } + // ── External corpus: decode error cases (cobs-c, nanocobs) ── + + #[test] + fn decode_err_truncated_code_block() { + // Code says 5 data bytes, only 3 follow. + assert_eq!(dec(&[0x05, 0x31, 0x32, 0x33]), None); + } + + #[test] + fn decode_err_zero_after_code_block() { + // Zero appears after a valid code block's data. + assert_eq!(dec(&[0x05, 0x31, 0x32, 0x33, 0x34, 0x00]), None); + } + + #[test] + fn decode_zero_in_data_position_is_passthrough() { + // A 0x00 in a data position is not checked by the decoder — it is + // copied through as-is. This is correct COBS behavior: the decoder + // only validates code-byte positions, not data bytes. + assert_eq!( + dec(&[0x05, 0x31, 0x32, 0x00, 0x34]), + Some(vec![0x31, 0x32, 0x00, 0x34]) + ); + } + + // ── External corpus: Wikipedia vectors 9-11 ───────────────── + + #[test] + fn corpus_wiki_vec9() { + // 01..FF (255 bytes) → [FF 01..FE 02 FF] + let input: Vec = (1..=255).map(|i| i as u8).collect(); + let mut expected = vec![0xFF]; + expected.extend(1u8..=254); + expected.extend([0x02, 0xFF]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + #[test] + fn corpus_wiki_vec10() { + // 02..FF 00 (255 bytes) → [FF 02..FF 01 01] + let mut input: Vec = (2..=255).map(|i| i as u8).collect(); + input.push(0x00); + let mut expected = vec![0xFF]; + expected.extend(2u8..=255); + expected.extend([0x01, 0x01]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + #[test] + fn corpus_wiki_vec11() { + // 03..FF 00 01 (255 bytes) → [FE 03..FF 02 01] + let mut input: Vec = (3..=255).map(|i| i as u8).collect(); + input.push(0x00); + input.push(0x01); + let mut expected = vec![0xFE]; + expected.extend(3u8..=255); + expected.extend([0x02, 0x01]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + // ── External corpus: Cheshire & Baker 1997 paper (Figure 2) ─ + + #[test] + fn corpus_paper_ipv4_header() { + // IPv4 header fragment from the original SIGCOMM paper. + let input = [ + 0x45, 0x00, 0x00, 0x2C, 0x4C, 0x79, 0x00, 0x00, 0x40, 0x06, 0x4F, 0x37, + ]; + let expected = [ + 0x02, 0x45, 0x01, 0x04, 0x2C, 0x4C, 0x79, 0x01, 0x05, 0x40, 0x06, 0x4F, 0x37, + ]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + // ── External corpus: nanocobs vectors ─────────────────────── + + #[test] + fn corpus_nanocobs_single_nonzero() { + assert_eq!(enc(&[0x34]), Some(vec![0x02, 0x34])); + } + + #[test] + fn corpus_nanocobs_two_nonzero() { + assert_eq!(enc(&[0x34, 0x56]), Some(vec![0x03, 0x34, 0x56])); + } + + #[test] + fn corpus_nanocobs_eight_nonzero() { + let input = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xFF]; + let expected = [0x09, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xFF]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_nanocobs_eight_zeros() { + let input = [0x00; 8]; + let expected = [0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_nanocobs_interleaved_00_11() { + // 00 11 00 22 + let input = [0x00, 0x11, 0x00, 0x22]; + let expected = [0x01, 0x02, 0x11, 0x02, 0x22]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_nanocobs_trailing_zero() { + // 11 00 22 00 + let input = [0x11, 0x00, 0x22, 0x00]; + let expected = [0x02, 0x11, 0x02, 0x22, 0x01]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_nanocobs_253_fill() { + // 253 bytes of 0x42 → [FE 42 42 ... 42] + let input = vec![0x42u8; 253]; + let mut expected = vec![0xFE]; + expected.extend(vec![0x42u8; 253]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + #[test] + fn corpus_nanocobs_255_ones() { + // 255 bytes of 0x01 → [FF 01x254 02 01] + let input = vec![0x01u8; 255]; + let mut expected = vec![0xFF]; + expected.extend(vec![0x01u8; 254]); + expected.extend([0x02, 0x01]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + #[test] + fn corpus_nanocobs_508_fill() { + // 508 bytes of 0xAA → two full blocks [FF AAx254 FF AAx254] + let input = vec![0xAAu8; 508]; + let mut expected = vec![0xFF]; + expected.extend(vec![0xAAu8; 254]); + expected.push(0xFF); + expected.extend(vec![0xAAu8; 254]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + // ── External corpus: cobs-c vectors ───────────────────────── + + #[test] + fn corpus_cobsc_five_nonzero() { + let input = [0x31, 0x32, 0x33, 0x34, 0x35]; + let expected = [0x06, 0x31, 0x32, 0x33, 0x34, 0x35]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_cobsc_two_segments() { + // "12345\06789" + let input = [0x31, 0x32, 0x33, 0x34, 0x35, 0x00, 0x36, 0x37, 0x38, 0x39]; + let expected = [ + 0x06, 0x31, 0x32, 0x33, 0x34, 0x35, 0x05, 0x36, 0x37, 0x38, 0x39, + ]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_cobsc_leading_zero_two_segments() { + // "\012345\06789" + let input = [ + 0x00, 0x31, 0x32, 0x33, 0x34, 0x35, 0x00, 0x36, 0x37, 0x38, 0x39, + ]; + let expected = [ + 0x01, 0x06, 0x31, 0x32, 0x33, 0x34, 0x35, 0x05, 0x36, 0x37, 0x38, 0x39, + ]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_cobsc_trailing_zero_two_segments() { + // "12345\06789\0" + let input = [ + 0x31, 0x32, 0x33, 0x34, 0x35, 0x00, 0x36, 0x37, 0x38, 0x39, 0x00, + ]; + let expected = [ + 0x06, 0x31, 0x32, 0x33, 0x34, 0x35, 0x05, 0x36, 0x37, 0x38, 0x39, 0x01, + ]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + #[test] + fn corpus_cobsc_three_zeros() { + assert_eq!(enc(&[0x00, 0x00, 0x00]), Some(vec![0x01, 0x01, 0x01, 0x01])); + } + + #[test] + fn corpus_cobsc_253_ascending() { + // 01..FD (253 bytes) → [FE 01..FD] + let input: Vec = (1..=253).map(|i| i as u8).collect(); + let mut expected = vec![0xFE]; + expected.extend(1u8..=253); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + #[test] + fn corpus_cobsc_zero_then_256_bytes() { + // 00 01..FF (256 bytes) → [01 FF 01..FE 02 FF] + let mut input = vec![0x00u8]; + input.extend(1u8..=255); + let mut expected = vec![0x01, 0xFF]; + expected.extend(1u8..=254); + expected.extend([0x02, 0xFF]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + // ── External corpus: cobs2-rs vector ──────────────────────── + + #[test] + fn corpus_cobs2rs_abc_ghij_xyz() { + // "ABC\0ghij\0xyz" + let input = [ + 0x41, 0x42, 0x43, 0x00, 0x67, 0x68, 0x69, 0x6A, 0x00, 0x78, 0x79, 0x7A, + ]; + let expected = [ + 0x04, 0x41, 0x42, 0x43, 0x05, 0x67, 0x68, 0x69, 0x6A, 0x04, 0x78, 0x79, 0x7A, + ]; + assert_eq!(enc(&input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + + // ── External corpus: Jacques Fortier vector ───────────────── + + #[test] + fn corpus_fortier_254_nonzero_then_zero() { + // 01..FE 00 (255 bytes) → [FF 01..FE 01 01] + let mut input: Vec = (1..=254).collect(); + input.push(0x00); + let mut expected = vec![0xFF]; + expected.extend(1u8..=254); + expected.extend([0x01, 0x01]); + assert_eq!(enc(&input), Some(expected.clone())); + assert_eq!(dec(&expected), Some(input)); + } + + // ── External corpus: Python cobs package ──────────────────── + + #[test] + fn corpus_python_hello_world() { + // "Hello world\0This is a test" + let input = b"Hello world\x00This is a test"; + let expected = [ + 0x0C, 0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x77, 0x6F, 0x72, 0x6C, 0x64, 0x0F, 0x54, + 0x68, 0x69, 0x73, 0x20, 0x69, 0x73, 0x20, 0x61, 0x20, 0x74, 0x65, 0x73, 0x74, + ]; + assert_eq!(enc(input), Some(expected.to_vec())); + assert_eq!(dec(&expected), Some(input.to_vec())); + } + // ── Encode tests ──────────────────────────────────────────── #[test] @@ -414,12 +682,15 @@ mod tests { // corncobs includes the sentinel — strip it for our decoder let their_encoded = &their_buf[..their_len]; // Find the sentinel and exclude it - let data_end = their_encoded.iter().rposition(|&b| b != 0x00).map_or(0, |i| i + 1); + let data_end = their_encoded + .iter() + .rposition(|&b| b != 0x00) + .map_or(0, |i| i + 1); let their_no_sentinel = &their_encoded[..data_end]; let mut our_buf = vec![0u8; input.len() + 1]; - let our_len = decode(their_no_sentinel, &mut our_buf) - .expect("our decode failed on corncobs output"); + let our_len = + decode(their_no_sentinel, &mut our_buf).expect("our decode failed on corncobs output"); assert_eq!(&our_buf[..our_len], input, "cross-decode mismatch"); } @@ -485,6 +756,122 @@ mod tests { cross_decode(&data); } } + + // ── Dest-size edge cases ──────────────────────────────────── + + #[test] + fn encode_dest_exact_size() { + let input = [0x11, 0x00, 0x33]; + let needed = max_encoded_len(input.len()); + let mut buf = vec![0u8; needed]; + assert_eq!(encode(&input, &mut buf), Some(4)); + } + + #[test] + fn encode_dest_one_too_small() { + let input = [0x11, 0x00, 0x33]; + let needed = max_encoded_len(input.len()); + let mut buf = vec![0u8; needed - 1]; + assert_eq!(encode(&input, &mut buf), None); + } + + #[test] + fn encode_dest_empty() { + assert_eq!(encode(&[0x11], &mut []), None); + } + + #[test] + fn encode_empty_into_single_byte() { + let mut buf = [0u8; 1]; + assert_eq!(encode(&[], &mut buf), Some(1)); + assert_eq!(buf[0], 0x01); + } + + #[test] + fn decode_dest_too_small() { + // Encoded [0x11, 0x22] = [0x03, 0x11, 0x22], needs 2 bytes output + assert_eq!(decode(&[0x03, 0x11, 0x22], &mut [0u8; 1]), None); + } + + #[test] + fn decode_dest_exact_size() { + let mut out = [0u8; 2]; + assert_eq!(decode(&[0x03, 0x11, 0x22], &mut out), Some(2)); + assert_eq!(out, [0x11, 0x22]); + } + + // ── Multi-block boundary tests ────────────────────────────── + + #[test] + fn roundtrip_508_nonzero() { + // Two full 254-byte blocks back-to-back + let data: Vec = (0..508).map(|i| (i % 254 + 1) as u8).collect(); + roundtrip(&data); + } + + #[test] + fn roundtrip_762_nonzero() { + // Three full 254-byte blocks + let data: Vec = (0..762).map(|i| (i % 254 + 1) as u8).collect(); + roundtrip(&data); + } + + #[test] + fn roundtrip_254_nonzero_then_zero() { + // Full block immediately followed by a zero + let mut data: Vec = (1..=254).collect(); + data.push(0x00); + roundtrip(&data); + } + + #[test] + fn roundtrip_254_nonzero_then_zeros() { + // Full block followed by multiple zeros + let mut data: Vec = (1..=254).collect(); + data.extend([0x00, 0x00, 0x00]); + roundtrip(&data); + } + + // ── max_encoded_len edge cases ────────────────────────────── + + #[test] + fn max_encoded_len_zero() { + assert_eq!(max_encoded_len(0), 1); + } + + #[test] + fn max_encoded_len_one() { + assert_eq!(max_encoded_len(1), 2); + } + + #[test] + fn max_encoded_len_254() { + assert_eq!(max_encoded_len(254), 256); + } + + #[test] + fn max_encoded_len_255() { + assert_eq!(max_encoded_len(255), 257); + } + + // ── Encode always fits in max_encoded_len ─────────────────── + + #[test] + fn encode_fits_max_encoded_len_all_patterns() { + for len in [0, 1, 2, 10, 100, 253, 254, 255, 256, 508, 512, 1024] { + for fill in [0x00u8, 0x01, 0x7F, 0xFF] { + let data = vec![fill; len]; + let max = max_encoded_len(len); + let mut buf = vec![0u8; max]; + let n = encode(&data, &mut buf) + .unwrap_or_else(|| panic!("encode failed for len={len}, fill=0x{fill:02X}")); + assert!( + n <= max, + "encoded len {n} exceeds max {max} for len={len}, fill=0x{fill:02X}" + ); + } + } + } } // ── Property-based tests (proptest) ───────────────────────────── @@ -492,14 +879,28 @@ mod tests { #[cfg(test)] mod proptests { extern crate alloc; - use alloc::vec; use super::*; + use alloc::vec; use proptest::prelude::*; - // Round-trip: encode then decode must recover the original. + // Bump case count: default 256 is too low for a codec this critical. + const CASES: u32 = 25_000; + + fn config() -> ProptestConfig { + ProptestConfig { + cases: CASES, + ..ProptestConfig::default() + } + } + + // ── Round-trip invariants ─────────────────────────────────── + proptest! { + #![proptest_config(config())] + + // Core invariant: encode then decode recovers the original. #[test] - fn roundtrip_any(data in proptest::collection::vec(any::(), 0..1024)) { + fn roundtrip_any(data in proptest::collection::vec(any::(), 0..4096)) { let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; let enc_len = encode(&data, &mut enc_buf).unwrap(); let encoded = &enc_buf[..enc_len]; @@ -512,13 +913,118 @@ mod proptests { prop_assert_eq!(&dec_buf[..dec_len], &data[..]); } + // Round-trip with exact-size dest (no extra room). + #[test] + fn roundtrip_exact_dest(data in proptest::collection::vec(any::(), 0..4096)) { + let max = max_encoded_len(data.len()); + let mut enc_buf = vec![0u8; max]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + prop_assert!(enc_len <= max); + + let mut dec_buf = vec![0u8; data.len()]; + let dec_len = decode(&enc_buf[..enc_len], &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); + } + } + + // ── Encoding properties ───────────────────────────────────── + + proptest! { + #![proptest_config(config())] + + // Encoded length never exceeds the theoretical maximum. + #[test] + fn encoded_length_bounded(data in proptest::collection::vec(any::(), 0..4096)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + prop_assert!(enc_len <= max_encoded_len(data.len())); + } + + // Encoded output never contains 0x00 (the sentinel). + #[test] + fn encoded_no_zeros(data in proptest::collection::vec(any::(), 0..4096)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + prop_assert!(!enc_buf[..enc_len].contains(&0x00)); + } + + // Encoding into a too-small dest returns None, never panics. + #[test] + fn encode_small_dest_never_panics( + data in proptest::collection::vec(any::(), 1..512), + shrink in 1usize..256, + ) { + let max = max_encoded_len(data.len()); + let dest_size = if shrink >= max { 0 } else { max - shrink }; + let mut buf = vec![0u8; dest_size]; + let _ = encode(&data, &mut buf); // may return None, must not panic + } + + // Empty input always encodes to [0x01]. + #[test] + fn encode_empty_is_0x01(_dummy in 0u8..1) { + let mut buf = [0u8; 1]; + let n = encode(&[], &mut buf).unwrap(); + prop_assert_eq!(n, 1); + prop_assert_eq!(buf[0], 0x01); + } + } + + // ── Decoding properties ───────────────────────────────────── + + proptest! { + #![proptest_config(config())] + + // Decoding random garbage must never panic. + #[test] + fn decode_garbage_never_panics(data in proptest::collection::vec(any::(), 0..4096)) { + let mut dec_buf = vec![0u8; data.len() + 256]; + let _ = decode(&data, &mut dec_buf); + } + + // Decoding into a too-small dest returns None, never panics. + #[test] + fn decode_small_dest_never_panics( + data in proptest::collection::vec(any::(), 0..512), + dest_size in 0usize..64, + ) { + let mut buf = vec![0u8; dest_size]; + let _ = decode(&data, &mut buf); + } + + // A 0x00 at a code-byte position is always rejected. + #[test] + fn decode_rejects_zero_code_byte( + suffix in proptest::collection::vec(any::(), 0..64), + ) { + // 0x00 as the very first byte (always a code-byte position). + let mut data = vec![0x00]; + data.extend(&suffix); + let mut buf = vec![0u8; data.len() + 256]; + prop_assert_eq!(decode(&data, &mut buf), None); + } + + // Decoding the output of encode always succeeds. + #[test] + fn decode_of_encode_always_succeeds(data in proptest::collection::vec(any::(), 0..4096)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let mut dec_buf = vec![0u8; data.len() + 1]; + prop_assert!(decode(&enc_buf[..enc_len], &mut dec_buf).is_some()); + } + } + + // ── Cross-validation against corncobs ─────────────────────── + + proptest! { + #![proptest_config(config())] + // Our encode → corncobs decode must match. #[test] - fn interop_our_encode_their_decode(data in proptest::collection::vec(any::(), 0..1024)) { + fn interop_our_encode_their_decode(data in proptest::collection::vec(any::(), 0..4096)) { let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 2]; let enc_len = encode(&data, &mut enc_buf).unwrap(); - // Append sentinel for corncobs. - enc_buf[enc_len] = 0x00; + enc_buf[enc_len] = 0x00; // append sentinel for corncobs let mut dec_buf = vec![0u8; data.len() + 1]; let dec_len = corncobs::decode_buf(&enc_buf[..enc_len + 1], &mut dec_buf).unwrap(); @@ -527,10 +1033,9 @@ mod proptests { // corncobs encode → our decode must match. #[test] - fn interop_their_encode_our_decode(data in proptest::collection::vec(any::(), 0..1024)) { + fn interop_their_encode_our_decode(data in proptest::collection::vec(any::(), 0..4096)) { let mut enc_buf = vec![0u8; corncobs::max_encoded_len(data.len())]; let enc_len = corncobs::encode_buf(&data, &mut enc_buf); - // Strip trailing sentinel for our decoder. let data_end = enc_buf[..enc_len].iter().rposition(|&b| b != 0x00).map_or(0, |i| i + 1); let mut dec_buf = vec![0u8; data.len() + 1]; @@ -538,19 +1043,201 @@ mod proptests { prop_assert_eq!(&dec_buf[..dec_len], &data[..]); } - // Encoded length is always within the theoretical maximum. + // Both crates must produce byte-identical encoded output for the same input. #[test] - fn encoded_length_bounded(data in proptest::collection::vec(any::(), 0..1024)) { - let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; - let enc_len = encode(&data, &mut enc_buf).unwrap(); - prop_assert!(enc_len <= max_encoded_len(data.len())); + fn interop_encode_byte_identical(data in proptest::collection::vec(any::(), 0..4096)) { + // Encode with ucobs. + let mut our_buf = vec![0u8; max_encoded_len(data.len()) + 2]; + let our_len = encode(&data, &mut our_buf).unwrap(); + + // Encode with corncobs (includes trailing sentinel). + let mut their_buf = vec![0u8; corncobs::max_encoded_len(data.len())]; + let their_len = corncobs::encode_buf(&data, &mut their_buf); + // Strip corncobs sentinel for comparison. + let their_end = their_buf[..their_len] + .iter() + .rposition(|&b| b != 0x00) + .map_or(0, |i| i + 1); + + prop_assert_eq!( + &our_buf[..our_len], + &their_buf[..their_end], + "encoded output differs for input len={}", + data.len() + ); + } + } + + // ── Structural / algebraic properties ─────────────────────── + + proptest! { + #![proptest_config(config())] + + // Encoding is deterministic: same input always produces same output. + #[test] + fn encode_deterministic(data in proptest::collection::vec(any::(), 0..2048)) { + let mut buf1 = vec![0u8; max_encoded_len(data.len()) + 1]; + let mut buf2 = vec![0u8; max_encoded_len(data.len()) + 1]; + let n1 = encode(&data, &mut buf1).unwrap(); + let n2 = encode(&data, &mut buf2).unwrap(); + prop_assert_eq!(n1, n2); + prop_assert_eq!(&buf1[..n1], &buf2[..n2]); } - // Decoding random garbage must never panic (returns None on invalid input). + // Decoding is deterministic. #[test] - fn decode_never_panics(data in proptest::collection::vec(any::(), 0..512)) { - let mut dec_buf = vec![0u8; data.len() + 256]; - let _ = decode(&data, &mut dec_buf); // may return None, must not panic + fn decode_deterministic(data in proptest::collection::vec(any::(), 0..2048)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + + let mut buf1 = vec![0u8; data.len() + 1]; + let mut buf2 = vec![0u8; data.len() + 1]; + let n1 = decode(encoded, &mut buf1).unwrap(); + let n2 = decode(encoded, &mut buf2).unwrap(); + prop_assert_eq!(n1, n2); + prop_assert_eq!(&buf1[..n1], &buf2[..n2]); + } + + // Encoded length is always >= 1 (even for empty input). + #[test] + fn encoded_length_at_least_one(data in proptest::collection::vec(any::(), 0..4096)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + prop_assert!(enc_len >= 1); + } + + // Encoded length is always > input length (COBS always adds overhead). + #[test] + fn encoded_length_strictly_greater(data in proptest::collection::vec(any::(), 0..4096)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + prop_assert!(enc_len > data.len()); + } + + // Decoded length equals original input length (bijection). + #[test] + fn decoded_length_matches_original(data in proptest::collection::vec(any::(), 0..4096)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let mut dec_buf = vec![0u8; data.len() + 1]; + let dec_len = decode(&enc_buf[..enc_len], &mut dec_buf).unwrap(); + prop_assert_eq!(dec_len, data.len()); + } + + // First byte of encoded output is always a valid code byte (1..=255). + #[test] + fn first_encoded_byte_is_valid_code(data in proptest::collection::vec(any::(), 0..4096)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + prop_assert!(enc_len >= 1); + prop_assert!(enc_buf[0] >= 1); // code byte is never 0x00 + } + } + + // ── Targeted boundary-region tests ────────────────────────── + + proptest! { + #![proptest_config(config())] + + // Payloads near the 254-byte block boundary (the critical COBS edge). + #[test] + fn roundtrip_near_block_boundary( + data in proptest::collection::vec(any::(), 250..260) + ) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + prop_assert!(!encoded.contains(&0x00)); + + let mut dec_buf = vec![0u8; data.len() + 1]; + let dec_len = decode(encoded, &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); + } + + // Payloads spanning multiple block boundaries. + #[test] + fn roundtrip_multi_block( + data in proptest::collection::vec(any::(), 500..520) + ) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + prop_assert!(!encoded.contains(&0x00)); + + let mut dec_buf = vec![0u8; data.len() + 1]; + let dec_len = decode(encoded, &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); + } + + // Very small payloads (0–8 bytes) — high code-byte-to-data ratio. + #[test] + fn roundtrip_tiny(data in proptest::collection::vec(any::(), 0..8)) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + prop_assert!(!encoded.contains(&0x00)); + + let mut dec_buf = vec![0u8; data.len() + 1]; + let dec_len = decode(encoded, &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); + } + + // All-zeros payloads of varying length — worst-case overhead. + #[test] + fn roundtrip_all_zeros(len in 0usize..2048) { + let data = vec![0u8; len]; + let mut enc_buf = vec![0u8; max_encoded_len(len) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + prop_assert!(!encoded.contains(&0x00)); + + let mut dec_buf = vec![0u8; len + 1]; + let dec_len = decode(encoded, &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); + } + + // All-0xFF payloads — no zeros at all, tests block-boundary logic. + #[test] + fn roundtrip_all_ff(len in 0usize..2048) { + let data = vec![0xFFu8; len]; + let mut enc_buf = vec![0u8; max_encoded_len(len) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + prop_assert!(!encoded.contains(&0x00)); + + let mut dec_buf = vec![0u8; len + 1]; + let dec_len = decode(encoded, &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); + } + + // Single repeated byte — catches any byte-value-specific bugs. + #[test] + fn roundtrip_single_byte_repeated(byte in any::(), len in 0usize..1024) { + let data = vec![byte; len]; + let mut enc_buf = vec![0u8; max_encoded_len(len) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + prop_assert!(!encoded.contains(&0x00)); + + let mut dec_buf = vec![0u8; len + 1]; + let dec_len = decode(encoded, &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); + } + + // Two-byte alphabet — high density of zeros mixed with non-zeros. + #[test] + fn roundtrip_binary_alphabet( + data in proptest::collection::vec(prop_oneof![Just(0x00u8), Just(0xFFu8)], 0..2048) + ) { + let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1]; + let enc_len = encode(&data, &mut enc_buf).unwrap(); + let encoded = &enc_buf[..enc_len]; + prop_assert!(!encoded.contains(&0x00)); + + let mut dec_buf = vec![0u8; data.len() + 1]; + let dec_len = decode(encoded, &mut dec_buf).unwrap(); + prop_assert_eq!(&dec_buf[..dec_len], &data[..]); } } }