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
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)
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);
}
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,
}
}
}
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;
}
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)
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
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.
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 layout ✅ Track 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.