RustForge

rust-kernel

Rustbelt: a hobby x86_64 kernel written in Rust

authorTyler Fowler <tyler@localhost>2026-10-30 02:16:46 -0700
commit856182a546a770c0e1f776ded343d91ae316343c
parent3b489faecb168e0e2b623eaa8eaff1ec5edd7a5a
treebrowse files at this commit
Stage 9: exFAT driver (0.9.0)

Hand-written exFAT (Microsoft spec) plugged into the VFS, read + write:
boot/up-case checksums, allocation bitmap, FAT chains and NoFatChain
(with conversion), entry sets with SetChecksum/NameHash, long UTF-16
names with case-insensitive lookup, volume label, PercentInUse.
Mounted at /mnt/disk2 = D:. Images built with mkfs.exfat plus a small
host-side populator (no loop devices/FUSE on the box). tests/exfat.rs,
scripts/exfat-check.sh (fsck.exfat -n clean), README exFAT section and
roadmap, new boot.png.
MCargo.lock+1 -1
MCargo.toml+7 -2
MREADME.md+367 -50
Mboot.png+0 -0
Mbuild.rs+56 -4
Abuild/exfat_populate.rs+233 -0
Mdisk/scripts/selftest.sh+11 -0
Adisk3/Long File Name With Spaces.txt+2 -0
Adisk3/MixedCase.TXT+1 -0
Adisk3/Photos/2026 Trip/trip notes.txt+1 -0
Adisk3/a-really-long-file-name-that-takes-several-file-name-directory-entries-in-exfat-xxxxxxxxxxxxxxxxxxxx.txt+1 -0
Adisk3/readme.txt+6 -0
Adisk3/Ünïcödé café.txt+1 -0
Ascripts/exfat-check.sh+87 -0
Mscripts/ext2-check.sh+3 -1
Mscripts/run-headless.sh+19 -1
Mscripts/screenshot.sh+7 -7
Msrc/block/partition.rs+9 -0
Asrc/fs/exfat.rs+993 -0
Msrc/fs/mod.rs+15 -3
Msrc/syscall.rs+1 -1
Atests/exfat.rs+302 -0
Mtests/ext2.rs+7 -6
Mtests/shell.rs+1 -1

24 files changed, 2131 insertions, 77 deletions

@@ -160,7 +160,7 @@ dependencies = [  [[package]] name = "rustbelt"-version = "0.8.0"+version = "0.9.0" dependencies = [  "bootloader",  "conquer-once",
@@ -1,6 +1,6 @@ [package] name = "rustbelt"-version = "0.8.0"+version = "0.9.0" edition = "2024"  [dependencies]@@ -79,6 +79,8 @@ run-args = [     "-drive", "file=target/disk.img,format=raw,if=virtio,snapshot=on",     # Third disk: the ext2 image (target/ext2.img), also snapshot=on.     "-drive", "file=target/ext2.img,format=raw,if=virtio,snapshot=on",+    # Fourth disk: the exFAT image (target/exfat.img), drive D:.+    "-drive", "file=target/exfat.img,format=raw,if=virtio,snapshot=on",     "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04",  # lets the kernel exit QEMU     "-serial", "stdio",                                    # serial port -> your terminal ]@@ -89,9 +91,12 @@ test-args = [     # so the image is the same at every boot (and tests can't corrupt it).     "-drive", "file=target/disk.img,format=raw,if=virtio,snapshot=on",     "-drive", "file=target/ext2.img,format=raw,if=virtio,snapshot=on",-    # Two more ext2 images just for tests/ext2.rs: 4 KiB blocks, revision 0.+    "-drive", "file=target/exfat.img,format=raw,if=virtio,snapshot=on",+    # More images just for the tests: ext2 with 4 KiB blocks (/mnt/disk3),+    # ext2 revision 0 (/mnt/disk4), exFAT with 512-byte clusters (/mnt/disk5).     "-drive", "file=target/ext2-4k.img,format=raw,if=virtio,snapshot=on",     "-drive", "file=target/ext2-rev0.img,format=raw,if=virtio,snapshot=on",+    "-drive", "file=target/exfat-512.img,format=raw,if=virtio,snapshot=on",     "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04",     "-serial", "stdio",     "-display", "none",
@@ -25,9 +25,12 @@ Under the shell:   `read`, `write`, `open`, `stat`, `getdents64`, `pipe`, `uname`... Two are   our own: `spawn` and `wait`. - **Files.** Files live behind a Linux-style **VFS**: a ramfs at `/` and-  `/tmp`, a **FAT32** partition mounted at `/mnt/disk0`, and an **ext2**-  disk at `/mnt/disk1` with a hand-written driver that reads and writes it-  (permissions, symlinks, indirect blocks; checked with the real `e2fsck`).+  `/tmp`, a **FAT32** partition mounted at `/mnt/disk0`, an **ext2**+  disk at `/mnt/disk1` and an **exFAT** disk at `/mnt/disk2`. The ext2 and+  exFAT drivers are hand-written and read and write their disks (ext2:+  permissions, symlinks, indirect blocks; exFAT: long Unicode names,+  case-insensitive lookup, FAT chains). The real `e2fsck` and `fsck.exfat`+  check what they write.   The disks are **virtio-blk** devices found by **PCI** enumeration, with   their **GPT/MBR** partitions parsed. The shell and its programs are   loaded from the FAT disk.@@ -39,9 +42,9 @@ Most code follows the blog_os sources (MIT / Apache-2.0) closely, with extra comments. Everything else is new: the serial mirroring, uptime clock, demo features and scripts, and everything past chapter 12. That covers preemptive threads, user mode, syscalls, ELF loading, the VFS, PCI, virtio-blk,-partitions, FAT32, the shell and the ext2 driver.+partitions, FAT32, the shell, and the ext2 and exFAT drivers. -![Rustbelt shell session on the ext2 drive C:: drives, ls -l with permissions and a symlink, cat through the symlink, cp from B: to C:](boot.png)+![Rustbelt shell session on the exFAT drive D:: drives, ls -l with long, mixed-case and Unicode names, cp from the ext2 drive C: to D:, then type with the name in other case](boot.png)  ## What's installed (on this box) @@ -84,7 +87,7 @@ cargo run                           # build + boot in a QEMU window; serial outp **Using it:** `cargo run` opens a QEMU window. The kernel prints a few boot lines, then you're at the shell prompt `A:/ $`. Click into the window and type, for example `help`, `cd B:`, `ls` or `cat readme.txt | wc`, or `ls -l C:/`-for the ext2 disk. See+for the ext2 disk, or `ls -l D:/` for the exFAT one. See [The shell](#the-shell-stage-7) for everything it can do. Everything on the screen is also mirrored to your terminal over serial. Typing `exit` ends the shell, and the kernel starts a fresh one.@@ -102,8 +105,10 @@ Headless (no window, good for SSH / scripts): ./scripts/run-headless.sh stack-overflow  # deliberately overflow the stack; passes if the double-fault handler catches it ./scripts/run-headless.sh page-fault      # write to unmapped 0xdeadbeaf; passes if the page-fault handler catches it EXT2_IMG=copy.img ./scripts/run-headless.sh  # use copy.img as the ext2 disk, writable (no snapshot)+EXFAT_IMG=copy.img ./scripts/run-headless.sh # the same for the exFAT disk ./scripts/ext2-check.sh                   # Rustbelt writes to copies of the ext2 images, then the                                           # host's e2fsck -fn checks them (see "ext2 (stage 8)")+./scripts/exfat-check.sh                  # the same for exFAT with fsck.exfat -n (see "exFAT (stage 9)") cargo run --features headless -- -display none                 # the self-test via cargo                                           # (QEMU's exit status is 33 = success; cargo just reports it as non-zero) cargo run --features demos                # the earlier stages' demos first, then the shell@@ -132,27 +137,35 @@ only, so the VGA screen shows just the shell session (and kernel messages such as a killed process).  ```-Rustbelt 0.8.0 -- a hobby x86_64 kernel in Rust+Rustbelt 0.9.0 -- a hobby x86_64 kernel in Rust [pci] 00:00.0 8086:1237 host bridge-... (8 PCI devices)-[pci] 8 devices on the PCI bus+... (9 PCI devices)+[pci] 9 devices on the PCI bus [disk] vda: virtio-blk, 131072 sectors (64 MiB) [disk] vda: GPT partition table, 1 partition(s) [disk] vda1: start 2048 sectors 126976 type Microsoft basic data (FAT/exFAT/NTFS) name "fat32" [disk] vdb: virtio-blk, 65536 sectors (32 MiB) [disk] vdb: no partition table (NoSignature)+[disk] vdc: virtio-blk, 65536 sectors (32 MiB)+[disk] vdc: no partition table (WholeDiskExfat) [disk] vda1: FAT32 volume "TYLERDISK" mounted at /mnt/disk0 [disk] vdb: ext2 volume "RUSTBELT" (rev 1, 1 KiB blocks, 4 groups) mounted at /mnt/disk1+[disk] vdc: exFAT volume "TYLEREXFAT" (4 KiB clusters, 7680 clusters) mounted at /mnt/disk2 [vfs] mounted ramfs at / [vfs] mounted ramfs at /tmp [vfs] mounted fat32 at /mnt/disk0 [vfs] mounted ext2 at /mnt/disk1+[vfs] mounted exfat at /mnt/disk2+[process] started pid 1 (sh), entry 0x8000406b60+A:/ $ # Rustbelt shell self-test: `cargo run --features headless` runs this with+A:/ $ # `sh /mnt/disk0/scripts/selftest.sh` (B:/scripts/selftest.sh) and scripts/run-headless.sh checks the output. A:/ $ ver-Rustbelt 0.8.0 (stage 8: ext2) x86_64+Rustbelt 0.9.0 (stage 9: exFAT) x86_64 A:/ $ drives A:  /            ramfs B:  /mnt/disk0   fat32 C:  /mnt/disk1   ext2+D:  /mnt/disk2   exfat A:/ $ pwd / A:/ $ cd B:@@ -280,40 +293,94 @@ drwxr-xr-x      <DIR>  made-here/ -rw-r--r--   69206016  sparse.bin -rw-r--r--        125  todo.txt [process] pid 28 (ls) exited with code 0-C:/ $ run evil-[process] started pid 29 (evil), entry 0x80004000f0-[evil] pid 29: reading kernel memory at 0x200000...+C:/ $ cd D:+D:/ $ ls+[process] started pid 29 (ls), entry 0x8000401880+Long File Name With Spaces.txt+MixedCase.TXT+Photos/+a-really-long-file-name-that-takes-several-file-name-directory-entries-in-exfat-xxxxxxxxxxxxxxxxxxxx.txt+frag-chain.txt+numbers.txt+readme.txt+Ünïcödé café.txt+[process] pid 29 (ls) exited with code 0+D:/ $ cat "long file name with spaces.txt"+[process] started pid 30 (cat), entry 0x8000400260+This file has a long name with spaces in it. exFAT stores names of up+to 255 UTF-16 characters, 15 per "File Name" directory entry.+[process] pid 30 (cat) exited with code 0+D:/ $ cp B:/notes/todo.txt D:/+[process] started pid 31 (cp), entry 0x80004006b0+[process] pid 31 (cp) exited with code 0+D:/ $ cp C:/hello.txt "Copied From Ext2.txt"+[process] started pid 32 (cp), entry 0x80004006b0+[process] pid 32 (cp) exited with code 0+D:/ $ cp mixedcase.txt C:/from-exfat.txt+[process] started pid 33 (cp), entry 0x80004006b0+[process] pid 33 (cp) exited with code 0+D:/ $ cat C:/from-exfat.txt+[process] started pid 34 (cat), entry 0x8000400260+MixedCase.TXT: exFAT keeps the case you wrote but ignores it when looking names up.+[process] pid 34 (cat) exited with code 0+D:/ $ md "New Folder"+[process] started pid 35 (mkdir), entry 0x8000400100+[process] pid 35 (mkdir) exited with code 0+D:/ $ echo hi from exFAT > "New Folder/Note.txt"+D:/ $ type "new folder/NOTE.TXT"+[process] started pid 36 (cat), entry 0x8000400260+hi from exFAT+[process] pid 36 (cat) exited with code 0+D:/ $ ls -l D:/+[process] started pid 37 (ls), entry 0x8000401880+       142  Copied From Ext2.txt+       132  Long File Name With Spaces.txt+        84  MixedCase.TXT+     <DIR>  New Folder/+     <DIR>  Photos/+        88  a-really-long-file-name-that-takes-several-file-name-directory-entries-in-exfat-xxxxxxxxxxxxxxxxxxxx.txt+     38000  frag-chain.txt+    340000  numbers.txt+       363  readme.txt+       125  todo.txt+        61  Ünïcödé café.txt+[process] pid 37 (ls) exited with code 0+D:/ $ run evil+[process] started pid 38 (evil), entry 0x80004000f0+[evil] pid 38: reading kernel memory at 0x200000... [kernel] user page fault: address Ok(VirtAddr(0x200000)), error PageFaultErrorCode(PROTECTION_VIOLATION | USER_MODE) [kernel] page fault in user mode at rip 0x800040017e-[process] pid 29 (evil) killed: page fault+[process] pid 38 (evil) killed: page fault [exit code 139]-C:/ $ nosuchprogram+D:/ $ nosuchprogram sh: nosuchprogram: command not found (try `help`)-C:/ $ cd Q:+D:/ $ cd Q: cd: there is no drive Q: (see `drives`)-C:/ $ cd A:/tmp+D:/ $ cd A:/tmp A:/tmp $ pwd /tmp A:/tmp $ exit 0 [process] pid 1 (sh) exited with code 0 [ok] shell self-test finished: Exited(0)-[memory] 20 frames in use-[run-headless] all 22+2 expected shell lines found+[memory] 30 frames in use+[run-headless] all 30+2 expected shell lines found [run-headless] kernel exited OK ``` -The run ends with `[memory] 20 frames in use`, even though 29 processes have+The run ends with `[memory] 30 frames in use`, even though 38 processes have come and gone. That's exactly what was in use right after boot (mostly the-two virtio disks' queues and buffers), so exited processes give their page+three virtio disks' queues and buffers), so exited processes give their page tables, code and stacks back.  The kernel boots from the bootimage on the first (IDE) disk; the scripts and-`Cargo.toml` attach a **second** disk, `target/disk.img` (GPT + FAT32), and a-**third**, `target/ext2.img` (raw ext2), as+`Cargo.toml` attach a **second** disk, `target/disk.img` (GPT + FAT32), a+**third**, `target/ext2.img` (raw ext2), and a **fourth**,+`target/exfat.img` (raw exFAT), as `-drive file=target/disk.img,format=raw,if=virtio,snapshot=on` and so on. `snapshot=on` makes QEMU throw away writes when it exits, so every boot and every test sees the same fresh image even though the kernel writes to it.-The tests attach two more ext2 images (4 KiB blocks, revision 0).+The tests attach two more ext2 images (4 KiB blocks, revision 0) and one+more exFAT image (512-byte clusters).  ## Tests @@ -349,7 +416,8 @@ heap_allocation::simple_allocation...               [ok] | `tests/vfs.rs` | read `/hello.txt` to EOF, path normalisation (`.`, `..`, relative to a cwd), `ENOENT`/`ENOTDIR`/`EINVAL`/`EISDIR`/`EEXIST`, `/tmp` is a separate ramfs, longest-prefix mounts, sparse writes zero-fill, listing, remove (`ENOTEMPTY`, `EBUSY` on mount points), seek/stat/truncate, `getdents64` record layout, pipes pass bytes and report EOF / `EPIPE` | | `tests/block.rs` | PCI scan finds the virtio disk; sector 1 of the image is the GPT header; multi-sector reads equal single-sector reads; write + read back; out-of-range and partial-sector requests fail; the GPT partition is registered as `vda1` (with a FAT32 boot sector); MBR parsing on a RAM disk | | `tests/fat.rs` | the FAT32 volume is mounted at `/mnt/disk0` with the expected files (`bin`, `notes`, `readme.txt`, `scripts`); read `readme.txt`; every program in `bin/` is a valid ELF; create/write/read/truncate/remove a 10 KB file; directories with long names, `ENOTEMPTY`; the `BlockIo` adapter handles unaligned reads and writes |-| `tests/ext2.rs` | the three ext2 images are mounted (`/mnt/disk1-3`); permissions and case-sensitive names; `big.txt` line by line and in one read (direct, single and double indirect blocks); `sparse.bin`'s data at 0 / 40 MiB / 65 MiB (triple indirect) and its holes; fast and slow symlinks, `readlink`, removing a link keeps its target; 4 KiB blocks and revision 0; on each image: mkdir, create, a 300 KB write, append, overwrite across a block boundary, a write at 70 MiB (hole + triple indirect), truncate, `ENOTEMPTY`/`EISDIR`/`ENOTDIR`, unlink, rmdir, and the superblock's free counts are back where they started; 200 files grow a directory; copy from FAT to ext2. `scripts/ext2-check.sh` then checks the same kernel's writes with the host's `e2fsck` |+| `tests/ext2.rs` | the three ext2 images are mounted (`/mnt/disk1`, `disk3`, `disk4`); permissions and case-sensitive names; `big.txt` line by line and in one read (direct, single and double indirect blocks); `sparse.bin`'s data at 0 / 40 MiB / 65 MiB (triple indirect) and its holes; fast and slow symlinks, `readlink`, removing a link keeps its target; 4 KiB blocks and revision 0; on each image: mkdir, create, a 300 KB write, append, overwrite across a block boundary, a write at 70 MiB (hole + triple indirect), truncate, `ENOTEMPTY`/`EISDIR`/`ENOTDIR`, unlink, rmdir, and the superblock's free counts are back where they started; 200 files grow a directory; copy from FAT to ext2. `scripts/ext2-check.sh` then checks the same kernel's writes with the host's `e2fsck` |+| `tests/exfat.rs` | both exFAT images are mounted (`/mnt/disk2`, `/mnt/disk5`), label and cluster size; long, mixed-case and Unicode names listed as written; case-insensitive lookup (`ÜNÏCÖDÉ CAFÉ.TXT`, nested paths); `numbers.txt` at a cluster boundary and at its end; the FAT-chained `frag-chain.txt` on 4 KiB and 512-byte clusters; bad names (`EINVAL`, `ENAMETOOLONG`, `EEXIST` for a name in another case); on each image: mkdir, create, write across clusters, append, truncate, a write past the end (zero gap), `ENOTEMPTY`, unlink, rmdir, and the bitmap and PercentInUse are back where they started; a contiguous file turns into a FAT chain; 200 files grow a directory; the root grows; copies between FAT, ext2 and exFAT; a last test leaves files for `scripts/exfat-check.sh`'s `fsck.exfat` | | `tests/threads.rs` | two busy-looping threads that never yield both make progress while the main thread busy-waits too (only possible with preemption); a thread can exit and the others keep running |  How it works: `#![feature(custom_test_frameworks)]` collects every `#[test_case]`@@ -401,19 +469,25 @@ src/tty.rs                console line discipline (cooked: echo + line editing;                            as escape sequences) + Console (the File behind fds 0/1/2) src/fs/mod.rs             VFS: Inode + FileSystem traits, mount table, path normalize/lookup/create/remove,                           symlink following (ELOOP after 8), init (boot files),-                          mount_disks (ext2 or FAT partitions/disks -> /mnt/diskN)+                          mount_disks (ext2, exFAT or FAT partitions/disks -> /mnt/diskN) src/fs/file.rs            File trait (what an fd points to), InodeFile (inode + offset), Stat, getdents64 src/fs/ramfs.rs           in-memory filesystem src/fs/fat.rs             FAT32 via the fatfs crate: BlockIo adapter + FatFs/FatInode for the VFS src/fs/ext2.rs            hand-written ext2: superblock, groups, bitmaps, inodes, indirect blocks,                           directories, symlinks; read + write (the layout is explained at the top)+src/fs/exfat.rs           hand-written exFAT: boot checksum, bitmap, FAT chains + NoFatChain, up-case+                          table, entry sets + SetChecksum; read + write (layout explained at the top) src/fs/pipe.rs            pipes: a byte queue with a blocking read end and a write end build.rs                  compiles user/*.rs to ELF files, then builds target/disk.img (GPT + FAT32)-                          and the ext2 images target/ext2.img, ext2-4k.img, ext2-rev0.img (mke2fs -d)+                          and the ext2 images target/ext2.img, ext2-4k.img, ext2-rev0.img (mke2fs -d),+                          and the exFAT images target/exfat.img, exfat-512.img (mkfs.exfat + populator)+build/exfat_populate.rs   build.rs's own little exFAT writer: copies a tree into a fresh mkfs.exfat image disk/                     files copied onto the FAT32 disk (readme.txt, notes/todo.txt,                           scripts/selftest.sh = the headless shell session) disk2/                    files copied onto the ext2 disk (hello.txt, readme.txt, docs/...);                           build.rs adds big.txt, sparse.bin and two symlinks+disk3/                    files copied onto the exFAT disk (long, mixed-case, Unicode names, Photos/...);+                          build.rs adds numbers.txt and frag-chain.txt user/ulib.rs              tiny user-space "libc": syscall wrappers, println!/eprintln!, a 64 KiB heap                           (Vec/String), line reading, strerror, _start, panic handler user/link.ld              linker script for user programs (base 0x80_0040_0000)@@ -428,6 +502,7 @@ scripts/runner.sh         `cargo run`/`cargo test` runner: `bootimage runner` +  scripts/run-headless.sh   headless boot + shell self-test, timeout, exit-code and output checks scripts/screenshot.sh     headless boot + a typed shell session + PNG screenshot via QEMU monitor scripts/ext2-check.sh     Rustbelt writes to copies of the ext2 images, then host e2fsck -fn + debugfs+scripts/exfat-check.sh    Rustbelt writes to copies of the exFAT images, then host fsck.exfat -n + dump.exfat boot.png                  screenshot of the VGA screen (a shell session) ``` @@ -697,7 +772,7 @@ Numbers are Linux's wherever Linux has the same call, so man pages and | 39 | `getpid` | | | | 60 | `exit` | code | | | 61 | `wait` | pid, flags | **simpler than Linux `wait4`**: blocks, returns the exit code (139 = killed, like a shell shows SIGSEGV), reaps the child (freeing its memory); `ECHILD` if not your child; flags `WNOHANG` (1): don't block, `EAGAIN` while it's still running |-| 63 | `uname` | *utsname | Linux's struct: 6 NUL-padded 65-byte fields: "Rustbelt", "qemu", "0.8.0", "stage 8: ext2", "x86_64", "(none)" |+| 63 | `uname` | *utsname | Linux's struct: 6 NUL-padded 65-byte fields: "Rustbelt", "qemu", "0.9.0", "stage 9: exFAT", "x86_64", "(none)" | | 79 | `getcwd` | buf, len | returns the length (no NUL); `ERANGE` if too small | | 80 | `chdir` | path, path_len | per-process cwd, inherited by spawned children | | 83 | `mkdir` | path, path_len | |@@ -1257,15 +1332,15 @@ Then `mke2fs -t ext2 -d <tree>` builds the images without root or mounting: | Image | Size | Block size | Notes | Attached as | |---|---|---|---|---| | `target/ext2.img` | 32 MiB | 1 KiB | revision 1, label RUSTBELT, the full tree | `vdb` -> `/mnt/disk1` = `C:` (run and tests) |-| `target/ext2-4k.img` | 8 MiB | 4 KiB | label FOURK, small tree | `vdc` -> `/mnt/disk2`, tests only |-| `target/ext2-rev0.img` | 4 MiB | 1 KiB | **revision 0** (`-E revision=0`), small tree | `vdd` -> `/mnt/disk3`, tests only |+| `target/ext2-4k.img` | 8 MiB | 4 KiB | label FOURK, small tree | `vdd` -> `/mnt/disk3`, tests only |+| `target/ext2-rev0.img` | 4 MiB | 1 KiB | **revision 0** (`-E revision=0`), small tree | `vde` -> `/mnt/disk4`, tests only |  **Raw, not partitioned.** The FAT disk has a GPT because that's what real disks have. The ext2 image is the bare file system, with no partition table, so the host's `e2fsck`, `debugfs` and `dumpe2fs` work on the file directly. `mount_disks` mounts a whole disk if it has no partitions, and its partitions if it does. The disks are numbered in device order: `vda1` =-disk0, `vdb` = disk1.+disk0, `vdb` = disk1, `vdc` (exFAT) = disk2.  ### Checking Rustbelt's writes with the real e2fsck @@ -1307,6 +1382,217 @@ Pass 5 is the one that catches allocation bugs: it recounts every bitmap and compares the result with the free counts in the group descriptors and the superblock. +## exFAT (stage 9)++The fourth disk, `target/exfat.img`, holds an **exFAT** file system:+the format on SD cards over 32 GB and most USB sticks, which Windows,+macOS, Linux, cameras and phones all understand. Rustbelt reads and writes+it with its own driver, `src/fs/exfat.rs`: about 1000 lines including a+long explanation at the top, written from+[Microsoft's exFAT specification](https://learn.microsoft.com/en-us/windows/win32/fileio/exfat-specification),+with no crates. It is mounted at `/mnt/disk2`, which is drive **`D:`**:++```+A:/ $ cd d:+D:/ $ ls -l+       132  Long File Name With Spaces.txt+        84  MixedCase.TXT+     <DIR>  Photos/+        88  a-really-long-file-name-that-takes-several-file-name-directory-entries-in-exfat-xxxxxxxxxxxxxxxxxxxx.txt+     38000  frag-chain.txt+    340000  numbers.txt+       363  readme.txt+        61  Ünïcödé café.txt+D:/ $ cat "long file name with spaces.txt"+This file has a long name with spaces in it. exFAT stores names of up+to 255 UTF-16 characters, 15 per "File Name" directory entry.+D:/ $ cp C:/hello.txt "From Ext2.txt"+D:/ $ type "FROM EXT2.TXT"+Hello from the ext2 disk! This file lives on a second virtio disk, in an+ext2 file system that Rustbelt reads and writes with its own driver.+```++Names keep the case you typed but are found in any case, like on Windows.+Quote names with spaces. Inside quotes the shell doesn't translate drive+letters, so `cd D:` first (or write `/mnt/disk2/...`). exFAT has no+permissions, so `ls -l` shows sizes only, like on the FAT disk.++### The on-disk layout, for a learner++exFAT is FAT's successor, redesigned for big flash cards. If you know+FAT32, three things are new: a **bitmap** says which clusters are free,+contiguous files can skip the FAT entirely, and a file's directory entry+is a **set** of 32-byte entries with a checksum.++```text+sector 0      1-8        9-10   11        12-23          FatOffset    ClusterHeapOffset+| boot sector | ext. boot | OEM | checksum | backup of 0-11 | ... | FAT | ... | cluster 2 | cluster 3 | ...+```++- **Boot sector.** Says `EXFAT   ` at byte 3; that's how `mount_disks`+  recognizes it. Then the geometry, all in sectors: where the FAT and the+  **cluster heap** start, how many clusters there are, the root directory's+  first cluster, and the sector and cluster sizes as powers of two (`9` =+  512 bytes). Byte 112 is **PercentInUse**, which we keep up to date.+- **Boot checksum.** Sector 11 is the 32-bit "rotate right, add the next+  byte" checksum of sectors 0-10, repeated to fill the sector. It skips+  the three bytes that change while the volume is in use (VolumeFlags and+  PercentInUse), so updating those doesn't mean rewriting the checksum. We+  check it at mount.+- **FAT.** One 32-bit entry per cluster: the next cluster of the file, or+  `0xFFFFFFFF` for the end. Clusters are numbered from 2, and cluster N+  starts at `ClusterHeapOffset + (N - 2) * cluster size`.+- **Everything else lives in clusters**, even the "system files":+  - **Allocation bitmap**: 1 bit per cluster. This, not the FAT, says+    what's free. We keep it in memory (960 bytes for our disk) and write+    each changed byte straight through.+  - **Up-case table**: the upper-case form of every UTF-16 character. Name+    lookups compare up-cased names. Because the table is on the disk, every+    implementation agrees on what "the same name" means, even for `é`/`É`.+    It's stored compressed (`0xFFFF, n` = "the next n characters map to+    themselves") and has its own checksum, which we verify. We keep only+    the characters that change, in a sorted `Vec`, and binary-search it.+  - **Root directory**: like any directory, except that it has no entry+    of its own, so its size is the length of its FAT chain.++**Directories** are files of 32-byte entries. Byte 0 is the type, and bit 7+of it means "in use". Deleting just clears that bit, and type `0x00` marks+the end. The root also holds a volume label entry (`0x83`), the bitmap's+(`0x81`) and the up-case table's (`0x82`). A file or directory is an+**entry set**:++```text+0x85 File              SecondaryCount, SetChecksum, attributes (0x10 = directory), 3 timestamps+0xC0 Stream Extension  flags, NameLength, NameHash, ValidDataLength, FirstCluster, DataLength+0xC1 File Name         15 UTF-16 characters  <- repeated: a 104-character name needs 7+```++- **SetChecksum** is a 16-bit "rotate right, add" over every byte of the+  set except itself. Change a file's size and you must recompute it, or+  other systems treat the file as corrupt.+- **NameHash** is the same checksum over the up-cased name, so a lookup can+  skip most entries without comparing names.+- **NoFatChain** (stream flag `0x02`): "my clusters are consecutive; don't+  read the FAT". Most files are contiguous, so most files never touch the+  FAT. When a file must grow and the next cluster is taken, the driver+  writes FAT links for the clusters it already has, clears the flag, and+  carries on wherever there's room (`Volume::grow`). Directories work the+  same way.+- **ValidDataLength** <= **DataLength**: bytes past it read as zeros+  (Windows uses this to create big files quickly). We read it that way and+  always write the two equal.+- Directories have no `.` or `..` entries, and their size is always whole+  clusters.++### What the driver does and doesn't do++- **Reading:** contiguous and FAT-chained files, long UTF-16 names,+  case-insensitive lookup through the volume's own up-case table, nested+  directories, the volume label, and both cluster sizes we test (4 KiB and+  512 bytes). Damaged entry sets (bad SetChecksum) are skipped.+- **Writing:** create, write, append, truncate to 0, mkdir, unlink and+  rmdir (`ENOTEMPTY` if it isn't empty). That includes:+  - allocating from the bitmap, preferring the cluster right after the+    file's last one;+  - growing directories when a new entry set doesn't fit, the root too;+  - converting NoFatChain files to FAT chains, freeing chains, and+    recomputing SetChecksums, NameHashes and PercentInUse.+  - Names are checked: 1-255 UTF-16 characters, no control characters or+    `" * / : < > ? \ |`. A name that differs only in case is `EEXIST`.+- **Not supported:**+  - rename and symlinks (exFAT has no symlinks);+  - real timestamps: there's no clock driver, so everything is 1 Jan 2026;+  - a second FAT (TexFAT): such a volume mounts read-only;+  - the VolumeDirty flag (writes go straight through);+  - shrinking directories (nobody does).+- **Like the ext2 driver**, there's no block cache: only the bitmap and+  one FAT sector are cached. An inode is just the path from the root,+  resolved again on every call, because exFAT has no inode numbers (a file+  *is* its directory entry).++### The test images, and why there's a populator (`build.rs`)++`mkfs.exfat` (exfatprogs) formats an image, but unlike `mke2fs -d` it can't+copy files in, and mtools doesn't speak exFAT. The usual way is to mount the+image, but that needs root and a loop device (or FUSE, which doesn't work on+this box either). So `build/exfat_populate.rs` is a **small exFAT writer of+its own** (~230 lines) that `build.rs` runs after `mkfs.exfat`. It only+handles a freshly formatted volume, which makes it simple: clusters are+handed out in order, each file is one contiguous NoFatChain run, and files+named `frag-*` get every other cluster, linked through the FAT, so the+kernel has a fragmented file to read. It deliberately shares no code with+the kernel driver, so a misreading of the spec in one doesn't hide in the+other, and the host's `fsck.exfat` checks its output on every run of+`scripts/exfat-check.sh`.++The tree is `disk3/` (long names with spaces, mixed case, non-ASCII names, a+104-character name, `Photos/2026 Trip/trip notes.txt`) plus two generated+files: `numbers.txt` (20 000 lines, 340 000 bytes, contiguous) and+`frag-chain.txt` (38 000 bytes, fragmented).++| Image | Size | Clusters | Notes | Attached as |+|---|---|---|---|---|+| `target/exfat.img` | 32 MiB | 4 KiB | label TYLEREXFAT, the full tree | `vdc` -> `/mnt/disk2` = `D:` (run and tests) |+| `target/exfat-512.img` | 8 MiB | 512 bytes | label SMALLCLUST, small tree: every file spans many clusters | `vdf` -> `/mnt/disk5`, tests only |++Like the ext2 disk, it's a bare file system with no partition table, so+`fsck.exfat` and `dump.exfat` work on the file directly. An exFAT boot+sector also ends in `55 AA`, like an MBR, so the partition scanner checks+for `EXFAT   ` and reports `no partition table (WholeDiskExfat)` instead of+"MBR, 0 partitions".++### Checking Rustbelt's writes with the real fsck.exfat++`scripts/exfat-check.sh` works like `ext2-check.sh`. It copies the images to+`target/fsck/` and boots Rustbelt on the **writable** copies: once for the+shell self-test (`EXFAT_IMG=... ./scripts/run-headless.sh`), and once for the+`tests/exfat.rs` kernel. Its last test, `written_files_stay_for_fsck`,+deletes nothing, so fsck sees the tricky cases on disk: 300 files in a grown+directory that became a FAT chain, deleted entries between live ones, and a+file converted from NoFatChain to a FAT chain. Then it runs `fsck.exfat -n`+(report, change nothing) on every copy and reads a new file back with+`dump.exfat` and `dd`:++```sh+cargo test --test exfat && ./scripts/exfat-check.sh+```++```+== fsck.exfat -n target/exfat.img (pristine, as built)+target/exfat.img: clean. directories 3, files 8+== fsck.exfat -n target/exfat-512.img (pristine, as built)+target/exfat-512.img: clean. directories 3, files 4+13 exFAT tests ok+== fsck.exfat -n target/fsck/selftest-exfat.img (after Rustbelt wrote to it)+target/fsck/selftest-exfat.img: clean. directories 4, files 11+== fsck.exfat -n target/fsck/tests-exfat.img (after Rustbelt wrote to it)+target/fsck/tests-exfat.img: clean. directories 4, files 212+== fsck.exfat -n target/fsck/tests-exfat-512.img (after Rustbelt wrote to it)+target/fsck/tests-exfat-512.img: clean. directories 4, files 32+== dump.exfat -s "/New Folder": the entry set Rustbelt wrote for Note.txt+  Path: /New Folder/Note.txt+  Entrytype:                      0x85+  SetChecksum:                    0xAF76+  Entrytype:                      0xC0+  GeneralSecondaryFlags:          0x03+  NameLength:                     8+  NameHash:                       0x2C4C+  ValidDataLength:                14+  FirstCluster:                   19+  DataLength:                     14+  ...+== its 14 bytes at cluster 19, read with dd:+hi from exFAT+== all images are clean+```++`fsck.exfat` recomputes the bitmap from every file's clusters, and checks+each SetChecksum and the boot checksum. A flipped bitmap bit or one changed+byte in an entry set gives "files corrupted 1". It doesn't check+PercentInUse, so the tests check that themselves: their `usage()` helper+reads the bitmap and byte 112 straight off the disk with a few lines of+independent parsing.+ ## Roadmap  Done: all 12 chapters of the 2nd edition, plus preemptive kernel threads,@@ -1341,6 +1627,7 @@ memory cleanup, the shell, and a hand-written ext2 driver. - [x] Shell as a user program: line editing, builtins, PATH, pipes, redirection, background jobs, drive letters, `run --trace` — [OSDev: Shell](https://wiki.osdev.org/Shell) - [x] Process cleanup: frame free list, address spaces freed on reap - [x] ext2, hand-written: read + write, indirect blocks, symlinks, 1/4 KiB blocks, revision 0/1, checked with `e2fsck` — [OSDev: Ext2](https://wiki.osdev.org/Ext2), [The Second Extended File System (Dave Poirier)](https://www.nongnu.org/ext2-doc/ext2.html)+- [x] exFAT, hand-written: read + write, entry sets + checksums, up-case table, NoFatChain and FAT chains, checked with `fsck.exfat` — [Microsoft exFAT specification](https://learn.microsoft.com/en-us/windows/win32/fileio/exfat-specification)  ### Filesystem roadmap (Tyler's plan) @@ -1360,22 +1647,40 @@ whose `root()` returns `Inode`s; mounting it is one `fs::mount` call.    rename, hard links, a block cache, real timestamps once there's an RTC    driver. Later, ext4's extents would be a nice follow-up. [OSDev: Ext2](https://wiki.osdev.org/Ext2),    [The Second Extended File System (Dave Poirier)](https://www.nongnu.org/ext2-doc/ext2.html)-6. **Next, in this order:**-   - **exFAT** (stage 9) — because it works on literally anything (cameras, phones,-     Windows, macOS, Linux), so a kernel that speaks exFAT can share USB sticks-     and SD cards with every other machine. Allocation bitmap + FAT-like cluster-     chains + directory entry sets with checksums.-     [Microsoft exFAT specification](https://learn.microsoft.com/en-us/windows/win32/fileio/exfat-specification)-   - **btrfs**, read-only (after exFAT) — because most of Tyler's Linux systems will use-     btrfs going forward, so this kernel could read their disks. Much bigger:-     copy-on-write B-trees, the chunk tree (logical -> physical mapping), the-     root/fs trees, checksums, compression (zlib/lzo/zstd). Read-only means-     parsing the superblock, chunk tree and fs tree -- no transactions or-     space allocation. [btrfs on-disk format](https://btrfs.readthedocs.io/en/latest/dev/On-disk-format.html),-     [btrfs docs](https://btrfs.readthedocs.io/)-7. **NTFS** — last, maybe never.--Other next stages:+6. **exFAT** — done (stage 9, `src/fs/exfat.rs`): written by hand from+   Microsoft's spec, read and write: boot checksum, allocation bitmap, FAT+   chains and NoFatChain runs, up-case table, entry sets with SetChecksum+   and NameHash. Chosen because it works on literally anything (cameras,+   phones, Windows, macOS, Linux), so Rustbelt can share USB sticks and SD+   cards with every other machine. Still to do: rename, timestamps,+   TexFAT. [Microsoft exFAT specification](https://learn.microsoft.com/en-us/windows/win32/fileio/exfat-specification)+7. **Next: btrfs, read-only** (stage 10) — because most of Tyler's Linux+   systems will use btrfs going forward, so this kernel could read their+   disks. Much bigger: copy-on-write B-trees, the chunk tree (logical ->+   physical mapping), the root/fs trees, checksums, compression+   (zlib/lzo/zstd). Read-only means parsing the superblock, chunk tree and+   fs tree -- no transactions or space allocation.+   [btrfs on-disk format](https://btrfs.readthedocs.io/en/latest/dev/On-disk-format.html),+   [btrfs docs](https://btrfs.readthedocs.io/)+8. **NTFS** — last, maybe never.++After btrfs, in this order:++1. **Core polish**: SMP (bring up the other CPUs through the APIC, per-CPU+   data, locks that work across cores) and a memory cleanup (a real+   physical frame allocator instead of the boot-time walk plus free list,+   a bigger or growable heap, guard pages).+2. **Networking**: a virtio-net driver plus [smoltcp](https://github.com/smoltcp-rs/smoltcp)+   for TCP/IP. [virtio spec](https://docs.oasis-open.org/virtio/virtio/v1.2/virtio-v1.2.html)+3. **More virtio devices**: virtio-rng (entropy), virtio-console, and+   virtio-gpu (a framebuffer instead of VGA text mode).++Out of scope for now: **drivers for physical hardware** (real disks, NICs,+USB). Rustbelt targets QEMU's virtio devices. A **Raspberry Pi / ARM port**+is the planned future hardware target. The split into a separate+**userland and OS** (its own repo layout, libc, toolchain) comes later.++Other ideas, any time:  - **Shell extras**: Left/Right cursor movement, Tab completion of file   names, `$?` and environment variables, Ctrl+C to kill the foreground job@@ -1411,15 +1716,27 @@ and for a UEFI/framebuffer setup later, the newer [`bootloader` 0.11](https://gi   and boots the wrong build. `scripts/runner.sh` and the scripts `touch` the ELF   first to avoid this. If you call `cargo bootimage --features ...` by hand after   switching features, `touch target/x86_64-rust_kernel/debug/rustbelt` first.-- `build.rs` reruns when `user/`, `disk/`, `disk2/` or `build.rs` change and-  rebuilds `target/disk.img` and the ext2 images. QEMU opens them with+- `build.rs` reruns when `user/`, `disk/`, `disk2/`, `disk3/`, `build/` or+  `build.rs` change and rebuilds `target/disk.img` and the ext2 and exFAT images. QEMU opens them with   `snapshot=on`, so the kernel's writes (the tests create and delete files)-  never reach the files on disk (except in `scripts/ext2-check.sh`).+  never reach the files on disk (except in `scripts/ext2-check.sh` and+  `scripts/exfat-check.sh`). - e2fsprogs: `sudo apt install e2fsprogs`. `mke2fs`, `e2fsck`, `debugfs` and   `dumpe2fs` live in `/usr/sbin`, which isn't on a normal user's `PATH`   (`build.rs` and the script look there). Newer `mke2fs` dropped `-r 0`;   revision 0 is `-E revision=0`. `mke2fs -d` stores all-zero blocks as   holes, so `sparse.bin`'s data chunks are deliberately non-zero.+- exfatprogs: `sudo apt install exfatprogs`. `mkfs.exfat`, `fsck.exfat` and+  `dump.exfat` live in `/usr/sbin` too. Mounting an exFAT image to fill it+  needs root and a loop device, and `exfat-fuse` can't mount here either+  (`fusermount3: unknown option 'user=box'`; with sudo, "Block device+  required"), hence `build/exfat_populate.rs`. `dump.exfat -d PATH` in+  exfatprogs 1.2.9 can't find files inside some directories (e.g.+  `/NewFolder/Note.txt`), even on images that only mkfs and the populator+  touched; `dump.exfat -s DIR` lists them fine, so `exfat-check.sh` uses that.+- The VGA console is ASCII-only: `Ünïcödé café.txt` shows as `■n■c■d■+  caf■.txt` on screen (serial shows it right). Mapping UTF-8 to code page+  437 in `vga_buffer.rs` would be a small fix. - `docs/long-link` points through lots of `..` and `.`, but along a path   that also works on Linux (`cat` it after `debugfs -R "rdump /docs /tmp/x"`).   The VFS resolves `..` lexically, so a target like `file.txt/..` would

Binary file changed.

@@ -31,12 +31,25 @@ //!    1993 revision 0 format: fixed 128-byte inodes, no file types in //!    directory entries). //!-//! Needs dosfstools (mkfs.vfat), mtools (mmd, mcopy), fdisk (sfdisk) and-//! e2fsprogs (mke2fs):-//!     sudo apt install dosfstools mtools fdisk e2fsprogs+//! 4. Build the fourth QEMU disk, `target/exfat.img` (32 MiB): a whole-disk+//!    exFAT file system. `mkfs.exfat` formats it, but no tool can copy files+//!    into an exFAT image without mounting it (root or a loop device), so+//!    build/exfat_populate.rs does that itself: a small exFAT writer of its+//!    own, separate from the kernel's driver. The tree is disk3/ (long+//!    names with spaces, mixed case, non-ASCII) plus numbers.txt (340 KB,+//!    contiguous) and frag-chain.txt (fragmented, linked through the FAT).+//!    Mounted at /mnt/disk2 (drive D:). exfat-512.img (512-byte clusters,+//!    so everything spans many clusters) is for `cargo test` only.+//!+//! Needs dosfstools (mkfs.vfat), mtools (mmd, mcopy), fdisk (sfdisk),+//! e2fsprogs (mke2fs) and exfatprogs (mkfs.exfat):+//!     sudo apt install dosfstools mtools fdisk e2fsprogs exfatprogs  use std::{env, fs, io::Write, path::{Path, PathBuf}, process::{Command, Stdio}}; +#[path = "build/exfat_populate.rs"]+mod exfat_populate;+ /// Everything in /bin on the disk: the shell, its tools, and the test and /// demo programs of the earlier stages. const PROGRAMS: &[&str] = &[@@ -56,6 +69,8 @@ fn main() {     println!("cargo:rerun-if-changed=user");     println!("cargo:rerun-if-changed=disk");     println!("cargo:rerun-if-changed=disk2");+    println!("cargo:rerun-if-changed=disk3");+    println!("cargo:rerun-if-changed=build");     println!("cargo:rerun-if-changed=build.rs");      for program in PROGRAMS {@@ -63,6 +78,7 @@ fn main() {     }     build_disk(&manifest, &out_dir);     build_ext2_disks(&manifest, &out_dir);+    build_exfat_disks(&manifest, &out_dir); }  fn build_program(user_dir: &Path, out_dir: &Path, program: &str) {@@ -90,7 +106,7 @@ fn tool(name: &str) -> PathBuf {             return candidate;         }     }-    panic!("`{name}` not found: install it with `sudo apt install dosfstools mtools fdisk e2fsprogs`");+    panic!("`{name}` not found: install it with `sudo apt install dosfstools mtools fdisk e2fsprogs exfatprogs`"); }  fn run(cmd: &mut Command, stdin: Option<&str>) {@@ -235,3 +251,39 @@ fn build_ext2_disks(manifest: &Path, out_dir: &Path) {     mke2fs(&target_dir.join("ext2-4k.img"), "8M", &["-b", "4096", "-L", "FOURK", "-E", "root_owner=0:0"], &small);     mke2fs(&target_dir.join("ext2-rev0.img"), "4M", &["-b", "1024", "-E", "root_owner=0:0,revision=0"], &small); }++/// numbers.txt on the exFAT disk: 20 000 lines of 17 bytes = 340 000 bytes.+pub const NUMBER_LINES: usize = 20_000;+/// frag-chain.txt: 2 000 lines of 19 bytes = 38 000 bytes.+pub const FRAG_LINES: usize = 2_000;++fn build_exfat_disks(manifest: &Path, out_dir: &Path) {+    let target_dir = env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| manifest.join("target"));+    let numbers: String = (0..NUMBER_LINES).map(|i| format!("exfat line {i:05}\n")).collect();+    let frag: String = (0..FRAG_LINES).map(|i| format!("fragment line {i:04}\n")).collect();++    let tree = out_dir.join("exfat-tree");+    let _ = fs::remove_dir_all(&tree);+    copy_tree(&manifest.join("disk3"), &tree);+    fs::write(tree.join("numbers.txt"), &numbers).unwrap();+    fs::write(tree.join("frag-chain.txt"), &frag).unwrap();++    // A small tree for the test image with 512-byte clusters (its root+    // directory is one 512-byte cluster: room for 16 entries).+    let small = out_dir.join("exfat-small");+    let _ = fs::remove_dir_all(&small);+    copy_tree(&manifest.join("disk3/Photos"), &small.join("Photos"));+    fs::copy(manifest.join("disk3/MixedCase.TXT"), small.join("MixedCase.TXT")).unwrap();+    fs::write(small.join("numbers.txt"), &numbers).unwrap();+    fs::write(small.join("frag-chain.txt"), &frag).unwrap();++    // mkfs.exfat wants an existing file of the right size.+    let mkfs = |img: &Path, size: u64, cluster: &str, label: &str, src: &Path| {+        let _ = fs::remove_file(img);+        fs::File::create(img).unwrap().set_len(size).unwrap();+        run(Command::new(tool("mkfs.exfat")).args(["-q", "-c", cluster, "-L", label]).arg(img), None);+        exfat_populate::populate(img, src);+    };+    mkfs(&target_dir.join("exfat.img"), 32 << 20, "4K", "TYLEREXFAT", &tree);+    mkfs(&target_dir.join("exfat-512.img"), 8 << 20, "512", "SMALLCLUST", &small);+}
@@ -0,0 +1,233 @@+//! Host-side helper for build.rs: copy a directory tree into a freshly+//! formatted exFAT image. `mkfs.exfat` (exfatprogs) can format but not+//! populate, mtools doesn't speak exFAT, and mounting needs root or a loop+//! device. So this is a tiny exFAT *writer* of its own.+//!+//! It is deliberately separate from the kernel's driver (src/fs/exfat.rs)+//! and shares no code with it, so a misunderstanding of the spec in one is+//! not silently copied into the other. The host's `fsck.exfat` checks what+//! this writes (scripts/exfat-check.sh).+//!+//! It only needs to handle a *fresh* volume, which keeps it simple:+//! * Free clusters are handed out in order after the last used one.+//! * Files and directories get one contiguous run of clusters and the+//!   NoFatChain flag (their FAT entries stay unused), except files whose+//!   name starts with `frag-`: they get every *other* cluster, linked+//!   through the FAT, so the kernel has a fragmented file to read.+//! * Everything gets the same timestamp (1 Jan 2026), like the kernel,+//!   which has no clock yet.+//!+//! Layout reminders are in the kernel driver's comments.++use std::{fs, path::Path};++const ATTR_DIRECTORY: u16 = 0x10;+const ATTR_ARCHIVE: u16 = 0x20;+const FLAG_ALLOCATION_POSSIBLE: u8 = 0x01;+const FLAG_NO_FAT_CHAIN: u8 = 0x02;+/// 2026-01-01 00:00:00 in exFAT's DOS-style format (year since 1980 in the+/// top 7 bits, then month, day, hour, minute, seconds / 2).+const TIMESTAMP: u32 = (46 << 25) | (1 << 21) | (1 << 16);++fn get32(b: &[u8], at: usize) -> u32 {+    u32::from_le_bytes(b[at..at + 4].try_into().unwrap())+}+fn get64(b: &[u8], at: usize) -> u64 {+    u64::from_le_bytes(b[at..at + 8].try_into().unwrap())+}++struct Image {+    data: Vec<u8>,+    cluster: usize,  // bytes per cluster+    fat: usize,      // byte offset of the FAT+    heap: usize,     // byte offset of cluster 2+    count: u32,      // clusters in the heap+    bitmap: usize,   // byte offset of the allocation bitmap (contiguous on a fresh volume)+    upcase: Vec<u16>, // upcase[c] = upper-case of UTF-16 unit c (identity past the end)+    next: u32,       // next cluster to hand out+}++impl Image {+    fn pos(&self, cluster: u32) -> usize {+        self.heap + (cluster as usize - 2) * self.cluster+    }++    fn set_fat(&mut self, cluster: u32, value: u32) {+        let at = self.fat + 4 * cluster as usize;+        self.data[at..at + 4].copy_from_slice(&value.to_le_bytes());+    }++    fn used(&self, cluster: u32) -> bool {+        let i = (cluster - 2) as usize;+        self.data[self.bitmap + i / 8] & (1 << (i % 8)) != 0+    }++    /// Take `n` clusters: consecutive ones, or every other one (`spread`).+    fn alloc(&mut self, n: usize, spread: bool) -> Vec<u32> {+        let mut out = Vec::new();+        for _ in 0..n {+            let c = self.next;+            assert!(c - 2 < self.count, "exFAT image is full");+            let i = (c - 2) as usize;+            self.data[self.bitmap + i / 8] |= 1 << (i % 8);+            out.push(c);+            self.next += if spread { 2 } else { 1 };+        }+        out+    }++    /// Write `bytes` into `clusters` (zero-padding the last one).+    fn fill(&mut self, clusters: &[u32], bytes: &[u8]) {+        for (i, &c) in clusters.iter().enumerate() {+            let at = self.pos(c);+            let chunk = &bytes[(i * self.cluster).min(bytes.len())..((i + 1) * self.cluster).min(bytes.len())];+            self.data[at..at + self.cluster].fill(0);+            self.data[at..at + chunk.len()].copy_from_slice(chunk);+        }+    }++    /// The File + Stream Extension + File Name entries for one file.+    fn entry_set(&self, name: &str, attrs: u16, first: u32, size: u64, no_fat_chain: bool) -> Vec<u8> {+        let units: Vec<u16> = name.encode_utf16().collect();+        assert!(units.len() <= 255, "name too long: {name}");+        let names = units.len().div_ceil(15);+        let mut set = vec![0u8; 32 * (2 + names)];+        // File entry.+        set[0] = 0x85;+        set[1] = (1 + names) as u8; // SecondaryCount+        set[4..6].copy_from_slice(&attrs.to_le_bytes());+        for at in [8, 12, 16] {+            set[at..at + 4].copy_from_slice(&TIMESTAMP.to_le_bytes()); // created, modified, accessed+        }+        // Stream Extension entry.+        let s = &mut set[32..64];+        s[0] = 0xC0;+        s[1] = FLAG_ALLOCATION_POSSIBLE | if no_fat_chain && first != 0 { FLAG_NO_FAT_CHAIN } else { 0 };+        s[3] = units.len() as u8;+        let mut hash: u16 = 0;+        for &u in &units {+            let up = self.upcase.get(u as usize).copied().unwrap_or(u);+            for byte in up.to_le_bytes() {+                hash = hash.rotate_right(1).wrapping_add(byte as u16);+            }+        }+        s[4..6].copy_from_slice(&hash.to_le_bytes());+        s[8..16].copy_from_slice(&size.to_le_bytes()); // ValidDataLength+        s[20..24].copy_from_slice(&first.to_le_bytes());+        s[24..32].copy_from_slice(&size.to_le_bytes()); // DataLength+        // File Name entries, 15 UTF-16 units each.+        for (i, chunk) in units.chunks(15).enumerate() {+            let e = &mut set[64 + 32 * i..96 + 32 * i];+            e[0] = 0xC1;+            for (j, u) in chunk.iter().enumerate() {+                e[2 + 2 * j..4 + 2 * j].copy_from_slice(&u.to_le_bytes());+            }+        }+        // SetChecksum: every byte of the set except the checksum itself.+        let mut sum: u16 = 0;+        for (i, &b) in set.iter().enumerate() {+            if i != 2 && i != 3 {+                sum = sum.rotate_right(1).wrapping_add(b as u16);+            }+        }+        set[2..4].copy_from_slice(&sum.to_le_bytes());+        set+    }++    /// Write everything in `dir` to the image; returns its directory entries.+    fn write_tree(&mut self, dir: &Path) -> Vec<u8> {+        let mut children: Vec<_> = fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect();+        children.sort();+        let mut entries = Vec::new();+        for path in children {+            let name = path.file_name().unwrap().to_str().unwrap().to_string();+            if path.is_dir() {+                let sub = self.write_tree(&path);+                // At least one cluster; a directory's size is always whole clusters.+                let n = sub.len().div_ceil(self.cluster).max(1);+                let clusters = self.alloc(n, false);+                self.fill(&clusters, &sub);+                entries.extend(self.entry_set(&name, ATTR_DIRECTORY, clusters[0], (n * self.cluster) as u64, true));+            } else {+                let bytes = fs::read(&path).unwrap();+                let n = bytes.len().div_ceil(self.cluster);+                let spread = name.starts_with("frag-");+                let clusters = self.alloc(n, spread);+                self.fill(&clusters, &bytes);+                if spread {+                    for w in clusters.windows(2) {+                        self.set_fat(w[0], w[1]);+                    }+                    if let Some(&last) = clusters.last() {+                        self.set_fat(last, 0xFFFF_FFFF); // end of chain+                    }+                }+                let first = clusters.first().copied().unwrap_or(0);+                entries.extend(self.entry_set(&name, ATTR_ARCHIVE, first, bytes.len() as u64, !spread));+            }+        }+        entries+    }+}++/// Copy `tree` into the exFAT file system in `image` (made by mkfs.exfat).+pub fn populate(image: &Path, tree: &Path) {+    let data = fs::read(image).unwrap();+    assert_eq!(&data[3..11], b"EXFAT   ", "not an exFAT image");+    let sector = 1usize << data[108];+    let cluster = sector << data[109];+    let fat = get32(&data, 80) as usize * sector;+    let heap = get32(&data, 88) as usize * sector;+    let count = get32(&data, 92);+    let root = get32(&data, 96);+    let mut img = Image { data, cluster, fat, heap, count, bitmap: 0, upcase: Vec::new(), next: 0 };++    // The root directory (one cluster on a fresh volume) holds the+    // allocation bitmap (0x81) and up-case table (0x82) entries.+    let root_at = img.pos(root);+    let mut free_slot = None;+    for i in 0..cluster / 32 {+        let e = &img.data[root_at + 32 * i..root_at + 32 * i + 32];+        match e[0] {+            0x81 => img.bitmap = img.pos(get32(e, 20)),+            0x82 => {+                let (at, len) = (img.pos(get32(e, 20)), get64(e, 24) as usize);+                // Compressed table: 0xFFFF, n = "the next n characters map+                // to themselves".+                let raw: Vec<u16> = img.data[at..at + len].chunks(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();+                let mut i = 0;+                while i < raw.len() {+                    if raw[i] == 0xFFFF && i + 1 < raw.len() {+                        for _ in 0..raw[i + 1] {+                            let c = img.upcase.len() as u16;+                            img.upcase.push(c);+                        }+                        i += 2;+                    } else {+                        img.upcase.push(raw[i]);+                        i += 1;+                    }+                }+            }+            0x00 => {+                free_slot = Some(i);+                break;+            }+            _ => {}+        }+    }+    assert!(img.bitmap != 0 && !img.upcase.is_empty(), "mkfs.exfat left no bitmap or up-case table?");+    // Hand out clusters after the last one mkfs used.+    img.next = (2..count + 2).rev().find(|&c| img.used(c)).unwrap() + 1;++    let entries = img.write_tree(tree);+    let slot = free_slot.expect("root directory full");+    assert!(32 * slot + entries.len() <= cluster, "too many files for one root cluster");+    img.data[root_at + 32 * slot..root_at + 32 * slot + entries.len()].copy_from_slice(&entries);++    // PercentInUse (boot sector byte 112). It isn't covered by the boot+    // checksum, so changing it doesn't invalidate the boot region.+    let used = (2..count + 2).filter(|&c| img.used(c)).count();+    img.data[112] = (used * 100 / count as usize) as u8;+    fs::write(image, &img.data).unwrap();+}
@@ -34,6 +34,17 @@ md C:/made-here echo written by Rustbelt > C:/made-here/note.txt type C:/made-here/note.txt ls -l C:/+cd D:+ls+cat "long file name with spaces.txt"+cp B:/notes/todo.txt D:/+cp C:/hello.txt "Copied From Ext2.txt"+cp mixedcase.txt C:/from-exfat.txt+cat C:/from-exfat.txt+md "New Folder"+echo hi from exFAT > "New Folder/Note.txt"+type "new folder/NOTE.TXT"+ls -l D:/ run evil nosuchprogram cd Q:
@@ -0,0 +1,2 @@+This file has a long name with spaces in it. exFAT stores names of up+to 255 UTF-16 characters, 15 per "File Name" directory entry.
@@ -0,0 +1 @@+MixedCase.TXT: exFAT keeps the case you wrote but ignores it when looking names up.
@@ -0,0 +1 @@+Notes from the 2026 trip, two directories down.
@@ -0,0 +1 @@+A name longer than 15 characters needs several File Name entries; this one needs seven.
@@ -0,0 +1,6 @@+Hello from the exFAT disk! This is drive D: (/mnt/disk2), the fourth disk,+read and written by Rustbelt's own exFAT driver (src/fs/exfat.rs).++exFAT keeps long names in UTF-16 and compares them without case, so+`type "long file name with spaces.txt"` finds "Long File Name With Spaces.txt".+Try `ls -l D:/`, `cp B:/notes/todo.txt D:/` and `cat d:/MIXEDCASE.txt`.
@@ -0,0 +1 @@+Ünïcödé names work too: the up-case table maps é to É.
@@ -0,0 +1,87 @@+#!/usr/bin/env bash+# Check Rustbelt's exFAT writes with the host's own tools (exfatprogs).+#+#   1. fsck.exfat the pristine images build.rs made (this checks the+#      build-time populator, build/exfat_populate.rs).+#   2. Copy them to target/fsck/ and boot Rustbelt on the *writable* copies+#      (no snapshot=on), so whatever it writes really lands in the files:+#        - the shell self-test (cp B:/C: -> D:, cp D: -> C:, md, echo >)+#        - the tests/exfat.rs kernel (create/write/append/truncate/unlink/+#          rmdir, NoFatChain -> FAT chain, a directory grown to 200 files,+#          root directory growth; 4 KiB and 512-byte clusters)+#   3. `fsck.exfat -n` every copy: -n = open read-only and answer "no" to+#      every question (report, don't fix). It checks the boot checksum,+#      bitmap vs. cluster chains, SetChecksums, name hashes... Any+#      complaint means the driver wrote something inconsistent.+#   4. Show the self-test copy's root directory and one new file with+#      dump.exfat, and read that file's bytes straight off the image.+#+# Usage: ./scripts/exfat-check.sh   (after `cargo build` and+#        `cargo test --test exfat`, which build the images and the kernels)+set -euo pipefail+cd "$(dirname "$0")/.."+export PATH="$HOME/.cargo/bin:$PATH:/usr/sbin:/sbin"+for tool in fsck.exfat dump.exfat qemu-system-x86_64; do+    command -v $tool >/dev/null || { echo "need $tool (apt install exfatprogs qemu-system-x86)"; exit 1; }+done++fsck() { # fsck <image> <title>+    echo "== fsck.exfat -n $1 ($2)"+    fsck.exfat -n "$1" || { echo "!! fsck.exfat found problems in $1"; status=1; }+}+status=0++for img in target/exfat.img target/exfat-512.img; do+    fsck $img "pristine, as built"+done++mkdir -p target/fsck+cp target/exfat.img target/fsck/selftest-exfat.img+for img in exfat exfat-512; do cp target/$img.img target/fsck/tests-$img.img; done++echo "== boot the shell self-test on target/fsck/selftest-exfat.img"+EXFAT_IMG=target/fsck/selftest-exfat.img ./scripts/run-headless.sh >/dev/null && echo "self-test OK"++echo "== run the tests/exfat.rs kernel on target/fsck/tests-exfat*.img"+KERNEL=$(ls -t target/x86_64-rust_kernel/debug/build/rustbelt/*/out/bootimage-exfat-*.bin 2>/dev/null | head -1 || true)+[ -n "$KERNEL" ] || { echo "no test kernel: run 'cargo test --test exfat' first"; exit 1; }+set +e+timeout 120 qemu-system-x86_64 -drive format=raw,file="$KERNEL" -no-reboot \+    -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \+    -drive file=target/ext2.img,format=raw,if=virtio,snapshot=on \+    -drive file=target/fsck/tests-exfat.img,format=raw,if=virtio \+    -drive file=target/ext2-4k.img,format=raw,if=virtio,snapshot=on \+    -drive file=target/ext2-rev0.img,format=raw,if=virtio,snapshot=on \+    -drive file=target/fsck/tests-exfat-512.img,format=raw,if=virtio \+    -device isa-debug-exit,iobase=0xf4,iosize=0x04 \+    -serial stdio -display none > target/fsck/tests-exfat.log+code=$?+set -e+grep -c "\[ok\]" target/fsck/tests-exfat.log | sed 's/$/ exFAT tests ok/'+[ $code = 33 ] || { echo "!! test kernel failed (exit $code), see target/fsck/tests-exfat.log"; exit 1; }++for img in target/fsck/selftest-exfat.img target/fsck/tests-exfat.img target/fsck/tests-exfat-512.img; do+    fsck "$img" "after Rustbelt wrote to it"+done++IMG=target/fsck/selftest-exfat.img+echo "== dump.exfat: what is in / on the self-test copy"+dump.exfat -s / $IMG 2>/dev/null | sed -n 's/^Path: /  /p'+# (dump.exfat -s, a directory scan, rather than -d "/New Folder/Note.txt":+# exfatprogs 1.2.9's -d can't find files inside some directories, e.g.+# "NewFolder", even on images only mkfs + our populator ever touched.)+echo "== dump.exfat -s \"/New Folder\": the entry set Rustbelt wrote for Note.txt"+set_info=$(dump.exfat -s "/New Folder" $IMG 2>/dev/null)+echo "$set_info" | grep -E "^Path|Entrytype|SetChecksum|GeneralSecondaryFlags|NameLength|NameHash|FirstCluster|DataLength" | sed 's/^ */  /'+# Read the file's bytes straight from the image: cluster N starts at+# ClusterHeapOffset + (N - 2) * cluster size.+info=$(dump.exfat $IMG 2>/dev/null)+heap=$(echo "$info" | sed -n 's/^Cluster Heap Offset (sector offset): *//p')+spc=$(echo "$info" | sed -n 's/^Sectors per Cluster: *//p')+first=$(echo "$set_info" | sed -n 's/^ *FirstCluster: *//p' | head -1)+len=$(echo "$set_info" | sed -n 's/^ *DataLength: *//p' | head -1)+echo "== its $len bytes at cluster $first, read with dd:"+dd if=$IMG bs=512 skip=$((heap + (first - 2) * spc)) count=1 status=none | head -c "$len"++[ $status = 0 ] && echo "== all images are clean" || echo "== fsck.exfat found problems"+exit $status
@@ -45,8 +45,10 @@ set +e timeout 120 qemu-system-x86_64 -drive format=raw,file="$KERNEL" -no-reboot \     -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \     -drive file=target/fsck/tests-ext2.img,format=raw,if=virtio \+    -drive file=target/exfat.img,format=raw,if=virtio,snapshot=on \     -drive file=target/fsck/tests-ext2-4k.img,format=raw,if=virtio \     -drive file=target/fsck/tests-ext2-rev0.img,format=raw,if=virtio \+    -drive file=target/exfat-512.img,format=raw,if=virtio,snapshot=on \     -device isa-debug-exit,iobase=0xf4,iosize=0x04 \     -serial stdio -display none > target/fsck/tests.log code=$?@@ -54,7 +56,7 @@ set -e grep -c "\[ok\]" target/fsck/tests.log | sed 's/$/ ext2 tests ok/' [ $code = 33 ] || { echo "!! test kernel failed (exit $code), see target/fsck/tests.log"; exit 1; } -for img in target/fsck/selftest.img target/fsck/tests-*.img; do+for img in target/fsck/selftest.img target/fsck/tests-ext2*.img; do     fsck "$img" "after Rustbelt wrote to it" done 
@@ -13,6 +13,9 @@ #            use copy.img as the ext2 disk *without* snapshot=on, so the #            self-test's writes land in that file (scripts/ext2-check.sh #            then runs e2fsck on it)+#        EXFAT_IMG=copy.img ./scripts/run-headless.sh+#            the same for the exFAT disk (scripts/exfat-check.sh runs+#            fsck.exfat on it) set -euo pipefail cd "$(dirname "$0")/.." @@ -28,12 +31,19 @@ else     EXT2_DRIVE="file=target/ext2.img,format=raw,if=virtio,snapshot=on" fi +if [ -n "${EXFAT_IMG:-}" ]; then+    EXFAT_DRIVE="file=$EXFAT_IMG,format=raw,if=virtio"+else+    EXFAT_DRIVE="file=target/exfat.img,format=raw,if=virtio,snapshot=on"+fi+ LOG=target/headless-serial.log set +e timeout 90 qemu-system-x86_64 \     -drive format=raw,file="$IMG" \     -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \     -drive "$EXT2_DRIVE" \+    -drive "$EXFAT_DRIVE" \     -device isa-debug-exit,iobase=0xf4,iosize=0x04 \     -serial stdio -display none -no-reboot | tee "$LOG" code=${PIPESTATUS[0]}@@ -42,9 +52,10 @@ set -e # Lines the shell self-test must have printed (only for a plain boot). if [ -z "${1:-}" ] && [ "$code" = 33 ]; then     expect=(-        "Rustbelt 0.8.0 (stage 8: ext2) x86_64"       # ver+        "Rustbelt 0.9.0 (stage 9: exFAT) x86_64"      # ver         "B:  /mnt/disk0   fat32"                       # drives         "C:  /mnt/disk1   ext2"+        "D:  /mnt/disk2   exfat"         "B:/ $ ls"                                     # cd B: changed the prompt         "readme.txt  scripts/"                         # ls in columns         "In the Rustbelt shell this disk is drive B:"  # type B:/readme.txt@@ -60,6 +71,13 @@ if [ -z "${1:-}" ] && [ "$code" = 33 ]; then         "-rw-r--r--        125  todo.txt"              # cp B: -> C:, ls -l         "lrwxrwxrwx             hello-link -> hello.txt"         "drwxr-xr-x      <DIR>  made-here/"+        "D:/ $ ls"                                     # the exFAT disk+        "This file has a long name with spaces in it." # quoted name, other case+        "MixedCase.TXT: exFAT keeps the case"          # cp D: -> C:, cat+        "hi from exFAT"                                # md + echo >, other case+        "       125  todo.txt"                         # cp B: -> D:, ls -l D:/+        "     <DIR>  New Folder/"+        "    340000  numbers.txt"         "[exit code 139]"                              # run evil         "sh: nosuchprogram: command not found"         "cd: there is no drive Q:"
@@ -26,15 +26,14 @@ if [ -z "$SESSION" ]; then     SESSION=$(mktemp /tmp/session.XXXXXX)     cat > "$SESSION" <<'TXT' drives-cd c:+cd d: lx{BS}s -l-cat hello-link-cp B:/notes/todo.txt C:/-cat todo.txt | grep -i ext2+cp C:/hello.txt "From Ext2.txt"+type "FROM EXT2.TXT" TXT-    set -- "C:  /mnt/disk1   ext2" "C:/ $ ls -l" "hello-link -> hello.txt" \-        "-rw-r--r--     304000  big.txt" "Hello from the ext2 disk!" \-        "Next: more file systems -- ext2"+    set -- "D:  /mnt/disk2   exfat" "D:/ $ ls -l" "    340000  numbers.txt" \+        "Long File Name With Spaces.txt" "D:/ $ type \"FROM EXT2.TXT\"" \+        "Hello from the ext2 disk!" fi SOCK=$(mktemp -u /tmp/qemu-mon.XXXXXX) SERIAL_LOG=$(mktemp /tmp/serial.XXXXXX)@@ -42,6 +41,7 @@ SERIAL_LOG=$(mktemp /tmp/serial.XXXXXX) qemu-system-x86_64 -drive format=raw,file="$IMG" \     -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \     -drive file=target/ext2.img,format=raw,if=virtio,snapshot=on \+    -drive file=target/exfat.img,format=raw,if=virtio,snapshot=on \     -serial file:"$SERIAL_LOG" -display none -monitor unix:"$SOCK",server,nowait & QPID=$! sleep 4   # boot until the shell prompt
@@ -32,6 +32,9 @@ pub struct PartitionInfo { #[derive(Debug, PartialEq, Eq)] pub enum PartitionError {     NoSignature, // sector 0 doesn't end in 55 AA: not partitioned+    /// Sector 0 ends in 55 AA but is an exFAT boot sector, not an MBR: the+    /// whole disk is one exFAT volume.+    WholeDiskExfat,     BadGpt,     Io(Errno), }@@ -50,6 +53,12 @@ pub fn scan(disk: &Arc<dyn BlockDevice>) -> Result<(&'static str, Vec<PartitionI     if mbr[510..512] != [0x55, 0xaa] {         return Err(PartitionError::NoSignature);     }+    // A file system's boot sector also ends in 55 AA. exFAT's is easy to+    // tell apart by its name at byte 3 (and its partition table area is+    // just zeros, which would read as "MBR, 0 partitions").+    if &mbr[3..11] == b"EXFAT   " {+        return Err(PartitionError::WholeDiskExfat);+    }     let entries: Vec<&[u8]> = (0..4).map(|i| &mbr[446 + 16 * i..446 + 16 * (i + 1)]).collect();     if entries.iter().any(|e| e[4] == 0xee) {         return Ok(("GPT", scan_gpt(disk)?));
@@ -0,0 +1,993 @@+//! exFAT, Microsoft's "extended FAT" (2006): the file system on SD cards+//! over 32 GB and most USB sticks, made for flash. Written by hand from+//! Microsoft's published specification, no crate.+//!+//! # On-disk layout+//!+//! ```text+//! sector 0       1..8           9..10  11        12..23        FatOffset   ClusterHeapOffset+//! | boot sector | extended boot | OEM | checksum | backup copy | ... | FAT | ... | cluster heap ... |+//! ```+//!+//! * **Boot sector** (sector 0): "EXFAT   " at byte 3, then the geometry,+//!   all in sectors (whose size is `1 << BytesPerSectorShift`, 512-4096):+//!   where the FAT and the cluster heap start, how many clusters there are,+//!   the first cluster of the root directory, and the cluster size+//!   (`1 << SectorsPerClusterShift` sectors). Byte 112 is **PercentInUse**.+//! * **Boot checksum** (sector 11): sectors 0-10 summed with a "rotate right+//!   and add" checksum, skipping the three bytes that change while the+//!   volume is in use (VolumeFlags, PercentInUse). Sector 11 holds that+//!   32-bit value over and over. Sectors 12-23 are a backup of 0-11.+//! * **FAT**: one 32-bit entry per cluster, like FAT32: the number of the+//!   next cluster of a file, 0xFFFFFFFF at the end of a chain, 0xFFFFFFF7+//!   for a bad cluster. Clusters are numbered from 2.+//! * **Cluster heap**: cluster N starts at `ClusterHeapOffset + (N - 2) *+//!   cluster size`. Everything else lives here, *including* the system+//!   files that FAT32 kept in fixed places:+//!   - the **allocation bitmap**: 1 bit per cluster, 1 = in use. Unlike+//!     FAT32, a free FAT entry doesn't mean a free cluster: only the bitmap+//!     says what's free.+//!   - the **up-case table**: for each UTF-16 character, its upper-case+//!     form. Names are compared without case, and the table says exactly+//!     what "without case" means, so every implementation agrees. It's+//!     stored compressed: `0xFFFF, n` means "the next n characters map to+//!     themselves".+//!   - the **root directory**.+//!+//! # Directories and entry sets+//!+//! A directory is a file of 32-byte entries. Byte 0 is the type; bit 7 of+//! it means "in use", so deleting an entry just clears that bit, and type+//! 0x00 marks the end of the directory. The root directory holds the+//! special entries: volume label (0x83), allocation bitmap (0x81) and up-case+//! table (0x82). Each file is an **entry set** of 3 to 19 entries:+//!+//! ```text+//! 0x85 File            secondary count, SetChecksum, attributes, times+//! 0xC0 Stream Ext.     flags, name length, name hash, ValidDataLength,+//!                      first cluster, DataLength+//! 0xC1 File Name       15 UTF-16 characters  (as many as the name needs)+//! ```+//!+//! * **SetChecksum** (16 bits, "rotate right and add") covers every byte of+//!   the set except itself, so changing a size means recomputing it.+//! * **NameHash** is the same checksum over the up-cased name, to skip+//!   most non-matching names quickly.+//! * **NoFatChain**: a flag in the stream entry saying "my clusters are+//!   consecutive; don't look at the FAT". Most files are contiguous, so+//!   most files never touch the FAT at all. If a file can't grow in place,+//!   we write out its FAT chain and clear the flag.+//! * **ValidDataLength** <= DataLength: bytes past it read as zeros+//!   (Windows uses this to make big files quickly). We always write the+//!   two equal.+//! * Directories have no "." or "..", and their size is whole clusters.+//!   The root has no entry set at all, so its size comes from its FAT chain.+//!+//! # What we do+//!+//! * Read and write: create, write and append, truncate to 0, mkdir,+//!   unlink and rmdir. We keep the bitmap, FAT chains, SetChecksums and+//!   PercentInUse right, so the host's `fsck.exfat` finds a clean volume.+//! * Lookup is case-insensitive using the volume's own up-case table.+//! * We check the boot checksum and the up-case table's checksum at mount.+//! * Not supported: rename, timestamps (we have no clock: everything is+//!   1 Jan 2026), TexFAT's second FAT (a volume with two FATs mounts+//!   read-only), and the VolumeDirty flag (we write straight through and+//!   never leave half-done work behind a lock).+//!+//! Like our ext2, there's no cache except the bitmap, which we keep in+//! memory (one bit per cluster: 960 bytes for our 32 MiB disk) and write+//! through. Inodes are just paths, resolved again on every call.++use super::{DirEntry, FileSystem, Inode, InodeKind};+use crate::block::{BlockDevice, SECTOR_SIZE};+use crate::errno::Errno;+use alloc::{format, string::String, sync::Arc, vec, vec::Vec};+use core::cell::RefCell;+use spin::Mutex;++// Boot sector fields (byte offsets).+const BS_NAME: usize = 3; // "EXFAT   "+const BS_FAT_OFFSET: usize = 80; // in sectors+const BS_CLUSTER_HEAP_OFFSET: usize = 88; // in sectors+const BS_CLUSTER_COUNT: usize = 92;+const BS_ROOT_CLUSTER: usize = 96;+const BS_REVISION: usize = 104; // minor, major+const BS_VOLUME_FLAGS: usize = 106; // not in the checksum+const BS_SECTOR_SHIFT: usize = 108;+const BS_CLUSTER_SHIFT: usize = 109;+const BS_NUMBER_OF_FATS: usize = 110;+const BS_PERCENT_IN_USE: usize = 112; // not in the checksum++// Directory entry types (byte 0). Bit 7 = in use.+const ENTRY_END: u8 = 0x00;+const ENTRY_IN_USE: u8 = 0x80;+const ENTRY_BITMAP: u8 = 0x81;+const ENTRY_UPCASE: u8 = 0x82;+const ENTRY_LABEL: u8 = 0x83;+const ENTRY_FILE: u8 = 0x85;+const ENTRY_STREAM: u8 = 0xC0;+const ENTRY_NAME: u8 = 0xC1;+const ENTRY_SIZE: usize = 32;+const NAME_CHARS_PER_ENTRY: usize = 15;++// File entry fields.+const FILE_SECONDARY_COUNT: usize = 1;+const FILE_SET_CHECKSUM: usize = 2; // u16+const FILE_ATTRIBUTES: usize = 4; // u16+const FILE_TIMES: [usize; 3] = [8, 12, 16]; // created, modified, accessed+const ATTR_DIRECTORY: u16 = 0x10;+const ATTR_ARCHIVE: u16 = 0x20; // "changed since last backup": set on every new file+// Stream extension fields.+const STREAM_FLAGS: usize = 1;+const STREAM_NAME_LENGTH: usize = 3;+const STREAM_NAME_HASH: usize = 4; // u16+const STREAM_VALID_DATA_LENGTH: usize = 8; // u64+const STREAM_FIRST_CLUSTER: usize = 20; // u32 (also in bitmap/up-case entries)+const STREAM_DATA_LENGTH: usize = 24; // u64 (also in bitmap/up-case entries)+const FLAG_ALLOCATION_POSSIBLE: u8 = 0x01;+const FLAG_NO_FAT_CHAIN: u8 = 0x02;+// Up-case table entry: TableChecksum at byte 4.+const UPCASE_CHECKSUM: usize = 4;++const FAT_END: u32 = 0xFFFF_FFFF; // (0xFFFFFFF7, "bad", fails valid_cluster)+/// We have no clock: 2026-01-01 00:00:00 in exFAT's DOS-style format+/// (years since 1980 << 25 | month << 21 | day << 16 | h << 11 | m << 5 | s/2).+const TIMESTAMP: u32 = (46 << 25) | (1 << 21) | (1 << 16);++fn get16(b: &[u8], at: usize) -> u16 {+    u16::from_le_bytes([b[at], b[at + 1]])+}+fn get32(b: &[u8], at: usize) -> u32 {+    u32::from_le_bytes(b[at..at + 4].try_into().unwrap())+}+fn get64(b: &[u8], at: usize) -> u64 {+    u64::from_le_bytes(b[at..at + 8].try_into().unwrap())+}+fn put16(b: &mut [u8], at: usize, v: u16) {+    b[at..at + 2].copy_from_slice(&v.to_le_bytes());+}+fn put32(b: &mut [u8], at: usize, v: u32) {+    b[at..at + 4].copy_from_slice(&v.to_le_bytes());+}+fn put64(b: &mut [u8], at: usize, v: u64) {+    b[at..at + 8].copy_from_slice(&v.to_le_bytes());+}++/// SetChecksum: rotate right by one bit, add the next byte. Bytes 2-3 (the+/// checksum itself) are skipped. NameHash uses the same 16-bit sum.+fn set_checksum(set: &[u8]) -> u16 {+    let mut sum: u16 = 0;+    for (i, &b) in set.iter().enumerate() {+        if i != FILE_SET_CHECKSUM && i != FILE_SET_CHECKSUM + 1 {+            sum = sum.rotate_right(1).wrapping_add(b as u16);+        }+    }+    sum+}++/// The 32-bit version, used for the boot region and the up-case table.+fn checksum32(sum: u32, bytes: &[u8]) -> u32 {+    bytes.iter().fold(sum, |s, &b| s.rotate_right(1).wrapping_add(b as u32))+}++/// Does `dev` hold an exFAT volume? Its boot sector says "EXFAT   " at byte 3.+pub fn probe(dev: &dyn BlockDevice) -> bool {+    let mut sector = [0u8; SECTOR_SIZE];+    dev.read_sectors(0, &mut sector).is_ok() && &sector[BS_NAME..BS_NAME + 8] == b"EXFAT   "+}++/// Where a file's clusters are. With `contiguous` (NoFatChain) they are+/// `first, first+1, ...`; otherwise follow the FAT from `first`. 0 = none.+#[derive(Clone, Copy)]+struct Chain {+    first: u32,+    contiguous: bool,+}++/// One file or directory found in a directory.+struct Found {+    /// Its parent directory's clusters, and the entry index of its File entry+    /// there: what we need to rewrite the entry set. None for the root.+    at: Option<(Vec<u32>, usize)>,+    attrs: u16,+    chain: Chain,+    size: u64,  // DataLength+    valid: u64, // ValidDataLength+}++impl Found {+    fn is_dir(&self) -> bool {+        self.attrs & ATTR_DIRECTORY != 0+    }+}++/// An entry set, parsed.+struct Set {+    index: usize, // entry index of the File entry in the directory+    name: Vec<u16>,+    attrs: u16,+    chain: Chain,+    size: u64,+    valid: u64,+}++struct Volume {+    dev: Arc<dyn BlockDevice>,+    writable: bool,+    cluster_size: usize,+    fat_offset: u64,  // bytes+    heap_offset: u64, // bytes+    cluster_count: u32,+    root_cluster: u32,+    /// The allocation bitmap, kept in memory, and where it lives on disk.+    bitmap: Vec<u8>,+    bitmap_clusters: Vec<u32>,+    used: u32,+    percent: u8,+    /// Where to start looking for a free cluster.+    hint: u32,+    /// Up-case table: only the characters that change, sorted.+    upcase: Vec<(u16, u16)>,+    label: String,+    /// The last FAT sector we read (device sector number, bytes), so that+    /// following a chain doesn't read the same sector 128 times. A RefCell+    /// because reading is `&self`; the Mutex around Volume serializes us.+    fat_cache: RefCell<Option<(u64, [u8; SECTOR_SIZE])>>,+}++impl Volume {+    fn open(dev: Arc<dyn BlockDevice>) -> Result<Volume, Errno> {+        let mut boot = vec![0u8; SECTOR_SIZE];+        dev.read_sectors(0, &mut boot)?;+        if &boot[BS_NAME..BS_NAME + 8] != b"EXFAT   " {+            return Err(Errno::EINVAL);+        }+        let sector_shift = boot[BS_SECTOR_SHIFT] as u32;+        let cluster_shift = boot[BS_CLUSTER_SHIFT] as u32;+        if !(9..=12).contains(&sector_shift) || sector_shift + cluster_shift > 25 || boot[BS_REVISION + 1] != 1 {+            return Err(Errno::EINVAL);+        }+        let sector = 1usize << sector_shift;++        // Boot checksum: sectors 0-10, skipping VolumeFlags and+        // PercentInUse, must match every u32 of sector 11.+        let mut region = vec![0u8; 12 * sector];+        dev.read_sectors(0, &mut region)?;+        let mut sum = 0;+        for (i, &b) in region[..11 * sector].iter().enumerate() {+            if i != BS_VOLUME_FLAGS && i != BS_VOLUME_FLAGS + 1 && i != BS_PERCENT_IN_USE {+                sum = checksum32(sum, &[b]);+            }+        }+        if region[11 * sector..].chunks(4).any(|c| get32(c, 0) != sum) {+            return Err(Errno::EIO);+        }++        let cluster_count = get32(&boot, BS_CLUSTER_COUNT);+        let mut vol = Volume {+            dev,+            // TexFAT's second FAT would need keeping in sync: read-only.+            writable: boot[BS_NUMBER_OF_FATS] == 1,+            cluster_size: sector << cluster_shift,+            fat_offset: get32(&boot, BS_FAT_OFFSET) as u64 * sector as u64,+            heap_offset: get32(&boot, BS_CLUSTER_HEAP_OFFSET) as u64 * sector as u64,+            cluster_count,+            root_cluster: get32(&boot, BS_ROOT_CLUSTER),+            bitmap: Vec::new(),+            bitmap_clusters: Vec::new(),+            used: 0,+            percent: boot[BS_PERCENT_IN_USE],+            hint: 2,+            upcase: Vec::new(),+            label: String::new(),+            fat_cache: RefCell::new(None),+        };+        if !vol.valid_cluster(vol.root_cluster) {+            return Err(Errno::EIO);+        }++        // The root directory has the label, bitmap and up-case entries.+        let root = vol.root()?;+        let clusters = vol.clusters(root.chain, root.size)?;+        let mut dir = vec![0u8; root.size as usize];+        vol.read_chain(&clusters, 0, &mut dir)?;+        let (mut bitmap, mut upcase) = (None, None);+        for e in dir.chunks(ENTRY_SIZE) {+            match e[0] {+                ENTRY_END => break,+                ENTRY_BITMAP if bitmap.is_none() => bitmap = Some((get32(e, STREAM_FIRST_CLUSTER), get64(e, STREAM_DATA_LENGTH))),+                ENTRY_UPCASE => upcase = Some((get32(e, STREAM_FIRST_CLUSTER), get64(e, STREAM_DATA_LENGTH), get32(e, UPCASE_CHECKSUM))),+                ENTRY_LABEL => {+                    let n = (e[1] as usize).min(11);+                    let units: Vec<u16> = (0..n).map(|i| get16(e, 2 + 2 * i)).collect();+                    vol.label = String::from_utf16_lossy(&units);+                }+                _ => {}+            }+        }+        let (Some((bm_first, bm_len)), Some((up_first, up_len, up_sum))) = (bitmap, upcase) else {+            return Err(Errno::EIO);+        };++        // The allocation bitmap: one bit per cluster.+        if bm_len < (cluster_count as u64).div_ceil(8) || bm_len > 1 << 29 {+            return Err(Errno::EIO);+        }+        let bm_clusters = vol.clusters(Chain { first: bm_first, contiguous: false }, bm_len)?;+        let mut bm = vec![0u8; bm_len as usize];+        vol.read_chain(&bm_clusters, 0, &mut bm)?;+        vol.bitmap_clusters = bm_clusters;+        vol.bitmap = bm;+        vol.used = (2..cluster_count + 2).filter(|&c| vol.is_used(c)).count() as u32;++        // The up-case table, checked against its checksum, then expanded.+        if up_len > 2 * 65536 * 2 {+            return Err(Errno::EIO);+        }+        let up_clusters = vol.clusters(Chain { first: up_first, contiguous: false }, up_len)?;+        let mut raw = vec![0u8; up_len as usize];+        vol.read_chain(&up_clusters, 0, &mut raw)?;+        if checksum32(0, &raw) != up_sum {+            return Err(Errno::EIO);+        }+        let mut ch: u32 = 0; // the character the next table slot is for+        let mut words = raw.chunks_exact(2).map(|c| get16(c, 0));+        while let Some(w) = words.next() {+            if w == 0xFFFF {+                ch += words.next().unwrap_or(0) as u32; // a run of "maps to itself"+            } else {+                if w as u32 != ch && ch <= 0xFFFF {+                    vol.upcase.push((ch as u16, w));+                }+                ch += 1;+            }+        }+        Ok(vol)+    }++    fn describe(&self) -> String {+        let size = if self.cluster_size >= 1024 { format!("{} KiB", self.cluster_size / 1024) } else { format!("{} byte", self.cluster_size) };+        format!(+            "exFAT volume \"{}\" ({} clusters, {} clusters{})",+            self.label,+            size,+            self.cluster_count,+            if self.writable { "" } else { ", read-only" }+        )+    }++    fn upcase(&self, c: u16) -> u16 {+        match self.upcase.binary_search_by_key(&c, |&(from, _)| from) {+            Ok(i) => self.upcase[i].1,+            Err(_) => c,+        }+    }++    fn same_name(&self, a: &[u16], b: &[u16]) -> bool {+        a.len() == b.len() && a.iter().zip(b).all(|(&x, &y)| self.upcase(x) == self.upcase(y))+    }++    // ---- bytes on the device -------------------------------------------++    /// Read `buf.len()` bytes at byte offset `pos` (any alignment).+    fn read_bytes(&self, pos: u64, buf: &mut [u8]) -> Result<(), Errno> {+        let skip = (pos % SECTOR_SIZE as u64) as usize;+        if skip == 0 && buf.len() % SECTOR_SIZE == 0 {+            return self.dev.read_sectors(pos / SECTOR_SIZE as u64, buf);+        }+        let mut tmp = vec![0u8; (skip + buf.len()).div_ceil(SECTOR_SIZE) * SECTOR_SIZE];+        self.dev.read_sectors(pos / SECTOR_SIZE as u64, &mut tmp)?;+        buf.copy_from_slice(&tmp[skip..skip + buf.len()]);+        Ok(())+    }++    /// Write `data` at byte offset `pos`: read-modify-write partial sectors.+    fn write_bytes(&self, pos: u64, data: &[u8]) -> Result<(), Errno> {+        let skip = (pos % SECTOR_SIZE as u64) as usize;+        if skip == 0 && data.len() % SECTOR_SIZE == 0 {+            return self.dev.write_sectors(pos / SECTOR_SIZE as u64, data);+        }+        let mut tmp = vec![0u8; (skip + data.len()).div_ceil(SECTOR_SIZE) * SECTOR_SIZE];+        self.dev.read_sectors(pos / SECTOR_SIZE as u64, &mut tmp)?;+        tmp[skip..skip + data.len()].copy_from_slice(data);+        self.dev.write_sectors(pos / SECTOR_SIZE as u64, &tmp)+    }++    fn cluster_pos(&self, cluster: u32) -> u64 {+        self.heap_offset + (cluster as u64 - 2) * self.cluster_size as u64+    }++    fn valid_cluster(&self, c: u32) -> bool {+        c >= 2 && c - 2 < self.cluster_count+    }++    // ---- FAT and bitmap --------------------------------------------------++    fn fat_get(&self, cluster: u32) -> Result<u32, Errno> {+        let pos = self.fat_offset + 4 * cluster as u64;+        let (lba, at) = (pos / SECTOR_SIZE as u64, (pos % SECTOR_SIZE as u64) as usize);+        let mut cache = self.fat_cache.borrow_mut();+        if cache.map(|(cached, _)| cached) != Some(lba) {+            let mut sector = [0u8; SECTOR_SIZE];+            self.dev.read_sectors(lba, &mut sector)?;+            *cache = Some((lba, sector));+        }+        Ok(get32(&cache.as_ref().unwrap().1, at))+    }++    fn fat_set(&self, cluster: u32, value: u32) -> Result<(), Errno> {+        let pos = self.fat_offset + 4 * cluster as u64;+        *self.fat_cache.borrow_mut() = None; // simplest way to keep it right+        self.write_bytes(pos, &value.to_le_bytes())+    }++    fn is_used(&self, cluster: u32) -> bool {+        let i = (cluster - 2) as usize;+        self.bitmap[i / 8] & (1 << (i % 8)) != 0+    }++    /// Flip one bitmap bit, in memory and on disk, and keep the counts.+    fn set_used(&mut self, cluster: u32, used: bool) -> Result<(), Errno> {+        let i = (cluster - 2) as usize;+        if used {+            self.bitmap[i / 8] |= 1 << (i % 8);+            self.used += 1;+        } else {+            self.bitmap[i / 8] &= !(1 << (i % 8));+            self.used -= 1;+        }+        let byte = i / 8;+        let on_disk = self.bitmap_clusters[byte / self.cluster_size];+        self.write_bytes(self.cluster_pos(on_disk) + (byte % self.cluster_size) as u64, &self.bitmap[byte..byte + 1])+    }++    /// Write PercentInUse (boot sector byte 112) if it changed. It's+    /// outside the boot checksum, so no other sector needs updating.+    fn update_percent(&mut self) -> Result<(), Errno> {+        let percent = (self.used as u64 * 100 / self.cluster_count as u64) as u8;+        if percent != self.percent {+            self.percent = percent;+            self.write_bytes(BS_PERCENT_IN_USE as u64, &[percent])?;+        }+        Ok(())+    }++    /// Take a free cluster, preferring `goal`, and fill it with zeros.+    fn alloc(&mut self, goal: u32) -> Result<u32, Errno> {+        let n = self.cluster_count;+        let start = if self.valid_cluster(goal) { goal - 2 } else { self.hint - 2 };+        let c = (0..n).map(|i| (start + i) % n + 2).find(|&c| !self.is_used(c)).ok_or(Errno::ENOSPC)?;+        self.set_used(c, true)?;+        self.hint = if c + 1 - 2 < n { c + 1 } else { 2 };+        self.write_bytes(self.cluster_pos(c), &vec![0u8; self.cluster_size])?;+        Ok(c)+    }++    // ---- chains ------------------------------------------------------------++    /// The clusters holding the first `size` bytes of a chain.+    fn clusters(&self, chain: Chain, size: u64) -> Result<Vec<u32>, Errno> {+        let n = size.div_ceil(self.cluster_size as u64);+        if n == 0 {+            return Ok(Vec::new());+        }+        if n > self.cluster_count as u64 || !self.valid_cluster(chain.first) {+            return Err(Errno::EIO);+        }+        if chain.contiguous {+            if chain.first as u64 - 2 + n > self.cluster_count as u64 {+                return Err(Errno::EIO);+            }+            return Ok((chain.first..chain.first + n as u32).collect());+        }+        let mut out = vec![chain.first];+        while (out.len() as u64) < n {+            let next = self.fat_get(*out.last().unwrap())?;+            if !self.valid_cluster(next) {+                return Err(Errno::EIO); // chain ends early, or is bad+            }+            out.push(next);+        }+        Ok(out)+    }++    /// The root directory: no entry set, so follow its FAT chain to the end.+    fn root(&self) -> Result<Found, Errno> {+        let mut n: u64 = 1;+        let mut c = self.root_cluster;+        loop {+            match self.fat_get(c)? {+                FAT_END => break,+                next if self.valid_cluster(next) && n < self.cluster_count as u64 => {+                    c = next;+                    n += 1;+                }+                _ => return Err(Errno::EIO), // free, bad, or a loop+            }+        }+        let size = n * self.cluster_size as u64;+        let chain = Chain { first: self.root_cluster, contiguous: false };+        Ok(Found { at: None, attrs: ATTR_DIRECTORY, chain, size, valid: size })+    }++    /// Copy bytes from offset `pos` of the data in `clusters` (merging+    /// consecutive clusters into one device read).+    fn read_chain(&self, clusters: &[u32], pos: u64, buf: &mut [u8]) -> Result<(), Errno> {+        self.chain_io(clusters, pos, buf.len(), |vol, disk, range| vol.read_bytes(disk, &mut buf[range]))+    }++    fn write_chain(&self, clusters: &[u32], pos: u64, data: &[u8]) -> Result<(), Errno> {+        self.chain_io(clusters, pos, data.len(), |vol, disk, range| vol.write_bytes(disk, &data[range]))+    }++    /// Split `len` bytes at chain offset `pos` into runs of consecutive+    /// clusters, calling `io(device byte offset, range of the buffer)`.+    fn chain_io(+        &self,+        clusters: &[u32],+        pos: u64,+        len: usize,+        mut io: impl FnMut(&Self, u64, core::ops::Range<usize>) -> Result<(), Errno>,+    ) -> Result<(), Errno> {+        let cs = self.cluster_size as u64;+        let mut done = 0;+        while done < len {+            let at = pos + done as u64;+            let mut k = (at / cs) as usize;+            let first = *clusters.get(k).ok_or(Errno::EIO)?;+            // Extend the run while the next cluster follows this one.+            let mut run_end = (k as u64 + 1) * cs;+            while run_end < at + (len - done) as u64 && k + 1 < clusters.len() && clusters[k + 1] == clusters[k] + 1 {+                k += 1;+                run_end += cs;+            }+            let n = ((run_end - at) as usize).min(len - done);+            io(self, self.cluster_pos(first) + at % cs, done..done + n)?;+            done += n;+        }+        Ok(())+    }++    /// Make `clusters` long enough for `size` bytes. Grows in place+    /// (NoFatChain stays set) while the next cluster is free; otherwise the+    /// chain is written into the FAT and continues elsewhere.+    fn grow(&mut self, chain: &mut Chain, clusters: &mut Vec<u32>, size: u64) -> Result<(), Errno> {+        while (clusters.len() as u64) * (self.cluster_size as u64) < size {+            let Some(&last) = clusters.last() else {+                let c = self.alloc(0)?;+                *chain = Chain { first: c, contiguous: true };+                clusters.push(c);+                continue;+            };+            let c = self.alloc(last + 1)?;+            if c != last + 1 && chain.contiguous {+                // Can't stay contiguous: give the existing clusters FAT links.+                for w in clusters.windows(2) {+                    self.fat_set(w[0], w[1])?;+                }+                chain.contiguous = false;+            }+            if !chain.contiguous {+                self.fat_set(last, c)?;+                self.fat_set(c, FAT_END)?;+            }+            clusters.push(c);+        }+        self.update_percent()+    }++    /// Free every cluster of a chain.+    fn free(&mut self, chain: Chain, clusters: &[u32]) -> Result<(), Errno> {+        for &c in clusters {+            if !chain.contiguous {+                self.fat_set(c, 0)?;+            }+            self.set_used(c, false)?;+        }+        self.update_percent()+    }++    // ---- directories -----------------------------------------------------++    /// Read a directory and parse its entry sets.+    fn read_dir(&self, dir: &Found) -> Result<(Vec<u32>, Vec<u8>, Vec<Set>), Errno> {+        let clusters = self.clusters(dir.chain, dir.size)?;+        let mut data = vec![0u8; dir.size as usize];+        self.read_chain(&clusters, 0, &mut data)?;+        let mut sets = Vec::new();+        let count = data.len() / ENTRY_SIZE;+        let mut i = 0;+        while i < count {+            let e = &data[i * ENTRY_SIZE..(i + 1) * ENTRY_SIZE];+            if e[0] == ENTRY_END {+                break;+            }+            if e[0] != ENTRY_FILE {+                i += 1; // deleted, label, bitmap, up-case... or a stray entry+                continue;+            }+            let entries = 1 + e[FILE_SECONDARY_COUNT] as usize;+            if let Some(set) = Self::parse_set(&data, i, entries) {+                sets.push(set);+                i += entries; // skip the whole set+            } else {+                i += 1; // damaged set: skip it (fsck can sort it out)+            }+        }+        Ok((clusters, data, sets))+    }++    fn parse_set(data: &[u8], index: usize, entries: usize) -> Option<Set> {+        let bytes = data.get(index * ENTRY_SIZE..(index + entries) * ENTRY_SIZE)?;+        if entries < 3 || bytes[ENTRY_SIZE] != ENTRY_STREAM || set_checksum(bytes) != get16(bytes, FILE_SET_CHECKSUM) {+            return None;+        }+        let stream = &bytes[ENTRY_SIZE..2 * ENTRY_SIZE];+        let len = stream[STREAM_NAME_LENGTH] as usize;+        let mut name = Vec::new();+        for e in bytes[2 * ENTRY_SIZE..].chunks(ENTRY_SIZE).filter(|e| e[0] == ENTRY_NAME) {+            name.extend((0..NAME_CHARS_PER_ENTRY).map(|j| get16(e, 2 + 2 * j)));+        }+        if len == 0 || name.len() < len {+            return None;+        }+        name.truncate(len);+        Some(Set {+            index,+            name,+            attrs: get16(bytes, FILE_ATTRIBUTES),+            chain: Chain {+                first: get32(stream, STREAM_FIRST_CLUSTER),+                contiguous: stream[STREAM_FLAGS] & FLAG_NO_FAT_CHAIN != 0,+            },+            size: get64(stream, STREAM_DATA_LENGTH),+            valid: get64(stream, STREAM_VALID_DATA_LENGTH),+        })+    }++    /// Follow a path (already split into names) from the root.+    fn resolve(&self, path: &[String]) -> Result<Found, Errno> {+        let mut cur = self.root()?;+        for name in path {+            if !cur.is_dir() {+                return Err(Errno::ENOTDIR);+            }+            let units: Vec<u16> = name.encode_utf16().collect();+            let (clusters, _, sets) = self.read_dir(&cur)?;+            let s = sets.into_iter().find(|s| self.same_name(&s.name, &units)).ok_or(Errno::ENOENT)?;+            cur = Found { at: Some((clusters, s.index)), attrs: s.attrs, chain: s.chain, size: s.size, valid: s.valid };+        }+        Ok(cur)+    }++    /// Rewrite a file's stream entry (where its data is and how big it is)+    /// and its SetChecksum.+    fn update_entry(&self, f: &Found) -> Result<(), Errno> {+        let Some((parent, index)) = &f.at else {+            return Ok(()); // the root has no entry; its size lives in the FAT+        };+        let pos = (*index * ENTRY_SIZE) as u64;+        let mut head = [0u8; ENTRY_SIZE];+        self.read_chain(parent, pos, &mut head)?;+        let mut set = vec![0u8; (1 + head[FILE_SECONDARY_COUNT] as usize) * ENTRY_SIZE];+        self.read_chain(parent, pos, &mut set)?;+        let stream = &mut set[ENTRY_SIZE..2 * ENTRY_SIZE];+        stream[STREAM_FLAGS] = FLAG_ALLOCATION_POSSIBLE | if f.chain.contiguous && f.chain.first != 0 { FLAG_NO_FAT_CHAIN } else { 0 };+        put64(stream, STREAM_VALID_DATA_LENGTH, f.valid);+        put32(stream, STREAM_FIRST_CLUSTER, f.chain.first);+        put64(stream, STREAM_DATA_LENGTH, f.size);+        let sum = set_checksum(&set);+        put16(&mut set, FILE_SET_CHECKSUM, sum);+        self.write_chain(parent, pos, &set)+    }++    /// Build the entry set for a new, empty file or directory.+    fn new_set(&self, name: &[u16], attrs: u16, chain: Chain, size: u64) -> Vec<u8> {+        let names = name.len().div_ceil(NAME_CHARS_PER_ENTRY);+        let mut set = vec![0u8; (2 + names) * ENTRY_SIZE];+        set[0] = ENTRY_FILE;+        set[FILE_SECONDARY_COUNT] = (1 + names) as u8;+        put16(&mut set, FILE_ATTRIBUTES, attrs);+        for at in FILE_TIMES {+            put32(&mut set, at, TIMESTAMP);+        }+        let mut hash_input = Vec::new();+        for &u in name {+            hash_input.extend_from_slice(&self.upcase(u).to_le_bytes());+        }+        let stream = &mut set[ENTRY_SIZE..2 * ENTRY_SIZE];+        stream[0] = ENTRY_STREAM;+        stream[STREAM_NAME_LENGTH] = name.len() as u8;+        // NameHash: the checksum over the up-cased name (no bytes skipped,+        // so sum by hand rather than with set_checksum).+        let hash = hash_input.iter().fold(0u16, |s, &b| s.rotate_right(1).wrapping_add(b as u16));+        put16(stream, STREAM_NAME_HASH, hash);+        stream[STREAM_FLAGS] = FLAG_ALLOCATION_POSSIBLE | if chain.contiguous && chain.first != 0 { FLAG_NO_FAT_CHAIN } else { 0 };+        put64(stream, STREAM_VALID_DATA_LENGTH, size);+        put32(stream, STREAM_FIRST_CLUSTER, chain.first);+        put64(stream, STREAM_DATA_LENGTH, size);+        for (k, chunk) in name.chunks(NAME_CHARS_PER_ENTRY).enumerate() {+            let e = &mut set[(2 + k) * ENTRY_SIZE..(3 + k) * ENTRY_SIZE];+            e[0] = ENTRY_NAME;+            for (j, &u) in chunk.iter().enumerate() {+                put16(e, 2 + 2 * j, u);+            }+        }+        let sum = set_checksum(&set);+        put16(&mut set, FILE_SET_CHECKSUM, sum);+        set+    }++    fn create(&mut self, dir_path: &[String], name: &str, kind: InodeKind) -> Result<(), Errno> {+        if !self.writable {+            return Err(Errno::EROFS);+        }+        let units = valid_name(name)?;+        let attrs = match kind {+            InodeKind::File => ATTR_ARCHIVE,+            InodeKind::Directory => ATTR_DIRECTORY,+            InodeKind::Symlink => return Err(Errno::EOPNOTSUPP), // exFAT has no symlinks+        };+        let mut dir = self.resolve(dir_path)?;+        if !dir.is_dir() {+            return Err(Errno::ENOTDIR);+        }+        let (mut clusters, data, sets) = self.read_dir(&dir)?;+        if sets.iter().any(|s| self.same_name(&s.name, &units)) {+            return Err(Errno::EEXIST);+        }+        let need = 2 + units.len().div_ceil(NAME_CHARS_PER_ENTRY);++        // First run of `need` free entries (not in use: deleted or past the+        // end). If the run reaches the end of the directory, grow it.+        let total = data.len() / ENTRY_SIZE;+        let is_free = |i: usize| i >= total || data[i * ENTRY_SIZE] & ENTRY_IN_USE == 0;+        let mut start = 0;+        while start < total && !(start..start + need).all(is_free) {+            start += 1;+        }+        let end = ((start + need) * ENTRY_SIZE) as u64;+        if end > dir.size {+            // Round up to whole clusters: a directory is always full clusters.+            let new_size = end.div_ceil(self.cluster_size as u64) * self.cluster_size as u64;+            let mut chain = dir.chain;+            self.grow(&mut chain, &mut clusters, new_size)?;+            dir.chain = chain;+            dir.size = new_size;+            dir.valid = new_size;+            self.update_entry(&dir)?;+        }++        // A directory gets one (zeroed) cluster: it must end with 0x00.+        let (chain, size) = if kind == InodeKind::Directory {+            let c = self.alloc(0)?;+            self.update_percent()?;+            (Chain { first: c, contiguous: true }, self.cluster_size as u64)+        } else {+            (Chain { first: 0, contiguous: false }, 0)+        };+        let set = self.new_set(&units, attrs, chain, size);+        self.write_chain(&clusters, (start * ENTRY_SIZE) as u64, &set)+    }++    fn remove(&mut self, dir_path: &[String], name: &str) -> Result<(), Errno> {+        if !self.writable {+            return Err(Errno::EROFS);+        }+        let mut path = dir_path.to_vec();+        path.push(name.into());+        let f = self.resolve(&path)?;+        let Some((parent, index)) = &f.at else {+            return Err(Errno::EBUSY); // the root+        };+        if f.is_dir() {+            let (_, data, _) = self.read_dir(&f)?;+            if data.chunks(ENTRY_SIZE).any(|e| e[0] & ENTRY_IN_USE != 0) {+                return Err(Errno::ENOTEMPTY);+            }+        }+        let clusters = self.clusters(f.chain, f.size)?;+        // Mark every entry of the set unused: clear bit 7 of its type.+        let pos = (*index * ENTRY_SIZE) as u64;+        let mut head = [0u8; ENTRY_SIZE];+        self.read_chain(parent, pos, &mut head)?;+        let mut set = vec![0u8; (1 + head[FILE_SECONDARY_COUNT] as usize) * ENTRY_SIZE];+        self.read_chain(parent, pos, &mut set)?;+        for e in set.chunks_mut(ENTRY_SIZE) {+            e[0] &= !ENTRY_IN_USE;+        }+        self.write_chain(parent, pos, &set)?;+        self.free(f.chain, &clusters)+    }++    fn read_file(&self, f: &Found, offset: u64, buf: &mut [u8]) -> Result<usize, Errno> {+        if offset >= f.size {+            return Ok(0);+        }+        let n = buf.len().min((f.size - offset) as usize);+        let clusters = self.clusters(f.chain, f.size)?;+        // Past ValidDataLength the data is "zeros" whatever the disk holds.+        let real = (f.valid.min(f.size).saturating_sub(offset) as usize).min(n);+        self.read_chain(&clusters, offset, &mut buf[..real])?;+        buf[real..n].fill(0);+        Ok(n)+    }++    fn write_file(&mut self, f: &mut Found, offset: u64, data: &[u8]) -> Result<usize, Errno> {+        if !self.writable {+            return Err(Errno::EROFS);+        }+        let end = offset.checked_add(data.len() as u64).ok_or(Errno::EFBIG)?;+        let mut clusters = self.clusters(f.chain, f.size)?;+        if f.valid < f.size.min(offset) {+            // Another system left bytes past ValidDataLength undefined; once+            // we make the file "all valid", they must really be zeros.+            let gap = f.size.min(offset) - f.valid;+            self.write_chain(&clusters, f.valid, &vec![0u8; gap as usize])?;+        }+        if end > f.size {+            let old = f.size;+            self.grow(&mut f.chain, &mut clusters, end)?;+            // Writing past the end leaves a gap that must read as zeros:+            // clear what's left of the old last cluster (new ones are zeroed).+            let allocated = old.div_ceil(self.cluster_size as u64) * self.cluster_size as u64;+            if offset > old && allocated > old {+                let gap = offset.min(allocated) - old;+                self.write_chain(&clusters, old, &vec![0u8; gap as usize])?;+            }+            f.size = end;+        }+        self.write_chain(&clusters, offset, data)?;+        f.valid = f.size;+        self.update_entry(f)?;+        Ok(data.len())+    }++    fn truncate(&mut self, path: &[String]) -> Result<(), Errno> {+        if !self.writable {+            return Err(Errno::EROFS);+        }+        let mut f = self.resolve(path)?;+        let clusters = self.clusters(f.chain, f.size)?;+        self.free(f.chain, &clusters)?;+        f.chain = Chain { first: 0, contiguous: false };+        f.size = 0;+        f.valid = 0;+        self.update_entry(&f)+    }+}++/// exFAT names: 1-255 UTF-16 units, no control characters or `"*/:<>?\|`.+fn valid_name(name: &str) -> Result<Vec<u16>, Errno> {+    let units: Vec<u16> = name.encode_utf16().collect();+    if units.len() > 255 {+        return Err(Errno::ENAMETOOLONG);+    }+    if units.is_empty() || name == "." || name == ".." || name.chars().any(|c| c < ' ' || "\"*/:<>?\\|".contains(c)) {+        return Err(Errno::EINVAL);+    }+    Ok(units)+}++// ---------------------------------------------------------------------------+// The VFS side+// ---------------------------------------------------------------------------++pub struct ExfatFs {+    vol: Arc<Mutex<Volume>>,+}++impl ExfatFs {+    pub fn mount(dev: Arc<dyn BlockDevice>) -> Result<Arc<ExfatFs>, Errno> {+        Ok(Arc::new(ExfatFs { vol: Arc::new(Mutex::new(Volume::open(dev)?)) }))+    }++    /// For the boot log, e.g. `exFAT volume "TYLEREXFAT" (4 KiB clusters, 7680 clusters)`.+    pub fn describe(&self) -> String {+        self.vol.lock().describe()+    }++    /// (used clusters, total clusters), from the in-memory bitmap.+    pub fn usage(&self) -> (u32, u32) {+        let v = self.vol.lock();+        (v.used, v.cluster_count)+    }+}++impl FileSystem for ExfatFs {+    fn name(&self) -> &'static str {+        "exfat"+    }+    fn root(&self) -> Arc<dyn Inode> {+        Arc::new(ExfatInode { vol: self.vol.clone(), path: Vec::new(), kind: InodeKind::Directory })+    }+}++/// A file or directory on an exFAT volume. exFAT has no inode numbers (a+/// file *is* its directory entry), so we remember the path from the root+/// and look it up again on every call.+pub struct ExfatInode {+    vol: Arc<Mutex<Volume>>,+    path: Vec<String>,+    kind: InodeKind,+}++impl ExfatInode {+    fn dir_only(&self) -> Result<(), Errno> {+        if self.kind == InodeKind::Directory { Ok(()) } else { Err(Errno::ENOTDIR) }+    }+    fn file_only(&self) -> Result<(), Errno> {+        if self.kind == InodeKind::Directory { Err(Errno::EISDIR) } else { Ok(()) }+    }+    fn child(&self, name: &str, kind: InodeKind) -> Arc<dyn Inode> {+        let mut path = self.path.clone();+        path.push(name.into());+        Arc::new(ExfatInode { vol: self.vol.clone(), path, kind })+    }+}++impl Inode for ExfatInode {+    fn kind(&self) -> InodeKind {+        self.kind+    }++    fn size(&self) -> usize {+        if self.kind == InodeKind::Directory {+            return self.list().map(|l| l.len()).unwrap_or(0);+        }+        self.vol.lock().resolve(&self.path).map(|f| f.size as usize).unwrap_or(0)+    }++    fn read_at(&self, offset: usize, buf: &mut [u8]) -> Result<usize, Errno> {+        self.file_only()?;+        let vol = self.vol.lock();+        let f = vol.resolve(&self.path)?;+        vol.read_file(&f, offset as u64, buf)+    }++    fn write_at(&self, offset: usize, buf: &[u8]) -> Result<usize, Errno> {+        self.file_only()?;+        let mut vol = self.vol.lock();+        let mut f = vol.resolve(&self.path)?;+        vol.write_file(&mut f, offset as u64, buf)+    }++    fn truncate(&self) -> Result<(), Errno> {+        self.file_only()?;+        self.vol.lock().truncate(&self.path)+    }++    fn lookup(&self, name: &str) -> Result<Arc<dyn Inode>, Errno> {+        self.dir_only()?;+        let mut path = self.path.clone();+        path.push(name.into());+        let f = self.vol.lock().resolve(&path)?;+        Ok(self.child(name, if f.is_dir() { InodeKind::Directory } else { InodeKind::File }))+    }++    fn create(&self, name: &str, kind: InodeKind) -> Result<Arc<dyn Inode>, Errno> {+        self.dir_only()?;+        self.vol.lock().create(&self.path, name, kind)?;+        Ok(self.child(name, kind))+    }++    fn list(&self) -> Result<Vec<DirEntry>, Errno> {+        self.dir_only()?;+        let vol = self.vol.lock();+        let dir = vol.resolve(&self.path)?;+        let (_, _, sets) = vol.read_dir(&dir)?;+        Ok(sets+            .into_iter()+            .map(|s| DirEntry {+                name: String::from_utf16_lossy(&s.name),+                kind: if s.attrs & ATTR_DIRECTORY != 0 { InodeKind::Directory } else { InodeKind::File },+            })+            .collect())+    }++    fn remove(&self, name: &str) -> Result<(), Errno> {+        self.dir_only()?;+        self.vol.lock().remove(&self.path, name)+    }+}
@@ -28,6 +28,7 @@ //! directory holding `link`, not the parent of the link's target. Linux does //! the latter; ours is simpler and fine for a hobby kernel. +pub mod exfat; pub mod ext2; pub mod fat; pub mod file;@@ -304,9 +305,9 @@ pub fn init() { /// /// Which driver? Like Linux's `mount` without `-t`, we look for each file /// system's *magic number*: ext2 has 0xEF53 at byte 56 of its superblock-/// (which starts at byte 1024); anything else is tried as FAT (fatfs checks-/// the boot sector itself). Later: exFAT ("EXFAT   " at byte 3), btrfs-/// ("_BHRfS_M" at 64 KiB + 64).+/// (which starts at byte 1024); exFAT says "EXFAT   " at byte 3 of its boot+/// sector; anything else is tried as FAT (fatfs checks the boot sector+/// itself). Later: btrfs ("_BHRfS_M" at 64 KiB + 64). pub fn mount_disks() -> Vec<String> {     let mut log = Vec::new();     let devices = crate::block::devices();@@ -329,6 +330,17 @@ pub fn mount_disks() -> Vec<String> {                     continue;                 }             }+        } else if exfat::probe(&**dev) {+            match exfat::ExfatFs::mount(dev.clone()) {+                Ok(fs) => {+                    let d = fs.describe();+                    (fs, d)+                }+                Err(e) => {+                    log.push(format!("{}: exFAT, but can't mount it: {:?}", name, e));+                    continue;+                }+            }         } else {             match fat::FatFs::mount(dev.clone()) {                 Ok(fs) => {
@@ -493,7 +493,7 @@ fn sys_uname(buf: u64) -> Result<u64, Errno> {         "Rustbelt",                     // sysname (`uname -s`)         "qemu",                         // nodename (hostname)         env!("CARGO_PKG_VERSION"),      // release (`uname -r`)-        "stage 8: ext2",                // version+        "stage 9: exFAT",               // version         "x86_64",                       // machine         "(none)",                       // domainname     ];
@@ -0,0 +1,302 @@+//! exFAT through the VFS. Cargo.toml's test-args attach two exFAT images+//! that build.rs makes (snapshot=on, so writes vanish after the test):+//!+//!   vdc  target/exfat.img      /mnt/disk2  4 KiB clusters, disk3/ + more+//!   vdf  target/exfat-512.img  /mnt/disk5  512-byte clusters, small tree+//!+//! scripts/exfat-check.sh runs this kernel on *writable* copies and then+//! checks them with the host's fsck.exfat.+#![no_std]+#![no_main]+#![feature(custom_test_frameworks)]+#![test_runner(rustbelt::test_runner)]+#![reexport_test_harness_main = "test_main"]++extern crate alloc;++use alloc::{format, string::String, vec, vec::Vec};+use bootloader::{BootInfo, entry_point};+use core::panic::PanicInfo;+use rustbelt::block;+use rustbelt::errno::Errno;+use rustbelt::fs::{self, InodeKind, exfat::ExfatFs};++entry_point!(main);++fn main(boot_info: &'static BootInfo) -> ! {+    rustbelt::init();+    rustbelt::init_memory(boot_info);+    fs::init();+    rustbelt::init_storage();+    test_main();+    rustbelt::hlt_loop();+}++const DISK: &str = "/mnt/disk2";+const SMALL: &str = "/mnt/disk5";+/// The small disk's root is one 512-byte cluster with 1 free entry left, so+/// its write tests run in Photos/ (which has room): growing the root is+/// tested on its own, because a directory never shrinks again.+const SMALL_DIR: &str = "/mnt/disk5/Photos";++fn read(path: &str, offset: usize, len: usize) -> Vec<u8> {+    let mut buf = vec![0u8; len];+    let n = fs::lookup(path).unwrap().read_at(offset, &mut buf).unwrap();+    buf.truncate(n);+    buf+}++fn names(path: &str) -> Vec<String> {+    let mut names: Vec<String> = fs::lookup(path).unwrap().list().unwrap().into_iter().map(|e| e.name).collect();+    names.sort();+    names+}++/// (clusters marked used in the allocation bitmap, PercentInUse), read+/// straight from the disk with a few lines of independent parsing: boot+/// sector -> root directory -> bitmap entry (0x81). On a freshly made+/// volume the bitmap and root are both single, contiguous runs.+fn usage(device: &str) -> (u32, u8) {+    let dev = block::get(device).unwrap();+    let mut boot = [0u8; 512];+    dev.read_sectors(0, &mut boot).unwrap();+    let get32 = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap());+    let sector_shift = boot[108] as u32;+    let cluster_bytes = 1usize << (sector_shift + boot[109] as u32);+    let heap = (get32(&boot, 88) as u64) << sector_shift;+    let count = get32(&boot, 92);+    let pos = |cluster: u32| heap + (cluster as u64 - 2) * cluster_bytes as u64;+    let read = |at: u64, len: usize| {+        let mut b = vec![0u8; len.div_ceil(512) * 512];+        dev.read_sectors(at / 512, &mut b).unwrap();+        b+    };+    let root = read(pos(get32(&boot, 96)), cluster_bytes);+    let e = root.chunks(32).find(|e| e[0] == 0x81).unwrap();+    let bitmap = read(pos(get32(e, 20)), count.div_ceil(8) as usize);+    let used = (0..count as usize).filter(|&i| bitmap[i / 8] & (1 << (i % 8)) != 0).count();+    (used as u32, boot[112])+}++#[test_case]+fn mounted_as_exfat() {+    let mounts = fs::mounts();+    for path in [DISK, SMALL] {+        assert!(mounts.iter().any(|(p, name)| p == path && *name == "exfat"), "{} not exFAT", path);+    }+    // A second instance on the same device, just to read its description.+    let d = ExfatFs::mount(block::get("vdc").unwrap()).unwrap().describe();+    assert_eq!(d, "exFAT volume \"TYLEREXFAT\" (4 KiB clusters, 7680 clusters)");+    let d = ExfatFs::mount(block::get("vdf").unwrap()).unwrap().describe();+    assert!(d.starts_with("exFAT volume \"SMALLCLUST\" (512 byte clusters"), "{}", d);+}++#[test_case]+fn root_listing_keeps_case_and_long_names() {+    let long = format!("a-really-long-file-name-that-takes-several-file-name-directory-entries-in-exfat-{}.txt", "x".repeat(20));+    let want = vec![+        String::from("Long File Name With Spaces.txt"),+        "MixedCase.TXT".into(),+        "Photos".into(),+        long.clone(),+        "frag-chain.txt".into(),+        "numbers.txt".into(),+        "readme.txt".into(),+        "Ünïcödé café.txt".into(),+    ];+    // (Contains, not equals: tests run in name order, and the copy test+    // adds files to this directory first.)+    let have = names(DISK);+    for name in &want {+        assert!(have.contains(name), "{} missing from {:?}", name, have);+    }+    assert_eq!(read(&format!("{}/{}", DISK, long), 0, 13), b"A name longer");+    assert_eq!(fs::lookup(&format!("{}/Photos", DISK)).unwrap().kind(), InodeKind::Directory);+}++#[test_case]+fn lookup_ignores_case() {+    assert!(read("/mnt/disk2/mixedcase.txt", 0, 100).starts_with(b"MixedCase.TXT: exFAT keeps"));+    assert!(read("/mnt/disk2/LONG FILE NAME WITH SPACES.TXT", 0, 100).starts_with(b"This file has a long name"));+    // Non-ASCII: the volume's up-case table maps ü -> Ü, é -> É.+    assert!(read("/mnt/disk2/ÜNÏCÖDÉ CAFÉ.TXT", 0, 100).starts_with("Ünïcödé names work".as_bytes()));+    assert_eq!(read("/mnt/disk2/photos/2026 TRIP/Trip Notes.txt", 0, 100), b"Notes from the 2026 trip, two directories down.\n");+    assert_eq!(fs::lookup("/mnt/disk2/mixedcase.tx").err(), Some(Errno::ENOENT));+    assert_eq!(fs::lookup("/mnt/disk2/readme.txt/x").err(), Some(Errno::ENOTDIR));+}++#[test_case]+fn contiguous_file() {+    // numbers.txt: 20 000 lines "exfat line NNNNN\n", one NoFatChain run.+    let path = "/mnt/disk2/numbers.txt";+    assert_eq!(fs::lookup(path).unwrap().size(), 340_000);+    assert_eq!(read(path, 12345 * 17, 17), b"exfat line 12345\n");+    assert_eq!(read(path, 4090, 12), b" 00240\nexfat"); // across a cluster boundary+    assert_eq!(read(path, 339_983, 100), b"exfat line 19999\n"); // the end+    assert_eq!(read(path, 400_000, 10), b"");+}++#[test_case]+fn fragmented_file() {+    // frag-chain.txt uses every other cluster, linked through the FAT.+    let want: String = (0..2000).map(|i| format!("fragment line {:04}\n", i)).collect();+    for disk in [DISK, SMALL] {+        assert_eq!(fs::read_all(&format!("{}/frag-chain.txt", disk)).unwrap(), want.as_bytes(), "{}", disk);+    }+}++#[test_case]+fn bad_names() {+    assert_eq!(fs::create("/mnt/disk2/a:b.txt", InodeKind::File).err(), Some(Errno::EINVAL));+    assert_eq!(fs::create("/mnt/disk2/what?.txt", InodeKind::File).err(), Some(Errno::EINVAL));+    assert_eq!(fs::create(&format!("/mnt/disk2/{}", "n".repeat(256)), InodeKind::File).err(), Some(Errno::ENAMETOOLONG));+    // Same name in other case = the same file.+    assert_eq!(fs::create("/mnt/disk2/README.TXT", InodeKind::File).err(), Some(Errno::EEXIST));+}++/// Create, write across clusters, append, truncate, mkdir, rmdir, unlink:+/// afterwards the bitmap and PercentInUse are back where they started.+fn write_round_trip(disk: &str, device: &str) {+    let before = usage(device);+    let dir = format!("{}/New Folder", disk);+    fs::create(&dir, InodeKind::Directory).unwrap();+    let path = format!("{}/Data File.BIN", dir);+    let f = fs::create(&path, InodeKind::File).unwrap();+    assert_eq!(f.size(), 0);+    let data: Vec<u8> = (0..10_000u32).map(|i| (i * 7 % 251) as u8).collect();+    f.write_at(0, &data[..3000]).unwrap();+    f.write_at(3000, &data[3000..]).unwrap();+    assert_eq!(fs::read_all(&path).unwrap(), data);+    // Same file, other case.+    f.write_at(10_000, b"appended").unwrap();+    assert_eq!(read(&format!("{}/new folder/data file.bin", disk), 9_998, 20), [&data[9998..], b"appended"].concat());+    assert_eq!(fs::remove(&dir, true), Err(Errno::ENOTEMPTY));+    f.truncate().unwrap();+    assert_eq!(f.size(), 0);+    f.write_at(0, b"short").unwrap();+    assert_eq!(fs::read_all(&path).unwrap(), b"short");+    // Writing past the end leaves a gap of zeros.+    f.write_at(5000, b"!").unwrap();+    let all = fs::read_all(&path).unwrap();+    assert_eq!(all.len(), 5001);+    assert!(all[5..5000].iter().all(|&b| b == 0));+    fs::remove(&path, false).unwrap();+    assert_eq!(names(&dir), Vec::<String>::new());+    fs::remove(&dir, true).unwrap();+    assert_eq!(fs::lookup(&dir).err(), Some(Errno::ENOENT));+    assert_eq!(usage(device), before);+}++#[test_case]+fn write_4k_clusters() {+    write_round_trip(DISK, "vdc");+}++#[test_case]+fn write_512_byte_clusters() {+    write_round_trip(SMALL_DIR, "vdf");+}++#[test_case]+fn contiguous_file_turns_into_a_fat_chain() {+    // a.bin gets a cluster, b.bin the very next one. Growing a.bin can't+    // stay contiguous, so its clusters get FAT links (NoFatChain cleared).+    let before = usage("vdf");+    let a = fs::create("/mnt/disk5/Photos/a.bin", InodeKind::File).unwrap();+    a.write_at(0, &[b'a'; 500]).unwrap();+    let b = fs::create("/mnt/disk5/Photos/b.bin", InodeKind::File).unwrap();+    b.write_at(0, &[b'b'; 1500]).unwrap();+    a.write_at(500, &[b'A'; 3000]).unwrap();+    let all = fs::read_all("/mnt/disk5/Photos/a.bin").unwrap();+    assert_eq!(all.len(), 3500);+    assert!(all[..500].iter().all(|&c| c == b'a') && all[500..].iter().all(|&c| c == b'A'));+    assert_eq!(fs::read_all("/mnt/disk5/Photos/b.bin").unwrap(), [b'b'; 1500]);+    fs::remove("/mnt/disk5/Photos/a.bin", false).unwrap();+    fs::remove("/mnt/disk5/Photos/b.bin", false).unwrap();+    assert_eq!(usage("vdf"), before);+}++#[test_case]+fn many_files_grow_a_directory() {+    // 200 entry sets of 3 entries need 38 clusters of 512 bytes: the+    // directory grows (and, with file data in between, becomes a FAT chain).+    let before = usage("vdf");+    fs::create("/mnt/disk5/Photos/many", InodeKind::Directory).unwrap();+    for i in 0..200 {+        let f = fs::create(&format!("/mnt/disk5/Photos/many/File Number {:03}.txt", i), InodeKind::File).unwrap();+        f.write_at(0, format!("{}\n", i).as_bytes()).unwrap();+    }+    assert_eq!(fs::lookup("/mnt/disk5/Photos/many").unwrap().size(), 200);+    assert_eq!(read("/mnt/disk5/PHOTOS/MANY/file number 123.TXT", 0, 10), b"123\n");+    assert_eq!(read("/mnt/disk5/Photos/many/File Number 199.txt", 0, 10), b"199\n");+    for i in 0..200 {+        fs::remove(&format!("/mnt/disk5/Photos/many/file number {:03}.txt", i), false).unwrap();+    }+    fs::remove("/mnt/disk5/Photos/many", true).unwrap();+    assert_eq!(usage("vdf"), before);+}++#[test_case]+fn root_directory_grows() {+    // The root has no entry set: its size is its FAT chain. Two new files+    // don't fit in its one 512-byte cluster, so it gains a second one.+    let (used, _) = usage("vdf");+    for name in ["First New File.txt", "Second New File.txt"] {+        let f = fs::create(&format!("{}/{}", SMALL, name), InodeKind::File).unwrap();+        f.write_at(0, name.as_bytes()).unwrap();+    }+    assert_eq!(read("/mnt/disk5/second new file.txt", 0, 100), b"Second New File.txt");+    assert!(names(SMALL).contains(&String::from("First New File.txt")));+    assert_eq!(usage("vdf").0, used + 3); // one root cluster + one per file+    for name in ["First New File.txt", "Second New File.txt"] {+        fs::remove(&format!("{}/{}", SMALL, name), false).unwrap();+    }+    assert_eq!(usage("vdf").0, used + 1); // the root keeps its new cluster+    assert_eq!(read("/mnt/disk5/numbers.txt", 17, 17), b"exfat line 00001\n");+}++#[test_case]+fn copy_from_fat_and_ext2() {+    // What `cp B:/notes/todo.txt D:/` and `cp C:/hello.txt D:/` do.+    for (from, to) in [("/mnt/disk0/notes/todo.txt", "/mnt/disk2/todo.txt"), ("/mnt/disk1/hello.txt", "/mnt/disk2/Copied From Ext2.txt")] {+        let bytes = fs::read_all(from).unwrap();+        fs::create(to, InodeKind::File).unwrap().write_at(0, &bytes).unwrap();+        assert_eq!(fs::read_all(to).unwrap(), bytes);+    }+    // And the 340 KB file the other way, onto ext2.+    let numbers = fs::read_all("/mnt/disk2/numbers.txt").unwrap();+    fs::create("/mnt/disk1/numbers.txt", InodeKind::File).unwrap().write_at(0, &numbers).unwrap();+    assert_eq!(fs::read_all("/mnt/disk1/numbers.txt").unwrap(), numbers);+}++#[test_case]+fn written_files_stay_for_fsck() {+    // Runs last (tests go in name order) and deletes nothing, so that+    // scripts/exfat-check.sh's fsck.exfat sees the tricky cases on disk: a+    // grown directory that became a FAT chain, a file that went from+    // NoFatChain to a FAT chain, deleted entries in the middle of a+    // directory, and the grown root.+    for (disk, n) in [(SMALL_DIR, 40usize), (DISK, 300)] {+        let dir = format!("{}/Kept For fsck", disk);+        fs::create(&dir, InodeKind::Directory).unwrap();+        for i in 0..n {+            let f = fs::create(&format!("{}/Kept File {:03}.txt", dir, i), InodeKind::File).unwrap();+            f.write_at(0, format!("kept {}\n", i).as_bytes()).unwrap();+        }+        for i in (0..n).step_by(3) {+            fs::remove(&format!("{}/kept file {:03}.TXT", dir, i), false).unwrap();+        }+        let a = fs::create(&format!("{}/Grows Into A Chain.bin", dir), InodeKind::File).unwrap();+        a.write_at(0, &[1; 100]).unwrap();+        fs::create(&format!("{}/Blocker.txt", dir), InodeKind::File).unwrap().write_at(0, b"in the way\n").unwrap();+        a.write_at(100, &[2; 9000]).unwrap();+        assert_eq!(a.size(), 9100);+        assert_eq!(fs::lookup(&dir).unwrap().size(), n - n.div_ceil(3) + 2);+    }+    assert_eq!(read("/mnt/disk2/kept for FSCK/KEPT FILE 299.txt", 0, 20), b"kept 299\n");+}++#[panic_handler]+fn panic(info: &PanicInfo) -> ! {+    rustbelt::test_panic_handler(info)+}
@@ -2,9 +2,10 @@ //! that build.rs makes (all snapshot=on, so writes vanish after the test): //! //!   vdb  target/ext2.img       /mnt/disk1  1 KiB blocks, revision 1-//!   vdc  target/ext2-4k.img    /mnt/disk2  4 KiB blocks-//!   vdd  target/ext2-rev0.img  /mnt/disk3  revision 0 (no file types in+//!   vdd  target/ext2-4k.img    /mnt/disk3  4 KiB blocks+//!   vde  target/ext2-rev0.img  /mnt/disk4  revision 0 (no file types in //!                                          directory entries)+//! (vdc, /mnt/disk2, is the exFAT disk: see tests/exfat.rs.) //! //! scripts/ext2-check.sh runs this kernel on *writable* copies and then //! checks them with the host's e2fsck.@@ -64,7 +65,7 @@ fn names(path: &str) -> Vec<String> { #[test_case] fn ext2_volumes_are_mounted() {     let mounts = fs::mounts();-    for path in ["/mnt/disk1", "/mnt/disk2", "/mnt/disk3"] {+    for path in ["/mnt/disk1", "/mnt/disk3", "/mnt/disk4"] {         assert!(mounts.iter().any(|(p, kind)| p == path && *kind == "ext2"), "{} is not ext2", path);     }     // (Tests run in name order and share the disk, so others may have@@ -143,7 +144,7 @@ fn fast_and_slow_symlinks() {  #[test_case] fn four_k_blocks_and_revision_0() {-    for disk in ["/mnt/disk2", "/mnt/disk3"] {+    for disk in ["/mnt/disk3", "/mnt/disk4"] {         assert_eq!(names(disk), vec!["big.txt", "docs", "hello-link", "hello.txt", "lost+found"], "{}", disk);         let big = format!("{}/big.txt", disk);         assert_eq!(read(&big, 15_999 * 19, 19), b"big.txt line 15999\n");@@ -212,12 +213,12 @@ fn write_1k_blocks() {  #[test_case] fn write_4k_blocks() {-    write_round_trip("/mnt/disk2", "vdc");+    write_round_trip("/mnt/disk3", "vdd"); }  #[test_case] fn write_revision_0() {-    write_round_trip("/mnt/disk3", "vdd");+    write_round_trip("/mnt/disk4", "vde"); }  #[test_case]
@@ -175,7 +175,7 @@ fn scripted_shell_session() {         "piped text\n",         "wc -l\n2\n",         "saved\nmore\n",-        "Rustbelt 0.8.0",+        "Rustbelt 0.9.0",         "B:/ $ exit 3\n",     ] {         assert!(out.contains(expected), "missing {:?} in:\n{}", expected, out);