13 Commits
Author SHA1 Message Date
swaits ee40455c5f chore: repoint URLs at git.swaits.com and bump for release
CI / Test (1.93) (push) Canceled after 0s
CI / Test (stable) (push) Canceled after 0s
CI / Test (1.83, legacy-msrv) (push) Canceled after 0s
CI / Clippy (push) Canceled after 0s
CI / Format (push) Canceled after 0s
CI / Doc (push) Canceled after 0s
CI / Cargo Deny (push) Canceled after 0s
Rewrites remaining github.com / git.sr.ht references to git.swaits.com,
translating GitHub URL shapes to Gitea's (/blob/<ref>/ -> /src/branch/<ref>/,
/tree/<tag> -> /src/tag/<tag>, /actions/workflows/X -> /actions?workflow=X,
/edit/ -> /_edit/).

Left pointing at GitHub on purpose: pull-request links (PRs were not migrated)
and GitHub-only surfaces Gitea lacks (security advisories, discussions).
2026-08-31 17:58:39 -06:00
swaits 1eb7e09412 chore: point repository at git.swaits.com
CI / Test (1.93) (push) Canceled after 0s
CI / Test (stable) (push) Canceled after 0s
CI / Test (1.83, legacy-msrv) (push) Canceled after 0s
CI / Clippy (push) Canceled after 0s
CI / Format (push) Canceled after 0s
CI / Doc (push) Canceled after 0s
CI / Cargo Deny (push) Canceled after 0s
2026-08-31 17:19:24 -06:00
swaitsandClaude Opus 4.6 f805386bb8 chore: audit cleanup, CI, cargo-deny, encode fuzz target (0.3.4)
CI / Test (1.93) (push) Canceled after 0s
CI / Test (stable) (push) Canceled after 0s
CI / Test (1.83, legacy-msrv) (push) Canceled after 0s
CI / Clippy (push) Canceled after 0s
CI / Format (push) Canceled after 0s
CI / Doc (push) Canceled after 0s
CI / Cargo Deny (push) Canceled after 0s
Simplify encoder exit branch (−41 B code size), add GitHub Actions CI
with MSRV matrix, cargo-deny with permissive-only license policy,
encode-only fuzz target, and feature coverage across test gates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:01:19 -06:00
swaitsandClaude Opus 4.6 f6778e20b8 fix: gate #[mutants::skip] behind cfg(test) for downstream compat (0.3.3)
The #[mutants::skip] proc macro from the mutants dev-dep was not available
to downstream users, causing compilation failure. Gate it with cfg_attr(test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:24:05 -06:00
swaitsandClaude Opus 4.6 aa09783cdd test: 100% mutation kill rate, cargo-mutants v27, #[mutants::skip] (0.3.2)
89 mutants tested: 59 caught, 30 unviable (type system rejects them),
0 missed. Added mutants dev-dep for #[mutants::skip] on legacy-msrv
dead code. Upgraded cargo-mutants to v27.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:19:20 -06:00
swaitsandClaude Opus 4.6 b91fbb2ed9 test: mutation testing, block boundary tests, exact-fit decode tests (0.3.2)
Added cargo-mutants with 88 mutants tested, 59 caught, 0 missed. Added
targeted tests for 254-byte block boundary encoding (interop-verified)
and exact-fit/too-small decode buffer sizing for the batch zero path.
111 tests total (was 106).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:12:40 -06:00
swaitsandClaude Opus 4.6 777a37d4cd perf: decode rewrite, encode zeros optimization, iai-callgrind benchmarks (0.3.1)
Decode rewritten with shrinking-slice pattern (split_first/split_at)
instead of index arithmetic. Decode zeros 14% faster, decode nonzero
flips from 5% behind corncobs to 2% ahead, decode mixed closes to tied.

Encode zeros fast path moved before split_at — now tied with cobs
(was 6% behind). Code size reduced to 821B (was 869B).

Replaced criterion with iai-callgrind for deterministic instruction-count
benchmarks via Valgrind. Zero run-to-run variance. Expanded to 162
benchmarks across 9 sizes.

ucobs now wins or ties 16 of 18 benchmarks vs corncobs and cobs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:43:06 -06:00
swaitsandClaude Opus 4.6 ff883d6f8e docs: update README benchmarks with fresh 7840U numbers and emoji indicators
Replace all N100 benchmark data with accurate 7840U results. Add emoji
legend (winner/loser/tie) to every comparison row. Update crate property
table, machine description, and recommendation prose to reflect current
competitive picture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:30:05 -06:00
swaitsandClaude Opus 4.6 63ebaa0232 docs: document legacy-msrv feature in README and crate docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:39:48 -06:00
swaitsandClaude Opus 4.6 f4665a46cb chore: bump version to 0.3.0, add CHANGELOG
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:35:32 -06:00
swaitsandClaude Opus 4.6 92021a2608 feat: add legacy-msrv feature for Rust 1.83+ compat
Adds a `legacy-msrv` feature flag that replaces the const
copy_from_slice (Rust 1.93+) with a manual byte loop, allowing
the crate to compile on older toolchains like Xtensa.

All other optimizations (sub-slice scan, zero fast path, decode
batch fill) work on 1.83 without the feature.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:34:31 -06:00
swaitsandClaude Opus 4.6 adb0db7ee5 perf: major encode/decode optimization via const copy_from_slice and batch zero fill
Encode: rewrite to two-pass (scan + copy_from_slice) leveraging Rust 1.93's
const fn copy_from_slice for memcpy at runtime while keeping const fn encode.
Sub-slice scan pattern helps LLVM eliminate bounds checks. Added zero byte
fast path for all-zeros workloads. Now leads corncobs on nonzero/mixed encode.

Decode: batch consecutive 0x01 codes (all-zeros data) and fill zeros via
memset instead of per-byte loop. Decode zeros throughput jumps from ~1.2 GB/s
to ~4.6 GB/s, now 3x faster than corncobs on that workload.

Bump MSRV 1.83 → 1.93 for const copy_from_slice.
Update dev-deps: cobs 0.3→0.5, criterion 0.5→0.8, rand 0.8→0.10.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:00:33 -06:00
swaitsandClaude Opus 4.6 3d674989ab feat: const fn encode, decode optimization, comprehensive docs and examples
- Make `encode` const fn for compile-time COBS encoding (MSRV bumped to 1.83)
- Optimize decode by removing unnecessary branch in inner loop
- Add comprehensive rustdoc with examples for every function
- Add crate-level doc sections: quick start, buffer sizing, framing,
  stream parsing, compile-time encoding, error handling
- Expand README with decision matrix, examples section, testing depth
- Bump version to 0.2.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:31:41 -06:00
15 changed files with 1197 additions and 252 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# Use nextest for faster test execution
test_tool = "nextest"
# Skip slow proptests during mutation testing — the 60+ unit/interop tests
# are sufficient to catch mutants. Full proptests run separately via just test-unit.
additional_cargo_test_args = ["-E", "not test(proptests)"]
# False positives excluded:
#
# - `> 0` → `>= 0` or `< 0` on usize: tautological (usize always >= 0)
# - `< 254` → `<= 254` in max_run: both branches yield 254 at the boundary
#
# The legacy-msrv const_copy is excluded via #[mutants::skip] in source.
exclude_re = [
"replace > with < in encode",
"replace > with >= in encode",
"replace < with <= in encode",
]
+69
View File
@@ -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
+2
View File
@@ -2,6 +2,8 @@
/fuzz/target
/fuzz/corpus
/fuzz/artifacts
/mutants.out
/mutants.out.old
Cargo.lock
__pycache__/
*.pyc
+122
View File
@@ -0,0 +1,122 @@
# 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
- **Fix compilation for downstream users** — `#[mutants::skip]` attribute was
not gated behind `cfg(test)`, causing unresolved module error when ucobs is
used as a dependency.
## 0.3.2 — 2026-04-01 (yanked)
### Testing
- **Mutation testing: 100% kill rate** — 89 mutants tested, 59 caught, 30
unviable (type system rejects them), 0 missed. Uses `cargo-mutants` v27
with nextest. Added `mutants` crate for `#[mutants::skip]` annotations.
- **111 tests** (was 106): added block-boundary interop verification against
corncobs, exact-fit and too-small decode buffer sizing for the batch zero
path.
## 0.3.1 — 2026-03-31
### Performance
- **Decode: rewritten with shrinking-slice pattern** — decode zeros is 14%
faster (25,018 vs 29,114 instructions at 4 KB), decode nonzero flipped from
5% behind corncobs to 2% ahead, decode mixed closed from 8% behind to tied.
ucobs now wins or ties 16 of 18 benchmarks.
- **Encode zeros: 6% faster** — zero fast path moved before `split_at` calls,
eliminating per-byte overhead. Now tied with cobs (previously 6% behind).
- **Code size reduced** — 821 B total (was 869 B). Encode 429 B, decode 392 B.
### Changed
- **Replaced criterion with iai-callgrind** — benchmarks now measure
deterministic instruction counts via Valgrind instead of wall-clock time.
Zero run-to-run variance. Requires `valgrind` installed.
- **Expanded benchmark coverage** — 9 payload sizes (0–4096 B) × 3 patterns
× 3 crates × encode/decode = 162 benchmarks, all running in ~2 minutes.
- **Code size measurement** added to `just bench` via `just bench-size`.
- **Mutation testing** added via `cargo-mutants` (`just test-mutants`).
- Added `mise.toml` for dev tool management.
## 0.3.0 — 2026-03-31
### Performance
- **Encode nonzero/mixed: ~2x faster** — uses const `copy_from_slice` (memcpy at
runtime) via Rust 1.93's stabilization of const slice methods. Sub-slice scan
pattern helps LLVM eliminate bounds checks.
- **Encode zeros: ~50% faster** — fast path skips scan loop overhead for leading
zero bytes.
- **Decode zeros: ~4x faster** — batch consecutive `0x01` codes and fill zeros
via `fill(0)` (memset) instead of per-byte iteration.
- Encode now wins or ties corncobs on nonzero/mixed workloads.
- Decode zeros now leads all competitors by 3x.
### Added
- `legacy-msrv` feature flag — enables compilation on Rust 1.83+ (e.g. Xtensa
toolchain) by replacing const `copy_from_slice` with a manual byte loop.
All other optimizations (sub-slice scan, zero fast path, decode batch fill)
work without this feature on any supported Rust version.
### Changed
- **MSRV bumped from 1.83 to 1.93** — required for const `copy_from_slice`.
Use the `legacy-msrv` feature for older toolchains.
- Dev-dependencies updated: `cobs` 0.3 to 0.5, `criterion` 0.5 to 0.8,
`rand` 0.8 to 0.10.
## 0.2.0 — 2026-03-30
### Added
- `const fn` encode — encode data at compile time with zero runtime cost.
- Comprehensive documentation with 7 usage examples (basic, buffer sizing,
framing, stream parsing, compile-time encoding, error handling).
- Initial encode/decode throughput optimizations.
### Changed
- MSRV set to 1.83.
## 0.1.0 — 2026-03-29
- Initial release.
- `no_std`, zero-alloc, `unsafe`-free COBS encoder and decoder.
- 106 tests: canonical vectors, property-based (proptest), fuzz targets,
cross-crate interop with `corncobs` and `cobs`.
+16 -13
View File
@@ -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<usize>
pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize>
pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize>
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
+9 -7
View File
@@ -1,13 +1,13 @@
[package]
name = "ucobs"
version = "0.1.0"
version = "0.3.5"
edition = "2021"
rust-version = "1.56"
rust-version = "1.93"
license = "MIT"
description = "μCOBS — Consistent Overhead Byte Stuffing. no_std, zero-alloc, fastest, most-tested COBS implementation."
authors = ["Stephen Waits <steve@waits.net>"]
repository = "https://github.com/swaits/ucobs"
homepage = "https://github.com/swaits/ucobs"
repository = "https://git.swaits.com/swaits/ucobs"
homepage = "https://git.swaits.com/swaits/ucobs"
documentation = "https://docs.rs/ucobs"
readme = "README.md"
keywords = ["cobs", "framing", "no-std", "embedded", "codec"]
@@ -15,6 +15,7 @@ categories = ["encoding", "no-std::no-alloc", "embedded"]
[features]
default = []
legacy-msrv = []
[lints.rust]
unsafe_code = "forbid"
@@ -23,11 +24,12 @@ unsafe_code = "forbid"
all-features = true
[dev-dependencies]
cobs = "0.3"
cobs = "0.5"
corncobs = "0.1"
criterion = { version = "0.5", features = ["html_reports"] }
iai-callgrind = "0.16"
mutants = "0.0.4"
proptest = "1"
rand = "0.8"
rand = "0.10"
[[bench]]
name = "bench"
+236 -48
View File
@@ -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,111 +18,299 @@ 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
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.
[iai-callgrind](https://github.com/iai-callgrind/iai-callgrind) (instruction
counts via Valgrind — deterministic, zero run-to-run variance). Three payload
patterns: **all zeros** (worst case), **no zeros**, and **mixed** (0x00–0xFF
cycling). Instruction count per call, lower is better.
Legend: 🥇 winner, 🥉 last, 🤝 tie (<3%).
### Encode
| Payload | Pattern | μCOBS | `cobs` 0.3 | `corncobs` 0.1 |
| Payload | Pattern | μCOBS | `cobs` 0.5 | `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 |
| 64 B | zeros | 🤝 1,171 | 🤝 1,164 | 🥉 4,098 |
| 64 B | nonzero | 🤝 512 | 🥉 1,100 | 🤝 514 |
| 64 B | mixed | 🥇 523 | 🥉 1,101 | 570 |
| 256 B | zeros | 🤝 4,243 | 🤝 4,236 | 🥉 15,810 |
| 256 B | nonzero | 🤝 1,552 | 🥉 3,992 | 🤝 1,531 |
| 256 B | mixed | 🤝 1,561 | 🥉 3,993 | 🤝 1,585 |
| 4096 B | zeros | 🤝 65,904 | 🤝 65,897 | 🥉 250,271 |
| 4096 B | nonzero | 🤝 22,077 | 🥉 61,993 | 🤝 21,711 |
| 4096 B | mixed | 🤝 22,932 | 🥉 62,009 | 🤝 22,656 |
### Decode
| Payload | Pattern | μCOBS | `cobs` 0.3 | `corncobs` 0.1 |
| Payload | Pattern | μCOBS | `cobs` 0.5 | `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 |
| 64 B | zeros | 🥇 499 | 🥉 2,354 | 1,507 |
| 64 B | nonzero | 🥇 179 | 🥉 1,778 | 185 |
| 64 B | mixed | 226 | 🥉 1,787 | 🥇 206 |
| 256 B | zeros | 🥇 1,473 | 🥉 8,882 | 5,539 |
| 256 B | nonzero | 🤝 240 | 🥉 6,607 | 🤝 247 |
| 256 B | mixed | 285 | 🥉 6,613 | 🥇 266 |
| 4096 B | zeros | 🥇 25,018 | 🥉 139,698 | 86,435 |
| 4096 B | nonzero | 🤝 1,315 | 🥉 103,253 | 🤝 1,337 |
| 4096 B | mixed | 🤝 2,086 | 🥉 103,394 | 🤝 2,067 |
### Code size
Measured from `.text` section of release-optimized symbols (`encode` + `decode`
combined):
combined). Run `just bench-size` to reproduce.
| Crate | Code size |
|----------------|----------:|
| μCOBS | 541 B |
| `cobs` 0.3 | 537 B |
| `corncobs` 0.1 | 637 B |
| Crate | `encode` | `decode` | Total |
|----------------|--------:|---------:|------:|
| μ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 tiny. The difference is negligible for any target.
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
| Property | μCOBS | `cobs` 0.3 | `corncobs` 0.1 |
| Property | μCOBS | `cobs` 0.5 | `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 |
| `const fn` encode | yes | no | no |
| Implementation | ~140 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 `just bench` to
> reproduce. Full Criterion reports are generated in `target/criterion/report/`.
> Benchmarked with iai-callgrind (Valgrind instruction counting) for
> deterministic, reproducible results. Run `just bench` to reproduce.
> Requires `valgrind` installed.
## Usage
## When to use μCOBS
| You need… | μCOBS | `cobs` 0.5 | `corncobs` 0.1 |
|---|:---:|:---:|:---:|
| Minimal code to audit | **~140 LOC** | ~790 LOC | ~645 LOC |
| Compile-time (`const fn`) encode | **yes** | no | no |
| Fewest encode instructions | **ties cobs/corncobs** | zeros only | nonzero only |
| Fewest decode instructions (zero-heavy) | **yes (3–5×)** | no | 2nd |
| Fewest decode instructions (nonzero) | **yes/tied** | no | tied |
| Fewest decode instructions (mixed) | tied at 4 KB | no | **slight edge at small sizes** |
| 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, 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
instruction efficiency across most workloads — plus dominant decode performance
on zero-heavy data (3–5× fewer instructions than alternatives). Wins or ties
16 of 18 benchmarks.
**Choose `corncobs`** if you need in-place encoding, an iterator-based API,
or your workload is dominated by small mixed-pattern decoding where it uses
~8% fewer instructions at 64–256 B.
**Choose `cobs`** if you need `std` convenience features or a streaming state
machine. Note: `cobs` uses 50–75× more instructions than μCOBS/corncobs for
decoding non-zero data.
## Examples
### Basic encode and decode
```rust
// Encode
// Encode: [0x11, 0x00, 0x33] → [0x02, 0x11, 0x02, 0x33]
let mut buf = [0u8; 16];
let n = ucobs::encode(&[0x11, 0x00, 0x33], &mut buf).unwrap();
assert_eq!(&buf[..n], &[0x02, 0x11, 0x02, 0x33]);
// Decode
// Decode reverses it
let mut out = [0u8; 16];
let n = ucobs::decode(&[0x02, 0x11, 0x02, 0x33], &mut out).unwrap();
let m = ucobs::decode(&buf[..n], &mut out).unwrap();
assert_eq!(&out[..m], &[0x11, 0x00, 0x33]);
```
### Buffer sizing
Use `max_encoded_len` to determine the required destination buffer size:
```rust
let data = [0x01, 0x02, 0x03];
let max = ucobs::max_encoded_len(data.len()); // 4 bytes
let mut buf = [0u8; 4];
let n = ucobs::encode(&data, &mut buf).unwrap();
assert_eq!(n, 4); // fits exactly
```
If the destination is too small, `encode` returns `None` rather than panicking:
```rust
let mut tiny = [0u8; 1];
assert_eq!(ucobs::encode(&[0x01, 0x02], &mut tiny), None);
```
### Framing for transport
Append a `0x00` sentinel after encoding to delimit frames on a wire. Strip
it before decoding:
```rust
let data = [0x11, 0x00, 0x33];
// Encode and append sentinel
let mut frame = [0u8; 16];
let n = ucobs::encode(&data, &mut frame).unwrap();
frame[n] = 0x00;
let wire = &frame[..n + 1];
// Receive: strip sentinel, then decode
let cobs_data = &wire[..wire.len() - 1];
let mut out = [0u8; 16];
let m = ucobs::decode(cobs_data, &mut out).unwrap();
assert_eq!(&out[..m], &data);
```
### Parsing a stream of frames
Split a byte stream on `0x00` to extract individual COBS frames:
```rust
// Two frames separated by 0x00 sentinels
let stream = [
0x02, 0x11, 0x02, 0x33, 0x00, // frame 1: [0x11, 0x00, 0x33]
0x03, 0xAA, 0xBB, 0x00, // frame 2: [0xAA, 0xBB]
];
let mut out = [0u8; 16];
let frames: Vec<&[u8]> = stream.split(|&b| b == 0x00)
.filter(|f| !f.is_empty())
.collect();
let n = ucobs::decode(frames[0], &mut out).unwrap();
assert_eq!(&out[..n], &[0x11, 0x00, 0x33]);
// Buffer sizing
let max = ucobs::max_encoded_len(256); // 258
let n = ucobs::decode(frames[1], &mut out).unwrap();
assert_eq!(&out[..n], &[0xAA, 0xBB]);
```
### Compile-time encoding
`encode` is `const fn`, so you can build COBS-encoded tables at compile
time with zero runtime cost:
```rust
const PING: [u8; 2] = {
let mut buf = [0u8; 2];
match ucobs::encode(&[0x01], &mut buf) {
Some(_) => buf,
None => panic!("buffer too small"),
}
};
const ACK: [u8; 3] = {
let mut buf = [0u8; 3];
match ucobs::encode(&[0x06, 0x00], &mut buf) {
Some(_) => buf,
None => panic!("buffer too small"),
}
};
// Baked into the binary — no runtime encoding needed
assert_eq!(PING, [0x02, 0x01]);
assert_eq!(ACK, [0x02, 0x06, 0x01]);
```
### Error handling
Both `encode` and `decode` return `Option<usize>` — `None` on failure,
never panic:
```rust
let mut buf = [0u8; 8];
// Destination too small
assert_eq!(ucobs::encode(&[1, 2, 3], &mut buf[..1]), None);
// Malformed input (zero byte in encoded stream)
assert_eq!(ucobs::decode(&[0x00], &mut buf), None);
// Truncated frame (code byte promises more data than exists)
assert_eq!(ucobs::decode(&[0x05, 0x11], &mut buf), None);
// Empty input is valid
assert_eq!(ucobs::encode(&[], &mut buf), Some(1)); // encodes to [0x01]
assert_eq!(ucobs::decode(&[], &mut buf), Some(0)); // decodes to []
```
## API
| Function | Signature | Description |
|---|---|---|
| `encode` | `(src: &[u8], dest: &mut [u8]) -> Option<usize>` | COBS-encode `src` into `dest`. Returns byte count or `None` if buffer too small. |
| `encode` | `const fn(src: &[u8], dest: &mut [u8]) -> Option<usize>` | COBS-encode `src` into `dest`. Returns byte count or `None` if buffer too small. |
| `decode` | `(src: &[u8], dest: &mut [u8]) -> Option<usize>` | Decode COBS data. Returns byte count or `None` if input is malformed. |
| `max_encoded_len` | `const fn(src_len: usize) -> usize` | Maximum encoded size for a given input length. |
**Note:** The encoder does *not* append a trailing `0x00` sentinel. Append it
yourself when framing for transport.
## Minimum Supported Rust Version
The default build requires **Rust 1.93+** (for const `copy_from_slice`).
### `legacy-msrv` feature
If you're targeting a toolchain older than 1.93 (e.g. Xtensa), enable the
`legacy-msrv` feature to fall back to a manual byte copy in the encoder:
```toml
[dependencies]
ucobs = { version = "0.3", features = ["legacy-msrv"] }
```
All other optimizations (sub-slice scan, zero fast path, decode batch fill)
are available regardless of this feature. Only the encode copy phase is
affected — it uses a byte loop instead of `copy_from_slice`/memcpy.
## Testing
μCOBS has one of the most thorough test suites of any COBS implementation —
111 tests across 8 categories:
- **Canonical vectors** — every example from Cheshire & Baker 1999 (the original
COBS paper), plus the full Wikipedia vector set
- **External corpora** — vectors drawn from `cobs-c`, `nanocobs`, `cobs2-rs`,
Jacques Fortier's C implementation, and the Python `cobs` package
- **Cross-crate interop** — byte-for-byte validation against both `corncobs` and
`cobs` crates on randomized payloads up to 4 KB
- **Property-based tests** — 6 proptest suites covering round-trip correctness,
no-zero-in-output invariants, encoding properties, decoding properties,
structural/algebraic properties, and boundary-region behavior
- **Fuzz targets** — 3 `cargo-fuzz` harnesses (decode, round-trip, small-dest)
for continuous coverage of edge cases
- **Mutation testing** — `cargo-mutants` with 100% kill rate (59/59 viable
mutants caught; 30 more rejected by the type system)
- **Boundary tests** — exhaustive coverage of 254/255-byte block boundaries,
multi-block splits, buffer-exact fits, and off-by-one dest sizes
- **Compile-time verification** — `const` assertions that validate encode
correctness at compile time
```sh
# Unit + property + interop tests
just test-unit
# All quality gates (tests, clippy, fmt, doc, fuzz, miri)
# All quality gates (tests, clippy, fmt, doc, fuzz, miri, mutants)
just test
# Fuzz testing only (requires nightly + cargo-fuzz)
just test-fuzz
# Mutation testing only
just test-mutants
```
## License
+244 -122
View File
@@ -1,149 +1,271 @@
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use iai_callgrind::{library_benchmark, library_benchmark_group, main};
use std::hint::black_box;
// ── Test payloads ───────────────────────────────────────────────────
fn payload_zeros(len: usize) -> Vec<u8> {
vec![0u8; len]
fn zeros(n: usize) -> Vec<u8> {
vec![0u8; n]
}
fn payload_nonzero(len: usize) -> Vec<u8> {
(0..len).map(|i| (i % 255 + 1) as u8).collect()
fn nonzero(n: usize) -> Vec<u8> {
(0..n).map(|i| (i % 255 + 1) as u8).collect()
}
fn payload_mixed(len: usize) -> Vec<u8> {
(0..len).map(|i| (i % 256) as u8).collect()
fn mixed(n: usize) -> Vec<u8> {
(0..n).map(|i| (i % 256) as u8).collect()
}
// ── ucobs ───────────────────────────────────────────────────────────
// ── Setup helpers ───────────────────────────────────────────────────
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 setup_encode(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let buf = vec![0u8; ucobs::max_encoded_len(data.len()) + 1];
(data, buf)
}
fn ucobs_decode(encoded: &[u8], out: &mut [u8]) -> usize {
ucobs::decode(encoded, out).unwrap()
fn setup_decode_ucobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let mut enc = vec![0u8; ucobs::max_encoded_len(data.len()) + 1];
let n = ucobs::encode(&data, &mut enc).unwrap();
enc.truncate(n);
let dec = vec![0u8; data.len() + 1];
(enc, dec)
}
// ── 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 setup_encode_corncobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let buf = vec![0u8; corncobs::max_encoded_len(data.len())];
(data, buf)
}
fn corncobs_decode(encoded: &[u8], out: &mut [u8]) -> usize {
// corncobs expects trailing sentinel
corncobs::decode_buf(encoded, out).unwrap()
fn setup_decode_corncobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let mut enc = vec![0u8; corncobs::max_encoded_len(data.len())];
let n = corncobs::encode_buf(&data, &mut enc);
enc.truncate(n);
let dec = vec![0u8; data.len() + 1];
(enc, dec)
}
// ── 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 setup_encode_cobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let buf = vec![0u8; cobs::max_encoding_length(data.len())];
(data, 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)
fn setup_decode_cobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let mut enc = vec![0u8; cobs::max_encoding_length(data.len())];
let n = cobs::encode(&data, &mut enc);
enc.truncate(n);
let dec = vec![0u8; data.len() + 1];
(enc, dec)
}
// ── Benchmarks ──────────────────────────────────────────────────────
// ── Encode 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();
}
#[library_benchmark]
#[bench::zeros_0(setup_encode(zeros(0)))]
#[bench::zeros_1(setup_encode(zeros(1)))]
#[bench::zeros_10(setup_encode(zeros(10)))]
#[bench::zeros_64(setup_encode(zeros(64)))]
#[bench::zeros_254(setup_encode(zeros(254)))]
#[bench::zeros_255(setup_encode(zeros(255)))]
#[bench::zeros_256(setup_encode(zeros(256)))]
#[bench::zeros_1024(setup_encode(zeros(1024)))]
#[bench::zeros_4096(setup_encode(zeros(4096)))]
#[bench::nonzero_0(setup_encode(nonzero(0)))]
#[bench::nonzero_1(setup_encode(nonzero(1)))]
#[bench::nonzero_10(setup_encode(nonzero(10)))]
#[bench::nonzero_64(setup_encode(nonzero(64)))]
#[bench::nonzero_254(setup_encode(nonzero(254)))]
#[bench::nonzero_255(setup_encode(nonzero(255)))]
#[bench::nonzero_256(setup_encode(nonzero(256)))]
#[bench::nonzero_1024(setup_encode(nonzero(1024)))]
#[bench::nonzero_4096(setup_encode(nonzero(4096)))]
#[bench::mixed_0(setup_encode(mixed(0)))]
#[bench::mixed_1(setup_encode(mixed(1)))]
#[bench::mixed_10(setup_encode(mixed(10)))]
#[bench::mixed_64(setup_encode(mixed(64)))]
#[bench::mixed_254(setup_encode(mixed(254)))]
#[bench::mixed_255(setup_encode(mixed(255)))]
#[bench::mixed_256(setup_encode(mixed(256)))]
#[bench::mixed_1024(setup_encode(mixed(1024)))]
#[bench::mixed_4096(setup_encode(mixed(4096)))]
fn encode_ucobs((data, mut buf): (Vec<u8>, Vec<u8>)) {
black_box(ucobs::encode(black_box(&data), &mut buf));
}
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();
}
#[library_benchmark]
#[bench::zeros_0(setup_encode_corncobs(zeros(0)))]
#[bench::zeros_1(setup_encode_corncobs(zeros(1)))]
#[bench::zeros_10(setup_encode_corncobs(zeros(10)))]
#[bench::zeros_64(setup_encode_corncobs(zeros(64)))]
#[bench::zeros_254(setup_encode_corncobs(zeros(254)))]
#[bench::zeros_255(setup_encode_corncobs(zeros(255)))]
#[bench::zeros_256(setup_encode_corncobs(zeros(256)))]
#[bench::zeros_1024(setup_encode_corncobs(zeros(1024)))]
#[bench::zeros_4096(setup_encode_corncobs(zeros(4096)))]
#[bench::nonzero_0(setup_encode_corncobs(nonzero(0)))]
#[bench::nonzero_1(setup_encode_corncobs(nonzero(1)))]
#[bench::nonzero_10(setup_encode_corncobs(nonzero(10)))]
#[bench::nonzero_64(setup_encode_corncobs(nonzero(64)))]
#[bench::nonzero_254(setup_encode_corncobs(nonzero(254)))]
#[bench::nonzero_255(setup_encode_corncobs(nonzero(255)))]
#[bench::nonzero_256(setup_encode_corncobs(nonzero(256)))]
#[bench::nonzero_1024(setup_encode_corncobs(nonzero(1024)))]
#[bench::nonzero_4096(setup_encode_corncobs(nonzero(4096)))]
#[bench::mixed_0(setup_encode_corncobs(mixed(0)))]
#[bench::mixed_1(setup_encode_corncobs(mixed(1)))]
#[bench::mixed_10(setup_encode_corncobs(mixed(10)))]
#[bench::mixed_64(setup_encode_corncobs(mixed(64)))]
#[bench::mixed_254(setup_encode_corncobs(mixed(254)))]
#[bench::mixed_255(setup_encode_corncobs(mixed(255)))]
#[bench::mixed_256(setup_encode_corncobs(mixed(256)))]
#[bench::mixed_1024(setup_encode_corncobs(mixed(1024)))]
#[bench::mixed_4096(setup_encode_corncobs(mixed(4096)))]
fn encode_corncobs((data, mut buf): (Vec<u8>, Vec<u8>)) {
black_box(corncobs::encode_buf(black_box(&data), &mut buf));
}
criterion_group!(benches, bench_encode, bench_decode);
criterion_main!(benches);
#[library_benchmark]
#[bench::zeros_0(setup_encode_cobs(zeros(0)))]
#[bench::zeros_1(setup_encode_cobs(zeros(1)))]
#[bench::zeros_10(setup_encode_cobs(zeros(10)))]
#[bench::zeros_64(setup_encode_cobs(zeros(64)))]
#[bench::zeros_254(setup_encode_cobs(zeros(254)))]
#[bench::zeros_255(setup_encode_cobs(zeros(255)))]
#[bench::zeros_256(setup_encode_cobs(zeros(256)))]
#[bench::zeros_1024(setup_encode_cobs(zeros(1024)))]
#[bench::zeros_4096(setup_encode_cobs(zeros(4096)))]
#[bench::nonzero_0(setup_encode_cobs(nonzero(0)))]
#[bench::nonzero_1(setup_encode_cobs(nonzero(1)))]
#[bench::nonzero_10(setup_encode_cobs(nonzero(10)))]
#[bench::nonzero_64(setup_encode_cobs(nonzero(64)))]
#[bench::nonzero_254(setup_encode_cobs(nonzero(254)))]
#[bench::nonzero_255(setup_encode_cobs(nonzero(255)))]
#[bench::nonzero_256(setup_encode_cobs(nonzero(256)))]
#[bench::nonzero_1024(setup_encode_cobs(nonzero(1024)))]
#[bench::nonzero_4096(setup_encode_cobs(nonzero(4096)))]
#[bench::mixed_0(setup_encode_cobs(mixed(0)))]
#[bench::mixed_1(setup_encode_cobs(mixed(1)))]
#[bench::mixed_10(setup_encode_cobs(mixed(10)))]
#[bench::mixed_64(setup_encode_cobs(mixed(64)))]
#[bench::mixed_254(setup_encode_cobs(mixed(254)))]
#[bench::mixed_255(setup_encode_cobs(mixed(255)))]
#[bench::mixed_256(setup_encode_cobs(mixed(256)))]
#[bench::mixed_1024(setup_encode_cobs(mixed(1024)))]
#[bench::mixed_4096(setup_encode_cobs(mixed(4096)))]
fn encode_cobs((data, mut buf): (Vec<u8>, Vec<u8>)) {
black_box(cobs::encode(black_box(&data), &mut buf));
}
// ── Decode benchmarks ──────────────────────────────────────────────
#[library_benchmark]
#[bench::zeros_0(setup_decode_ucobs(zeros(0)))]
#[bench::zeros_1(setup_decode_ucobs(zeros(1)))]
#[bench::zeros_10(setup_decode_ucobs(zeros(10)))]
#[bench::zeros_64(setup_decode_ucobs(zeros(64)))]
#[bench::zeros_254(setup_decode_ucobs(zeros(254)))]
#[bench::zeros_255(setup_decode_ucobs(zeros(255)))]
#[bench::zeros_256(setup_decode_ucobs(zeros(256)))]
#[bench::zeros_1024(setup_decode_ucobs(zeros(1024)))]
#[bench::zeros_4096(setup_decode_ucobs(zeros(4096)))]
#[bench::nonzero_0(setup_decode_ucobs(nonzero(0)))]
#[bench::nonzero_1(setup_decode_ucobs(nonzero(1)))]
#[bench::nonzero_10(setup_decode_ucobs(nonzero(10)))]
#[bench::nonzero_64(setup_decode_ucobs(nonzero(64)))]
#[bench::nonzero_254(setup_decode_ucobs(nonzero(254)))]
#[bench::nonzero_255(setup_decode_ucobs(nonzero(255)))]
#[bench::nonzero_256(setup_decode_ucobs(nonzero(256)))]
#[bench::nonzero_1024(setup_decode_ucobs(nonzero(1024)))]
#[bench::nonzero_4096(setup_decode_ucobs(nonzero(4096)))]
#[bench::mixed_0(setup_decode_ucobs(mixed(0)))]
#[bench::mixed_1(setup_decode_ucobs(mixed(1)))]
#[bench::mixed_10(setup_decode_ucobs(mixed(10)))]
#[bench::mixed_64(setup_decode_ucobs(mixed(64)))]
#[bench::mixed_254(setup_decode_ucobs(mixed(254)))]
#[bench::mixed_255(setup_decode_ucobs(mixed(255)))]
#[bench::mixed_256(setup_decode_ucobs(mixed(256)))]
#[bench::mixed_1024(setup_decode_ucobs(mixed(1024)))]
#[bench::mixed_4096(setup_decode_ucobs(mixed(4096)))]
fn decode_ucobs((enc, mut buf): (Vec<u8>, Vec<u8>)) {
black_box(ucobs::decode(black_box(&enc), &mut buf));
}
#[library_benchmark]
#[bench::zeros_0(setup_decode_corncobs(zeros(0)))]
#[bench::zeros_1(setup_decode_corncobs(zeros(1)))]
#[bench::zeros_10(setup_decode_corncobs(zeros(10)))]
#[bench::zeros_64(setup_decode_corncobs(zeros(64)))]
#[bench::zeros_254(setup_decode_corncobs(zeros(254)))]
#[bench::zeros_255(setup_decode_corncobs(zeros(255)))]
#[bench::zeros_256(setup_decode_corncobs(zeros(256)))]
#[bench::zeros_1024(setup_decode_corncobs(zeros(1024)))]
#[bench::zeros_4096(setup_decode_corncobs(zeros(4096)))]
#[bench::nonzero_0(setup_decode_corncobs(nonzero(0)))]
#[bench::nonzero_1(setup_decode_corncobs(nonzero(1)))]
#[bench::nonzero_10(setup_decode_corncobs(nonzero(10)))]
#[bench::nonzero_64(setup_decode_corncobs(nonzero(64)))]
#[bench::nonzero_254(setup_decode_corncobs(nonzero(254)))]
#[bench::nonzero_255(setup_decode_corncobs(nonzero(255)))]
#[bench::nonzero_256(setup_decode_corncobs(nonzero(256)))]
#[bench::nonzero_1024(setup_decode_corncobs(nonzero(1024)))]
#[bench::nonzero_4096(setup_decode_corncobs(nonzero(4096)))]
#[bench::mixed_0(setup_decode_corncobs(mixed(0)))]
#[bench::mixed_1(setup_decode_corncobs(mixed(1)))]
#[bench::mixed_10(setup_decode_corncobs(mixed(10)))]
#[bench::mixed_64(setup_decode_corncobs(mixed(64)))]
#[bench::mixed_254(setup_decode_corncobs(mixed(254)))]
#[bench::mixed_255(setup_decode_corncobs(mixed(255)))]
#[bench::mixed_256(setup_decode_corncobs(mixed(256)))]
#[bench::mixed_1024(setup_decode_corncobs(mixed(1024)))]
#[bench::mixed_4096(setup_decode_corncobs(mixed(4096)))]
fn decode_corncobs((enc, mut buf): (Vec<u8>, Vec<u8>)) {
let _ = black_box(corncobs::decode_buf(black_box(&enc), &mut buf));
}
#[library_benchmark]
#[bench::zeros_0(setup_decode_cobs(zeros(0)))]
#[bench::zeros_1(setup_decode_cobs(zeros(1)))]
#[bench::zeros_10(setup_decode_cobs(zeros(10)))]
#[bench::zeros_64(setup_decode_cobs(zeros(64)))]
#[bench::zeros_254(setup_decode_cobs(zeros(254)))]
#[bench::zeros_255(setup_decode_cobs(zeros(255)))]
#[bench::zeros_256(setup_decode_cobs(zeros(256)))]
#[bench::zeros_1024(setup_decode_cobs(zeros(1024)))]
#[bench::zeros_4096(setup_decode_cobs(zeros(4096)))]
#[bench::nonzero_0(setup_decode_cobs(nonzero(0)))]
#[bench::nonzero_1(setup_decode_cobs(nonzero(1)))]
#[bench::nonzero_10(setup_decode_cobs(nonzero(10)))]
#[bench::nonzero_64(setup_decode_cobs(nonzero(64)))]
#[bench::nonzero_254(setup_decode_cobs(nonzero(254)))]
#[bench::nonzero_255(setup_decode_cobs(nonzero(255)))]
#[bench::nonzero_256(setup_decode_cobs(nonzero(256)))]
#[bench::nonzero_1024(setup_decode_cobs(nonzero(1024)))]
#[bench::nonzero_4096(setup_decode_cobs(nonzero(4096)))]
#[bench::mixed_0(setup_decode_cobs(mixed(0)))]
#[bench::mixed_1(setup_decode_cobs(mixed(1)))]
#[bench::mixed_10(setup_decode_cobs(mixed(10)))]
#[bench::mixed_64(setup_decode_cobs(mixed(64)))]
#[bench::mixed_254(setup_decode_cobs(mixed(254)))]
#[bench::mixed_255(setup_decode_cobs(mixed(255)))]
#[bench::mixed_256(setup_decode_cobs(mixed(256)))]
#[bench::mixed_1024(setup_decode_cobs(mixed(1024)))]
#[bench::mixed_4096(setup_decode_cobs(mixed(4096)))]
fn decode_cobs((enc, mut buf): (Vec<u8>, Vec<u8>)) {
black_box(
cobs::decode(black_box(&enc), &mut buf)
.map(|r| r.parsed_size())
.unwrap_or(0),
);
}
// ── Groups ─────────────────────────────────────────────────────────
library_benchmark_group!(
name = encode;
benchmarks = encode_ucobs, encode_corncobs, encode_cobs
);
library_benchmark_group!(
name = decode;
benchmarks = decode_ucobs, decode_corncobs, decode_cobs
);
main!(library_benchmark_groups = encode, decode);
+37
View File
@@ -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 = []
+6
View File
@@ -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
+33
View File
@@ -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);
}
});
+28 -5
View File
@@ -4,8 +4,8 @@
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
# 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
@@ -39,10 +46,26 @@ test-fuzz:
test-miri:
cargo +nightly miri test -- --skip proptests
# run criterion benchmarks (ucobs vs cobs vs corncobs)
bench:
# mutation testing (requires cargo-mutants)
test-mutants:
cargo mutants --no-shuffle
# run iai-callgrind benchmarks (deterministic instruction counts, requires valgrind)
bench: bench-iai bench-size
# run iai-callgrind benchmarks (ucobs vs cobs vs corncobs)
bench-iai:
cargo bench
# measure compiled code size (encode + decode .text bytes)
bench-size:
@cargo build --release 2>/dev/null
@echo ""
@echo "Code size (release, encode + decode .text):"
@nm -S target/release/libucobs.rlib 2>/dev/null \
| grep 'T _ZN5ucobs' \
| awk '{size=strtonum("0x"$2); name=$4; sub(/.*ucobs[0-9]*/, "", name); sub(/17h.*/, "", name); printf " ucobs %-7s %4d B\n", name, size}'
# run python cobs benchmarks (requires: pip install cobs)
bench-python:
python3 bench_python.py
+7
View File
@@ -0,0 +1,7 @@
[tools]
rust = "1.93"
"cargo:cargo-deny" = "latest"
"cargo:cargo-fuzz" = "latest"
"cargo:cargo-mutants" = "latest"
"cargo:cargo-nextest" = "latest"
"cargo:iai-callgrind-runner" = "latest"
+10
View File
@@ -0,0 +1,10 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 00411472de4d6cdb296c04bc52fd533354bc25ba1765807da8b7994868fcb987 # shrinks to len = 1
cc 81ebf688f1dd5ea2a735756a7fb703b92c2a2033057ae91886577c6a63763cf5 # shrinks to data = [0]
cc 32d0d2d26e2be6ec66b432f1e9baee378777b1a00b959922cff7a5784292ec4b # shrinks to data = [0]
cc fe447e0e58f4c0b21f6e1b38a90bd33578c968967c956964a06013a4df977d68 # shrinks to data = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
+360 -57
View File
@@ -9,54 +9,234 @@
//! 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`, zero-alloc, and `unsafe`-free — it runs
//! everywhere from 8-bit MCUs to servers.
//!
//! # Sentinel convention
//! # Feature flags
//!
//! - **`legacy-msrv`** — Enables compilation on Rust 1.83+ by replacing
//! the const `copy_from_slice` (requires Rust 1.93) with a manual byte
//! loop in the encoder. All other optimizations are unaffected.
//!
//! # Quick start
//!
//! ```
//! // Encode
//! let mut buf = [0u8; 16];
//! let n = ucobs::encode(&[0x11, 0x00, 0x33], &mut buf).unwrap();
//! assert_eq!(&buf[..n], &[0x02, 0x11, 0x02, 0x33]);
//!
//! // Decode
//! let mut out = [0u8; 16];
//! let m = ucobs::decode(&buf[..n], &mut out).unwrap();
//! assert_eq!(&out[..m], &[0x11, 0x00, 0x33]);
//! ```
//!
//! # Buffer sizing
//!
//! Use [`max_encoded_len`] to determine the required destination buffer
//! size before encoding:
//!
//! ```
//! let data = [0x01, 0x02, 0x03];
//! let max = ucobs::max_encoded_len(data.len()); // 4
//!
//! let mut buf = [0u8; 4];
//! let n = ucobs::encode(&data, &mut buf).unwrap();
//! assert_eq!(n, 4); // fits exactly
//! ```
//!
//! If the destination is too small, `encode` returns `None` rather than
//! panicking:
//!
//! ```
//! let mut tiny = [0u8; 1];
//! assert_eq!(ucobs::encode(&[0x01, 0x02], &mut tiny), None);
//! ```
//!
//! # Framing for transport
//!
//! 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:
//! expects input **without** a trailing sentinel. Append and strip the
//! sentinel yourself when framing for transport:
//!
//! ```
//! let data = [0x11, 0x00, 0x33];
//!
//! // Encode — leave room for the sentinel byte
//! // Encode into a wire frame: [COBS bytes...] [0x00 sentinel]
//! let mut frame = [0u8; 16];
//! let n = ucobs::encode(&data, &mut frame).unwrap();
//! frame[n] = 0x00; // append sentinel for framing
//! frame[n] = 0x00; // append sentinel
//! let wire = &frame[..n + 1];
//!
//! // Decode — strip sentinel before decoding
//! // On the receiving end, strip the sentinel before decoding
//! let cobs_data = &wire[..wire.len() - 1];
//! let mut out = [0u8; 16];
//! let m = ucobs::decode(&frame[..n], &mut out).unwrap();
//! let m = ucobs::decode(cobs_data, &mut out).unwrap();
//! assert_eq!(&out[..m], &data);
//! ```
//!
//! # Parsing a stream of frames
//!
//! In a byte stream, split on `0x00` to extract individual COBS frames,
//! then decode each one:
//!
//! ```
//! // Simulate a stream containing two frames separated by 0x00 sentinels
//! let stream = [
//! 0x02, 0x11, 0x02, 0x33, 0x00, // frame 1: encodes [0x11, 0x00, 0x33]
//! 0x03, 0xAA, 0xBB, 0x00, // frame 2: encodes [0xAA, 0xBB]
//! ];
//!
//! let mut out = [0u8; 16];
//! let frames: Vec<&[u8]> = stream.split(|&b| b == 0x00)
//! .filter(|f| !f.is_empty())
//! .collect();
//!
//! let n = ucobs::decode(frames[0], &mut out).unwrap();
//! assert_eq!(&out[..n], &[0x11, 0x00, 0x33]);
//!
//! let n = ucobs::decode(frames[1], &mut out).unwrap();
//! assert_eq!(&out[..n], &[0xAA, 0xBB]);
//! ```
//!
//! # Compile-time encoding
//!
//! [`encode`] is `const fn`, so you can build COBS-encoded lookup tables
//! or protocol headers at compile time with zero runtime cost:
//!
//! ```
//! // Pre-encode a command table at compile time
//! const PING: [u8; 2] = {
//! let mut buf = [0u8; 2];
//! match ucobs::encode(&[0x01], &mut buf) {
//! Some(_) => buf,
//! None => panic!("buffer too small"),
//! }
//! };
//!
//! const ACK: [u8; 3] = {
//! let mut buf = [0u8; 3];
//! match ucobs::encode(&[0x06, 0x00], &mut buf) {
//! Some(_) => buf,
//! None => panic!("buffer too small"),
//! }
//! };
//!
//! // No runtime encoding needed — these are baked into the binary
//! assert_eq!(PING, [0x02, 0x01]);
//! assert_eq!(ACK, [0x02, 0x06, 0x01]);
//! ```
//!
//! # Error handling
//!
//! Both [`encode`] and [`decode`] return `Option<usize>` — `None` on
//! failure, never panic.
//!
//! ```
//! let mut buf = [0u8; 8];
//!
//! // Destination too small for encode
//! assert_eq!(ucobs::encode(&[1, 2, 3], &mut buf[..1]), None);
//!
//! // Malformed COBS data (zero byte in encoded stream)
//! assert_eq!(ucobs::decode(&[0x00], &mut buf), None);
//!
//! // Truncated frame (code byte promises more data than exists)
//! assert_eq!(ucobs::decode(&[0x05, 0x11], &mut buf), None);
//!
//! // Empty input is valid for both
//! assert_eq!(ucobs::encode(&[], &mut buf), Some(1)); // encodes to [0x01]
//! assert_eq!(ucobs::decode(&[], &mut buf), Some(0)); // decodes to []
//! ```
#![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.
/// Copy `src` into `dest[offset..]` in a const-compatible way.
/// Uses `copy_from_slice` (Rust 1.93+, compiles to memcpy).
/// With `legacy-msrv` feature, falls back to a manual loop for Rust 1.83+.
#[inline(always)]
#[cfg(not(feature = "legacy-msrv"))]
const fn const_copy(dest: &mut [u8], di: usize, src: &[u8], n: usize) {
let (_, dt) = dest.split_at_mut(di);
let (dc, _) = dt.split_at_mut(n);
let (sc, _) = src.split_at(n);
dc.copy_from_slice(sc);
}
/// Fallback const copy for toolchains older than Rust 1.93.
#[cfg_attr(test, mutants::skip)] // dead code under default features
#[inline(always)]
#[cfg(feature = "legacy-msrv")]
const fn const_copy(dest: &mut [u8], di: usize, src: &[u8], n: usize) {
let mut i = 0;
while i < n {
dest[di + i] = src[i];
i += 1;
}
}
/// COBS-encode `src` into `dest`.
///
/// The output does **not** include a trailing `0x00` sentinel — the caller
/// should append it as a frame delimiter.
/// Returns the number of bytes written, or `None` if `dest` is too small.
/// Use [`max_encoded_len`] to size the destination buffer.
///
/// The output does **not** include a trailing `0x00` sentinel — append it
/// yourself when framing for transport.
///
/// This function is `const fn`, so it can encode data at compile time.
///
/// # Examples
///
/// ```
/// let mut buf = [0u8; 8];
/// let n = ucobs::encode(&[0x00], &mut buf).unwrap();
/// assert_eq!(&buf[..n], &[0x01, 0x01]);
/// ```
///
/// Compile-time encoding:
///
/// ```
/// const ENCODED: [u8; 4] = {
/// let mut buf = [0u8; 4];
/// match ucobs::encode(&[0x11, 0x00, 0x33], &mut buf) {
/// Some(_) => buf,
/// None => panic!("buffer too small"),
/// }
/// };
/// assert_eq!(ENCODED, [0x02, 0x11, 0x02, 0x33]);
/// ```
#[must_use]
pub fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
let mut si = 0;
let mut di = 0;
loop {
// Scan for next zero (or 254-byte cap).
let remaining = src.len() - si;
let max_run = if remaining < 254 { remaining } else { 254 };
let run = if max_run > 0 && src[si] == 0x00 {
0
} else {
src[si..si + max_run]
.iter()
.position(|&b| b == 0x00)
.unwrap_or(max_run)
};
// Fast path: leading zero byte — skip split_at overhead entirely.
if max_run > 0 && src[si] == 0x00 {
if di >= dest.len() {
return None;
}
dest[di] = 0x01;
di += 1;
si += 1;
continue;
}
// Create bounded source window for scan + copy.
let (_, src_tail) = src.split_at(si);
let (src_window, _) = src_tail.split_at(max_run);
// Scan for next zero byte.
let mut run = 0;
while run < src_window.len() && src_window[run] != 0x00 {
run += 1;
}
let full_block = run == 254;
// Write code byte.
@@ -66,27 +246,20 @@ pub fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
dest[di] = if full_block { 0xFF } else { (run + 1) as u8 };
di += 1;
// Bulk-copy the run.
// Copy the run.
if run > 0 {
if di + run > dest.len() {
return None;
}
dest[di..di + run].copy_from_slice(&src[si..si + run]);
const_copy(dest, di, src_window, 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;
}
if si >= src.len() {
break;
}
if !full_block {
si += 1; // skip the zero byte
}
}
@@ -94,34 +267,72 @@ pub fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
Some(di)
}
/// Decode a COBS-encoded buffer. Returns the number of decoded bytes
/// written to `dest`, or `None` if the input is malformed or `dest` is
/// too small.
/// Decode a COBS-encoded buffer.
///
/// Returns the number of decoded bytes 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
/// it before calling this function.
///
/// # Examples
///
/// ```
/// let mut out = [0u8; 8];
/// let n = ucobs::decode(&[0x02, 0x11, 0x02, 0x33], &mut out).unwrap();
/// assert_eq!(&out[..n], &[0x11, 0x00, 0x33]);
/// ```
///
/// Malformed input returns `None`:
///
/// ```
/// let mut out = [0u8; 8];
/// assert_eq!(ucobs::decode(&[0x00], &mut out), None); // unexpected zero
/// assert_eq!(ucobs::decode(&[0x05, 0x11], &mut out), None); // truncated
/// ```
#[must_use]
pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
let mut si = 0;
let mut src = src;
let mut di = 0;
while si < src.len() {
let code = src[si] as usize;
si += 1;
while let Some((&code_byte, rest)) = src.split_first() {
src = rest;
let code = code_byte as usize;
if code == 0 {
return None; // unexpected zero in COBS data
return None;
}
// Bulk-copy `code - 1` data bytes.
let n = code - 1;
if n > 0 {
if si + n > src.len() || di + n > dest.len() {
if code == 1 {
// Batch consecutive 0x01 codes and fill zeros in bulk.
let mut count = 1usize;
while let Some((&next, rest)) = src.split_first() {
if next != 0x01 {
break;
}
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;
}
dest[di..di + n].copy_from_slice(&src[si..si + n]);
di += n;
si += n;
dest[di..di + zeros].fill(0);
di += zeros;
continue;
}
// If code < 0xFF, an implicit zero follows (unless we're at the end).
if code < 0xFF && si < src.len() {
// Bulk-copy `code - 1` data bytes.
let n = code - 1;
if src.len() < n || di + n > dest.len() {
return None;
}
let (block, rest) = src.split_at(n);
dest[di..di + n].copy_from_slice(block);
di += n;
src = rest;
if code != 0xFF && !src.is_empty() {
if di >= dest.len() {
return None;
}
@@ -133,6 +344,17 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
}
/// Maximum encoded size for a given source length (excluding sentinel).
///
/// Use this to allocate a destination buffer for [`encode`]. The formula
/// is `src_len + (src_len / 254) + 1`.
///
/// # Examples
///
/// ```
/// assert_eq!(ucobs::max_encoded_len(0), 1);
/// assert_eq!(ucobs::max_encoded_len(1), 2);
/// assert_eq!(ucobs::max_encoded_len(254), 256);
/// ```
#[must_use]
pub const fn max_encoded_len(src_len: usize) -> usize {
src_len + (src_len / 254) + 1
@@ -155,6 +377,21 @@ mod tests {
encode(input, &mut buf).map(|n| buf[..n].to_vec())
}
// ── Compile-time (const fn) encode ────────────────────────────
const _: () = {
let input = [0x11, 0x00, 0x33];
let mut buf = [0u8; 8];
let Some(n) = encode(&input, &mut buf) else {
panic!("const encode failed");
};
assert!(n == 4);
assert!(buf[0] == 0x02);
assert!(buf[1] == 0x11);
assert!(buf[2] == 0x02);
assert!(buf[3] == 0x33);
};
// ── Wikipedia canonical decode vectors (Cheshire & Baker 1999) ──
#[test]
@@ -597,6 +834,49 @@ mod tests {
assert_eq!(enc(&input), Some(expected));
}
#[test]
fn encode_254_block_boundary_exact() {
// 254 nonzero bytes MUST produce code 0xFF (full block), NOT 0xFF+1=0x00.
// This catches max_run being 255 instead of 254.
let input: Vec<u8> = (1..=254).map(|i| i as u8).collect();
let encoded = enc(&input).unwrap();
assert_eq!(encoded[0], 0xFF); // code byte for full 254-byte block
assert_eq!(encoded.len(), 255); // 1 code byte + 254 data bytes
// Verify it round-trips
let mut dec = vec![0u8; 256];
let n = decode(&encoded, &mut dec).unwrap();
assert_eq!(&dec[..n], &input[..]);
}
#[test]
fn encode_255_nonzero_needs_two_blocks() {
// 255 nonzero bytes must split into a 254-byte full block + 1-byte block.
// If max_run were 255 (bug), this would be a single block — wrong.
let input: Vec<u8> = (1..=255).map(|i| (i % 255 + 1) as u8).collect();
let encoded = enc(&input).unwrap();
assert_eq!(encoded[0], 0xFF); // first block: full 254
assert_eq!(encoded[255], 0x02); // second block: 1 byte (code = 2)
assert_eq!(encoded.len(), 257); // 1 + 254 + 1 + 1
}
#[test]
fn encode_block_boundary_matches_corncobs() {
// Verify byte-for-byte match with corncobs at the critical 254-byte
// block boundary. Catches max_run being off by one.
for len in [254, 255, 256, 508, 512] {
let input: Vec<u8> = (0..len).map(|i| (i % 254 + 1) as u8).collect();
let ours = enc(&input).unwrap();
let mut their_buf = vec![0u8; corncobs::max_encoded_len(input.len())];
let their_len = corncobs::encode_buf(&input, &mut their_buf);
// corncobs appends a sentinel; strip it for comparison
assert_eq!(
&ours[..],
&their_buf[..their_len - 1],
"mismatch at len={len}"
);
}
}
// ── Round-trip tests ────────────────────────────────────────
#[test]
@@ -729,11 +1009,11 @@ mod tests {
#[test]
fn interop_random_payloads() {
use rand::Rng;
let mut rng = rand::thread_rng();
use rand::RngExt;
let mut rng = rand::rng();
for _ in 0..500 {
let len = rng.gen_range(0..=512);
let data: Vec<u8> = (0..len).map(|_| rng.gen()).collect();
let len = rng.random_range(0..=512);
let data: Vec<u8> = (0..len).map(|_| rng.random()).collect();
cross_encode(&data);
cross_decode(&data);
}
@@ -800,6 +1080,29 @@ mod tests {
assert_eq!(out, [0x11, 0x22]);
}
#[test]
fn decode_zeros_dest_exact_size() {
// Decode all-zeros into an exact-fit buffer.
// Encoded: N+1 bytes of 0x01 → N bytes of 0x00.
// This catches `> dest.len()` mutated to `>= dest.len()`.
for n in [1, 2, 10, 64, 254, 255, 256] {
let encoded: Vec<u8> = vec![0x01; n + 1];
let mut out = vec![0u8; n]; // exact fit
assert_eq!(decode(&encoded, &mut out), Some(n), "failed at n={n}");
assert!(out.iter().all(|&b| b == 0x00), "wrong data at n={n}");
}
}
#[test]
fn decode_zeros_dest_one_too_small() {
// Buffer one byte too small for all-zeros decode.
for n in [2, 10, 64, 256] {
let encoded: Vec<u8> = vec![0x01; n + 1];
let mut out = vec![0u8; n - 1]; // one too small
assert_eq!(decode(&encoded, &mut out), None, "should fail at n={n}");
}
}
// ── Multi-block boundary tests ──────────────────────────────
#[test]
@@ -955,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
}