DEV Community

Cover image for I Ported an 830-Star Python Library to Rust and the Fuzzer Found 12 Bugs β€” All of Them Mine πŸ¦€
GSK
GSK

Posted on

I Ported an 830-Star Python Library to Rust and the Fuzzer Found 12 Bugs β€” All of Them Mine πŸ¦€

9,549 differential fuzz cases against the real Python original. Zero divergences. Twelve bugs found β€” every single one was something I wrote, not something Python did.

tinytag-rs terminal demo

tinytag is an 830-star, zero-dependency Python library that reads tags and audio properties out of WAV, AIFF, FLAC, MP3, Ogg, MP4, and WMA files. I picked it on purpose, not at random: six binary formats in one small, well-tested codebase means six different byte-layout quirks, six different chances to get "close enough" instead of exactly right β€” a good stress test for what "behavioral equivalence" actually costs, not just what it means in theory. For Port Mortem / Code Resurrection 2026 (Track D, Python β†’ Rust), I rewrote six of those seven formats from scratch β€” no libpython, no PyO3, no shortcuts β€” and checked every claim below against the real source files in this repo, not memory.

Key Takeaways

  • 9,549 differential fuzz cases against the real Python original, 0 divergences, in the last qualifying 65-second run (fuzz/log.txt)
  • 3 real bugs in upstream tinytag, found by fuzzing, reproduced faithfully β€” not fixed, because matching Python's actual output is the scoring target here
  • 12 bugs the fuzzer found in this port during development, all fixed before this was written
  • 0 unsafe blocks, 0 dependencies, 1.65x average speedup, honestly measured

πŸ‘€ What This Thing Actually Does, in 10 Seconds

Before any of the bug stories, here's the whole point of the project in one command. Point the CLI at a real MP3, and it reads the tags straight out of the file β€” no metadata database, no network call, just parsing bytes:

$ ./target/release/tinytag tests/samples/cbr.mp3
Enter fullscreen mode Exit fullscreen mode
{
  "filename": "cbr.mp3",
  "filesize": 8186,
  "mime_type": "audio/mpeg",
  "is_lossless": false,
  "duration": 0.47020408163265304,
  "channels": 2,
  "bitrate": 127.99548611111112,
  "samplerate": 44100,
  "artist": ["Basshunter"],
  "album": ["I Can Walk On Water I Can Fly"],
  "title": ["I Can Walk On Water I Can Fly"],
  "track": ["1"],
  "genre": ["Dance"],
  "year": ["2007"],
  "comment": ["Ripped by THSLIVE"]
}
Enter fullscreen mode Exit fullscreen mode

Real output, from a real fixture file in this repo. That's the entire contract: same file in, same JSON out, whether you run the Python original or this Rust port. Everything below is the story of how hard that "same" turned out to be to actually prove β€” and where it wasn't quite the case yet.

🎯 9,549 Cases, Zero Divergences β€” Here's How I Checked

The scoring target isn't "does it work" β€” it's "does it produce byte-for-byte identical output to the Python original, on the same inputs, including the malformed ones." That bar is what surfaced almost everything interesting in this project.

To check it, I built a differential fuzz harness (src/bin/fuzz_harness.rs) that mutates real audio fixtures and runs both the vendored Python original and the Rust port against the exact same bytes, then diffs the structured output field by field. It doesn't link Python into the Rust binary β€” the rules explicitly disallow that β€” it spawns a real CPython subprocess and compares against it, which is the honest shape of differential testing.

Last qualifying run, quoted directly from fuzz/log.txt:

differential fuzz run started (rust-orchestrated), seed=1785680270143253800
seed corpus: 206 fixtures (full in-scope set, see fuzz_harness.rs)


9549 cases run over 65.0s
0 divergences found
ZERO DIVERGENCES
Enter fullscreen mode Exit fullscreen mode

177 unit tests, each traced 1:1 to an expected value in upstream's own test_all.py β€” not hand-guessed β€” plus that fuzz run, are the actual evidence of correctness here, not "I read the code carefully."

πŸ› The Fuzzer Found 12 Bugs. All of Them Were Mine.

The single biggest source of divergence across this whole project was my own instinct to write more defensive Rust than the Python original actually is. Twelve times, the fuzzer caught it. A representative few, documented in full in DECISIONS.md:

Bug one: AIFF's sample rate flipped across a rounding boundary, because of a function name one letter off from the right one. Here's the actual line that shipped first:

let sr = (mantissa as f64) * 2f64.powi(exp as i32 - 0x3FFF - 63);
Enter fullscreen mode Exit fullscreen mode

powi and powf both raise a number to a power β€” the difference is powi takes an integer exponent and uses a fast approximation, while powf takes a float exponent and routes through the platform's real pow(). Rust is explicit that powi is not guaranteed to be correctly rounded. Python's 2 ** negative_int always goes the pow() route β€” the powf route. Nine times out of ten that difference is invisible. But right at the edge of u32::MAX, that tiny rounding error was enough to flip a truncated integer by exactly one β€” and "one over the line" made Rust reject a sample rate Python happily accepted. The fix ended up needing exact integer arithmetic instead of floats at all, once fuzzing pushed on it hard enough (full diff in the commit history; the one-letter version above is the part that actually mattered). Found by fuzzing mutated exponent bytes in no_audio.aiff (DECISIONS.md #14e).

Bug two, and the one that never seemed to fully die: MP4's atom-tree traversal. More on that below β€” it's worth its own section, because "found it, fixed it" undersells what actually happened.

Bug three: a too-short WAV fact chunk returned an empty value instead of raising an error, like Python does. My first instinct was to add a safety check β€” "if the chunk is at least 4 bytes, read it; otherwise skip." That felt like good defensive programming. It was also wrong. Python's own unpack_from('I', chunk) has no such check: a chunk shorter than 4 bytes just raises struct.error, which upstream converts into a real parse error. The fixed version reads exactly like that β€” no polite skip, just propagate the error:

// Matches upstream's unguarded `unpack_from('I', chunk)` β€” a too-short
// buffer is a real parse error there (struct.error -> ParseError), not
// something to skip gracefully.
num_samples = u32_le(&chunk, 0)? as u64;
Enter fullscreen mode Exit fullscreen mode

That ? at the end is doing the actual work: it means "if this read fails, stop and hand the error up" β€” no fallback, no default value, exactly what Python does when struct.unpack_from blows up (DECISIONS.md #14a).

The pattern underneath all twelve: Rust's standard tools (read_exact, bounds-checked slicing, length guards) nudge you toward code that's more correct than the reference by default. For a behavioral-equivalence port, that's a bug class of its own β€” every one of these had to be found by actually running the same corrupted bytes through both implementations, not by code review.

πŸ•³οΈ The Edge Case That Took the Longest to Actually Nail

Every other bug in this post, I fixed once and it stayed fixed. MP4's atom-tree traversal took multiple separate rounds, across seven different fuzzed files, before it actually held β€” and each time I thought it was done, a new mutated .m4a would find another angle on the same underlying mistake.

Terminal recording of tinytag-rs parsing a corrupted MP4/M4A file and correctly recovering tags after a mid-atom corruption

The root problem: MP4 files are nested trees of "atoms" (moov β†’ udta β†’ meta β†’ ilst, and so on), and Python's traversal tracks two numbers per level β€” the file handle's real read position, and a logical position it's supposed to be at. On well-formed files those two numbers never disagree, so the distinction is invisible. On a corrupted file, they can legitimately drift apart: a header read that fails immediately still consumes bytes from the stream, even though the code that failed never got to use them. Python just keeps reading from wherever the handle actually sits after that β€” it never tries to force the two numbers back in sync.

My first version did try to force them back in sync. It looked like the responsible thing to do β€” reset to each atom's declared end after every step, like a checkpoint. It was exactly backwards: forcing a resync meant every parent atom kept re-reading the same corrupted bytes instead of drifting past them the way Python does, so a corruption Python shrugs off and recovers from (going on to find real tags further in the file) made my port just... stop, silently returning less than it should have.

The fix that actually held is one line of discipline, applied consistently everywhere a position gets threaded through:

curr_pos = on_atom(reader, t, size, curr_pos)?;
Enter fullscreen mode Exit fullscreen mode

Always take the actual resulting position back from whatever just ran β€” never assume it matches what you expected going in. skip_to, the function that skips a genuinely unrecognized atom, follows the same rule: a relative seek from wherever the reader currently is, never an absolute jump to a precomputed value:

fn skip_to<R: Seek>(reader: &mut R, atom_size: u64) -> Result<u64, ParseError> {
    let pos = reader.seek(SeekFrom::Current(atom_size as i64))?;
    Ok(pos)
}
Enter fullscreen mode Exit fullscreen mode

Simple once it's written down. What made it take multiple rounds is that the bug doesn't announce itself as one bug β€” it shows up as a different symptom depending on which atom happens to be corrupted and which sibling atom comes after it: sometimes a missing tag, sometimes a whole covr (cover art) atom's image data getting mis-skipped because a child atom inside it was independently corrupted (DECISIONS.md #14g). Each new fuzzed file (multi_value.m4a, classical.m4a, mixed_case_atoms.m4a, xmp_empty.m4a, test2.m4a, mvhd_version_1.m4a, and finally mpeg4_with_image.m4a) looked like a new bug the first time it failed. It wasn't β€” it was the same root cause, wearing a different fixture each time, until the "always return the real position" rule got applied everywhere instead of just where the first failure pointed (DECISIONS.md #14f, #14g).

πŸ”¬ The Bug I Found in an 830-Star Library (and Didn't Fix)

Three real bugs in upstream tinytag, all found by fuzzing, all left exactly as-is in the Rust port. That's deliberate: fixing any of them would make the port diverge from Python's real output on that input, which is the opposite of what's being measured.

The one worth reading in full: MP4's gnre atom has a genuine off-by-one bug, and it's the kind every programmer has written at 2am. Upstream reads a 2-byte genre index, subtracts 1 (MP4 genre codes are 1-indexed), and looks it up in a genre table. It checks that the index isn't too big β€” but never checks that it isn't negative:

let raw = u16_be(data, 8)?;
let len = crate::id3v1::GENRES.len() as i64;
let idx = raw as i64 - 1;
if idx < len {
    let real_idx = if idx < 0 { (len + idx) as usize } else { idx as usize };
    if let Some(genre) = crate::id3v1::GENRES.get(real_idx) {
        tag.set_str_field("genre", (*genre).to_string());
    }
}
Enter fullscreen mode Exit fullscreen mode

Walk through it with a stored value of 0, meaning "no genre set": idx = 0 - 1 = -1. The bounds check idx < len passes fine (-1 is less than the table length). Then real_idx = len + idx β€” Python's negative-indexing behavior, deliberately reproduced here β€” lands on the last entry in the genre table, "Anime" in this pinned version. So a file that explicitly says "no genre" ends up tagged as Anime. Nothing in the original code marks 0 as meaning "give me the last genre" β€” it reads exactly like a missing if idx < 0: return that nobody caught, on either side, for years. This port keeps it exactly as-is, on purpose (DECISIONS.md #18a).

The other two, same treatment: an ID3v2 extended-header seek hardcoded to skip - 6 bytes regardless of how many were actually available (wrong offset on truncation, #17), and track/disc "X/Y" splitting that silently discards a third slash-segment via Python's split('/')[:2] (#18).

⚑ 1.65x Faster, and I'm Not Going to Oversell It

Horizontal bar chart of Rust vs Python parse speed per file, ranging from 1.37x to 2.00x, overall average 1.65x

Per-file speedup, Rust vs Python, 200 iterations per file. Overall average: 1.65x. Source: bench/results.json, methodology in bench/methodology.md.

File Python ns/call Rust ns/call Speedup
vbr_xing_header.mp3 75,714 37,786 2.00x
flac453sStereo.flac 78,752 40,715 1.93x
vbri.mp3 133,237 72,671 1.83x
M1F1-mulawC-AFsp.afc 71,670 40,502 1.77x
test.wav 59,566 34,668 1.72x
test3sMono.wav 62,809 36,550 1.72x
flac1sMono.flac 84,086 60,926 1.38x
UTF16.mp3 151,569 110,596 1.37x

Overall: 1.65x faster than the Python original, averaged across an 8-file workload spanning WAV/AIFF/FLAC/MP3. That's not dramatic, and I'm not going to pretend it is. Both implementations do the same fundamentally small, byte-shuffling work β€” parsing a WAV chunk header or an ID3v2 frame is a handful of comparisons and a slice index, not a hot numeric loop where Rust routinely wins by 10-50x over CPython. The pattern that does show up consistently: files with more tag frames to walk (UTF16.mp3, 1.37x) show a smaller speedup than files that are mostly audio-property parsing with few or no frames (vbr_xing_header.mp3, 2.00x) β€” consistent with per-frame Python interpreter overhead being the more meaningful cost driver here, not some inherent parsing-algorithm gap. Real, reproducible, not cherry-picked, and the honest headline is "correctly and safely faster," not "wildly faster."

🚧 The One Thing I Couldn't Fix

One disclosed, un-fixed edge case: AIFF's extended-precision sample rate can, on sufficiently corrupted input, exceed u32::MAX. Python's arbitrary-precision int represents that value exactly (values like 10Β³Β³ Hz have shown up under fuzzing β€” obviously not a real sample rate). Rust's samplerate: u32 genuinely cannot. Rather than wrapping to garbage via a lossy cast, this port declines the value (None) when it would overflow. It's a real language-capability limit, not a bug β€” and it's rare enough that a typical 65-second fuzz run doesn't always hit it, but no real-world AIFF file comes anywhere near this range either (DECISIONS.md #15).

You might expect an "unsafe block I couldn't remove" story here β€” there isn't one. This port has zero unsafe blocks; byte-level tag parsing doesn't need raw pointers, and the format-tree traversal work stayed entirely inside safe, bounds-checked slices the whole way through. The equivalent trade-off isn't a memory-safety compromise, it's the one directly above: a case where "faithful to Python's exact output" and "representable in Rust's type system" genuinely conflict, and something had to give. That's the actual cost of this port, not an unsafe block.

πŸ”„ The Decision I'd Take Back

If I'm honest about one call I got wrong: I started the fuzz and benchmark harnesses in Python.

That felt reasonable at the time β€” the harness needs to spawn a reference Python process anyway, so writing the orchestration logic (mutate a fixture, run both sides, diff the output, report) in Python too meant one language for that whole layer, ~369 lines split across fuzz/diff_fuzz.py and bench/run_bench.py. It worked. It just wasn't fast, because every single fuzz case meant spawning a fresh Python subprocess β€” and that per-case process-spawn cost dominated the actual work being measured.

Partway through, I rewrote both as Rust binaries (src/bin/fuzz_harness.rs, src/bin/bench.rs) that spawn Python exactly once and feed it an embedded reference-oracle script, instead of once per case. Same total amount of "real Python running real Python code" β€” just amortized across the whole run instead of paid per-case. The result wasn't just cleaner: the Rust-orchestrated harness ran roughly 2x more cases in the same wall-clock time, and that extra throughput is directly how two of the twelve bugs above (#14a, #14b) got found at all β€” the original Python-orchestrated version's slower cases-per-second never reached them inside a qualifying run's time budget.

If I started over, the harness would be a Rust binary from the first commit, not a mid-build rewrite. Not because the Python version was wrong β€” it worked, it just quietly capped how many bugs I was going to find, and I didn't realize that was the cost until I'd already paid it.

πŸ“Ό Watch It Live

Full video walkthrough: youtu.be/Hg3GoT8XJr0

Plus six ~30-second recorded terminal demos (one per format) and the full test-suite-and-fuzz-run, captured for real, not staged:

Terminal recording of the full cargo test suite passing and a live differential fuzz run against the vendored Python reference, ending with zero divergences

The other six β€” WAV, AIFF, FLAC, MP3, Ogg, MP4 β€” are in public/demo/.

πŸ“Š All the Numbers in One Place

  • 177/177 tests passing, traced 1:1 from upstream's own test data
  • 9,549 cases, 65 seconds, 0 divergences on the last differential fuzz run
  • 34 documented architectural decisions (DECISIONS.md)
  • 0 unsafe blocks, 0 dependencies
  • 3 real bugs found in the original, reproduced faithfully
  • 12 bugs the fuzzer found in this port during development, all fixed
  • 1.65x average speedup, honestly measured, honestly reported

Every decision, including the ones that were wrong on the first attempt, is in DECISIONS.md β€” 34 of them, not a highlight reel.

πŸ”§ Go Poke at It Yourself

git clone https://github.com/GauravS13/tinytag-rs.git
cd tinytag-rs
cargo test                                              # 177 tests, traced 1:1 from upstream
cargo build --release --bin fuzz_harness && ./target/release/fuzz_harness 65   # run the fuzz yourself
Enter fullscreen mode Exit fullscreen mode

Full source, tests, DECISIONS.md, and the fuzz/bench harnesses: github.com/GauravS13/tinytag-rs.


Submission by Siddhivinayk for Port Mortem / Code Resurrection 2026, Track D (Python β†’ Rust).Tagging @partnerships_raptors

Top comments (1)

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