An inode table, a block bitmap and 32 data blocks inside one flat file — where deleting a file writes nothing at all, and a three-byte file you own hands you 61 bytes of it back through read(). A study guide for tinyfs.py.
disk.img in the current directory, which is deliberate — the whole filesystem is 2,472 bytes you can xxd in one screen. No dependencies, stdlib only. Every transcript below was captured on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3].
cd toy-filesystem
python3 demo.py # the aha (§6)
python3 test_tinyfs.py # pins every number this page claims
xxd disk.img | less # the entire filesystem, after a demo run
This toy is a filesystem: a superblock, an inode table, a free-space bitmap and an array of fixed-size data blocks, all laid out inside one ordinary file. It supports write, read, ls, rm and truncate, and it has no directories, no permissions and no journal.
The shortest path to the result is the CLI:
python3 tinyfs.py format
printf 'ROOT PASSWORD: hunter2-correct-horse\nSSN: 123-45-6789\nCARD: 4111 1111 1111 1111 exp 09/29 cvv 402\n' | python3 tinyfs.py write secret.txt
python3 tinyfs.py ls
python3 tinyfs.py rm secret.txt
printf 'ok\n' | python3 tinyfs.py write notes.txt
python3 tinyfs.py ls
python3 tinyfs.py read notes.txt
python3 tinyfs.py truncate notes.txt 64
python3 tinyfs.py read notes.txt
notes.txt is a file you created, you own, and never wrote anything into except ok\n. The last command is read. That is the toy.
By the end you should be able to:
rm performs (none) and why that is a deliberate choice rather than an oversight;read(), on your own file) versus what you'd need on a real system (a raw device and root);A filesystem has to answer one question fast: given a name and an offset, which bytes on the device? Everything in the layout serves that. Fixed-size inodes mean inode 5 is at a computable address. A block bitmap means "find free space" is a scan of 32 bytes rather than a walk of the whole disk. Fixed-size blocks mean the file's nth byte is at data_start + blocks[n // block_size] * block_size + n % block_size — one multiply, no search.
Fixed-size blocks buy that speed with a specific waste: a file's last block is almost never full. A 3-byte file in a 64-byte-block filesystem occupies a whole block; 61 bytes go unused. That gap has a name — slack — and the usual thing said about it is that it wastes disk. This toy is about the other thing about it.
The competing goals that make more than one design defensible:
rm should be instant. Users delete a 10 GB directory and expect the prompt back. Making delete proportional to file size rather than to metadata size is a real cost users would notice.ftruncate growing a file says the extended part "reads as null bytes." A filesystem that hands back whatever was there before has broken a documented promise, not merely been untidy.You cannot have all three for free. Something has to write zeros somewhere, and the entire design question is where you put that cost. This toy puts it nowhere by default, so you can watch what falls out.
| Concept | Where it's used here | One source |
|---|---|---|
| Inode | Inode.pack/unpack, a 45-byte fixed record; the name lives in the inode because there are no directories |
inode |
| Free-space bitmap | FileSystem.bitmap, one byte per block; alloc_block first-fits it, and that policy is why the leak is instant |
free-space bitmap |
| Internal fragmentation / slack | the 61 bytes between notes.txt's length and the end of block 0 — the whole result lives here |
Fragmentation § internal |
ftruncate semantics |
FileSystem.truncate deliberately violates the "extended part reads as null bytes" rule |
ftruncate(2) |
| Data remanence | why rm writing nothing is a security property and not just a performance one |
Data remanence |
| Unwritten extents | Not in this toy — the mechanism ext4 uses to get the zero guarantee without paying for it (§7.4) | ext4 inode extents |
The two that carry the result are slack and ftruncate semantics. Slack is where the old bytes physically survive; ftruncate is the legal, unprivileged operation that moves a file's length over them. Neither is exotic. The result is what happens when a system implements the first without honouring the second.
Before any code. The whole disk, as laid out by format() with the default geometry:
275 lines, two classes and a main. Read it in this order.
rm — the four lines that write nothing def rm(self, name):
"""Free the blocks, clear the inode. Note what is missing: any write
to the data region at all, unless zero_on_free is set.
"""
i, ino = self.lookup(name)
if ino is None:
raise FileNotFoundError(name)
for b in ino.blocks[:self.nblocks(ino.size)]:
self.free_block(b)
self.put_inode(i, Inode())
put_inode(i, Inode()) writes 45 zero bytes over the inode record, and free_block writes one byte in the bitmap per block. Both are metadata. Nothing in this function touches the data region, and that is not an omission — it is the reason rm is O(1) in the file's size. Deleting a 2 GB file costs the same as deleting a 2-byte one.
Watch what the disk looks like immediately afterwards (§6.2 has the run): the inode is 45 zero bytes, the bitmap is all zeros, and block 0 is still the password.
alloc_block and free_block — the bitmap, and first fit def alloc_block(self):
"""First fit: the lowest-numbered free block wins. Deterministic,
two lines, and the reason a just-freed block comes straight back.
"""
bm = self.bitmap()
for b in range(self.num_blocks):
if not bm[b]:
bm[b] = 1
self.put_bitmap(bm)
if self.zero_on_alloc:
self.put_block(b, b"\0" * self.block_size)
return b
raise OSError("no free blocks")
def free_block(self, b):
bm = self.bitmap()
bm[b] = 0
self.put_bitmap(bm)
if self.zero_on_free:
self.put_block(b, b"\0" * self.block_size)
These are a matched pair, and the two if statements are the toy's only policy knobs. Both default to off, and off is what every mainstream filesystem does literally — see §7.4 for what they do instead.
The for b in range(self.num_blocks) is first fit, and it is doing more work in the story than it looks. It guarantees the lowest free block wins, which means the block you just freed is the block you get next. That is excellent for locality and terrible for secrets, and §6.5 shows an allocator that makes the same demo leak nothing at all without any zeroing — by refusing to reuse.
write — where the slack is created blocks = []
for off in range(0, len(data), self.block_size):
b = self.alloc_block()
blocks.append(b)
# Only len(data)-off bytes are written. Whatever sat in the rest
# of this block is still sitting there. That is the whole toy.
self.put_block(b, data[off:off + self.block_size])
self.put_inode(i, Inode(1, len(data), name.encode(), blocks))
data[off:off + self.block_size] is a Python slice, so for the last block it is short: writing b"ok\n" writes exactly three bytes at the start of block 0 and leaves bytes 3–63 alone.
This is the single most important divergence from a real disk, and it cuts against the toy's own result — a real block device cannot write three bytes. The smallest unit it accepts is a sector (512 B, or 4 KiB on modern drives), so the kernel must assemble a full block in memory and write all of it. What it assembles from is a page-cache page, and a page handed to a file for the first time is zero-filled. That, not any explicit erase, is why the tail of a short file on a real system reads as zeros. §6.6 measures it.
read — the cap that makes the leak need a second step def read(self, name):
i, ino = self.lookup(name)
if ino is None:
raise FileNotFoundError(name)
out = b"".join(self.block(ino.blocks[n])
for n in range(self.nblocks(ino.size)))
return out[:ino.size] # the cap that hides the tail
read fetches whole blocks — it cannot do otherwise, the device is block-addressed — and then throws the tail away. Delete the [:ino.size] and the toy leaks with no truncate at all; that is the counterfactual in §6.4, and it returns 64 bytes for a 3-byte file.
So the cap is load-bearing, and it is also the only thing standing between a fresh write and a disclosure. It is a correct line. It is just not a sufficient one, because it derives its authority entirely from ino.size being trustworthy — and the next function is the one that sets ino.size.
truncate — the crack def truncate(self, name, newsize):
"""POSIX `ftruncate`: any owner may resize their own file, and the
bytes a grow exposes are required to read back as zeros. This
implementation just moves the size field -- which is the bug.
"""
i, ino = self.lookup(name)
if ino is None:
raise FileNotFoundError(name)
need, have = self.nblocks(newsize), self.nblocks(ino.size)
if need > self.direct:
raise OSError("file too large: %d direct blocks" % self.direct)
# Trim first: unpack always hands back `direct` entries, and the ones
# past the file's length are whatever the last, longer file left.
blocks = ino.blocks[:have]
for _ in range(have, need):
blocks.append(self.alloc_block())
for b in blocks[need:]:
self.free_block(b)
ino.blocks = blocks[:need]
ino.size = newsize
self.put_inode(i, ino)
Every line here is defensible on its own. Growing a file should allocate the blocks it now needs; shrinking should free them; ino.size = newsize is what "truncate" means. What is missing is the one thing ftruncate(2) promises: "If the file previously was shorter, it is extended, and the extended part reads as null bytes." Nothing in this function writes a null byte anywhere.
The fix is three lines and costs one block write, no matter how large the grow — §6.7. It is not here because the toy's job is to show you what its absence looks like.
One aside worth having, since it is a bug I actually hit while building this: the blocks = ino.blocks[:have] trim. Inode.unpack always returns direct block numbers, because the record is fixed-size, and the entries past the file's real length are whatever the previous, longer file left in that inode. Growing without trimming appends after the stale entries, and the new blocks land past index 3 where nothing reads them. It is the same "leftover bytes in a fixed-size container" bug as the one the toy is about, one level up in the metadata.
python3 demo.py. Sections A–E below are its real output, in order.
The arithmetic, so no number here is unexplained:
secret.txt is 98 bytes. ⌈98 / 64⌉ = 2 blocks, so it takes blocks 0 and 1 (first fit on an empty disk). Block 1 holds 98 − 64 = 34 bytes and has 30 bytes of its own slack, which on a freshly formatted image is zeros.rm frees both: free blocks go 30 → 32, at a cost of 0 block writes.notes.txt is 3 bytes. ⌈3 / 64⌉ = 1 block, and first fit returns block 0 — the front of the password.SECRET[3:64] unchanged.truncate sets size = 64, so read returns out[:64] instead of out[:3], and 3 + 61 = 64.test_the_honest_read_path_leaks_nothing in test_tinyfs.py does exactly the steps above, stops before the truncate, and asserts:
got = fs.read("notes.txt")
assert got == b"ok\n" and len(got) == 3 # capped at inode.size
Three bytes, zero leaked. Step 5 in the transcript is that same check inside the demo. The disclosure is not "reading a file shows you old data" — the read path is correct. It is that the length is metadata a user controls, and the read path trusts it.
Meanwhile, the disk after step 3:
Forty-five zero bytes of inode, an all-zero bitmap, and the file. That is what "deleted" means. The same script counts what is left:
(2,472 = 32 superblock + 8 × 45 inode table + 32 bitmap + 32 × 64 data.)
Demo sections B, C and D:
61 → 0 either way. The difference is entirely in who pays:
zero_on_free makes rm proportional to the file's size: 4096 / 64 = 64 blocks, so 64 writes on an operation that previously did none. Deleting a 1 TB file now writes 1 TB. This is shred(1)'s bill, and it is why rm(1) doesn't do it.zero_on_alloc doubles the write path: 64 → 128, because every block is written twice, once with zeros and once with data. It is the wrong place to pay in this toy, since the very next line usually overwrites the whole block. On real hardware it is nearly free precisely because the block is written once anyway (§5.3), which is why the real answer looks like this one.Neither fix is retroactive. Mount with zero_on_free after the delete already happened and you get nothing:
[:ino.size] capread rewritten to return the whole blocks:
The truncate becomes unnecessary — a plain read of a 3-byte file returns 64 bytes. This is worth running because it locates the bug precisely: the toy has two independent ways to expose slack, read ignoring the size and truncate moving it, and only the second one is present in the shipped code. A real filesystem must get both right.
Bump is a subclass whose allocator keeps a cursor and never hands back a block below it:
Same rm writing nothing, same missing zero in truncate, same 61 bytes of slack — and no disclosure, because notes.txt landed on block 2, which nothing had ever written. This is the counterfactual that reframes the result: the leak is not caused by rm leaving data behind. It is caused by rm leaving data behind and the allocator being eager to reuse.
It is also a fix nobody would ship: never reusing blocks means running out of disk with a mostly-empty filesystem. Log-structured filesystems come closest to it, and pay for it with a cleaner.
The identical sequence through real syscalls (write, fsync, unlink, ftruncate, read) on the host's APFS volume, with a 6,272-byte secret and a 4,096-byte grow:
Zero non-zero bytes in 4,093 bytes of exposed tail. The real system honours the contract the toy breaks. It does not do it by zeroing on free — APFS is copy-on-write and its rm is also cheap — it does it by never letting an uninitialised block reach a read, which is §7.4.
A subclass that zeroes the tail of the last block inside truncate, before moving the size:
One block write, on an operation nobody performs in a hot loop, versus 64 on every rm. This is the fix a real implementation ships, and the reason it was left out of tinyfs.py is only that its absence is the demo.
Demo section E runs the whole trace again for four different lengths of the new file:
block_size − (size mod block_size), and it is zero when the write is block-aligned. Everything else follows from that.
So a reader can place their own system by asking four questions:
And where the boundary is not. SSD TRIM is orthogonal, and it is worth saying so because it gets offered as a defence. TRIM tells the drive that a freed block need no longer be preserved, which protects against someone desoldering the flash and reading the raw cells. It does nothing about the case above, where the filesystem hands you the block back through read() — at that point the block is allocated, live, and yours; there is nothing for TRIM to discard.
truncate rather than just a readThe strongest possible version of this demo would be a plain read that returns the neighbours' bytes, and it was available: drop [:ino.size] (§6.4) and the toy leaks 64 bytes on a 3-byte file. It was rejected because it makes the toy prove nothing. Every filesystem caps reads at the file length; a toy that omits the cap is demonstrating its own bug, not the mechanism's. Requiring the truncate keeps the read path correct and puts the whole weight of the result on the interaction between recycled blocks and a user-controlled length, which is the real thing.
A real bitmap is bits — that's the whole point of the name, and 32 blocks would fit in 4 bytes rather than 32. Bytes were chosen because bitmap()[b] is readable in a transcript ([0, 0, 0, 0, 0, 0, 0, 0] in §6.2) and bit twiddling adds three lines that teach nothing about this mechanism. The cost is the only thing lost: at 1 bit/block, a 1 TiB disk with 4 KiB blocks needs 32 MiB of bitmap; at 1 byte/block, 256 MiB. That ratio is why real ones use bits.
direct=4 caps a file at 256 bytes. Real inodes have 12 direct pointers plus single, double and triple indirect blocks (or, in ext4 and APFS, extents — an (offset, length) pair, so a contiguous file needs one entry instead of thousands). Indirect blocks would double the toy's length and add nothing: the aha needs one block to be recycled, and the recycling machinery is identical whether the block number came from inode.blocks[2] or from a pointer block. §9 Q1 shows the leak scaling to the whole file anyway.
Both flags work (§6.3) and both are the wrong shape:
shred and diskutil secureErase are for, and they exist as separate commands precisely because nobody wants that on every rm.What ext4 does for the preallocation case is better than either: an extent whose length field exceeds 32768 is flagged unwritten, and a read of it returns zeros from the flag, with no disk traffic at all. fallocate(2) documents the same trick for FALLOC_FL_ZERO_RANGE: the range is "not physically zeroed out on the device (except for partial blocks at the either end of the range)" — note the parenthesis, which is precisely the slack case in this toy, and precisely the case that is physically zeroed.
The road genuinely not taken is the one someone proposed. In 2012 Zheng Liu sent an RFC adding FALLOC_FL_NO_HIDE_STALE, letting an application ask for preallocated blocks without the zero guarantee, because the unwritten→written conversion was expensive: a random-write benchmark went from 76 seconds to 18. The patch's own posting conceded that "the malicious user could use this flag to get other user's data if (s)he doesn't do a initialization before reading this file." Ric Wheeler's reply was "I really, really don't like exposing stale data to users and applications"; Ted Ts'o was more open but wanted it behind CAP_SYS_RAWIO. The flag is not in fallocate(2) today. Someone offered to sell exactly this toy's behaviour for a 4× speedup, and the answer was no.
A real directory is a file whose contents are name → inode-number pairs, which is elegant and, for this toy, entirely beside the point: it would add a level of indirection to every operation and not change a single byte of the result. The 24-byte name field in the inode is the cheat that buys ls for one line — at the price of a hard 24-byte limit on names, and of making a rename a write to the inode rather than to a directory.
| Simplification | What a real filesystem does |
|---|---|
| One process, no locking | Every structure here is read-modify-written non-atomically: alloc_block reads the bitmap, flips a bit, writes it back. Two concurrent allocations would hand out the same block. Real filesystems put the bitmap under a lock or use per-CPU allocation groups (ext4's block groups, XFS's allocation groups) so allocations rarely contend. |
| No journal, no crash consistency | rm here performs several independent writes; a crash between them leaves the disk inconsistent (blocks marked free while an inode still points at them, or worse, the reverse). Real filesystems either journal metadata (ext4's jbd2), write it in a strict order, or never overwrite in place at all (APFS, btrfs, ZFS: copy-on-write plus an atomic superblock swap). |
| No page cache | Every _read/_write opens the image and hits the host filesystem. Real ones cache blocks in memory, which is also where the zero-fill of §5.3 lives — the thing that makes real short files safe is a property of the cache, not of the disk format. |
| Byte-per-block bitmap, 4 direct pointers | Bits, and extents (§7.2, §7.3). |
No permissions, no users, no unlink semantics |
An inode carries no uid/gid/mode, so "another user's file" cannot be expressed — the demo has to rely on you owning both files. On a real multi-user system that is the whole threat: user A's deleted block reappearing in user B's file. There is also no link count, so no distinction between unlink (drop a name) and delete (drop the file) — a real rm on a file with two hard links, or an open descriptor, frees nothing. |
No timestamps, no fsync, no ordering guarantees |
Nothing here has ever been asked when it is actually on the platter. |
| Fixed 64-byte blocks | 4 KiB is typical; ZFS and btrfs support variable records. Bigger blocks mean more slack per file, not less — §9 Q3. |
Answer before expanding. Each answer is derivable from the source, and each was verified by running it.
The demo recovers 61 bytes with one truncate to 64. You delete a 208-byte file instead, write ok\n, and truncate to 256 — the largest file four direct pointers allow. How much of the deleted file comes back?
205 of 208 bytes — everything except the three you overwrote.
The 61 bytes in §6.1 are a floor, not a ceiling. The truncate to 256 needs four blocks, alloc_block first-fits and hands back 0, 1, 2, 3 — the deleted file's own blocks, in its own order — and the size field then authorises reading all of them. Nothing was written to blocks 1–3 at any point.
You discover the problem and remount with --zero-on-free before writing notes.txt. Are you safe?
No. 61 stale bytes, exactly as before — the transcript is in §6.3. zero_on_free is consulted inside free_block, which ran during the rm, under the old mount. The blocks were already dirty and free when you changed the policy.
A zeroing policy only protects data freed after it is enabled, which is the operational reason "we turned on secure delete" is never an answer to a disclosure that already happened.
Real filesystems use 4 KiB blocks rather than 64 B. Does that make this better or worse, and by how much?
Worse, by a factor of 64. Formatting the toy with block_size=4096 and running the same trace:
Slack is block_size − (size mod block_size), so bigger blocks mean more slack per file, and a 3-byte file on a 4 KiB filesystem sits in front of 4,093 bytes of somebody's something. The reason this is not a catastrophe on your laptop is §7.4, not small blocks.
After the leak, you rewrite notes.txt with hi\n — same name, same length — hoping to scrub it. Does it help?
No. Still 61 stale bytes, still block 0:
write calls rm first (line 186), which frees block 0 without touching it, then re-allocates it by first fit and writes three bytes over the same three bytes. You can only overwrite slack by writing more bytes than the data that made it — which is Q5.
Same 208-byte deleted file, but now the new file is 200 bytes and you truncate to 256. How many of the old bytes survive?
8 — that is, 208 − 200.
Worth noticing that the file has 56 bytes of slack (256 − 200) but only 8 of them are stale. The other 48 are zeros, because the deleted file was itself only 208 bytes and had 48 bytes of slack of its own on a freshly formatted image.
Slack is layered: what you can read is the deepest thing ever written at that offset, not necessarily the most recent file.
You run this toy's logic as a storage backend on three machines behind a load balancer, each with its own image file. A user deletes their account. What have you actually deleted?
Whatever fraction of their data happened to live on the machine that served the delete — and of that, only the names. Nothing about the mechanism changes with replication; it just multiplies.
The general form is that deletion in any layered system is a promise about the top layer, and it only becomes a fact about the bytes when something rewrites them or destroys a key. This is also why "cryptographic erasure" (§6.8, condition 4) is the only deletion primitive that composes across replicas: you cannot visit every block on every machine, but you can destroy one key.
Every link below was fetched and confirmed live when this was written.
ftruncate(2) — the one sentence the toy breaks: "If the file previously was shorter, it is extended, and the extended part reads as null bytes ('\0')." Read it next to §5.5.fallocate(2) — the flag list, with no FALLOC_FL_NO_HIDE_STALE in it, and the FALLOC_FL_ZERO_RANGE note that blocks are "not physically zeroed out on the device (except for partial blocks at the either end of the range)". That parenthesis is this toy's 61 bytes, in production wording.CAP_SYS_RAWIO compromise. §7.4's story.