A wire length of u64::MAX collided with an internal sentinel that meant “keep decoding forever” — nine bytes of crafted input, handed to OxiCode’s serde bridge, and the decode loop simply never ends.
Today we released OxiCode 0.2.6 — a follow-on hardening release to 0.2.5, concentrated in three places: the serde bridge (which runs its own decode path and had therefore missed several of 0.2.5’s protections), allocation bounds for length-prefixed and streaming input, and decode contexts — previously usable only from hand-written Decode impls, now reachable from #[derive(Decode)] too.
One change in this release is wire-format relevant. is_human_readable now returns false on the serde serializer and both deserializers, matching bincode 2.0.1’s default, and restoring byte compatibility with bincode::serde for any type that branches on it — IpAddr, uuid, chrono, and anything using the same idiom. Bytes written by OxiCode 0.2.5 or earlier for those specific types will not decode under 0.2.6, and vice versa; re-encode any persisted data containing them. Plain structs, enums, collections, and primitives are entirely unaffected — this is the one behavior change in 0.2.6 that touches valid, non-malicious input.
No C. No Fortran. No unaudited decode path — not even the compatibility shim.
OxiCode stays a pure-Rust binary codec that compiles to a single static binary (or WASM); 0.2.6 makes sure that guarantee actually covers the serde bridge, not just the native path underneath it.
Why OxiCode 0.2.6 is a game changer
Serialization crates that support serde as a convenience layer share a common blind spot: the native path gets hardened, and the serde bridge — which walks Visitor callbacks instead of Decode impls — quietly keeps its own, older behavior. OxiCode 0.2.5 hardened the native path thoroughly; 0.2.6 goes back and closes the same gaps in the bridge:
- The serde bridge finally gets the native path’s guardrails.
SeqAccessused the in-band sentinelusize::MAXto mean “length driven by the visitor, not the wire” — a wire length ofu64::MAXcollided with it, andnext_element_seedreturnedSome(..)forever. The sentinel is now an explicitOption<usize>with named constructors. Separately, the bridge’sOption, newtype-struct, seq, map, and enum descents are now wrapped in the same recursion-depth guard the native path already enforced — a craftedenum Tree { Leaf, Node(Box<Tree>) }payload used to recurse roughly one stack frame per input byte and abort the process; it now returns an error. - Collection lengths through serde are checked and budgeted like everywhere else. Lengths decode through a checked
usize::try_frominstead of a truncatingas usize, and sequences/maps claim their length against the configured decode limit before any element is decoded — the same accountingVec<T>::decodealready performed on the native path. is_human_readabletells the truth again, restoringbincode::serdebyte compatibility (see the migration note above).- Bounded decode entry points reach files and sockets, not just slices.
decode_from_buffered_read_limited/decode_from_std_read_limited(and the serde counterpartserde::decode_from_std_read_limited) take the real length of a payload — a file size, an HTTPContent-Length, a length-delimited frame — and reject a forged oversized length prefix withError::UnexpectedEndbefore allocating for it. - Decode contexts reach derived types.
#[oxicode(decode_context = "Ctx")]existed as an attribute before 0.2.6 but was dead weight: derive always emittedDecode<()>, sodecode_from_slice_with_contexthad nothing to call for a#[derive(Decode)]type. Now a derived struct can carry a hand-written context-using field, or opt itself into any context via#[oxicode(context_generic)]. - Two silent mis-decodes are now compile errors. Duplicate discriminants between decodable enum variants (the second was always unreachable and silently decoded as the first) and a
#[oxicode(skip)]variant whose field types don’t match the successor it aliases both fail to compile now, instead of corrupting data at runtime.
Technical Deep Dive: what changed under the hood
- The serde bridge stops being a side door.
SeqAccess’s length bookkeeping moved off an in-bandusize::MAXsentinel and onto an explicitOption<usize>(from_wire/from_schema/unbounded), closing the collision that produced the infinite loop. TheOption, newtype-struct, seq, map, and enum descent paths inde.rs/de_borrowed.rsnow route through the same recursion-depth guardDecodeimpls use, and collection lengths claim against the configured decode limit before elements are read — three separate protections the bridge had never inherited from the native decoder it wraps. - Wire-format compatibility, restored on purpose.
is_human_readableflipping tofalseacross the serializer and both deserializers isn’t cosmetic — types likeIpAddrgenuinely branch on it, encoding as a length-prefixed ASCII string under the (wrong)truedefault versus a one-byte tag plus raw octets underfalse. The fix is also internally consistent: the human-readable branch is entitled to calldeserialize_any, which this non-self-describing format rejects outright. - Decode contexts, generic where you want them. Every built-in
Decode/BorrowDecodeimpl — primitives, arrays, tuples,Option,Result, thecore/alloc/stdtypes, the atomics — is now generic over the decode context (impl<Context> Decode<Context> for T, previously onlyDecode<()>). That’s what makes#[oxicode(decode_context = "Ctx")]and#[oxicode(context_generic)]viable on derived types: a derived struct can mix ordinary fields (which resolve under any context) with a hand-written field whoseDecode<Ctx>impl actually reads from and writes into that context. - Budgets that travel with the reader, not just the config.
de::IoReader::with_limit/set_limit/remaining_limitandde::read::BufferedIoReader::with_limitback the new bounded entry points, anddecode_from_file_with_confignow derives its budget from the file’s own size automatically. Streaming chunk payloads materialize in bounded 64 KiB steps rather than onevec![0u8; payload_len]up front, and element containers pre-reserve at most 4096 elements instead of the full decoded count — a reader’sremaining_bytesis only an upper bound, so a generous budget alone can’t be trusted to gate a single large allocation.
Getting Started
cargo add oxicode
Hand a bounded decoder the real length of the payload — the pattern this release extends from slices to files and sockets:
use std::io::Cursor;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let bytes = oxicode::encode_to_vec(&"hello".to_string())?;
let len = bytes.len();
let decoded: String = oxicode::decode_from_buffered_read_limited(
Cursor::new(bytes),
oxicode::config::standard(),
len,
)?;
assert_eq!(decoded, "hello");
// A forged 16 EiB length prefix is refused without allocating.
let forged = [253u8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
let err = oxicode::decode_from_buffered_read_limited::<String, _>(
Cursor::new(forged),
oxicode::config::standard(),
forged.len(),
)
.unwrap_err();
assert!(matches!(err, oxicode::Error::UnexpectedEnd { .. }));
Ok(())
}
New in 0.2.6 — a derived type carrying a context-using field, something decode_from_slice_with_context could never actually reach before this release:
use oxicode::{config, Decode, Encode};
#[derive(Default)]
struct Tracker {
decoded_values: Vec<u64>,
}
// A hand-written type whose Decode impl records into the context as it reads.
struct Recorded(u64);
impl Encode for Recorded {
fn encode<E: oxicode::enc::Encoder>(&self, encoder: &mut E) -> Result<(), oxicode::Error> {
self.0.encode(encoder)
}
}
impl Decode<Tracker> for Recorded {
fn decode<D: oxicode::de::Decoder<Context = Tracker>>(
decoder: &mut D,
) -> Result<Self, oxicode::Error> {
let value = u64::decode(decoder)?;
decoder.context().decoded_values.push(value);
Ok(Recorded(value))
}
}
#[derive(Encode, Decode)]
#[oxicode(decode_context = "Tracker")]
struct Envelope {
id: u32,
payload: Recorded,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let bytes = oxicode::encode_to_vec(&Envelope { id: 7, payload: Recorded(4242) })?;
let (decoded, _read): (Envelope, usize) =
oxicode::decode_from_slice_with_context(&bytes, config::standard(), Tracker::default())?;
assert_eq!(decoded.payload.0, 4242);
Ok(())
}
What’s New in 0.2.6
- Added:
decode_from_buffered_read_limited,decode_from_std_read_limited, andserde::decode_from_std_read_limited;de::IoReader::with_limit/set_limit/remaining_limitandde::read::BufferedIoReader::with_limit;de::Reader::remaining_bytesandde::Decoder::remaining_reader_bytes; context-aware entry pointsborrow_decode_from_slice_with_context,decode_from_std_read_with_context,decode_from_de_reader_with_context;#[oxicode(decode_context = "Ctx")],#[oxicode(borrow_decode_context = "Ctx")],#[oxicode(decode_context_generic)],#[oxicode(borrow_decode_context_generic)],#[oxicode(context_generic)]; compile-time rejection of duplicate enum discriminants and mismatched skip-variant field types in#[derive(Encode)]; a newfuzz_serdefuzz target covering the serde bridge end to end - Changed: every built-in
Decode/BorrowDecodeimpl is now generic over the decode context instead of pinned toDecode<()>;decode_from_file_with_configderives its allocation budget from the file’s own size; streaming chunk payloads materialize in bounded 64 KiB steps instead of one up-front allocation; element containers pre-reserve at most 4096 elements rather than the full claimed count, with byte buffers going through a shared bounded reader (16 MiB first allocation, 16 KiB growth steps); derive-generated code now resolvesVecand friends throughoxicode::__privateinstead of the invoking crate’s prelude, fixing#![no_std] + alloccall sites;AsyncStreamingEncoder::finishis now documented as not cancellation-safe - Fixed:
is_human_readablenow returnsfalseon the serde serializer/deserializers, restoringbincode::serdebyte compatibility forIpAddr/uuid/chrono-style types (wire-format relevant — see migration note above); the serde bridge’s infinite decode loop from a collidingusize::MAXsentinel; an uncatchable stack overflow on deeply nested serde input; uncheckedas usizelength casts and unbudgeted containers in the serde bridge; undefined behavior inAlignedVec<T>for a zero-sizedT; streaming decoders now poison on item-level errors, not only chunk-level ones; a forged chunkitem_countexceedingpayload_lenis now rejected up front;#[oxicode(bytes)]derive fields no longer reserve their claimed length before validating it - Dependencies:
oxiarc-lz4/oxiarc-zstd0.4.0 → 0.4.1
Tips
- Feed a bounded decoder the real payload length whenever you have it. A file size, an HTTP
Content-Length, a length-delimited frame —decode_from_buffered_read_limited(ordecode_from_std_read_limited, orserde::decode_from_std_read_limitedfor the serde path) turns that into a hard allocation bound, rejecting a forged oversized length prefix withError::UnexpectedEndbefore it costs you any memory. - Re-encode any persisted data containing
IpAddr,uuid, orchronotypes encoded through the serde bridge before 0.2.6. Theis_human_readablefix changes their wire bytes; everything else — plain structs, enums, collections, primitives — round-trips unchanged. - Give a derived type a context with
#[oxicode(decode_context = "Ctx")]when it needs to carry a hand-written field whoseDecode<Ctx>impl reads from — or writes into — shared state (an arena, an interner, a resource table). Use#[oxicode(context_generic)]instead if the type itself should decode under any context. - Tighten
IoReader’s budget mid-stream withset_limit, or checkremaining_limit()before deciding how much more to trust a peer for — useful for length-delimited protocols where the budget for the next frame isn’t known until the current one finishes. - Duplicate enum discriminants and type-mismatched skip variants are now compile errors, not runtime data corruption. If your build breaks after upgrading, the derive macro just caught a real bug your tests hadn’t found yet.
- Async streaming cancellation is safe everywhere except
finish.AsyncStreamingEncoder::finishtakesselfby value, so dropping it mid-.awaitdrops the buffered chunk data and the unwrittenEndmarker — drive it to completion rather than racing it in aselect!.
This is the foundation
OxiCode is the serialization layer beneath the COOLJAPAN data stack — checkpoints for SciRS2 and NumRS2, tensor buffers for ToRSh, UI state serialization for OxiUI, spatial data for OxiGeo, quantum circuit payloads for QuantRS2, and archive-embedded records via OxiARC. A hardening pass that reaches the serde bridge lands underneath every one of those consumers that touches serde-compatible types, without any of them having to change a line of their own code.
Repository: https://github.com/cool-japan/oxicode
Star the repo if you want a binary codec whose compatibility shim gets audited as seriously as its native path.
The era of “trust the length byte” is over. Pure Rust binary serialization is here — fast, compatible, and sovereign.
— KitaSan at COOLJAPAN OÜ August 6, 2026