An OSC receiver bound to a UDP socket has to decode whatever arrives on it — and until this release, a single crafted datagram (16384 open brackets, or roughly 2000 nested bundles, both well within the 65536-byte limit OscReceiver::recv already accepts) could send that decode into unbounded recursion and take the whole process down with a stack overflow.
Today we released OxiSound 0.2.1 — the COOLJAPAN Pure-Rust audio device I/O layer. This release closes three denial-of-service bugs in oxisound-osc’s untrusted-UDP-datagram decode path, and ships oxisound-pulse — a brand-new crate, its first-ever release — a 100% Pure Rust backend that speaks the PulseAudio native IPC protocol directly over a unix socket, giving Linux an audio path with genuinely zero C in it.
No libpulse. No alsa-lib in the new backend’s path — that’s the whole point of it existing. oxisound-pulse runs under #![forbid(unsafe_code)] and talks to the PulseAudio (or PipeWire, transparently, through its pipewire-pulse compatibility service) socket in ordinary safe Rust. The rest of the OxiSound facade is unchanged: it still compiles to a single static binary and runs anywhere Rust does, with the OS-boundary backends isolated and auto-selected by target.
Why OxiSound 0.2.1 is a game changer
A “Pure Rust” audio crate that hasn’t been pressure-tested on a few specific fronts still has real gaps:
- The default Linux path —
oxisound-cpalthroughcpal— goes throughalsa-lib, a C library at the OS boundary. Fine under COOLJAPAN’s OS-boundary exemption, but it means the only way to get audio in or out on Linux with zero C anywhere in the chain didn’t exist yet. oxisound-oscdecodes bytes that arrive over a UDP socket from whoever chooses to send them. A prior string’s 4-byte alignment padding could advance the read cursor past the buffer end before the next read even started. Neither array nesting ([/]) nor bundle-within-bundle recursion had a depth cap, so a single 65536-byte datagram could build a tree tens of thousands of levels deep. Anddecode_bundle’s element-size arithmetic used a plainpos + sizeaddition that wraps around on a 32-bit target whensize— an attacker-controlled 4-byte length prefix — approachesu32::MAX.stream_stats()returnedNoneunless at least one counter was non-zero, so a perfectly healthy, freshly opened stream and “stats aren’t available” looked identical at the API.- There was no fuzz coverage at all on the untrusted-UDP-datagram decode path — the exact surface the three bugs above lived on.
OxiSound 0.2.1 ends all of that:
oxisound-pulse— first release. A 100% Pure-Rust PulseAudio native-protocol backend, framing via the pure-Rustpulseaudiocrate. It works unchanged against PipeWire too, throughpipewire-pulse. Opt-inpulsefeature, deliberately not indefault = ["pure"]. 58 tests, 17 doctests, all host-independent — the stub on non-Linux targets returnsUnsupportedinstead of failing to compile.read_str’s out-of-bounds read is fixed with an explicitstart > data.len()guard before slicing, returning a typedOscErrorinstead of panicking.- Nesting is capped. An explicit depth counter threaded through
decode_with_depth/decode_bundlerejects further nesting onceMAX_NESTING_DEPTH(32) is reached — enforced at construction time, before a tree deep enough to overflow a stack can ever exist. - The 32-bit wraparound is closed with
checked_addin a newelement_endhelper, returning a typed error instead of silently wrapping into a bounds check it can slip past. - A new
fuzz/workspace with twocargo-fuzztargets —oxisound_osc::decodeandoxisound_smf::parse— both ran 200k crash-free iterations during verification, plus aproptest-based OSC round-trip test. stream_stats()always returnsSomenow. The underlying trait method is infallible, so the API no longer conflates “no stats yet” with “all-zero stats.”
Technical Deep Dive: the Pulse wire path, three fixes in one file, and a new fuzz workspace
oxisound-pulse’s architecture. Apulseaudioclient owns one reactor thread per connection — the only thread that touches the socket. Stream handles exchange bytes with it over lock-free SPSC rings and never block on it directly. Playback is server-clocked: an empty ring parks astd::task::Wakeron the reactor side instead of injecting silence, and the nextwrite()wakes it — real back-pressure, no phantom latency on an idle stream. Four modules are host-independent and unit-tested everywhere, not just on Linux:env(socket/cookie discovery),format(PulseSampleFormat,f32⇄ wire-byte conversion),model(device mapping, buffer arithmetic), andtimeout(block_on_timeout, thePULSE_*_TIMEOUTdeadlines bounding every blocking control-plane round-trip). Only the thin protocol adapter itself is Linux-gated, via[target.'cfg(target_os = "linux")'.dependencies].- All three OSC fixes live in
crates/oxisound-osc/src/decode.rs, and share one root cause: untrusted-input arithmetic that assumed well-formed input. The alignment-padding bug assumed a prior string’s padding never overruns the buffer. The nesting bug assumed a well-behaved sender wouldn’t nest arbitrarily. The wraparound bug assumedpos + sizefits inusize— true on 64-bit, false on 32-bit oncesize(a raw 4-byte length prefix under full sender control) gets close tou32::MAX. All three are now guarded explicitly rather than relying on downstream code to happen to fail safely. - The fuzz workspace mirrors the
oxitext/oxih5pattern — its own top-level[workspace]table, not a member of the main one, socargo +nightly fuzz buildandcargo +nightly fuzz run <target>work standalone.osc_decodefuzzes exactly the untrusted-UDP-datagram path the three Security fixes above harden;smf_parsefuzzesoxisound_smf::parseagainst arbitrary.midbytes. - Workspace hygiene that quietly matters: the
oxiaudio/oxiaudio-corebump from 0.2.0 to 0.2.1 pulledcrossbeam-epoch 0.9.20and an un-yankedspin 0.12.2through the(dev) oxiaudio → oxifft → rayonchain, clearing an outstandingcargo deny check advisoriesfailure. A newdeny.tomlexception letsjack-systhrough specifically whenjack(the safe binding, itself confined to theoxisound-jackquarantine crate) is its sole dependent — the ban had been firing on a dependency the architecture already quarantines correctly.
Getting Started
cargo add oxisound
Decode OSC safely — the same entry point now rejects a malicious datagram with a typed error instead of overrunning a buffer or overflowing the stack:
use oxisound_osc::{OscArg, OscMessage, OscPacket, encode, decode};
let packet = OscPacket::Message(OscMessage {
address: "/synth/freq".to_string(),
args: vec![OscArg::Float(440.0)],
});
let bytes = encode(&packet);
let decoded = decode(&bytes)?;
assert_eq!(decoded, packet);
// A well-formed address ("/a") whose type-tag string requests 16384
// levels of array nesting -- the exact attack payload from the audit --
// is now rejected during construction, within the first MAX_NESTING_DEPTH
// (32) loop iterations, instead of overflowing the stack on the first
// later traversal (Drop, Debug, PartialEq, or `encode`) of a 16384-deep tree.
let mut hostile: Vec<u8> = b"/a\0\0".to_vec();
let mut type_tags: Vec<u8> = vec![b','];
type_tags.extend(std::iter::repeat_n(b'[', 16384));
type_tags.extend(std::iter::repeat_n(b']', 16384));
type_tags.push(0);
while type_tags.len() % 4 != 0 {
type_tags.push(0);
}
hostile.extend_from_slice(&type_tags);
assert!(decode(&hostile).is_err());
Open the new Pure-Rust PulseAudio/PipeWire backend — opt-in, so enable the feature first:
[dependencies]
oxisound = { version = "0.2.1", features = ["pulse"] }
// Enumerate PulseAudio/PipeWire endpoints (sinks -> is_output, sources -> is_input;
// a sink's monitor source is reported as an input named "<sink>.monitor").
let devices = oxisound::pulse_enumerate_devices()?;
// Playback on the server default, no libpulse, no alsa-lib.
let mut out = oxisound::pulse_output(oxisound::StreamConfig::stereo_48k())?;
Or drive oxisound-pulse directly, without the facade:
use oxisound_core::{AudioDevice, OutputStream, StreamConfig};
use oxisound_pulse::PulseDevice;
let device = PulseDevice::default_output()?;
let mut stream = device.open_output_concrete(StreamConfig::stereo_48k())?;
stream.write(&vec![0.0f32; 4_800 * 2])?; // 100 ms at 48 kHz stereo
stream.drain()?;
What’s New in 0.2.1
- Security: three fixes in
oxisound-osc’s decode path — theread_strout-of-bounds read, unbounded bundle/array nesting (now capped atMAX_NESTING_DEPTH = 32), and a 32-bit-target integer wraparound indecode_bundle’s element-size arithmetic (nowchecked_add). - Added:
oxisound-pulse— new crate, first release, a 100% Pure Rust PulseAudio/PipeWire native-protocol backend, opt-inpulsefeature; aproptest-based OSC round-trip test; a newfuzz/workspace withosc_decodeandsmf_parsecargo-fuzztargets; a regression test suite for thestream_stats()fix. - Changed:
stream_stats()now always returnsSomeinstead of conflating “no stats” with “all-zero stats”;oxiaudio/oxiaudio-corebumped 0.2.0 → 0.2.1 (also fixing an outstandingcargo deny check advisoriesfailure); new workspace-rootrustfmt.toml/clippy.toml; a scopeddeny.tomlexception forjack-sysreached only through the safejackwrapper. - Fixed (docs, not behavior): documented the pre-existing precondition that OSC strings/addresses must not contain an embedded NUL byte, with a permanent regression test demonstrating the silent-truncation hazard.
- 297 passed / 8 skipped under default features, 313 passed / 11 skipped with
--all-features, plus 127 doctests — measured 2026-08-06 on macOS/aarch64.oxisound-pulsealone contributes 58 of those tests and 17 of the doctests.
Tips
- The
pulsefeature is opt-in on purpose — enable it explicitly.oxisound = { version = "0.2.1", features = ["pulse"] }. It is not part ofdefault = ["pure"]; making it the Linux default in your own build is a decision this release deliberately leaves to you. MAX_NESTING_DEPTH = 32is a hard cap, not a knob. If you have a legitimate OSC schema that nests bundles or arrays deeper than that, redesign the schema rather than looking for a config option — the cap exists specifically so a crafted datagram can’t force unbounded recursion.- Point
cargo +nightly fuzz runat the new targets if you’re auditing untrusted input elsewhere in your stack.osc_decodeandsmf_parselive infuzz/fuzz_targets/, each already exercised for 200k crash-free iterations, and thefuzz/workspace is standalone (its own[workspace]table) so it builds without touching the main one. stream_stats()now always returnsSome— stop treating an all-zero snapshot as an error. A freshly opened, healthy stream that hasn’t processed a frame yet returnsSome(StreamStats::default()), notNone; check the individual counters instead of theOption.- If you deploy on a 32-bit target, this release matters more than usual. The
decode_bundlewraparound was only reachable whenusizeis 32 bits wide — invisible on the 64-bit hosts most development happens on, but a real out-of-bounds panic vector on 32-bit Linux/embedded targets that accept OSC over the network. oxisound-pulsecan be a hard dependency even on macOS/Windows. Thepulseaudiocrate is gated under[target.'cfg(target_os = "linux")'.dependencies]; everywhere else the crate compiles a Pure-Rust stub whose constructors returnOxiSoundError::Unsupported, so downstream code doesn’t need its own#[cfg(target_os = "linux")]boilerplate.
This is the foundation
OxiSound is the device I/O half of the COOLJAPAN audio story. Its sibling oxiaudio — which OxiSound itself depends on for the optional oxiaudio/type-bridge feature — handles audio processing, DSP, and codecs; OxiSound handles getting the samples to and from the hardware, now with a genuinely C-free path on Linux via oxisound-pulse. OxiSound is part of NoFFI, the COOLJAPAN initiative to replace every C/C++/Fortran/-sys FFI dependency in the Rust ecosystem with a clean, memory-safe, 100% Pure Rust implementation — this release moves the Linux audio backend itself one step closer to that line.
Repository: https://github.com/cool-japan/oxisound
Star the repo if you want your Linux audio path to have zero C in it and your UDP-facing decoder to reject hostile input instead of overflowing a stack.
The era of “Pure Rust audio, except for the Linux backend and the network-facing decoder” is over. Pure Rust audio device I/O — sovereign, safe, and FFI-free.
— KitaSan at COOLJAPAN OÜ August 6, 2026