A couple of weeks ago I wrote about decayfmt, a file format where every open permanently corrupts the file a little. That post was about the idea. This one is about the code, because a few people asked what building it in Rust was actually like. decayfmt is a small CLI: it parses a fixed binary header, corrupts bytes with real randomness, writes the result back to disk, and refuses anything malformed with a clear error instead of a panic. None of that is exotic, but each piece pushed me toward a particular way of doing it, and a couple I only got right on the second try.
Errors are values, and there is no unwrap
The format has a lot of ways to fail: wrong magic bytes, an unsupported version, a read-only file, a filename with no instability value. I wanted every one of those to be a specific, named error rather than a panic or a bare string. So the whole crate returns a single enum:
pub enum DecayError {
WrongMagic { found: [u8; 4] },
UnsupportedVersion { found: u8 },
ReadOnly { path: String },
FilenameNoX { filename: String },
// ...and so on, one variant per way things fail
}
Every fallible function returns Result<_, DecayError>, and ? threads failures up to main, which is the only place anything is printed. I deliberately did not reach for thiserror; there were few enough variants that hand-writing the Display impl read more clearly than a macro and kept the dependency list short. The rule I held was: no unwrap() outside tests.
What I took from this: making errors values rather than panics forced me to name every failure up front instead of discovering them at runtime. By the time the enum was written, I had basically enumerated the entire contract of how the format can refuse you, which turned out to be a good way to think about the format itself.
Parsing untrusted bytes without panicking
A decayfmt file starts with a fixed 16-byte header. The lazy way to read it is to index straight into the slice, but indexing panics if the buffer is short, and a truncated or tampered file is exactly the case you have to survive. So read checks the length first, validates in order, and only hands back a header if everything matches:
pub fn read(buffer: &[u8]) -> Result<Header, DecayError> {
if buffer.len() < HEADER_SIZE {
return Err(DecayError::PayloadTooSmall {
found: buffer.len(),
needed: HEADER_SIZE,
});
}
let mut found_magic = [0u8; 4];
found_magic.copy_from_slice(&buffer[0..4]);
if found_magic != MAGIC {
return Err(DecayError::WrongMagic { found: found_magic });
}
// version and file_type validated the same way, each a typed refusal
}
The little-endian image dimensions are read with u32::from_le_bytes on a fixed four-byte array, not manual bit shifting.
What I learned: the habit of length-check-then-read, so that a malformed file is always a typed refusal and never a panic. from_le_bytes was new to me too, and it is a lot harder to get wrong than shifting bytes by hand.
The randomness is never seeded
Corruption picks bytes to replace with a probability derived from the instability value:
fn corrupt_text<R: Rng>(payload: &mut [u8], probability: f64, rng: &mut R) {
for byte in payload.iter_mut() {
if rng.gen::<f64>() < probability {
*byte = rng.gen_range(0x20..=0x7E); // a random printable ASCII byte
}
}
}
The generator is rand::thread_rng(), a cryptographically secure generator seeded from OS entropy, and it is never seeded with a fixed value. That is the one genuinely security-relevant decision in the project: a seeded, reproducible RNG would make the corruption sequence replayable, and a replayable corruption is a recoverable one, which defeats the whole point of the format. Making the function generic over the Rng trait (<R: Rng>) had a nice side effect: a test can hand it any generator it likes, including a seeded one, without the function knowing or caring.
What I learned: the difference between thread_rng and a SeedableRng, and that “generic over a trait” is not just an abstraction for its own sake here; it is the seam that lets a test control the randomness when it needs to.
Writing decay to disk without bricking the file (the one I got wrong first)
This is the part I had to fix after the first release. My initial version wrote the corrupted file back with std::fs::write:
std::fs::write(path, &file_bytes)?; // rewrites the whole file, header included
That works until it doesn’t. fs::write truncates the file and rewrites it from scratch, header and all. If the process is killed mid-write, you can be left with a zero-length or half-written file whose header is gone, which means it can never be opened again. For a format whose entire promise is “the file decays,” turning it into an unopenable brick on a bad crash is the wrong failure mode. It also quietly rewrote the header on every open, which the format contract says must never happen.
The fix was to open the file, seek past the header, and overwrite only the payload region in place:
let mut file = OpenOptions::new().write(true).open(path)?;
file.seek(SeekFrom::Start(HEADER_SIZE as u64))?;
file.write_all(&file_bytes[HEADER_SIZE..])?;
Because corruption never changes the payload’s length, this overwrites the same number of bytes with no truncation. The header on disk is now literally never touched, and a crash mid-write leaves a partially corrupted payload behind an intact header. The file still decays; it just cannot brick.
What I learned, and this was the real lesson of the project: the convenient standard-library call is not always the correct one. fs::write is perfect when you are writing a whole new file and terrible when you need to surgically overwrite part of an existing one. Reaching for OpenOptions, Seek, and Write instead was the difference between a durable format and a fragile one.
Testing something that is different every run
Testing corruption is strange because the output changes every time. You cannot assert on exact bytes. What you can assert on is statistical properties. At an instability of 1 the per-byte probability is about 0.095, so over a large buffer roughly 9.5% of bytes should change:
let original = vec![0u8; 100_000];
let mut payload = original.clone();
corrupt(&mut payload, FileType::Text, 1.0);
let fraction = changed_count(&original, &payload) as f64 / original.len() as f64;
assert!((fraction - 0.095).abs() < 0.01);
The “alpha channel is never corrupted” invariant is tested the same way: corrupt a known image payload many times over and confirm every alpha byte survived untouched. These live in inline #[cfg(test)] modules and run with cargo test; CI also runs cargo clippy -- -D warnings and cargo fmt --check, so nothing merges unformatted or with a warning.
What I learned: how to test randomness without pretending it is deterministic. You assert on the shape of the distribution over a big enough sample, not on any single output, and you use a large buffer so the law of large numbers does the work for you.
Closing
None of this is advanced Rust. But writing a format that has to be honest about failure, refuse bad input with a typed error, never hand back a free read, and never brick on a crash, pushed me toward exactly the parts of the language that make that kind of correctness cheap: enums for errors, ? for propagation, traits for testable randomness, and the standard library’s precise file handles for when the convenient one-liner is not enough.
The whole thing is a few hundred lines and MIT-licensed if you want to read it: github.com/aravpanwar/decayfmt.