Part 02 · Moving bzip2 to safe code · study · Aug 11, 2026 · 13 min read
The 0-unsafe trap: when zero means you changed the spec
Two ports of the same bzip2, byte-for-byte identical, both safe — and only one is the drop-in you asked for. What the difference is made of, and how to prove the unsafe you keep is sound.
“There are two ways of constructing a software design: one is to make it so simple that there are obviously no deficiencies, the other is to make it so complicated that there are no obvious deficiencies.”
Two ports, one question
Here are two Rust ports of the same bzip2 function. Both compress and decompress your data and hand you back
exactly the bytes you started with — proven, byte-for-byte, by the same differential harness. One keeps a small
amount of unsafe. The other is #![forbid(unsafe_code)] — zero unsafe, top to bottom.
Which is the better migration?
The reflex answer is the zero-unsafe one. Zero is a nice number; a dashboard loves it. But “how much unsafe”
is the wrong question asked with confidence, and this study is about the right one — because on this particular
function, the zero-unsafe port is safe the way a car with the engine removed is safe. Yes. And also it’s not
the thing.
What a drop-in actually owes the caller
A drop-in replacement isn’t defined by “same output.” It’s defined by same contract — everything a caller was allowed to rely on. bzip2’s C API writes three of those requirements down in plain sight.
1. The caller’s allocator. BZ2_bzCompressInit takes two function pointers and expects the library to route
all its working memory through them:
typedef struct {
/* ... */
void *(*bzalloc)(void *, int, int); /* the caller's allocator */
void (*bzfree )(void *, void *); /* the caller's free */
void *opaque; /* the caller's context */
} bz_stream;
This is not a vestige of old C. A caller might be an embedded arena, a custom pool, a language runtime with its own heap and its own idea of where bytes may live. “Use my allocator” is a promise the library made.
2. The memory layout / aliasing. bzip2’s sorting core allocates one block and views it through several pointers at once — the same bytes as words here, as raw bytes there, as an offset tail elsewhere. Those views alias on purpose; the algorithm is built on it.
3. The C ABI. The exported symbols, the #[repr(C)] struct, the calling convention — so an existing C
program can link the new library without recompiling the world.
How the zero-unsafe port gets to zero
Give a well-meaning translator “port this, and get unsafe to zero,” and watch what it does with requirement #1.
There is no safe way, on stable Rust, to own memory that came from someone else’s allocator — a raw pointer
into caller memory is exactly the case unsafe exists for. So to reach zero, the port stops using the caller’s
allocator and allocates its own:
// The zero-unsafe port — owns its memory. Look, no unsafe!
let mut buf: Vec<u8> = vec![0; n]; // Rust's global allocator, NOT the caller's
// The contract-honored port — honors the allocator the caller handed us. One small, bounded unsafe.
let raw = unsafe { (strm.bzalloc)(strm.opaque, 1, n as i32) }; // the caller's function pointer
let buf = unsafe { core::slice::from_raw_parts_mut(raw as *mut u8, n) };
The first compiles, looks idiomatic, and reports zero unsafe. It also silently deleted requirement #1. The
embedded caller with the custom arena now has bzip2 quietly calling the global allocator behind its back. The
number went to zero by changing the spec.
Both ports are published and diffable, module-for-module:
libbzip2-rs is the owned-Vec one — the zero-unsafe codec you’d
reach for when the requirements genuinely allow it — and
libbzip2-contract-honored-rs keeps the caller’s
allocator, the aliased layout, and the ABI, retaining only the bounded unsafe that forces. Run make check
against either and it’s byte-for-byte identical to bzip2 1.0.8. Duck typing in the wild: both walk and quack
like bzip2 from the outside; only one is the drop-in you actually asked for.
The number you want isn’t zero
Here’s the uncomfortable idea the whole series turns on: “100% safe” is not absolute — it’s safe relative to a
set of requirements. If you never wrote the requirements down, “safe” can always be bought by quietly dropping
one of them. Own the memory instead of borrowing it. Copy instead of alias. Widen a type here, drop a callback
there. Each move deletes an unsafe and, sometimes, deletes the point.
So the number you want isn’t zero. It’s exactly the unsafe the requirements force, and not one line more —
and then a proof that what you kept is sound. Notice what the people who know bzip2 best did for the drop-in:
the Trifecta Tech Foundation’s libbz2 replacement keeps
precisely the boundary unsafe a real drop-in must, contained and deliberate — a genuine piece of engineering
and the standard this points toward. They refused to change the spec to hit a number.
Proving the unsafe you keep
Keeping unsafe is only honest if you can show it’s sound — that the retained boundary code has no undefined
behavior, not just that it produces the right bytes today. Byte-identity can’t see UB: a program can round-trip
perfectly and still be one optimizer pass away from disaster.
So the contract-honored port carries a second proof next to the differential. make miri runs the retained
unsafe under Miri over a path-covering corpus, under both aliasing
models — Stacked Borrows and the stricter Tree Borrows. That pass was not a formality. It caught four real
latent UBs that make check was completely blind to, each fixed output-preservingly (the details are in the
repo’s SOUNDNESS-CERT.md):
- a self-referential reborrow — taking
&mutto a struct that a raw pointer inside it still aliased; fixed by funnelling the boundary through a single raw-pointer discipline at the call site. - an overlapping view — a byte-slice and a word-slice over the same allocation held live at once; fixed by re-deriving each view at point of use rather than holding both.
- two reads of uninitialized memory in scratch tables; fixed by a zeroing allocation at the boundary.
None of those changed a single output byte. All of them were genuine undefined behavior. That’s the point of a soundness receipt: it proves something a byte-for-byte test cannot — and it re-runs on your machine.
The honest edge: single-block
Here is the caveat I’d rather state than have you find. The contract-honored drop-in is proven byte-identical and Miri-sound over single-block inputs — the envelope the harness covers. It is not yet proven over multi-block streams (larger than ~100 KB at low compression levels, where bzip2 splits the input into multiple blocks). That’s a real boundary, the repo says so in its own README, and it’s the current edge of the work — not a footnote I’m hoping you skip. A contract you honor over one regime and haven’t proven over another is exactly the kind of thing this whole series exists to label honestly.
Write the contract down first
The fix is boring and, I think, correct: capture the requirements as an explicit contract before moving any code — the C ABI, whose allocator, whether the memory layout must be preserved, stable toolchain or not. Once it’s written down, you can ask a question that “is it safe?” can’t answer:
Did this port honor the requirements, or did it hit its safety number by dropping one?
That question is checkable, and it has a clean verdict. A port that keeps its own Vecs under a “use the
caller’s allocator” contract isn’t a safer bzip2 — it’s a contract violation, and it should be labeled one,
cleanly, instead of celebrated on a dashboard. The zero-unsafe codec isn’t a failure; it’s a perfectly good
answer to a different contract (one where owning memory is allowed). The trap is only when it’s scored as if
it answered yours.
For allies, and for critics
Two things you can do with this. Clone both ports and diff them module-for-module — put the zero-unsafe
decompress.rs beside the contract-honored one beside the C, and watch the allocator requirement appear and
disappear. And if you build FFI-boundary Rust, make miri under Tree Borrows is worth stealing regardless of
what you think of bzip2: it finds the aliasing sins that pass every functional test you have.
The received wisdom is that moving C to Rust is a transliteration problem — get the syntax across, chase the
unsafe to zero. That skips the actual question. The real one isn’t how do I remove the unsafe. It’s which
unsafe was I never allowed to remove — and can I prove I kept only that, and that it’s sound.
The next study writes the contract down as an explicit object — the allocator, aliasing, and ABI clauses — so “did you honor it?” stops being a judgment call and becomes something a machine can check.
Diff the two ports yourself — both public, both built to be picked apart: libbzip2-rs (the zero-unsafe codec) · libbzip2-contract-honored-rs (the contract-honored drop-in, Miri-sound over its single-block envelope). contact@kaizen-3c.dev.