DEV Community

Anish Prakash
Anish Prakash

Posted on

I Ported QOI to Rust. Here's What Almost Broke It.

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;
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

#[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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. Byte-for-byte diff against C reference — compile qoiconv.c with gcc, encode a corpus of PNGs with both, diff the outputs. Every image passes.

  3. 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

Top comments (0)