The PortMortem hackathon asked participants to choose a track and then within the track pick a library and port it — cleanly, correctly, and provably. I picked QOI (the "Quite OK Image Format" by phoboslab), a fast lossless image codec in a single 649-line header file.
QOI looked deceptively simple. It wasn't.
Why QOI
Most libraries have layers. QOI is a single header: qoi.h. One encoder, one decoder, ~650 lines, no dependencies. That sounds easy to port.
What it actually means is there's nowhere to hide. Every line has to be correct. Every edge case in the C has to be made explicit in Rust. That's the whole game.
Final numbers: 0 unsafe blocks, 0 core library dependencies, byte-for-byte identical output to the C reference, 22 integration tests, 27,966,810 fuzz iterations — zero divergences.
The Decision That Mattered Most: Ditching the Union
The central type in QOI's C source is this:
typedef union {
struct { unsigned char r, g, b, a; } rgba;
unsigned int v;
} qoi_rgba_t;
The v field is load-bearing. It lets the encoder check px.v == px_prev.v — one 32-bit integer compare to detect if all four channels are unchanged. Clean, clever, and completely unsafe in Rust if you try to replicate it literally.
My translation:
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub(crate) struct Pixel { pub r: u8, pub g: u8, pub b: u8, pub a: u8 }
#[derive(PartialEq)] generates a four-field comparison. At -O2, LLVM folds that into a single 32-bit integer compare — the exact same machine code as px.v == px_prev.v. The compiler does the work a human would otherwise do unsafely. Zero unsafe needed.
That was the cleanest decision in the whole port.
The Edge Case That Will Eat You Alive
The decoder maintains a 64-slot running index array of recently-seen pixels. When a new chunk is decoded, the index is updated. When a RUN chunk is processed (repeating the previous pixel N times), the index is not updated.
In C:
else if (p < chunks_len) {
// decode chunk...
index[QOI_COLOR_HASH(px) % 64] = px; // ← INSIDE this block
}
This seems obvious when you read it slowly. At 2am with a test that's almost passing, it's invisible. Placing that index update outside the else if — so it also runs during run-length repetitions — will produce output that looks correct on simple images and breaks on anything that mixes RUN and INDEX chunks.
I got this right on the first pass only because I was reading the C spec annotation carefully. The subtlety is documented as Decision #7 in my DECISIONS.md, but naming it doesn't convey how easy it is to get wrong. This is the one I'd warn every QOI porter about.
What Actually Broke: The Fuzz Harness
My plan was to use cargo-fuzz with libfuzzer-sys — the obvious Rust equivalent of the original qoifuzz.c. The harness compiled fine. Then:
STATUS_DLL_NOT_FOUND (exit code: 0xc0000135)
libFuzzer on Windows (MINGW64) requires runtime DLLs — vcruntime140.dll etc. — that simply aren't present in the Git Bash environment. cargo-fuzz is effectively Linux-only outside of WSL or a full MSVC setup.
I replaced it with a standalone binary fuzzer using an xorshift64 PRNG:
struct Xorshift64(u64);
// seeded from SystemTime
// ~470,000 iterations/second
The fuzzer mirrors qoifuzz.c's invariants exactly:
- First 4 bytes select
channels(0, 3, or 4) - Remaining bytes are the payload passed to
decode() - A successful decode must roundtrip:
decode → encode → decode → same pixels
Result: 27,966,810 decode iterations and 2,296,853 roundtrip iterations in 60 seconds — zero panics, zero divergences.
The tradeoff is real: PRNG fuzzing has no coverage-guided corpus evolution. What it has is throughput — roughly 12× more iterations per second than a typical libFuzzer run. For a 60-second run, that's not nothing.
Wrapping Arithmetic: The One You Can't Skip
QOI's diff encoding does this in C:
signed char vr = px.rgba.r - px_prev.rgba.r;
px.rgba.r += ((b1 >> 4) & 0x03) - 2;
C's unsigned char arithmetic wraps silently. Rust doesn't — in debug mode, it panics. In release mode it wraps, but invisibly.
The fix isn't hard, but it has to be intentional:
let vr = (px.r.wrapping_sub(px_prev.r)) as i8;
px.r = px.r.wrapping_add(dr as u8);
Casting i8(-2) to u8 gives 254. wrapping_add(254u8) is subtracting 2 modulo 256. Same two's-complement behavior as C, made visible in the source. A future reader — or a security auditor — can verify the arithmetic without knowing C's implicit conversion rules.
The Decision I'd Take Back
The module split into types.rs, encode.rs, decode.rs, io.rs was the right call for readability. But I put all 22 integration tests in a single tests/integration_test.rs file.
That file is long. It works, it's comprehensive, but it should have been split by module the same way the source is. tests/encode_test.rs, tests/decode_test.rs, tests/roundtrip_test.rs. The test file ended up harder to navigate than any of the source files it was testing — which is exactly backwards.
If I were starting over, test structure mirrors source structure, from day one.
Behavioral Equivalence: How I Actually Proved It
22 integration tests — all encoding paths (RUN, INDEX, DIFF, LUMA, RGB, RGBA chunks), RUN boundary conditions (63 vs 62 pixels), wrapping arithmetic on deliberate overflow inputs, channel override semantics, error paths.
Byte-for-byte diff against C reference — compile
qoiconv.cwith gcc, encode a corpus of PNGs with both,diffthe outputs. Every image passes.27M+ fuzz iterations — roundtrip invariant on pseudo-random byte streams. If the encoder and decoder disagree on anything, the harness finds it.
None of these alone is sufficient. The fuzz harness won't find a bug that only appears on your specific test image. The integration tests won't find a bug that only appears on random inputs. You need all three layers.
Resources
- Port: github.com/AnishPrakash/qoi-rust
- Original: github.com/phoboslab/qoi
- DECISIONS.md: 15 architectural divergences, each with the C source, Rust translation, and why it matters
- Hackathon: PortMortem
- Tag: @raptorsdev
Top comments (0)