automotive-wire-codec
The L0 foundation of a layered, no_std, no-alloc automotive diagnostic protocol
suite (DoIP, UDS, later SOME/IP). It provides the shared zero-copy codec traits and
big-endian byte-level leaf helpers that every protocol core (L1) implements — and
nothing else: no framing, no concrete message types, no owned forms, no alloc.
Features
- Zero-copy decode — [
Decode] borrows directly from the input buffer; no allocation, no intermediate copies. no_std/ no-alloc — builds on bare-metal targets (verified in CI againstthumbv6m-none-eabi).- Nested encode without a staging buffer — [
Encode::encoded_size] is exact and correct by construction (the default counts bytes through an infallible sink), so an outer protocol can size a header and serialize an inner value directly into the same buffer. - Generic, ergonomic errors — L1 crates keep their own rich error enum; leaf helpers
and trait defaults construct errors generically via small
Frombounds, so calls compose through?with no turbofish.
Error model
L0 defines no protocol error type. It defines two tiny error fragments —
[Incomplete] (a read ran out of bytes) and [TrailingBytes] (bytes remained after
an exact decode) — and the traits require the L1 error to be constructible From
them. This preserves each L1 crate's rich, typed error enum while letting shared
trait defaults and leaf helpers construct errors generically. Encode-side I/O
failures surface as [embedded_io::ErrorKind]; the [Encode] error bound is
From<embedded_io::ErrorKind>. Because the L1 error implements these From bounds,
helper calls (read_u8(buf)?, write_u16_be(w, x)?) compose through ? with no
turbofish and no generic error parameter at the call site.
The decode / decode_exact contract
[Decode::decode] consumes from the front of the buffer and returns the
remainder, so nested and sequential decodes thread the remainder along:
use ;
;
run.unwrap;
[Decode::decode_exact] instead requires the whole buffer to be consumed, returning
[TrailingBytes] otherwise — use it at a message boundary where framing has already
delimited the frame. L0 has no opinion on framing; that is an L1 concern.
Nested encode with no staging buffer
Because [Encode::encoded_size] is separate from [Encode::encode] and
&mut [u8] is an [embedded_io::Write] sink, an outer protocol serializes an inner
value directly into one buffer — no second allocation or copy:
use ;
;
let inner = Inner;
let mut tx_buf = ;
let payload_len = inner.encoded_size?;
let header = new;
let mut writer: &mut = &mut tx_buf; // one buffer
let mut total = header.encode?; // writes header, advances `writer`
total += inner.encode?; // writes inner into the remainder
assert_eq!;
Ok::
The closed-form encoded_size overrides matter here: the default
encoded_size counts by running encode against a counting sink, so sizing a
nested message with the default re-encodes each subtree once per level. Keep
closed-form overrides on types used for length-prefix pre-sizing.
Consumer idioms
Patterns every protocol crate on this codec ends up needing. They are conventions, not API — codified here so each consumer doesn't re-derive them.
Framing: decode a fixed header, re-slice the length-prefixed payload
A sans-io framer decodes the fixed-size header, then slices the payload out of the remainder using the header's length field:
let (header, rest) = Header::decode(buf)?; // fixed-size prefix
let payload_len = header.payload_length as usize;
let payload = rest.get(..payload_len) // delimit by declared length
.ok_or(Incomplete { needed: payload_len, available: rest.len() })?;
let remainder = &rest[payload_len..]; // start of the next frame
Dispatch: self-identifying vs externally-discriminated payloads
Decode deliberately is not a dispatch mechanism. Two standard shapes:
- Self-identifying (open set): the discriminant is the first byte(s) of the
buffer. Write an inherent
fn decode(buf) -> Result<Self, E>on the enum that reads the tag and delegates; unknown tags decode to a catch-all variant. - Externally discriminated: the tag lives in a sibling structure (e.g. a
header's payload-type field) and is stripped before the payload bytes are
seen. Write
fn decode(buf: &[u8], tag: PayloadType) -> Result<Self, E>— a trait method cannot express dispatch-by-external-tag, and should not try.
Validated views: validate once, then re-slice
A validated (L2) view over a lazy decode layer should not re-run fallible
decodes on every accessor. Construct-time: drain DecodeIter::iter() once,
surfacing the first error; cache counts/offsets. Accessors: re-slice the
already-validated bytes with purpose-built infallible iterators. The typed
Decode/DecodeIter layer is the validation pass, not the hot path.
Length prefixes: precompute, then one linear pass
When a length field precedes the bytes it measures, compute it from
encoded_size() before writing — sizes here are pure functions of the value,
so no backfill pass is needed (see the nested-encode example above).
Size-changing post-hoc transforms (encode, then rewrite bytes to a different
length — e.g. an E2E protect step) are deliberately out of scope for Encode;
model those as a consumer-owned two-phase API.
Why slice-first (no Read-based decode)
Decoding through a streaming Read cannot produce
Incomplete { needed, available } — a reader doesn't know available until it
has consumed the stream. Buffer first, then decode the slice.
Usage
Add the dependency:
Implement [Encode] and [Decode] for a message type using the big-endian leaf
helpers:
use ;
See the crate docs for the full API,
including the [DecodeIter] trait for repeated elements, the variable-width
[read_be_uint]/[read_be_uint_into] helpers, and
[Encode::encode_to_slice] for fixed-buffer encoding.
Migrating a protocol crate onto these traits? See MIGRATION.md.
no_std
This crate is no_std and does not require alloc. unsafe_code is forbidden
(#![forbid(unsafe_code)] at the workspace lint level). CI builds against a bare-metal
Cortex-M0 target (thumbv6m-none-eabi) to catch any std/alloc leaking in through a
dependency.
Minimum Supported Rust Version (MSRV)
The MSRV is tracked in Cargo.toml's rust-version field (currently 1.85) and enforced
in CI.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.