Why pack-io
Existing crates each cover a slice of the problem; none of them own all three properties together.
| Crate | Speed | Schema evolution | Zero-copy decode | Wire-format spec |
|---|---|---|---|---|
bincode |
✓ | — | — | — |
rkyv |
✓ | — | ✓ * | — |
postcard |
✓ | — | — | ✓ |
pack-io |
✓ | ✓ | ✓ | ✓ |
* rkyv requires alignment discipline at every use site; the cost is paid by the caller, not the codec.
The 1.0 contract is the same wire format on every supported platform, the same bytes for the same value every time, and no panic / no unbounded allocation on any input.
What it does
- Compact binary encoding of Rust values into a small, predictable wire format
- Schema-versioned messages - producer and consumer can be at different revisions and still interoperate
- Zero-copy deserialization for
&[u8]/&str/ length-prefixed slices when the input lives long enough - Deterministic output - the same value always produces the same bytes (canonical encoding)
- Safe under untrusted input - bounded allocation, length-prefix validation, no panics on malformed bytes
- Runtime-agnostic - synchronous codec, usable from any context
Features
- Compact — small, fixed-overhead encoding; varint integers; length-prefixed byte slices
- Schema evolution — additive field changes, optional fields, version negotiation
- Zero-copy decode — view types that borrow from the input buffer where possible
- Deterministic — canonical encoding for hashing, signing, content-addressing
- Safe defaults — bounded allocation, validated lengths, no panics on bad input
no_std-capable — embedded and constrained environments- Derive macro —
#[derive(Serialize, Deserialize)]for any struct or enum (featurederive) - Optional
serdeinterop — read and writeserdetypes via a thin adapter (featureserde)
Roadmap snapshot
| Version | Scope | Status |
|---|---|---|
0.1.0 |
Scaffold: structure, CI, lints, quality gates | ✅ shipped |
0.2.0 |
Foundation: encode / decode, primitive types, Serialize / Deserialize, round-trip + determinism + adversarial-decode proptests |
✅ shipped |
0.3.0 |
Wire-format freeze, collections (Vec, HashMap, BTreeMap, sets), streaming over Read / Write, normative docs/WIRE_FORMAT.md |
✅ shipped |
0.4.0 |
View<T> zero-copy decode + derive macro |
next |
0.5.0 |
Schema evolution attributes + version negotiation | planned |
0.6.0 |
Optimization pass + comparative benchmarks | planned |
0.7.0 |
Hardening, fuzz, API freeze | planned |
0.8.x → 0.9.x |
Alpha → Beta → RC | planned |
1.0.0 |
Wire-format + API freeze | planned |
The roadmap is followed strictly; phases are not skipped. Per-phase exit criteria are tracked internally and surfaced in each release note.
Installation
[]
= "0.3"
# With derive macro (planned for 0.4+):
= { = "0.3", = ["derive"] }
# no_std build:
= { = "0.3", = false }
API surface (v0.3.0)
The full Tier-1 / Tier-2 / Tier-3 surface is live, with the wire format frozen for the 1.x line. See docs/API.md for the complete reference and docs/WIRE_FORMAT.md for the normative byte-level spec.
Tier 1 — the lazy path
use ;
let bytes: = encode.unwrap;
let back: = decode.unwrap;
assert_eq!;
Tier 2a — the in-memory Encoder / Decoder
Re-use a single Vec<u8> across many encodes; read several values from one buffer. Configuration (Config::max_alloc) is validated at construction time, not on every operation.
use ;
let mut enc = new;
enc.write.unwrap;
enc.write.unwrap;
let bytes = enc.into_inner;
let cfg = new.with_max_alloc;
let mut dec = with_config.unwrap;
let n: u64 = dec.read.unwrap;
let s: String = dec.read.unwrap;
assert_eq!;
Tier 2b — the streaming IoEncoder<W> / IoDecoder<R> (new in v0.3)
Write directly into any std::io::Write, read from any std::io::Read. Gated on the default std feature.
use ;
use Cursor;
// Single-shot helpers (encode_into / decode_from) over any Read / Write:
let mut sink: = Vecnew;
encode_into.unwrap;
let back: = decode_from.unwrap;
assert_eq!;
// Or hold an encoder / decoder for multi-value streams:
let mut buf: = Vecnew;
let mut dec = new;
let a: u64 = dec.read.unwrap;
let b: u64 = dec.read.unwrap;
assert_eq!;
Tier 3 — implement [Serialize] / [Deserialize] on your own types
Both traits are generic over the new Encode / Decode behaviour traits, so one impl works through every encoder flavour the crate ships (in-memory and streaming).
use ;
The derive macro lands in 0.4.
Types supported in v0.3.0
| Group | Types |
|---|---|
| Unsigned integers | u8, u16, u32, u64, u128, usize |
| Signed integers | i8, i16, i32, i64, i128, isize |
| Floats | f32, f64 |
| Bool / unit | bool, () |
| Strings | String, &str (encode) |
| Sequences | Vec<T>, &[T] (encode), [T; N] |
| Tuples | arity 1 through 12 |
| Sums | Option<T>, Result<T, E> |
| Maps | BTreeMap<K, V>, HashMap<K, V> (std) |
| Sets | BTreeSet<T>, HashSet<T> (std) |
| References | &T where T: Serialize (encode) |
Canonical map / set encoding (the determinism contract)
Hash-based collections (HashMap, HashSet) are encoded with entries sorted lexicographically by their encoded key bytes. A HashMap and a BTreeMap holding the same logical data therefore encode to identical bytes, regardless of insertion order or build-flag-dependent hash randomisation. This is the load-bearing property for hashing, signing, and content-addressing pack-io payloads. Full normative spec: docs/WIRE_FORMAT.md §4.
Invariants (held from v0.1.0)
- Round-trip integrity —
decode(encode(v)) == vfor every supported type, under any input. - Determinism — the same value always produces the same bytes; no map-iteration-order leaks, no time-dependence, no platform-dependence.
- Safe decode — no panic, no unbounded allocation, no read past input, on any byte sequence.
- Wire-format stability — frozen at
1.0; any1.xdecoder reads any1.x-or-earlier encoding.
These invariants hold for every release in the 0.x series. As of 0.3.0 they are enforced by 177 tests: round-trip + determinism property tests for every primitive and every collection (including the load-bearing "HashMap and BTreeMap encode identically" property), plus adversarial-decode harnesses that fuzz every public decode entry point with random bytes, truncations, and hostile length prefixes. The wire format itself is frozen for the 1.x line as of this release. A cargo-fuzz harness lands in 0.7.
Testing
# Stable + MSRV (1.85) on Linux / macOS / Windows, full feature matrix
RUSTDOCFLAGS="-D warnings"
# Supply chain
# Concurrency model checking (decoders are stateless; loom coverage is light-touch)
RUSTFLAGS="--cfg loom"
# Microbenchmarks
Examples
Each example is self-contained and runs against the published API of the version it was added in.
Cross-Platform Support
Tier 1 Support:
- ✅ Linux (x86_64, aarch64)
- ✅ macOS (x86_64, Apple Silicon)
- ✅ Windows (x86_64)
Encoding is byte-deterministic across all three; the CI matrix runs every target on stable and MSRV. Platform-specific behaviour is forbidden in the codec — there is no #[cfg(target_os = …)] branch on the encode or decode path.
Where It Fits
pack-io is the serialization substrate under network-protocol, wire-codec, and Hive DB. It is consumed by raft-io for log entries and by event-stream (when it lands) for message framing. It stays foreign-compatible: it works on its own without any other crate in the family.
Contributing
Before opening a PR, the full local checklist must pass:
RUSTDOCFLAGS="-D warnings"
Any change touching the wire format requires a proptest round-trip and a determinism test in the same commit. Wire-format-breaking changes are not accepted after 0.3 without an accompanying migration note in CHANGELOG.md.