ADR-107 — Track A — FAT32 read path beyond FAT16 (second virtio-blk)
- Status: Accepted (runtime Verified only when this tip’s
qemu-smokeprintsfat32: okand the broken-chain negative printsfat32: fail reason=bad-chain; otherwise Documented) - Date: 2026-10-23
Context
ADR-028 put FAT16 on virtio-blk behind the thin VFS, and later ADRs grew write, readdir, unlink, grow, mkdir and rmdir on it. fat.rs rejects any volume with 65525 or more clusters, so a FAT32 volume (the kind mkfs.fat makes for anything past a few hundred MiB) cannot be read at all. The sponsor asked for FAT32 support on the existing virtio-blk path, tested in QEMU against an image built by mkfs.fat -F 32, with a fail-closed probe and a negative.
FAT32 differs from FAT16 in three places that matter here:
- BPB.
BPB_RootEntCntandBPB_FATSz16are 0; the FAT size is the 32-bitBPB_FATSz32, and the root directory’s first cluster isBPB_RootClus.BPB_FSInfonames the FSInfo sector, which carries three signatures and a free-cluster hint. - FAT table. Entries are 32 bits wide, of which the low 28 are used. End-of-chain is
>= 0x0FFFFFF8;0x0FFFFFF7is a bad cluster. - Root directory. There is no fixed root region. The root is an ordinary cluster chain.
Hypotheses
| ID | Option | Notes |
|---|---|---|
| H1 (chosen) | A separate read-only FAT32 reader (src/fat32.rs) on a second virtio-blk device holding a mkfs.fat -F 32 image. The FAT16 disk, fat.rs and the VFS stay unchanged. | Smallest honest step. FAT16 regressions are impossible by construction, and the FAT16 smoke still runs on the same boot. |
| H2 (rejected) | Widen fat.rs (u16 clusters everywhere) to FAT32 with write support in one PR. | Touches every FAT16 write path at once. Too large to review and test honestly in one step. |
| H3 (rejected) | Append the FAT32 volume to the FAT16 image at a fixed LBA. | The FAT16 host checks (mkfat16.py --check) pin the image size, and a magic offset is a hidden contract. |
| H4 (rejected) | Hand-roll the FAT32 image in Python. | The sponsor asked for mkfs.fat -F 32. A real tool also catches reader assumptions a home-made writer would share. |
Decision
-
Disk. QEMU gets a second
-drive …,readonly=on+-device virtio-blk-devicewithtarget/fat32.img, listed before the FAT16 disk. QEMU fills virtio-mmio slots from the top, so the FAT16 disk stays the guest’s first-found blk device, and older kernels (the cross-update prior OS) keep using it.virtio::init2()programs the second transport in scan order with its own queue memory; it exposesread_sector2only (no write). -
Image.
scripts/mkfat32.shrunsmkfs.fat -F 32 -s 1 -S 512on 34 MiB (about 68k clusters, above the 65525 floor), then usesmcopyto add:PROBE32.TXT(fat32-hi\n);BIG.BIN(3000 deterministic bytes, 6 clusters);- 20 small files, so the root directory needs more than one cluster;
LAST.TXT(fat32-root-chain\n), which therefore lands in a later root cluster.
mtoolsallocates forward, so the script then movesBIG.BIN’s last three clusters to a free run near cluster 40000 and relinks them in every FAT copy.fsck.fat -nmust pass on the result (a host check, not a guest probe). -
Reader (
fat32.rs). It does the following:- Mount checks the 0x55AA signature,
bps == 512, a power-of-twospc, andRootEntCnt == 0andFATSz16 == 0(otherwiseNotFat32). It requires a cluster count of at least 65525 (otherwiseNotFat32), checks that the FAT fits every cluster index and that the root cluster is in range, and checks the FSInfo lead/struct/trail signatures plus a free-count bound. - Chain walk masks entries to 28 bits and ends at
>= 0x0FFFFFF8. A free (0), reserved (1), bad (0x0FFFFFF7) or out-of-range link isBadLink. The walk is bounded by the cluster count, so a cycle isCycle. - File read requires the chain length to equal
ceil(size / cluster)exactly (otherwiseSizeMismatch). - Directory scan walks the root chain, stops at the first
0x00entry, and skips deleted, LFN and volume-label entries. It matches 8.3 names only.
- Mount checks the 0x55AA signature,
-
Probe (boot path, after the FAT16 probe). It prints:
fat32: mount clusters=N root=R fsinfo=okfat32: read /probe32.txt bytes=9fat32: chain /big.bin clusters=6 jumps=J bytes=3000, where J ≥ 1 non-adjacent links and the content matches byte for bytefat32: root-chain clusters=K last=found(K ≥ 2)fat32: ok
Any error prints
fat32: fail reason=<reason>and the kernel printsfat32: probe missed. -
Smoke (fail closed).
qemu-smoke.shrequires every marker above, plusclusters >= 65525,jumps >= 1and root clusters>= 2, and rejects anyfat32: failorprobe missed. Negative: a second boot of the same kernel with a copy of the image whoseFAT[root]is set to free (mkfat32.sh --corrupt) must printfat32: fail reason=bad-chain, must not printfat32: ok, and must still print FAT16fat: ok. -
cargo test(in QEMU). The tests are:- in-memory FAT32 images covering a good read through a two-cluster root, a free link, a cycle, bad and out-of-range links, a chain longer than the size, bad FSInfo, a FAT16-sized cluster count and a non-zero fixed root;
- the real-disk probe;
- a check that the FAT16 primary disk is rejected by the FAT32 mount (
NotFat32).
-
CI.
dosfstoolsandmtoolsare added to the smoke jobs’ apt lines and to the Dockerfile.
Not claimed
- No FAT32 write, create, delete or grow. No VFS mount of the FAT32 volume. No LFN decoding. No subdirectory walk on FAT32 (only the root chain). No FSInfo update. No FAT mirror comparison (copy 0 is read). No exFAT.
- Not a performance claim. Not a real-hardware claim (QEMU
virtonly). - FAT16 behaviour is unchanged. The FAT16 VFS is still the only mounted FAT.
Consequences
- QEMU runs (smoke,
cargo test, cross-update) now needmkfs.fat,mcopyandfsck.faton the host.mkfat32.shfails loudly if they are missing. - A later ADR can mount FAT32 behind the VFS or add write support, starting from this reader.