Capstone Container
Binary File Formats in Practice
Chapter 10 · Capstone: A Tiny Container Format
Nine chapters of taking other people's formats apart end here with building one. A container format is a file that holds other files: think of any archive you have opened. Yours will be small enough to read in one sitting, yet it will use every idea in the course: a magic number and version, a fixed header read with struct, length-prefixed records, a flags field, a checksum, optional compression, and the defensive reader from Binary File Formats in Practice 9. The finished program is about a hundred lines, and you will test it with round trips, hand-damaged files, a decompression bomb and a fuzzer.
Step 1: Design Before You Code
Writing a format down before writing any code is the habit that separates a format from an accident. The decisions are few, and each one answers a question from an earlier chapter. We call the format TBX (tiny box), version 1. All integers are little-endian, as in Binary File Formats in Practice 2.
| Part | Fields | struct format |
|---|---|---|
| Header (8 bytes) | magic TBX1, version, flags, entry count | <4sBBH |
| Entry header (13 bytes) | name length, stored size, original size, CRC-32 of the original data | <BIII |
| Entry body | the name in UTF-8, then the stored data | raw bytes |
The design choices, and why:
- Magic and version up front. The reader can reject a stranger's file in the first eight bytes (Binary File Formats in Practice 4). The version lets a later format change shape without confusing old readers.
- Flags with a rule for unknown bits. Bit 0 means "data is zlib-compressed". A reader that meets any other bit set must refuse, because it does not know what that bit promises (Binary File Formats in Practice 7).
- Stored size and original size. Both are written so that the reader can cap decompression at the declared original size, and so it can verify the claim afterwards.
- CRC of the original data. It catches accidental damage after decompression. Remember from Binary File Formats in Practice 9 that it detects accidents, not tampering.
- Counts instead of an end marker. The entry count in the header lets the reader reject an impossible count before looping.
- Limits written next to the format. At most 1,000,000 bytes per file, 100 entries and 500,000 bytes per entry, with names of 1 to 255 bytes. These are choices, not laws; the point is that they exist.
Step 2: The Writer
The writer is the easy half, but it still validates: a writer that can produce a file its own reader rejects is a bug. Names are checked by one shared function, used by both halves, so the rule lives in one place.
The uncompressed file is 384 bytes: the 8-byte header, three 13-byte entry headers, the names (9 + 9 + 5), and 14 + 300 + 0 bytes of data. The compressed one is 113 bytes because the 300 zero bytes shrink to almost nothing. Byte 4 is the version (01), and byte 5 is the flags: 00 in the first file, 01 in the second.
Step 3: The Reader
The reader is where the chapter on robust parsing pays off. It is built on the checked Reader class, converts every kind of failure into FormatError, and checks each claim before acting on it: file size, magic, version, flag bits, entry count (against a limit and against the bytes that could hold it), name encoding and content, duplicate names, declared sizes, the real decompressed size, the checksum, and finally the trailing bytes.
Read read_tbx once top to bottom and notice that it is a list of questions, in an order that never lets a later step trust something an earlier step has not checked. In inflate, requesting expected + 1 bytes lets a single call prove that the stream would produce more than promised, without holding the excess; unused_data catches bytes left after the stream ends. The empty archive is legal and round-trips to an empty list.
Step 4: Round Trips
The first test of any reader and writer pair is the round trip: write something, read it back, and expect the same thing. Doing it with random contents, of random sizes, both compressed and not, catches the cases you did not think of. The random generator is seeded so the test gives the same answer every time.
The accented name shows that names travel as UTF-8, and that the length field counts bytes rather than characters: é is one character but two bytes.
Step 5: Hand-Made Bad Files
A round trip only proves the happy path. Now damage a known-good file on purpose, one field at a time, and read the error message: each one should name the problem and, where it can, the offset. patched overwrites a field at a known position, using the layout from the design table (the first entry header starts at offset 8).
Twelve different damages, twelve different messages, and no exception that is not a FormatError. Writing these cases is also the quickest way to find a missing check: each time you can imagine a field lying, add a line here and see what the reader says.
Step 6: A Bomb and a Fuzzer
The last two tests are the heavy ones. First a decompression bomb: a 48 KB stream that inflates to 50 million bytes, hidden in an entry that claims a modest size. Then the fuzzer from the previous chapter, with one small change: it records a label for each case, so a crash could be traced to the exact damage that caused it.
The first bomb lies about its size and is stopped by the capped decompressor; the second tells the truth and is stopped by the per-entry limit before any decompression starts. (The compressed size, 48,610 bytes, is what this machine's zlib produced and may differ slightly with another version (check).)
Zero crashes in 1,634 damaged files: every one is either refused with FormatError or accepted. The accepted ones deserve a look, because accepting damaged input is what the fuzzer is really hunting. Here all 23 accepted cases in the uncompressed file turn out to be changes to a byte of an entry name (offsets 21 to 29, 57 to 65 and 379 to 383), which produce a different but still valid name. That is a real finding about the design, not a bug in the code: the CRC covers the data, and nothing covers the names. Exercise 2 closes that gap.
What You Would Add Next
A real format keeps growing, and the design above leaves room to do it safely:
- New flags. A reader that knows only bit 0 refuses a file using bit 1, instead of misreading it. Old files stay valid.
- A new version. Change the version byte when the layout changes, as Exercise 2 does with a whole-file checksum. A reader supports the versions it knows and rejects the rest by name.
- An index. Reading one file currently means walking every entry. A table of offsets at the end (like many real archives, which put the index at the end so files can be appended) allows direct access, at the price of the offset checks from Binary File Formats in Practice 9.
- Tagged extra data. A type-length-value area per entry, as in Binary File Formats in Practice 8, so that new metadata (timestamps, comments) can be added and unknown tags skipped.
- Streaming. The reader here takes the whole file as
bytes. For huge archives you would read from a file object, keeping the same checks on a bounded buffer.
The Whole Course in One Table
Each chapter contributed something to the format you have just built:
| Chapter | Used in TBX as |
|---|---|
| Binary File Formats in Practice 1 | The hex dump (hex(" ")) used to look at the output, and binary reading and writing |
| Binary File Formats in Practice 2 | Little-endian integers throughout |
| Binary File Formats in Practice 3 | The two struct.Struct objects and their sizes |
| Binary File Formats in Practice 4 | The magic number and version at the start |
| Binary File Formats in Practice 5 | CRC-32 with zlib, and compressed data |
| Binary File Formats in Practice 6 | Fixed header followed by sized chunks of data |
| Binary File Formats in Practice 7 | The flags byte and its unknown-bits rule |
| Binary File Formats in Practice 8 | Length-prefixed names and data |
| Binary File Formats in Practice 9 | The checked reader, limits, bomb protection, fuzzing |
Challenges
Connect the format to the disk. Write pack_dir(path, compress=False), which archives the regular files of one folder (skipping sub-folders, in sorted order), and unpack_to(buf, dest), which validates the whole archive first, refuses to overwrite any existing file, and only then writes the files. Use a temporary folder: pack a folder with two files and a sub-folder, unpack it elsewhere and compare, try to unpack twice, and show that a hand-built archive containing the name ../evil.txt is rejected and writes nothing.
Close the gap the fuzzer found. Define version 2 of TBX: the same layout with the version byte set to 2 and a 4-byte little-endian CRC-32 of everything before it added at the end. Write write_v2 and a read_any that accepts both versions (checking the whole-file checksum first for version 2). Fuzz both versions with the same harness and show that version 1 accepts some damaged files while version 2 accepts none, and show what a one-bit change to a name does in each.
Write a reusable selftest(seed, rounds, mutations) for the format: seeded random archives round-tripped both compressed and not, then thousands of randomly damaged copies (one to four byte changes, sometimes a truncation), counting round trips, accepted and rejected files and any crash by exception name. Show that the same seed gives the same report. Finish by reading a 50-million-byte bomb under tracemalloc and showing the peak memory stays small.
Quick Reference
- Design first: magic, version, flags with an unknown-bits rule, explicit sizes, a checksum, and written-down limits
- Header:
struct.Struct("<4sBBH"); entry header:struct.Struct("<BIII") - Writer: validate names, sizes and counts so it never produces what the reader refuses
- Reader: one gate (
take), one error type (FormatError), check every claim before using it, reject trailing bytes - Compression:
decompressobj().decompress(data, expected + 1), then compare length,eofandunused_data - Testing: seeded round trips, hand-damaged files, a bomb, and a truncate-and-mutate fuzzer
- A fuzzer's accepted cases: read them; they point at fields the format forgot to protect
- Evolving: new flag bits and a new version byte, never a silent change of meaning
struct, recognise files by their signatures, walk the chunks of real formats, pull apart flag bytes and nested records, defend a parser against hostile input, and design a format of your own. The next time you meet an unfamiliar binary file, the method is the same: find the signature, find the header, learn what each length and flag claims, and verify every claim before you trust it.