COOLJAPAN
← All posts

OxiCode 0.2.6 Released — Nine Bytes Was All It Took to Loop Forever

OxiCode 0.2.6 hardens the serde bridge, closing an infinite-decode-loop bug and unbounded allocations 0.2.5 missed. A wire-format-relevant fix restores byte compatibility with bincode::serde (migration note included). Adds bounded-length decode entry points and decode contexts in the derive macros. 20,198 tests passing.

release oxicode bincode serialization serde security hardening pure-rust

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:

Technical Deep Dive: what changed under the hood

  1. The serde bridge stops being a side door. SeqAccess’s length bookkeeping moved off an in-band usize::MAX sentinel and onto an explicit Option<usize> (from_wire / from_schema / unbounded), closing the collision that produced the infinite loop. The Option, newtype-struct, seq, map, and enum descent paths in de.rs / de_borrowed.rs now route through the same recursion-depth guard Decode impls 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.
  2. Wire-format compatibility, restored on purpose. is_human_readable flipping to false across the serializer and both deserializers isn’t cosmetic — types like IpAddr genuinely branch on it, encoding as a length-prefixed ASCII string under the (wrong) true default versus a one-byte tag plus raw octets under false. The fix is also internally consistent: the human-readable branch is entitled to call deserialize_any, which this non-self-describing format rejects outright.
  3. Decode contexts, generic where you want them. Every built-in Decode / BorrowDecode impl — primitives, arrays, tuples, Option, Result, the core/alloc/std types, the atomics — is now generic over the decode context (impl<Context> Decode<Context> for T, previously only Decode<()>). 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 whose Decode<Ctx> impl actually reads from and writes into that context.
  4. Budgets that travel with the reader, not just the config. de::IoReader::with_limit / set_limit / remaining_limit and de::read::BufferedIoReader::with_limit back the new bounded entry points, and decode_from_file_with_config now derives its budget from the file’s own size automatically. Streaming chunk payloads materialize in bounded 64 KiB steps rather than one vec![0u8; payload_len] up front, and element containers pre-reserve at most 4096 elements instead of the full decoded count — a reader’s remaining_bytes is 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

Tips

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

↑ Back to all posts