cld-toys › Toys › toy-filesystem

Commentary: toy-filesystem

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.

toy-filesystem/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with tinyfs.py open beside you. tinyfs.py is the toy itself (275 lines, two classes and a CLI); demo.py runs one trace under three mount options and then prices them; test_tinyfs.py pins all sixteen claims. Running any of them writes 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
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

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
$ python3 tinyfs.py format formatted disk.img $ 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 secret.txt 98 bytes blocks=[0, 1] $ python3 tinyfs.py rm secret.txt $ printf 'ok\n' | python3 tinyfs.py write notes.txt $ python3 tinyfs.py ls notes.txt 3 bytes blocks=[0] $ python3 tinyfs.py read notes.txt ok $ python3 tinyfs.py truncate notes.txt 64 $ python3 tinyfs.py ls notes.txt 64 bytes blocks=[0] $ python3 tinyfs.py read notes.txt ok T PASSWORD: hunter2-correct-horse SSN: 123-45-6789 CARD: 4111

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:


2. The problem this mechanism exists to solve

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:

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.


3. Background you need

ConceptWhere it's used hereOne 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.


4. The mental model

Before any code. The whole disk, as laid out by format() with the default geometry:

byte 0 32 392 424 2472 +-----------+-------------------------+---------+-------------------+ | superblock| inode table (8 x 45 B) | bitmap | 32 blocks x 64 B | +-----------+-------------------------+---------+-------------------+ geometry used/size/name/blocks[4] 1 B/blk the actual bytes A file is a size + a list of block numbers: secret.txt size=98 blocks=[0,1] | | v v block 0 [ROOT PASSWORD: hunter2-correct-horse\nSSN: 123-45-6789\nCARD: 4111] block 1 [ 1111 1111 1111 exp 09/29 cvv 402\n][...........30 bytes slack] rm secret.txt -> inode zeroed, bitmap[0]=bitmap[1]=0, blocks UNTOUCHED write notes.txt "ok\n" -> first fit returns block 0 again block 0 [ok\n][T PASSWORD: hunter2-correct-horse\nSSN: 123-45-6789\nCARD: 4111] ^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ yours 61 bytes of slack, and not empty read() returns out[:inode.size] = 3 bytes. The tail is unreachable... truncate("notes.txt", 64) sets inode.size = 64. ...and now it isn't.
Two sentences to carry away A block is recycled, not erased. And a file's length is metadata, so moving it is a metadata operation — cheap, legal, and, if nothing zeroed the block, a disclosure.

5. Reading the source

275 lines, two classes and a main. Read it in this order.

5.1 rm — the four lines that write nothing

tinyfs.py · lines 234–243
    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.

5.2 alloc_block and free_block — the bitmap, and first fit

tinyfs.py · lines 146–165
    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.

5.3 write — where the slack is created

tinyfs.py · lines 194–201
        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.

5.4 read — the cap that makes the leak need a second step

tinyfs.py · lines 204–210
    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.

5.5 truncate — the crack

tinyfs.py · lines 212–232
    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.


6. The demo, and what it proves

python3 demo.py. Sections A–E below are its real output, in order.

6.1 The trace, and the 61 bytes

secret.txt is 98 bytes; notes.txt is 3 bytes; block size is 64. A. Default mount: no zeroing anywhere ------------------------------------- 1. empty disk (empty) [free blocks: 32] 2. write secret.txt secret.txt size=98 blocks=[0, 1] [free blocks: 30] 3. rm secret.txt rm cost: 0 block writes after rm (empty) [free blocks: 32] 4. write notes.txt notes.txt size=3 blocks=[0] [free blocks: 31] 5. read notes.txt b'ok\n' 6. truncate to 64 notes.txt size=64 blocks=[0] [free blocks: 31] read('notes.txt') now returns 64 bytes: b'ok\nT PASSWORD: hunter2-correct-horse\nSSN: 123-45-6789\nCARD: 4111' of which 61 bytes are the deleted file's plaintext: b'T PASSWORD: hunter2-correct-horse\nSSN: 123-45-6789\nCARD: 4111' identical to SECRET[3:64]? True

The arithmetic, so no number here is unexplained:

What the reader had to do to see it Create their own file, write to it, resize it, read it. Four ordinary calls, no privilege, no second user's credentials, and no hex editor. This matters because the everyday version of this fact — "deleted data is still on the disk somewhere" — describes something you need a raw device and root to reach. This is a different and stronger claim: the bytes are inside a live file, in a namespace entry you own, reachable through the filesystem's public API.

6.2 The control: without the truncate there is no leak

test_the_honest_read_path_leaks_nothing in test_tinyfs.py does exactly the steps above, stops before the truncate, and asserts:

test_tinyfs.py · lines 93–94
    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:

== CF1: the image after rm, seen raw (offsets from data_start) == inode 0 raw : b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' bitmap[0:8] : [0, 0, 0, 0, 0, 0, 0, 0] block0 +00 : 52 4f 4f 54 20 50 41 53 53 57 4f 52 44 3a 20 68 |ROOT PASSWORD: h| block0 +10 : 75 6e 74 65 72 32 2d 63 6f 72 72 65 63 74 2d 68 |unter2-correct-h| block0 +20 : 6f 72 73 65 0a 53 53 4e 3a 20 31 32 33 2d 34 35 |orse.SSN: 123-45| block0 +30 : 2d 36 37 38 39 0a 43 41 52 44 3a 20 34 31 31 31 |-6789.CARD: 4111|

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:

== CF6: how much of the disk one deleted file leaves behind == blocks marked in use : 0 of 32 blocks holding data : 2 of 32 image size on host : 2472 bytes inode record size : 45 bytes (1 + 4 + 24 + 4*4) data region starts at: byte 424

(2,472 = 32 superblock + 8 × 45 inode table + 32 bitmap + 32 × 64 data.)

6.3 Both fixes work, and the bill is on different lines

Demo sections B, C and D:

B. Same trace, mounted with zero_on_free=True --------------------------------------------- 3. rm secret.txt rm cost: 2 block writes read('notes.txt') returns 64 bytes; 0 of them are the deleted file. tail is 61 zero bytes: True C. Same trace, mounted with zero_on_alloc=True ---------------------------------------------- 3. rm secret.txt rm cost: 0 block writes read('notes.txt') returns 64 bytes; 0 of them are the deleted file. tail is 61 zero bytes: True D. What the two fixes cost, on a 4096-byte file ----------------------------------------------- mount option write 4096B rm (default) 64 bw 0 bw zero_on_free 64 bw 64 bw zero_on_alloc 128 bw 0 bw

61 → 0 either way. The difference is entirely in who pays:

Neither fix is retroactive. Mount with zero_on_free after the delete already happened and you get nothing:

== Q2: mounting with --zero-on-free AFTER the rm == stale bytes: 61 read() : b'ok\nDEFGHIJKLMNOPQRSTUVWXYZABCDEF'

6.4 Counterfactual: delete the [:ino.size] cap

read rewritten to return the whole blocks:

== CF2: read() without the `[:ino.size]` cap == read('notes.txt') = b'ok\nT PASSWORD: hunter2-correct-horse\nSSN: 123-45-6789\nCARD: 4111' bytes returned = 64 (file is 3 bytes; no truncate needed)

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.

6.5 Counterfactual: the allocator decides whether there is anything to leak

Bump is a subclass whose allocator keeps a cursor and never hands back a block below it:

== CF3: allocation policy -- first fit vs. a never-reuse cursor == first fit : notes.txt on block [0], 61 stale bytes bump : notes.txt on block [2], 0 stale bytes

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.

6.6 The same trace against the real filesystem underneath

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:

host : Darwin 25.5.0, arm64 fs : /dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled, nobrowse, protect, root data) secret : 6272 bytes notes.txt : 4096 bytes after ftruncate(fd, 4096) head : b'ok\n\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' non-zero bytes past offset 3 : 0 contains 'hunter2-correct-horse' : False tail is all zeros : True

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.

6.7 The third fix: zero only what the grow exposes

A subclass that zeroes the tail of the last block inside truncate, before moving the size:

== CF4: the third fix -- zero only the tail a grow exposes == zero-on-grow : 0 stale bytes, 1 extra block write(s)

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.

6.8 The boundary condition — where the effect vanishes

Demo section E runs the whole trace again for four different lengths of the new file:

E. Where it vanishes: the new file's length, same trace ------------------------------------------------------- new file 3 bytes -> 61 stale bytes exposed new file 62 bytes -> 2 stale bytes exposed new file 63 bytes -> 1 stale bytes exposed new file 64 bytes -> 0 stale bytes exposed
Where the effect vanishes The exposure is exactly 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:

  1. Is the write block-aligned? A database writing 8 KiB pages onto 4 KiB blocks has no slack, ever. This whole class of bug is invisible to it.
  2. Does the allocator reuse eagerly? §6.5 — a bump or log-structured allocator removes the recycled block from the story.
  3. Does something zero the block before a read can reach it? On Linux and macOS, yes, always, and by a cheaper route than either flag here (§7.4). This is where the toy is a museum piece rather than a warning.
  4. Is the data encrypted at rest with a key that dies with the file? Then the recycled block is ciphertext under a discarded key.

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.


7. Design decisions and roads not taken

7.1 Why the leak needs a truncate rather than just a read

The 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.

7.2 One byte per block in the bitmap, not one bit

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.

7.3 Four direct block pointers, no indirect blocks

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.

7.4 Why neither shipped flag is what real filesystems do

Both flags work (§6.3) and both are the wrong shape:

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.

7.5 Names in the inode, no directories

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.


8. What's simplified vs. the real thing

SimplificationWhat 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.

9. Check yourself

Answer before expanding. Each answer is derivable from the source, and each was verified by running it.

Question 1

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?

Answer

205 of 208 bytes — everything except the three you overwrote.

== Q1: how far can ONE truncate reach? == secret.txt : 208 bytes on blocks [0, 1, 2, 3] notes.txt : 256 bytes, blocks [0, 1, 2, 3] recovered : 205 of the deleted file's 208 bytes first 48 : b'ok\nDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUV'

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.

Question 2

You discover the problem and remount with --zero-on-free before writing notes.txt. Are you safe?

Answer

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.

Question 3

Real filesystems use 4 KiB blocks rather than 64 B. Does that make this better or worse, and by how much?

Answer

Worse, by a factor of 64. Formatting the toy with block_size=4096 and running the same trace:

== Q3: a 4096-byte block size, 3-byte file == slack bytes exposed: 4093

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.

Question 4

After the leak, you rewrite notes.txt with hi\n — same name, same length — hoping to scrub it. Does it help?

Answer

No. Still 61 stale bytes, still block 0:

== Q4: does rewriting the file in place clear the slack? == blocks : [0] stale bytes: 61

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.

Question 5

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?

Answer

8 — that is, 208 − 200.

== Q5: two files, delete the first, write a long second == stale bytes: 8 slack of a 200-byte file in 64-byte blocks: 56

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.

Question 6

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?

Answer

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.


10. Further reading

Every link below was fetched and confirmed live when this was written.