feat: add benchmarks, justfile, optimize throughput, harden quality gates
Benchmarking & comparison: - Add criterion benchmarks (ucobs vs cobs 0.3 vs corncobs 0.1) - Add Python cobs benchmark script - Add performance comparison section to README with full results Performance: - Rewrite encode to scan-and-bulk-copy via copy_from_slice - Rewrite decode to bulk-copy via copy_from_slice - ~2-4x throughput improvement; now matches corncobs on most workloads Quality hardening: - Add #![forbid(unsafe_code)], #![warn(missing_docs)], #[must_use] - Add [lints.rust] unsafe_code = "forbid" in Cargo.toml - Add rust-version (MSRV) = "1.56" - Add docs.rs metadata - 106 tests: 80 unit/corpus + 25 property tests (25k cases each = 625k total) - External test vectors from: Wikipedia, Cheshire & Baker paper, nanocobs, cobs-c, cobs2-rs, Jacques Fortier, Python cobs package - Add fuzz_small_dest fuzz target (encode/decode with undersized buffers) - Improve doc examples to show sentinel append/strip convention Infrastructure: - Add justfile with test (nextest), check, bench, fuzz, miri, fmt targets - cargo-nextest for parallel test execution - Update .gitignore for Python cache files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10 files changed
+1182
-94
No files matched your search
@@ -3,3 +3,5 @@
|
|||||||
/fuzz/corpus
|
/fuzz/corpus
|
||||||
/fuzz/artifacts
|
/fuzz/artifacts
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
2. **Interoperable** — proven byte-for-byte compatibility with `corncobs`, `cobs` crates and Python `cobs` packages.
|
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.
|
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.
|
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.
|
6. **Trustworthy** — small enough to audit by hand. No unsafe. No panics in release. Fuzz-hardened.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
+13
@@ -2,6 +2,7 @@
|
|||||||
name = "ucobs"
|
name = "ucobs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
rust-version = "1.56"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "μCOBS — Consistent Overhead Byte Stuffing. no_std, zero-alloc, fastest, most-tested COBS implementation."
|
description = "μCOBS — Consistent Overhead Byte Stuffing. no_std, zero-alloc, fastest, most-tested COBS implementation."
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
@@ -15,7 +16,19 @@ categories = ["encoding", "no-std::no-alloc", "embedded"]
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
cobs = "0.3"
|
||||||
corncobs = "0.1"
|
corncobs = "0.1"
|
||||||
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "bench"
|
||||||
|
harness = false
|
||||||
@@ -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,
|
- **Insanely tested** — canonical vectors, property-based tests, fuzz targets,
|
||||||
cross-crate interop, randomized payloads, and more coming
|
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
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||||
|
|
||||||
|
// ── Test payloads ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn payload_zeros(len: usize) -> Vec<u8> {
|
||||||
|
vec![0u8; len]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_nonzero(len: usize) -> Vec<u8> {
|
||||||
|
(0..len).map(|i| (i % 255 + 1) as u8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_mixed(len: usize) -> Vec<u8> {
|
||||||
|
(0..len).map(|i| (i % 256) as u8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ucobs ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn ucobs_encode(data: &[u8]) -> Vec<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<dyn Fn(usize) -> Vec<u8>>)> = 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<dyn Fn(usize) -> Vec<u8>>)> = 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);
|
||||||
@@ -24,3 +24,9 @@ name = "fuzz_roundtrip"
|
|||||||
path = "fuzz_targets/fuzz_roundtrip.rs"
|
path = "fuzz_targets/fuzz_roundtrip.rs"
|
||||||
test = false
|
test = false
|
||||||
doc = false
|
doc = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_small_dest"
|
||||||
|
path = "fuzz_targets/fuzz_small_dest.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
+780
-93
@@ -6,106 +6,101 @@
|
|||||||
//!
|
//!
|
||||||
//! COBS transforms a byte sequence so that `0x00` never appears in the
|
//! COBS transforms a byte sequence so that `0x00` never appears in the
|
||||||
//! output, allowing `0x00` to be used as an unambiguous frame delimiter.
|
//! 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.
|
//! 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
|
//! // Encode — leave room for the sentinel byte
|
||||||
//! let n = ucobs::encode(&[0x11, 0x00, 0x33], &mut buf).unwrap();
|
//! let mut frame = [0u8; 16];
|
||||||
//! assert_eq!(&buf[..n], &[0x02, 0x11, 0x02, 0x33]);
|
//! 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 mut out = [0u8; 16];
|
||||||
//! let n = ucobs::decode(&[0x02, 0x11, 0x02, 0x33], &mut out).unwrap();
|
//! let m = ucobs::decode(&frame[..n], &mut out).unwrap();
|
||||||
//! assert_eq!(&out[..n], &[0x11, 0x00, 0x33]);
|
//! assert_eq!(&out[..m], &data);
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
/// Encode `src` into `dest` using COBS. Returns the number of bytes written,
|
/// Encode `src` into `dest` using COBS. Returns the number of bytes written,
|
||||||
/// or `None` if `dest` is too small.
|
/// 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.
|
/// should append it as a frame delimiter.
|
||||||
|
#[must_use]
|
||||||
pub fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
|
pub fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
|
||||||
let mut si = 0;
|
let mut si = 0;
|
||||||
let mut di = 0;
|
let mut di = 0;
|
||||||
let mut code_idx = di;
|
|
||||||
let mut need_final_code = true;
|
|
||||||
|
|
||||||
if di >= dest.len() && !src.is_empty() {
|
loop {
|
||||||
return None;
|
// Scan for next zero (or 254-byte cap).
|
||||||
}
|
let remaining = src.len() - si;
|
||||||
// Reserve space for the first code byte.
|
let max_run = if remaining < 254 { remaining } else { 254 };
|
||||||
di += 1;
|
let run = if max_run > 0 && src[si] == 0x00 {
|
||||||
let mut code: u8 = 1;
|
0
|
||||||
|
|
||||||
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;
|
|
||||||
} else {
|
} else {
|
||||||
if di >= dest.len() {
|
src[si..si + max_run]
|
||||||
return None;
|
.iter()
|
||||||
}
|
.position(|&b| b == 0x00)
|
||||||
dest[di] = src[si];
|
.unwrap_or(max_run)
|
||||||
di += 1;
|
};
|
||||||
si += 1;
|
let full_block = run == 254;
|
||||||
code += 1;
|
|
||||||
if code == 0xFF {
|
|
||||||
// Block full (254 data bytes) — emit code.
|
|
||||||
if code_idx >= dest.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
dest[code_idx] = code;
|
|
||||||
|
|
||||||
if si < src.len() {
|
// Write code byte.
|
||||||
// More input: start a new block.
|
|
||||||
code_idx = di;
|
|
||||||
if di >= dest.len() {
|
if di >= dest.len() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
dest[di] = if full_block { 0xFF } else { (run + 1) as u8 };
|
||||||
di += 1;
|
di += 1;
|
||||||
code = 1;
|
|
||||||
need_final_code = true;
|
// 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 {
|
} else {
|
||||||
// Input exhausted right at the block boundary.
|
// Run ended at a zero or end-of-input.
|
||||||
need_final_code = false;
|
if si >= src.len() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
si += 1; // skip the zero byte
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if need_final_code {
|
|
||||||
if code_idx >= dest.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
dest[code_idx] = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(di)
|
Some(di)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a COBS-encoded buffer. Returns the number of decoded bytes
|
/// 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.
|
/// it before calling this function.
|
||||||
|
#[must_use]
|
||||||
pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
|
pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
|
||||||
let mut si = 0;
|
let mut si = 0;
|
||||||
let mut di = 0;
|
let mut di = 0;
|
||||||
@@ -115,14 +110,15 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
|
|||||||
if code == 0 {
|
if code == 0 {
|
||||||
return None; // unexpected zero in COBS data
|
return None; // unexpected zero in COBS data
|
||||||
}
|
}
|
||||||
// Copy `code - 1` data bytes verbatim.
|
// Bulk-copy `code - 1` data bytes.
|
||||||
for _ in 1..code {
|
let n = code - 1;
|
||||||
if si >= src.len() || di >= dest.len() {
|
if n > 0 {
|
||||||
|
if si + n > src.len() || di + n > dest.len() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
dest[di] = src[si];
|
dest[di..di + n].copy_from_slice(&src[si..si + n]);
|
||||||
di += 1;
|
di += n;
|
||||||
si += 1;
|
si += n;
|
||||||
}
|
}
|
||||||
// If code < 0xFF, an implicit zero follows (unless we're at the end).
|
// 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() {
|
||||||
@@ -137,6 +133,7 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum encoded size for a given source length (excluding sentinel).
|
/// Maximum encoded size for a given source length (excluding sentinel).
|
||||||
|
#[must_use]
|
||||||
pub const fn max_encoded_len(src_len: usize) -> usize {
|
pub const fn max_encoded_len(src_len: usize) -> usize {
|
||||||
src_len + (src_len / 254) + 1
|
src_len + (src_len / 254) + 1
|
||||||
}
|
}
|
||||||
@@ -182,10 +179,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_zero_delimited() {
|
fn decode_zero_delimited() {
|
||||||
assert_eq!(
|
assert_eq!(dec(&[0x01, 0x02, 0x11, 0x01]), Some(vec![0x00, 0x11, 0x00]));
|
||||||
dec(&[0x01, 0x02, 0x11, 0x01]),
|
|
||||||
Some(vec![0x00, 0x11, 0x00])
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -280,6 +274,280 @@ mod tests {
|
|||||||
assert_eq!(dec(&[]), Some(vec![]));
|
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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (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 ────────────────────────────────────────────
|
// ── Encode tests ────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -414,12 +682,15 @@ mod tests {
|
|||||||
// corncobs includes the sentinel — strip it for our decoder
|
// corncobs includes the sentinel — strip it for our decoder
|
||||||
let their_encoded = &their_buf[..their_len];
|
let their_encoded = &their_buf[..their_len];
|
||||||
// Find the sentinel and exclude it
|
// 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 their_no_sentinel = &their_encoded[..data_end];
|
||||||
|
|
||||||
let mut our_buf = vec![0u8; input.len() + 1];
|
let mut our_buf = vec![0u8; input.len() + 1];
|
||||||
let our_len = decode(their_no_sentinel, &mut our_buf)
|
let our_len =
|
||||||
.expect("our decode failed on corncobs output");
|
decode(their_no_sentinel, &mut our_buf).expect("our decode failed on corncobs output");
|
||||||
assert_eq!(&our_buf[..our_len], input, "cross-decode mismatch");
|
assert_eq!(&our_buf[..our_len], input, "cross-decode mismatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +756,122 @@ mod tests {
|
|||||||
cross_decode(&data);
|
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<u8> = (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<u8> = (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<u8> = (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<u8> = (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) ─────────────────────────────
|
// ── Property-based tests (proptest) ─────────────────────────────
|
||||||
@@ -492,14 +879,28 @@ mod tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod proptests {
|
mod proptests {
|
||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
use alloc::vec;
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use alloc::vec;
|
||||||
use proptest::prelude::*;
|
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! {
|
||||||
|
#![proptest_config(config())]
|
||||||
|
|
||||||
|
// Core invariant: encode then decode recovers the original.
|
||||||
#[test]
|
#[test]
|
||||||
fn roundtrip_any(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
fn roundtrip_any(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||||
let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1];
|
let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1];
|
||||||
let enc_len = encode(&data, &mut enc_buf).unwrap();
|
let enc_len = encode(&data, &mut enc_buf).unwrap();
|
||||||
let encoded = &enc_buf[..enc_len];
|
let encoded = &enc_buf[..enc_len];
|
||||||
@@ -512,13 +913,118 @@ mod proptests {
|
|||||||
prop_assert_eq!(&dec_buf[..dec_len], &data[..]);
|
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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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.
|
// Our encode → corncobs decode must match.
|
||||||
#[test]
|
#[test]
|
||||||
fn interop_our_encode_their_decode(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
fn interop_our_encode_their_decode(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||||
let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 2];
|
let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 2];
|
||||||
let enc_len = encode(&data, &mut enc_buf).unwrap();
|
let enc_len = encode(&data, &mut enc_buf).unwrap();
|
||||||
// Append sentinel for corncobs.
|
enc_buf[enc_len] = 0x00; // append sentinel for corncobs
|
||||||
enc_buf[enc_len] = 0x00;
|
|
||||||
|
|
||||||
let mut dec_buf = vec![0u8; data.len() + 1];
|
let mut dec_buf = vec![0u8; data.len() + 1];
|
||||||
let dec_len = corncobs::decode_buf(&enc_buf[..enc_len + 1], &mut dec_buf).unwrap();
|
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.
|
// corncobs encode → our decode must match.
|
||||||
#[test]
|
#[test]
|
||||||
fn interop_their_encode_our_decode(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
fn interop_their_encode_our_decode(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||||
let mut enc_buf = vec![0u8; corncobs::max_encoded_len(data.len())];
|
let mut enc_buf = vec![0u8; corncobs::max_encoded_len(data.len())];
|
||||||
let enc_len = corncobs::encode_buf(&data, &mut enc_buf);
|
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 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];
|
let mut dec_buf = vec![0u8; data.len() + 1];
|
||||||
@@ -538,19 +1043,201 @@ mod proptests {
|
|||||||
prop_assert_eq!(&dec_buf[..dec_len], &data[..]);
|
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]
|
#[test]
|
||||||
fn encoded_length_bounded(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
fn interop_encode_byte_identical(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||||
let mut enc_buf = vec![0u8; max_encoded_len(data.len()) + 1];
|
// Encode with ucobs.
|
||||||
let enc_len = encode(&data, &mut enc_buf).unwrap();
|
let mut our_buf = vec![0u8; max_encoded_len(data.len()) + 2];
|
||||||
prop_assert!(enc_len <= max_encoded_len(data.len()));
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decoding random garbage must never panic (returns None on invalid input).
|
// ── Structural / algebraic properties ───────────────────────
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(config())]
|
||||||
|
|
||||||
|
// Encoding is deterministic: same input always produces same output.
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_never_panics(data in proptest::collection::vec(any::<u8>(), 0..512)) {
|
fn encode_deterministic(data in proptest::collection::vec(any::<u8>(), 0..2048)) {
|
||||||
let mut dec_buf = vec![0u8; data.len() + 256];
|
let mut buf1 = vec![0u8; max_encoded_len(data.len()) + 1];
|
||||||
let _ = decode(&data, &mut dec_buf); // may return None, must not panic
|
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 is deterministic.
|
||||||
|
#[test]
|
||||||
|
fn decode_deterministic(data in proptest::collection::vec(any::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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::<u8>(), 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[..]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user