rustbinary-derive
rustbinary-derive is the procedural-macro package for
rustbinary. It generates checked,
allocation-free schema metadata and bit-level codecs from ordinary Rust
structs and enums.
This crate is intentionally small at runtime: it contains procedural macros,
not a second serialization engine. The generated implementation calls traits
owned by rustbinary, so wire behavior, resource limits, error types, and
configuration remain in one runtime crate.
The macro crate itself runs on the host with std, as procedural macros do.
Its generated code is no_std: it uses core syntax and RustBinary runtime
traits without emitting std, Vec, or String references. Runtime features
and the derive feature are additive and independent.
What It Provides
| Derive | Generated contract | Typical use |
|---|---|---|
Fingerprint |
rustbinary::Fingerprint |
Detect type and codec-profile drift |
StaticSize |
rustbinary::StaticSize |
Compile-time worst-case bounds |
Reflect |
rustbinary::Reflect |
Inspect static field and variant metadata |
BitPacked |
rustbinary::BitPack |
Pack bounded fields at bit granularity |
The macros do not implement serde::Serialize or serde::Deserialize.
Combine them with Serde derives when the ordinary binary, CBOR, compression,
encryption, or schema-evolution APIs are needed.
Installation
Most applications should depend on the runtime crate and use its re-exported macros:
[]
= { = "1", = ["derive"] }
= { = "0.1.3", = [
"derive",
"fingerprint",
"reflection",
"static-size",
"bit-packing",
] }
The feature names are independent. derive enables macro re-exports, while
fingerprint, reflection, static-size, and bit-packing enable their
runtime contracts. Application code normally writes
rustbinary::Fingerprint, rustbinary::StaticSize, rustbinary::Reflect,
and rustbinary::BitPacked.
Direct use of this package is also supported for macro ownership or build
tooling, but the runtime crate must still be present because generated paths
refer to ::rustbinary:
[]
= { = "0.1.3", = [
"fingerprint",
"reflection",
"static-size",
"bit-packing",
] }
= "0.1.3"
The path plus version dependency in the workspace is intentional. Local
workspace builds use the path; a published package resolves the same version
from crates.io. Publish rustbinary-derive before rustbinary.
Complete Example
The following type uses every derive provided by this package. It is a normal Serde value, has a compatibility fingerprint, exposes static metadata, and has a separate bit-packed representation for bounded flags.
use ;
use ;
Fingerprint
Fingerprint generates an implementation of:
The generated type fingerprint is a compile-time FNV-1a compatibility identifier. It incorporates:
- the module path and declared type name;
- struct, tuple, or enum shape;
- field names or tuple indexes;
- the
Fingerprint::TYPE_FINGERPRINTof every field type; - declaration order;
- enum variant names, indexes, and payload fields.
The configuration fingerprint additionally includes effective endianness,
integer encoding, trailing-byte policy, resource limits, and the active format
wrapper. Endian::Native therefore produces different identities on little-
and big-endian targets.
Use it to reject accidental schema or configuration drift:
let config = options.with_fingerprint;
let frame = config.serialize?;
let value: Header = config.deserialize?;
Changing a field name, type, order, enum variant, module path, or relevant configuration intentionally changes the identity. This is useful for cache keys and compatibility gates, but it is not cryptographic authentication. Do not use it as a signature, password hash, authorization decision, or tamper-detection mechanism. Use the encryption or signature layer for those properties.
Generic fingerprints
Every type parameter receives a rustbinary::Fingerprint bound. The bound is
required because the generated constant incorporates the parameter's type
identity:
use ;
let a = TYPE_FINGERPRINT;
let b = TYPE_FINGERPRINT;
assert_ne!;
If a generic parameter is intentionally opaque, use a concrete wrapper that
implements Fingerprint explicitly rather than weakening the generated
contract.
StaticSize
StaticSize generates three compile-time constants:
MAX_SIZE is a conservative upper bound for the ordinary binary profile.
PACKED_MAX_BITS is the maximum meaningful bit count for a BitPack layout,
and PACKED_MAX_SIZE is its byte ceiling. The generated arithmetic saturates
instead of wrapping on overflow.
The derive works for structs, tuple structs, unit structs, and enums. Every
field type must implement StaticSize; dynamic containers such as String
and Vec<T> intentionally do not implement it because they have no finite
type-only upper bound. Use an application limit for those values instead:
let config = options
.with_limit
.with_collection_limit;
An enum bound includes the largest variant and its normal representation tag. The bound is not a promise that every value occupies that many bytes.
Reflect
Reflect emits immutable constants with no global registry and no runtime
allocation:
TypeShape::Struct contains FieldInfo values. TypeShape::Enum contains
VariantInfo values, each with its fields. A field descriptor includes its
declared name (or tuple index), token-form type name, and declaration index.
match SHAPE
This is structural metadata, not Rust ABI reflection. It does not expose memory offsets, padding, private runtime state, serde rename rules, or a dynamic type registry. Type aliases and generic parameters are represented by their declared token spelling.
BitPacked
BitPacked generates rustbinary::BitPack for structs, tuple structs, unit
structs, and enums. Bits are written least-significant-bit first into a caller-
owned byte slice.
There are two field modes:
- A field with
#[bits = N]usesBitValue. The value is range-checked for the declared width on encode and decode. - A field without the attribute recursively uses
BitPackand itsMAX_BITSconstant.
Supported built-in BitValue types are bool, all signed integers, and all
unsigned integers. bool requires exactly one bit. Signed values use
two's-complement sign extension for the declared width. A width of zero or a
width greater than 128 is rejected during macro expansion.
let value = ControlWord ;
let config = options.with_bit_packing;
let bytes = config.serialize?;
let decoded: ControlWord = config.deserialize?;
assert_eq!;
The encoder clears the caller-owned output before writing. The decoder rejects non-zero padding bits and, when configured, trailing bytes. Unknown enum tags are rejected. Enum tags use the minimum number of bits required for the number of declared variants; adding or reordering variants is therefore a wire format change.
Nested packed values compose naturally:
Custom field types can implement BitPack or BitValue in the runtime crate.
The derive only selects the appropriate trait path; it does not guess a
custom type's representation.
Accepted Rust Shapes
All four derives support structs and enums unless stated otherwise. Unions are rejected with a span-aware compile error because their active field cannot be represented safely from type syntax alone.
| Shape | Fingerprint |
StaticSize |
Reflect |
BitPacked |
|---|---|---|---|---|
| Named struct | yes | yes | yes | yes |
| Tuple struct | yes | yes | yes | yes |
| Unit struct | yes | yes | yes | yes |
| Enum | yes | yes | yes | yes |
| Union | rejected | rejected | rejected | rejected |
Generic parameters must satisfy the trait required by the selected derive. Where clauses are preserved. Serde attributes remain Serde's concern and are not interpreted by these macros.
Diagnostics and Failure Cases
The macros fail at compile time with a syn diagnostic for:
- unions;
- empty enums passed to
BitPacked; - malformed
#[bits]syntax; - widths outside
1..=128; - a field whose selected trait bound is missing.
Runtime errors still apply to values and buffers. A valid #[bits = 3] u8
field containing 8 is a runtime BitPacking error, not a silent truncation.
An undersized caller buffer returns BufferTooSmall; malformed input,
non-zero padding, unknown tags, and rejected trailing bytes return typed
rustbinary::Error values.
Production Patterns
Separate compatibility and storage layouts
Use Fingerprint on the Serde model that crosses a compatibility boundary and
BitPacked on a compact flags type used inside a frame. Do not assume that a
bit-packed layout is compatible with the ordinary Serde layout.
Bound untrusted input
StaticSize is a compile-time bound for finite types, not a replacement for
runtime limits. At network or storage boundaries, always configure both byte
and collection limits before deserializing.
Keep fingerprints out of cryptographic policy
Fingerprints detect accidental schema drift. Encryption authenticates a frame; signatures authenticate an application-level statement. Keep those decisions separate so a compatibility identifier is never treated as proof of origin.
Use reflection for tooling, not decoding
Reflect::SHAPE is suitable for diagnostics, schema dashboards, generated
documentation, and protocol inspection. It does not dynamically decode an
unknown Rust type; decoding still requires a statically selected type.
Testing and Documentation
From the repository root:
cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo doc --workspace --all-features --no-deps
cargo package -p rustbinary-derive --allow-dirty --no-verify --list
The package is designed for docs.rs. Its generated paths intentionally use
::rustbinary; consumers should enable the corresponding runtime features.
The root repository contains executable examples in examples/metadata.rs
and examples/complete.rs that exercise the macros against the real runtime.
Versioning and Compatibility
Changing a field name, type, order, enum variant order, module path, or packed
width changes the generated contract. Treat those changes as schema changes,
record the package version and feature set, and retain golden vectors for
long-lived data. The macros do not provide automatic schema migration; use
the runtime schema-evolution feature for stable field IDs and migrations.
License
Licensed under the Apache License, Version 2.0. The complete license text is at the repository root. Redistributions must preserve the license and attribution notices.