DEV Community

beTheNoob
beTheNoob

Posted on

I Ported cJSON to Rust, and My Own Test Suite Lied to My Face

I did a dumb thing on purpose: took a real, 8-year-old, still-in-production C library — cJSON — and rewrote it in Rust. Not "inspired by." A port. Same test suite, same weird edge cases, same bugs if I wasn't careful.

This was for Port Mortem, a hackathon whose entire personality is "prove it, don't just say it." You take the original test suite — hashed at kickoff so you can't sneak-edit it later — and make it pass against your rewrite, untouched. One edited test file = automatic zero. No vibes-based grading.

So here's the honest version: four real bugs, one unsafe block I genuinely couldn't remove, and a benchmark where C beat me and I'm not hiding it.

What's cJSON and why bother

A small, zero-dependency C library that parses/prints JSON, ~2,500 lines, used everywhere from embedded firmware to random C tools. It manages its own tree with raw pointers and hand-rolls its own growable print buffer — exactly the kind of code where "just rewrite it safely" sounds easy until you try. Bonus: it has a real, disclosed CVE, CVE-2025-57052 (CVSS 9.8) — so I wasn't just porting code, I was porting a bug on purpose, then un-porting it.

The setup

Original C headers get SHA-256 hashed at kickoff, never touched again. My Rust compiles to the exact same C ABI, so the original C test suite (Unity framework) links straight against my Rust binary with no idea it's not talking to C. One wrinkle: 13 of 20 original test files call cJSON's internal (non-public) functions directly — I handled that with a thin C shim (not hashed/pinned) that just forwards to the real ported logic. Not a mock, same logic, just re-exposed.

Bug #1: CI said "100% passed." It ran 1 out of 22 tests.

The most embarrassing one, and the one I'm proudest of catching. CI printed:

100% tests passed, 0 tests failed out of 1
Enter fullscreen mode Exit fullscreen mode

Out of 1. Not 22. cJSON gates its whole real test suite behind a CMake flag:

# adapter/CMakeLists.txt — this line was missing
set(ENABLE_CJSON_TEST ON)
Enter fullscreen mode Exit fullscreen mode

Upstream's own build sets this automatically; my from-scratch CMake file didn't. So the if(ENABLE_CJSON_TEST) block silently defined zero real tests, and ctest was truthfully reporting 100% of one leftover test. I only caught it by not trusting the green checkmark and running the build locally myself. One line fixed it — and immediately surfaced 2 more bugs (a missing preamble in my test shim, and cJSON_ParseWithOpts silently dropping its error-position output on failure) that had been hiding behind the disabled suite the whole time.

Bug #2: my fuzzer found a bug in under a minute

Port Mortem wants a differential fuzzer — same input, both builds, diff the output. Mine found 192,619 divergences on its first real run, all from string-printing unicode escapes:

// port/src/print.rs — print_string_ptr()
_ => {
    // C's `for` loop increments the pointer once per iteration on top of
    // this branch's own advance. My manual Rust loop has no implicit
    // increment, so it must add that missing +1 explicitly: add(5), not 4.
    libc::sprintf(out as *mut c_char, c"u%04x".as_ptr(), c as c_int);
    out = out.add(5);
}
Enter fullscreen mode Exit fullscreen mode

The C for loop's increment clause was quietly adding one extra byte of advance every iteration — a language feature I forgot to account for by hand. One-line fix, zero divergences after. Best proof I have that differential fuzzing actually works: it caught a subtle off-by-one that unit tests never would've hit.

The unsafe block I couldn't get rid of

Grepping the port shows 155 hits of "unsafe." Scary out of context, so here's the real breakdown:

What it actually is Count
unsafe extern "C" fn — public FFI entry points 89
unsafe fn — internal helper signatures 61
Actual unsafe { } blocks inside safe code 1
SAFETY: comments explaining why 121

89 of 91 public functions are unsafe extern "C" fn — not laziness, structurally required, since every one takes a raw pointer from C that Rust's compiler can't verify. Marking them safe fn wouldn't remove the risk, just mislabel it. The number that actually reflects my code quality is the 1:

// port/src/hooks.rs
// SAFETY: reads the `static mut global_hooks` — matches the original's
// own thread-safety characteristics (none), required for compatibility.
pub fn snapshot() -> HookSnapshot {
    unsafe {
        let h = global_hooks;
        HookSnapshot {
            allocate: h.allocate.unwrap_or(libc::malloc),
            deallocate: h.deallocate.unwrap_or(libc::free),
            reallocate: h.reallocate,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

cJSON lets any C caller globally swap the allocator via cJSON_InitHooks, with zero synchronization — that's just how the original works, and tests depend on it. The borrow checker can't reason about a C caller mutating a global from a function it can't see, so this one spot is explicitly marked and explained. Everything else — parsing, tree walks, JSON Pointer logic, string escaping — is plain safe Rust behind that one FFI wall.

The CVE, ported on purpose

CVE-2025-57052 (CVSS 9.8): resolving a JSON Pointer like /1A as an array index, the original C loop kept re-checking the first character instead of advancing — so "1A" got folded into a valid index and returned a real element for what should've been rejected.

// port/src/utils.rs — fixed
// Original checked pointer[0] (always the first byte) here forever.
// We check pointer[position] — the byte actually being consumed.
while *pointer.add(position) >= b'0' && *pointer.add(position) <= b'9' {
    parsed_index = parsed_index * 10 + (*pointer.add(position) - b'0') as usize;
    position += 1;
}
Enter fullscreen mode Exit fullscreen mode

I kept the original buggy C build as a permanent fuzzing oracle to prove the fix, not just claim it — real output from fuzz/log.txt:

pointer: "/1A" (malformed)
  C-original(v1.7.18) -> accepted (bug: folded '1A' into index 27), returned element 127
  Rust-port -> rejected (NULL, CVE-2025-57052 fix)
Enter fullscreen mode Exit fullscreen mode

The benchmark that mildly hurt my feelings

Same 500-record, ~50KB JSON workload, 2,000 iterations, real percentiles (not just an average):

Metric C Rust Winner
p50 latency 0.895 ms 0.767 ms Rust, ~14% faster
p99 latency 2.575 ms 2.044 ms Rust, ~21% faster
Throughput 892 ops/s 1,037 ops/s Rust, ~16% more
Max latency 6.90 ms 7.44 ms C, by a hair
Peak RSS 2,656 KB 2,656 KB tie

Rust wins where it matters most — median, p99, throughput, identical memory. But C beat me on max latency, and I'm leaving that row in the table instead of quietly cropping it out. One-off outlier on a shared machine, not a trend — but "honest numbers over confident claims" is the whole point of this track.

Watch it live

Full Docker build, all 22 tests, live differential fuzz including the CVE case, no cuts on the parts that matter.

All the numbers, in one place

  • 22/22 original Unity tests pass, byte-for-byte unmodified, hash-verified
  • 70M+ differential fuzz comparisons in a 60s run, 0 unexpected divergences (exact count varies by machine — it's a time budget, not an iteration count; two real runs hit 77.9M and 115.9M, both clean)
  • 1 real internal unsafe {} block in ~4,000 lines
  • 1 real CVE (CVSS 9.8), fixed and faithfully reproduced for proof, not just asserted
  • 4 real bugs found and fixed — including one about how confidently I thought I'd already verified this thing

Go poke at it yourself

GitHub logo DhruvP2205 / cjson-rust-port

🦀 C→Rust port of DaveGamble/cJSON — 🧪 22/22 original tests unmodified, 🐛 70M+ fuzz comparisons/0 divergences, 🎯 CVE-2025-57052 fixed.

🔀 cJSON ⇄ 🦀 Rust

Port Mortem 2026 · Track A (C → Rust)

A byte-for-byte faithful, memory-safe Rust port of a real, popular C library — proven against the original's own unmodified test suite, not a rewritten one.

Tests Fuzz CVE License Demo Video

Rust C CMake Docker GitHub Actions No external crates

Tip

Rewrite real code. Prove the port works. No new tests written against the new implementation — the original's own unmodified Unity suite runs straight against the Rust build.


🧭 Contents

🎥 Demo video 📊 At a glance 🤔 Why cJSON 🔬 How this port proves equivalence 🚀 Build & run 🧑‍💻 Try it yourself 📁 Repository layoutTrack criteria 🧪 Fuzz & benchmark evidence


📊 At a glance

📦 Source project DaveGamble/cJSON @ v1.7.18 (MIT)
🦀 Ported ~4,600 LOC — cJSON.c + cJSON_Utils.c → Rust, zero external crates
🧪 Test suite Original Unity suite, unmodified, hash-pinned, linked against the Rust build
Test result 🟢 22 / 22

Every number above is a committed file in the repo — fuzz logs, benchmark JSON, and DECISIONS.md (20 sections of reasoning, zero jokes, all receipts).

This was my Track A (C → Rust) submission for Port Mortem 2026. If you're doing a language port for anything — hackathon, work, fun — my one piece of advice is the whole post in one line: don't trust the green checkmark, run it yourself, count the tests, and if something doesn't add up, go find out why before you ship it.

Thanks for reading. 🦀

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.