Why wire-codec?
Most binary protocols boil down to three jobs: cut a byte stream into frames,
turn integers into compact wire representations, and pack tagged fields into
records. The Rust ecosystem has good crates for each of those jobs in
isolation, but they tend to assume a runtime (tokio, async-std), or a
specific serialization model (serde, prost), or a buffer abstraction
(bytes::Bytes, Vec<u8>) that propagates outward through your code.
wire-codec does not. It works on borrowed byte slices: you bring the buffer,
it provides the cursors, the codecs, and the framers. Nothing in the core
allocates on the encode or decode path (the optional BytesMut adapters
grow their buffer, by design). Nothing pulls in an async runtime. Nothing forces
a serde-style data model. You can drop it into an embedded firmware build,
a WebAssembly module, a kernel-bypass network stack, or a synchronous CLI
tool, and the public API is exactly the same. When you do live in Tokio or
a futures-based runtime, optional adapters plug any framer into
tokio_util::codec::Framed or asynchronous_codec::Framed.
If you are building a network protocol crate, a binary serialization layer, or a framing adapter for some existing transport, this is intended to sit underneath that work.
Features
- Zero-copy I/O.
ReadBufandWriteBufare cursors over borrowed byte slices. Reads return sub-slices that borrow from the input; writes go straight into the caller's buffer. Every operation is atomic: on error the cursor has not moved. - Codec trait pair.
EncodeandDecode<'de>are minimal, allocation-free, and composable, with impls foru8,i8,bool, byte arrays, tuples, and theBe/Le/Varintwrappers that make the byte order of every multi-byte integer explicit. - LEB128 varint.
u16,u32,u64,usize, and zigzagi32/i64, compatible with Protocol Buffers, WebAssembly, and DWARF. Canonical decoders reject overlong encodings for signature- and hash-sensitive protocols. - Zigzag. Signed-to-unsigned mapping so negative deltas produce short
varints.
const fnend-to-end. - Bit-level cursors and packed structs.
BitReaderandBitWriterpack arbitrary-width fields (signed or unsigned), MSB-first, with padding bits always zeroed.packed_struct!declares a struct with per-field bit widths, checked at compile time, and implementsEncode/Decodefor it. - Framing strategies, all behind one
Framertrait:LengthPrefixed: 1, 2, 3, 4, or 8 byte prefix, either byte order, optional length adjustment (for "length includes header" protocols).VarintPrefixed: protobuf-style varint length prefix, bounded and canonical by default.Delimited: single- or multi-byte delimiters, a fast SWAR search (ormemchrwith the optional feature), and refusal to write payloads that would split on the receiving side.Checksummed: appends and verifies a CRC-16, CRC-32, CRC-32C, or customChecksumafter the frames of any other framer.
- Linear-time streaming.
StreamDecoderresumes a delimiter search where the previous call stopped, so a peer that drips one byte at a time costs linear, not quadratic, CPU. - COBS. Allocation-free
cobsencode, decode, and in-place decode for zero-delimited serial framing. - Hardened for hostile input. Checked arithmetic on every length taken from the wire (no panics on 32-bit targets), bounded constructors for untrusted peers, fuzz targets for every decoder.
no_std. Disable the defaultstdfeature and the whole core, including the error type'score::error::Errorimpl, builds for bare-metal targets.- No required dependencies. Every integration (
memchr,bytes,tokio-util,asynchronous-codec) is an optional, off-by-default feature. - Stable API. From 1.0 onward, the public surface is frozen. 1.x releases are bug fixes and non-breaking additions.
Quick start
[]
= "1.2"
use WriteBuf;
use ;
let framer = bounded;
let mut out = ;
let mut buf = new;
framer.write_frame.unwrap;
let n = buf.position;
let frame = framer.next_frame.unwrap.unwrap;
assert_eq!;
The full reference lives in docs/API.md. Runnable
demonstrations are under examples/:
Usage patterns
Length-prefixed framing
Each frame is preceded by a fixed-width unsigned integer giving the payload length. The width and byte order are chosen at construction.
use WriteBuf;
use ;
// 16-bit big-endian length, capped at 4 KiB per frame.
let framer = bounded_to;
let mut out = ;
let mut buf = new;
framer.write_frame.unwrap;
// `buf` borrows `out` mutably, so read the frame back through `written()`.
let frame = framer.next_frame.unwrap.unwrap;
assert_eq!;
assert_eq!; // 2-byte header + 5-byte payload
Protocols whose length field counts the header itself use a length
adjustment: .with_length_adjustment(-2) for a 2-byte self-counting prefix.
Varint length-prefixed framing
The length-delimited stream format of Protocol Buffers: a LEB128 length, then the payload. Bounded (8 MiB) and canonical by default.
use WriteBuf;
use ;
let framer = new;
let mut out = ;
let mut buf = new;
framer.write_frame.unwrap;
assert_eq!;
Delimited (line-oriented) framing
Each frame is terminated by a configured delimiter byte sequence. The
delimiter is stripped from the emitted payload but counted in
Frame::consumed.
use ;
let framer = bounded.unwrap;
let request = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\nbody";
let line = framer.next_frame.unwrap.unwrap;
assert_eq!;
assert_eq!;
Streaming input
When bytes arrive in pieces, keep the framer in a StreamDecoder. It
remembers how far the previous call searched, so every byte is examined
about once no matter how the input is chunked.
use ;
let mut decoder = new;
let mut buffer: = Vecnew;
for chunk in
Untrusted input
Anything reading from a peer you do not control should use a limit:
| Constructor | Limit |
|---|---|
LengthPrefixed::bounded(width, endian) |
8 MiB (or the width's maximum, if smaller) |
LengthPrefixed::bounded_to(width, endian, max) |
max (or the width's maximum, if smaller) |
VarintPrefixed::new() |
8 MiB |
Delimited::bounded(delimiter) |
64 KiB |
Delimited::bounded_to(delimiter, max) |
max |
any framer .with_max_payload(n) |
n |
LengthPrefixed::unbounded and Delimited::unbounded accept payloads as
large as the prefix can describe (4 GiB for a u32 prefix) or, for a
delimiter, any size; use them only where something else bounds the input.
LengthPrefixed::new and Delimited::new keep that 1.0 unbounded
behaviour, are deprecated since 1.2.0, and become bounded in 2.0.
Compact integers (varint + zigzag)
varint encodes integers in one to ten bytes. The signed helpers apply
zigzag first, so small negative values stay short.
use ;
let mut out = ;
let mut buf = new;
// Encode the timestamp delta -750 as a 2-byte varint.
encode_i64.unwrap;
assert_eq!;
let mut read = new;
assert_eq!;
Typed fields
Encode and Decode compose through tuples and the byte-order wrappers.
use ;
let message = ;
let mut out = ;
let mut buf = new;
message.encode.unwrap;
assert_eq!;
let decoded = decode.unwrap;
assert_eq!;
Bit-packed records
packed_struct! declares the layout once; widths are checked at compile
time and values at encode time.
use ;
packed_struct!
let header = Header ;
let mut out = ;
header.encode.unwrap;
assert_eq!;
assert_eq!;
For one-off layouts, BitWriter and BitReader give direct bit-level
access:
use BitWriter;
let mut storage = ;
let mut w = new;
w.write_bits.unwrap; // 3-bit type tag
w.write_bits.unwrap; // 5-bit value
assert_eq!;
assert_eq!;
Checksummed frames
use Crc32;
use ;
use ;
let framer = new;
let mut out = ;
let mut buf = new;
framer.write_frame.unwrap;
let n = buf.position;
out ^= 0x01; // a bit flips in transit
assert_eq!;
Tokio and futures adapters
With the tokio-util (or asynchronous-codec) feature, wrap any framer in
FrameCodec and hand it to Framed:
use Framed;
use FrameCodec;
use ;
let codec = new;
let framed = new;
// Stream<Item = io::Result<BytesMut>> + Sink<&[u8]> / Sink<Bytes>
Custom framing
Implement Framer on your own type to plug in a custom framing strategy
without giving up the rest of the toolkit.
use ;
use ;
;
Performance
Reference numbers measured on the development machine (release profile,
fat LTO, single thread, Windows 11, x86_64). Decode inputs pass through
black_box, so nothing is constant-folded. Variation across machines is
expected; the numbers are a baseline for regression detection, not a
benchmark target.
| Operation | Time |
|---|---|
varint::encode_u32 (value 300) |
0.44 ns |
varint::encode_u32 (u32::MAX) |
0.57 ns |
varint::decode_u32 (2 bytes) |
0.49 ns |
varint::decode_u32 (5 bytes) |
1.16 ns |
varint::encode_u64 (u64::MAX) |
1.24 ns |
varint::decode_u64 (10 bytes) |
2.75 ns |
varint::decode_u64 (mixed 4096-value stream, per value) |
0.77 ns |
zigzag::encode_i32 |
0.26 ns |
ReadBuf::read_u32_be (16 reads) |
2.9 ns |
WriteBuf::write_u32_be (16 writes) |
1.6 ns |
LengthPrefixed::write_frame (64 B) |
1.25 ns |
LengthPrefixed::next_frame (64 B) |
0.47 ns |
VarintPrefixed::next_frame (64 B) |
0.92 ns |
Checksummed<LengthPrefixed, Crc32>::next_frame (64 B) |
19 ns |
Delimited::next_frame (short line) |
2.6 ns |
Delimited::next_frame (1 KiB scan + CRLF) |
26 ns |
Delimited::next_frame (64 KiB scan + LF) |
1.5 µs (~43 GB/s) |
StreamDecoder<Delimited>, 64 KiB line fed 1 byte per call |
155 µs total |
checksum::crc32 / crc32c (1 KiB) |
0.33 µs (~3 GB/s) |
checksum::crc16_ccitt_false (1 KiB) |
1.85 µs |
cobs::encode / cobs::decode (1 KiB) |
≈0.6–0.7 / ≈0.7–1.1 µs |
Numbers are the minimum of four runs; where repeated sessions disagreed by more than about 10% a range is given instead. For comparison, 1.0 scanned a 1 KiB line in about 1.8 µs and spent about 430 ms on the 64 KiB slow-drip case, which is now linear.
Reproduce locally:
The bench harness is a manual std::time::Instant loop with black_box
markers — zero dependencies and MSRV-compatible.
Stability and semver
From 1.0.0 onward, the public API surface is frozen. Subsequent 1.x
releases will only contain:
- Bug fixes (PATCH).
- Performance improvements that do not change observable behavior (PATCH).
- New additions that do not break existing code: new error variants (the
Errorenum is#[non_exhaustive]), new prefix widths (LengthWidthis#[non_exhaustive]), new helper methods on existing types, new provided trait methods, additional features behind feature flags (MINOR).
Any breaking change requires a 2.0.0 release. The crate follows
Keep a Changelog and
Semantic Versioning.
MSRV policy. The minimum supported Rust version is 1.85. It is raised
only in a MINOR release, with the change called out in the changelog, and
never in a PATCH release. Users pinned to an older compiler can stay on the
previous minor line by depending on wire-codec = "~1.0" (1.0 supports
Rust 1.75). Cargo only skips releases your compiler cannot build when your
own project uses MSRV-aware resolution, which needs Cargo 1.84 or newer
with resolver = "3" (the edition 2024 default) or
incompatible-rust-versions = "fallback". On older Cargo, a plain
wire-codec = "1" resolves to 1.1 and fails to build with a
rustc 1.85 requirement error, so pin ~1.0 there.
no_std and feature flags
| Feature | Default | Effect |
|---|---|---|
std |
enabled | Links the standard library and adds From<Error> for std::io::Error. The crate is no_std when this feature is disabled; Error implements core::error::Error either way. |
memchr |
off | Delimiter search through the memchr crate (SIMD kernels, linear-time multi-byte search). Works without std. |
bytes |
off | codec::FrameCodec, a bytes::BytesMut adapter for any framer. Works without std (needs alloc). |
tokio-util |
off | tokio_util::codec::{Decoder, Encoder} for FrameCodec. Implies bytes and std. |
asynchronous-codec |
off | asynchronous_codec::{Decoder, Encoder} for FrameCodec. Implies bytes and std. |
To use the crate without std:
[]
= { = "1.2", = false }
Project layout
wire-codec/
├── src/
│ ├── lib.rs # crate root, re-exports
│ ├── error.rs # Error, Result
│ ├── buf.rs # ReadBuf, WriteBuf
│ ├── traits.rs # Encode, Decode, Be, Le, Varint
│ ├── varint.rs # LEB128 (lenient and canonical)
│ ├── zigzag.rs # signed-to-unsigned mapping
│ ├── bitfield.rs # BitReader, BitWriter
│ ├── packed.rs # packed_struct!, PackedField
│ ├── checksum.rs # Checksum, CRC-16, CRC-32, CRC-32C
│ ├── cobs.rs # COBS byte stuffing
│ ├── codec.rs # FrameCodec (bytes / tokio-util / asynchronous-codec)
│ └── framing/
│ ├── mod.rs # Framer, Frame, StreamDecoder
│ ├── length.rs # LengthPrefixed
│ ├── varint_prefixed.rs # VarintPrefixed
│ ├── delimiter.rs # Delimited
│ ├── checksummed.rs # Checksummed
│ └── search.rs # SWAR / memchr delimiter search
├── tests/ # smoke, integration, proptest, fuzz-harness suites
├── fuzz/ # cargo-fuzz targets (not published)
├── benches/ # codec.rs, framing.rs (manual harness)
├── examples/ # runnable demos
├── docs/
│ ├── API.md # full API reference
│ └── release/ # per-version release notes
├── REPS.md # engineering standards (supreme authority)
└── deny.toml # supply-chain policy for cargo-deny
Standards
- REPS governs every decision. See
REPS.md. - MSRV: Rust 1.85 (verified in CI on Linux, macOS, and Windows).
- Edition: 2024.
- Cross-platform: Linux, macOS, Windows (CI on all three), plus 32-bit
(
i686),no_std(thumbv7em-none-eabihf), andwasm32builds in CI. - Safety:
#![forbid(unsafe_code)]. - Supply chain:
cargo auditandcargo denyenforced in CI. - Test discipline: unit, integration, property, doctests, and libFuzzer targets; README and API reference examples are compiled as doctests.
License
Dual-licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT License (LICENSE-MIT)
at your option.
Contributions are accepted under the same dual license.