Porting QOI from C to Rust: proving the port actually holds up
For Port Mortem I picked a small target: quite-ok-image-format, a ~280-line
C99 reference implementation of QOI (Quite OK Image Format), a lossless
image codec built to be dramatically faster than PNG. Small codebase,
clear input/output contract, no external dependencies. Should be a clean
weekend port.
It wasn't clean. It was much more interesting than clean.
What I picked, and why
QOI's whole appeal is simplicity — read raw pixels, emit one of four op
codes per pixel (a cached-color index, a small delta, a bigger delta, or a
raw literal), repeat. That simplicity is exactly what makes it a good
test of whether a port is honest: there's nowhere to hide. Either your
Rust port emits the identical byte for the identical pixel, or it doesn't.
How I proved behavioral equivalence
Compiling was the easy part (once I patched a Windows-only header issue
and a genuine C syntax bug in the original — more on bugs below). The real
work was proving the Rust port didn't just look right.
I built a differential test harness that runs both binaries — C and Rust —
against the same input and diffs their output byte-for-byte, for:
- Encode: does
.qokoutput match exactly? - Decode: does round-tripping back to
.ppmmatch exactly? - Cross-decode: does C decoding Rust's output match Rust decoding its own output, and vice versa?
I ran this across nine deliberately different images — solid color runs,
gradients, random noise, a hand-engineered hash collision (two different
colors forced to hash to the same internal cache slot), and a 130-pixel
run that needs to split across three separate run-length chunks. Then I
added a randomized differential fuzzer generating fresh images every
iteration, checking the two implementations agree with each other, run
continuously for 60+ seconds.
Result: byte-for-byte identical encode output, on every image, every time.
The edge case that ate six hours
Everything passed on small hand-written test images. Then I threw a
64x64 image at it — bigger, more varied — and the C and Rust outputs
diverged. No crash. No error. Just different bytes, starting a little
way into the file.
I spent a long stretch assuming I'd mis-ported some arithmetic — re-deriving
the diff/luma op math by hand, checking bit-packing order, convinced I'd
gotten a sign wrong somewhere. Nothing lined up.
The actual bug was upstream of all of it, in a single line I'd barely
looked at:
fscanf(f, "P6 %d %d 255\n", &img->width, &img->height);
That trailing \n in a scanf format string doesn't mean "match one
newline." Per the C standard, it means "consume any and all following
whitespace." My 64x64 test image happened to start with a block of pixels
valued (10, 10, 10) — and 10 is the same byte as ASCII newline. fscanf
was silently eating real pixel data, thinking it was whitespace, and
shifting every subsequent read out of alignment.
I only found it by writing a tiny standalone C program — not touching the
actual reference file — that printed exactly what the header parser
consumed. First five bytes it read after the header: not what I expected
at all. That's when it clicked.
The fix wasn't to "correct" the C reference. It was to make the Rust port
consume whitespace the same greedy way fscanf does, bug and all —
because the goal was proving equivalence with what the original actually
does, not shipping a quietly-improved version that no longer matches it.
Bugs found along the way
Five in total, all documented with rationale in DECISIONS.md:
-
Missing
byteswap.h— Linux-only header, doesn't exist on Windows. Build-environment fix, not a behavioral one. -
Invalid C: a
switchdefault:label directly followed by a variable declaration — not legal C. Needed a brace to fix. - Silent data loss: the encoder drops the final pixel of an image under certain conditions. Real bug. Left unfixed in the reference, deliberately reproduced in the Rust port.
-
Windows text-mode corruption:
fopen(f, "w")on Windows silently rewrites any pixel byte valued 10 into a two-byte CRLF sequence, corrupting binary image data. Fixed — this one's a portability bug, not intended behavior worth preserving. -
The
fscanfwhitespace quirk described above. Reproduced, not fixed.
The decision I'd take back
I spent too long debugging bug #5 by staring at the Rust and C source side
by side, convinced the answer was in the arithmetic, before writing a
proper isolated diagnostic tool. The diagnostic took fifteen minutes to
write and found the bug in one run. I probably burned three or four hours
before reaching for it. Next time: the instant a bug survives one round of
"read the code harder," stop reading and start printing actual runtime
values. Isolate before you theorize.
What "porting" actually means here
The biggest mindset shift for me: a port isn't done when it compiles, and
it isn't even done when it "looks equivalent" on a couple of test images.
It's done when you've actively tried to make it disagree with the
original and failed — repeatedly, on inputs you didn't hand-pick to be
easy. Bugs #3 and #5 could have been quietly "fixed" in the Rust version
and nobody reviewing a diff would have noticed anything wrong. That's
exactly the kind of silent, confident-looking divergence Port Mortem is
designed to catch.
Repo: https://github.com/Pravin881/quite-ok-image-format
Built for Port Mortem, Hackathon Raptors — Track A (C to Rust).
Top comments (0)