diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba54ee8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test (${{ matrix.rust }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + rust: [stable, "1.93"] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - run: cargo test + + test-legacy-msrv: + name: Test (1.83, legacy-msrv) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.83" + - run: cargo test --features legacy-msrv + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - run: cargo clippy -- -D warnings + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt -- --check + + doc: + name: Doc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo doc --no-deps + + deny: + name: Cargo Deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 32aa58d..4bdc1d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 0.3.4 — 2026-04-01 + +### Changed + +- **Encode shrunk by 41 bytes** — simplified branch structure in encoder exit + path. 388 B encode, 392 B decode, 780 B total (was 821 B). Zero benchmark + regressions. +- **Clarified decode invariant** — added inline comment explaining the subtle + trailing-0x01 COBS rule (end-of-data marker vs zero byte). +- **Clippy cleanup** — `saturating_sub` in proptest (cosmetic, test-only). + +### Added + +- **GitHub Actions CI** — tests on stable + MSRV 1.93 + 1.83/legacy-msrv, + clippy, fmt, doc, and cargo-deny. +- **`cargo-deny` integration** — `deny.toml` with permissive-only license + policy, advisory checks, source auditing. Added `just test-deny` gate. +- **Encode-only fuzz target** — `fuzz_encode` exercises buffer-too-small + paths and validates output properties independently of decode. +- **Feature coverage** — `just test-unit` and `just test-clippy` now test + both default and `legacy-msrv` features. `just test-doc` builds with + `--all-features`. + +### Fixed + +- **README accuracy** — overhead description changed from "exactly" to + "at most ... plus 1", added miri to test mentions, updated code size + numbers. +- **CLAUDE.md rewrite** — updated stale API signatures, LOC count, roadmap, + dev-deps list, and development commands. + ## 0.3.3 — 2026-04-01 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 96eea50..9c0b229 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ 1. **Correct** — 100% COBS spec-compliant (Cheshire & Baker 1999). No shortcuts, no deviations. 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. ~120 lines of implementation. Runs on 8-bit MCUs and servers alike. +3. **Insanely tested** — canonical vectors, property-based tests (proptest), fuzz targets (cargo-fuzz), cross-crate interop, miri, mutation testing, 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 run via `just bench`. +5. **Tiny** — `no_std`, zero-alloc, zero runtime dependencies. ~140 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 @@ -22,7 +22,7 @@ Single-file library: `src/lib.rs` ### Public API (3 functions) ```rust -pub fn encode(src: &[u8], dest: &mut [u8]) -> Option +pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option pub fn decode(src: &[u8], dest: &mut [u8]) -> Option pub const fn max_encoded_len(src_len: usize) -> usize ``` @@ -32,26 +32,29 @@ pub const fn max_encoded_len(src_len: usize) -> usize ## Development ```sh -# Run all tests -cargo test +# Run all quality gates (tests, clippy, fmt, doc, fuzz, miri, mutants) +just test + +# Unit + property + interop tests only +just test-unit + +# Benchmarks (iai-callgrind, requires valgrind) +just bench # Fuzz (requires nightly + cargo-fuzz) -cd fuzz -cargo +nightly fuzz run fuzz_decode -- -max_total_time=60 -cargo +nightly fuzz run fuzz_roundtrip -- -max_total_time=60 +just test-fuzz ``` ## Dev Dependencies (test-only) +- `cobs` — cross-validation against the `cobs` 0.5 crate - `corncobs` — cross-validation against another COBS implementation +- `iai-callgrind` — deterministic instruction-count benchmarks +- `mutants` — mutation testing annotations - `proptest` — property-based testing - `rand` — randomized test payloads ## Roadmap -- More interop targets: `cobs` crate, Python `cobs` package -- Benchmarks (criterion) vs corncobs, cobs, and others - quickcheck in addition to proptest - Even more fuzz targets and edge-case generators -- MSRV policy and CI -- `#[deny(unsafe_code)]` badge diff --git a/Cargo.toml b/Cargo.toml index 4f74aea..edd3b8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ucobs" -version = "0.3.3" +version = "0.3.4" edition = "2021" rust-version = "1.93" license = "MIT" diff --git a/README.md b/README.md index b3707ee..2d81dcf 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ [Consistent Overhead Byte Stuffing](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) (COBS) encodes arbitrary bytes so that `0x00` never appears in the output, -allowing `0x00` to serve as an unambiguous frame delimiter. Overhead is exactly -1 byte per 254 input bytes (worst case). +allowing `0x00` to serve as an unambiguous frame delimiter. Overhead is at most +1 byte per 254 input bytes, plus 1. ## Features @@ -18,7 +18,7 @@ allowing `0x00` to serve as an unambiguous frame delimiter. Overhead is exactly - **100% spec-compliant** — Cheshire & Baker, IEEE/ACM Transactions on Networking, 1999 - **Proven interoperability** — cross-validated against `corncobs`, `cobs`, and Python `cobs` - **Insanely tested** — canonical vectors, property-based tests, fuzz targets, - cross-crate interop, randomized payloads, and more coming + cross-crate interop, miri, mutation testing, randomized payloads ## Performance @@ -64,16 +64,16 @@ combined). Run `just bench-size` to reproduce. | Crate | `encode` | `decode` | Total | |----------------|--------:|---------:|------:| -| μCOBS | 429 B | 392 B | 821 B | +| μCOBS | 388 B | 392 B | 780 B | | `cobs` 0.5 | 282 B | 494 B | 776 B | | `corncobs` 0.1 | 375 B | 262 B | 637 B | -All three crates are under 1 KB. μCOBS is the largest because safe -`const fn` encoding with `copy_from_slice`/memcpy requires `split_at` -bounds checks that generate panic paths — the cost of combining -compile-time safety with runtime speed. Embedded users targeting size -over speed can build with `opt-level = "s"` which reduces μCOBS to -~738 B. +All three crates are under 1 KB. μCOBS and `cobs` are nearly tied; +`corncobs` is the smallest. μCOBS's safe `const fn` encoding with +`copy_from_slice`/memcpy requires `split_at` bounds checks that +generate panic paths — the cost of combining compile-time safety with +runtime speed. Embedded users targeting size over speed can build with +`opt-level = "s"` to shrink further. ### Crate properties @@ -105,7 +105,7 @@ over speed can build with `opt-level = "s"` which reduces μCOBS to | Dead-simple API (3 functions) | **yes** | yes | more surface area | | In-place / streaming encode | no | no | **yes** | | `no_std` + zero-alloc by default | **yes** | opt-in | **yes** | -| Thorough test suite | **111 tests, fuzz, proptest, mutants** | basic | basic | +| Thorough test suite | **111 tests, fuzz, proptest, miri, mutants** | basic | basic | **Choose μCOBS** if you want the smallest, most auditable COBS implementation with a `const fn` encoder, a dead-simple 3-function API, and leading or tied diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..3e1b555 --- /dev/null +++ b/deny.toml @@ -0,0 +1,37 @@ +[graph] +all-features = true + +[advisories] +yanked = "deny" +ignore = [ + # bincode 1.3.3 — transitive dev-dep via iai-callgrind (latest 0.16.1). + # Unmaintained but stable and complete; no security issue. Dev-only, never + # ships in ucobs. Remove this ignore when iai-callgrind drops bincode. + "RUSTSEC-2025-0141", +] + +[licenses] +# Permissive licenses only. All copyleft (GPL, LGPL, AGPL, MPL, etc.) is denied by omission. +allow = [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "Unlicense", + "CC0-1.0", + "Unicode-3.0", + "Unicode-DFS-2016", +] +unused-allowed-license = "allow" + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index e3e3dd3..bfe00cc 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -30,3 +30,9 @@ name = "fuzz_small_dest" path = "fuzz_targets/fuzz_small_dest.rs" test = false doc = false + +[[bin]] +name = "fuzz_encode" +path = "fuzz_targets/fuzz_encode.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/fuzz_encode.rs b/fuzz/fuzz_targets/fuzz_encode.rs new file mode 100644 index 0000000..c174ae3 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_encode.rs @@ -0,0 +1,33 @@ +//! Fuzz target: encode arbitrary input into buffers of varying sizes. +//! Exercises encode error paths (buffer too small) and validates output properties. +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if data.len() > 1024 { + return; + } + + let max = ucobs::max_encoded_len(data.len()); + + // Encode into a correctly-sized buffer — must always succeed. + let mut buf = vec![0u8; max]; + let n = ucobs::encode(data, &mut buf) + .expect("encode failed with max_encoded_len buffer"); + let encoded = &buf[..n]; + + // Output must never contain 0x00. + assert!(!encoded.contains(&0x00), "encoded output contains 0x00"); + + // Output length must be bounded by max_encoded_len. + assert!(n <= max, "encoded length exceeds max_encoded_len"); + + // Output must be at least 1 byte (even empty input encodes to [0x01]). + assert!(n >= 1, "encoded length is zero"); + + // Encode into every possible undersized buffer — must return None, never panic. + for size in 0..n { + let mut small = vec![0u8; size]; + assert_eq!(ucobs::encode(data, &mut small), None); + } +}); diff --git a/justfile b/justfile index cd0d3ca..a7123a0 100644 --- a/justfile +++ b/justfile @@ -4,8 +4,8 @@ default: @just --list -# run ALL quality gates (tests, clippy, fmt check, doc, fuzz, miri, mutants) -test: test-unit test-clippy test-fmt test-doc test-fuzz test-miri test-mutants +# run ALL quality gates (tests, clippy, fmt check, doc, deny, fuzz, miri, mutants) +test: test-unit test-clippy test-fmt test-doc test-deny test-fuzz test-miri test-mutants # quick compile check (no tests) check: @@ -14,11 +14,13 @@ check: # unit + integration + proptest tests (via nextest for parallelism) test-unit: cargo nextest run + cargo nextest run --features legacy-msrv cargo test --doc # clippy with all warnings as errors test-clippy: cargo clippy -- -D warnings + cargo clippy --features legacy-msrv -- -D warnings # check formatting test-fmt: @@ -26,11 +28,16 @@ test-fmt: # doc build (catches broken doc links) test-doc: - cargo doc --no-deps + cargo doc --no-deps --all-features + +# license and advisory audit (requires cargo-deny) +test-deny: + cargo deny check # 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_encode -- -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 diff --git a/mise.toml b/mise.toml index 53cf240..77856cd 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,7 @@ [tools] -"cargo:cargo-nextest" = "latest" +rust = "1.93" +"cargo:cargo-deny" = "latest" +"cargo:cargo-fuzz" = "latest" "cargo:cargo-mutants" = "latest" +"cargo:cargo-nextest" = "latest" "cargo:iai-callgrind-runner" = "latest" diff --git a/src/lib.rs b/src/lib.rs index 2a47dfb..35aefa7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -256,14 +256,10 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option { } si += run; - if full_block { - if si >= src.len() { - break; - } - } else { - if si >= src.len() { - break; - } + if si >= src.len() { + break; + } + if !full_block { si += 1; // skip the zero byte } } @@ -315,6 +311,9 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option { src = rest; count += 1; } + // If more data follows, every 0x01 represents a zero byte. + // If the stream ends here, the final 0x01 is the end-of-data + // marker, not a zero — so emit one fewer. let zeros = if !src.is_empty() { count } else { count - 1 }; if di + zeros > dest.len() { return None; @@ -1259,7 +1258,7 @@ mod proptests { shrink in 1usize..256, ) { let max = max_encoded_len(data.len()); - let dest_size = if shrink >= max { 0 } else { max - shrink }; + let dest_size = max.saturating_sub(shrink); let mut buf = vec![0u8; dest_size]; let _ = encode(&data, &mut buf); // may return None, must not panic }