Part 03 · Moving bzip2 to safe code · study · Aug 25, 2026 · 12 min read
A contract for migration
Part 02 said: write the requirements down. This is what “written down” actually means — four clauses, the exact unsafe they force, and the one verdict a safety score can never give you.
“With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.”
What part 02 left on the table
The last study ended on a promise. I argued that “100% safe” is not an absolute — it’s safe relative to a
set of requirements — and that a port can hit a perfect safety number by quietly dropping one of them. The
zero-unsafe bzip2 codec reached zero partly by owning its own memory instead of the caller’s: byte-for-byte
identical output, and a different product than the drop-in you asked for. I called that a contract violation
and said it should be labeled one, cleanly, instead of celebrated on a dashboard.
That leaves an obvious, almost embarrassing next move. If “safe” only means something against a written contract — write the contract down. Not in a README paragraph a reviewer skims, but as an explicit object the process can reason about before a single line moves. This study is that object, and the one useful thing that falls out of it for free.
A senior engineer at Google put the objection to me in the open, in the comments on part 02: if you’re writing
bzip2 you’re also writing the API, so if the requirement isn’t a written contract, that’s a bug and bugs
follow. He’s right on the narrow point, and I’ll concede it in the first paragraph: bzip2 does document
bzalloc/bzfree. Where a requirement is written down, ignoring it is simply a bug.
But the gap he’s pointing at is the whole reason this series exists. “If you’re writing bzip2 you’re also writing the API” is true when you author the thing and false when you replace it. In a migration you inherit the API plus every behavior callers came to depend on that nobody ever wrote down — the aliasing profile, when allocation happens, the exact layout. That’s Hyrum’s law with a thirty-year installed base: the contract is discovered, not authored. Which is exactly why it has to become a checkable object rather than prose — because half of it was never prose to begin with.
Four clauses
Here is the whole reveal, and it is not a tool so much as a discipline. Before moving any code, say out loud
what “faithful” means for this migration. For a libbz2 drop-in, four clauses do most of the work:
contract:
abi: c # expose the C ABI — FFI pointer marshaling is unavoidable
allocator: caller # route working memory through the caller's allocator, not Rust's
memory_fidelity: reuse # preserve the layout — overlapping buffers stay aliased
toolchain: stable # stable Rust only — no nightly-only escape hatches
Nothing here is exotic. Every one of these was an implicit human decision on every C→Rust port ever attempted — somebody chose, usually without noticing they were choosing. All the contract does is promote those four decisions to a first-class input the process reasons about. That promotion is the entire trick, because once the clauses are written down, something useful precipitates out of them mechanically.
The floor: the unsafe you’re forced to keep
Given the contract, you can compute — before writing any code — the irreducible unsafe floor: the minimum
unsafe those four clauses force, and, just as important, what they don’t. The rules are nearly mechanical:
abi: c→ you must accept raw caller pointers and marshal them across the boundary. Forced. Intrinsic to exposing a C ABI at all.allocator: caller+toolchain: stable→ you must hold memory that came from someone else’s allocator, and on stable Rust there is no safe type that owns foreign memory. So: a raw buffer view. Forced. (Give uptoolchain: stableandallocator_apimight one day soften this — which is precisely why the toolchain is a clause and not an afterthought.)memory_fidelity: reuse→ if the original aliased one buffer three ways, a faithful port keeps it aliased; de-aliasing into separate owned buffers changes the memory profile the caller relied on. So: overlapping views. Forced.- Everything else — a stray
unsafethat none of these clauses explains — is not on the floor.
That last bullet is the one I care about most.
Above the floor is a defect, not a tax
The floor cleanly splits every unsafe in the port into two piles. Forced by a clause — keep it, and (as
part 02 showed) prove it sound. Not forced by any clause — that’s not a tax you pay for talking to C; it’s a
quality defect you failed to remove. Fix it.
“How much unsafe does this port have?” was always the wrong question, because it scores those two piles with
the same number. A boundary reborrow the ABI requires and a lazy pointer cast nobody asked for both read as
“+1 unsafe” on a dashboard, and the dashboard can’t tell you which is which. The right question is the one the
floor makes askable:
Is every
unsafein this port on the floor — forced by a clause you can name — or is one of them just a line I failed to remove?
That question has an answer. “How much unsafe is left” only has a vibe.
Three verdicts a safety score can’t give you
Watch what the honest claim becomes once the floor exists. Not “zero unsafe” — which, under a caller-allocator contract, is a lie or a dropped clause. Instead:
Exactly the
unsafethese four requirements force — each line traced to the clause that demands it — and nothing else.
That’s checkable, and checking it produces one of three verdicts, where a safety score only ever produces a number:
- conformant-optimal — every
unsafesits on the floor, each traced to its clause. The port is faithful and clean. This is the target. - conformant-needs-review — the required clauses are honored, but there’s an
unsafesite the check couldn’t attribute to any clause. Not a failure; a flag. Send a human to look — it’s either a defect to remove or a floor rule the model didn’t know to write. - contract-violated — a required class is missing entirely. The contract said
allocator: callerand the port owns its ownVecs. That is not a safer bzip2. It’s a different product, and now a checkable one.
That third verdict is the one nobody hands you today, and it is the entire point of writing the contract down.
It turns “did we honor the hard requirements” from a thing you assert into a property you can test. You can
watch both ends of it land in the two public bzip2 ports:
libbzip2-contract-honored-rs sits on its floor —
the forced boundary unsafe, each line traced, and nothing else — while
libbzip2-rs, the zero-unsafe codec, for all that it’s
byte-identical to bzip2 1.0.8, comes back contract-violated, because it reached zero by dropping the
caller’s allocator. Same bytes out. Different product. And now the difference has a verdict instead of an
argument.
The honest edges
Two, stated plainly, because I’d rather you hear them from me than find them.
The floor rules are a model, not a proof. They encode how these four requirements force unsafe on
today’s stable Rust. If the contract is wrong, the verdict is wrong — garbage contract in, garbage floor out.
The discipline makes the requirement checkable; it does not make it correct. Writing the right clauses is
still a human judgment, and I’d rather be honest that the method only mechanizes the step after that one.
Real code carries residue. A genuine slice of a real library will have unsafe that no clean clause
explains and no honest scan can attribute — the conformant-needs-review pile is not empty in practice. The
right move is to flag that residue for a human, never to launder it into a pass. On this drop-in the floor is
fully attributed today; on other libraries it won’t always be, and a later study in this series is entirely
about reading that residue instead of hiding it.
The Trifecta Tech Foundation’s libbz2 replacement is
the instructive case here precisely because it doesn’t chase zero: it keeps the boundary unsafe a real
libbz2 replacement must, and no more. That’s a port sitting on its floor — careful, end-to-end work by a real
team, and the standard this points toward. The contract doesn’t improve on that instinct; it just makes the
target they hit by craft into something the rest of us can state explicitly and check.
For allies, and for critics
Two things you can do with this. Diff the two ports against the floor. Clone both, put the zero-unsafe
port’s allocation site beside the contract-honored one beside the C, and watch the allocator: caller clause
appear and disappear — one honors it with a bounded unsafe, one deletes it and reports zero. The verdict isn’t
rhetoric; it’s a line you can point at.
And if you maintain a library, write your contract’s hardest clause down. 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 requirements was I never allowed
to drop — and writing them down first is the only way “did the port honor them?” ever becomes a question a
machine can answer instead of a judgment you defend after the fact.
The next study hits the first wall we found trying to honor a contract — where bzip2’s shared state is one
buffer viewed three ways, and a naive port (plus, embarrassingly, our own tooling) fought the
memory_fidelity: reuse clause instead of keeping it. The bug wasn’t the model. It was the pipeline fighting
the requirement.
Here’s my question for you, and I’m building the vocabulary in the open: what hard requirement would you put in the contract for a library you maintain — the clause that, if a rewrite quietly dropped it, would make the result a different product? Tell me the clause.
See both verdicts yourself — both ports are public and diffable, module-for-module: libbzip2-contract-honored-rs (sits on its floor) · libbzip2-rs (zero-unsafe, contract-violated). contact@kaizen-3c.dev.