rust-kernel
Rustbelt: a hobby x86_64 kernel written in Rust
| author | Tyler Fowler <tyler@localhost> | 2026-10-30 02:16:46 -0700 |
|---|---|---|
| commit | 856182a546a770c0e1f776ded343d91ae316343c | |
| parent | 3b489faecb168e0e2b623eaa8eaff1ec5edd7a5a | |
| tree | browse 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.
| M | Cargo.lock | +1 -1 |
| M | Cargo.toml | +7 -2 |
| M | README.md | +367 -50 |
| M | boot.png | +0 -0 |
| M | build.rs | +56 -4 |
| A | build/exfat_populate.rs | +233 -0 |
| M | disk/scripts/selftest.sh | +11 -0 |
| A | disk3/Long File Name With Spaces.txt | +2 -0 |
| A | disk3/MixedCase.TXT | +1 -0 |
| A | disk3/Photos/2026 Trip/trip notes.txt | +1 -0 |
| A | disk3/a-really-long-file-name-that-takes-several-file-name-directory-entries-in-exfat-xxxxxxxxxxxxxxxxxxxx.txt | +1 -0 |
| A | disk3/readme.txt | +6 -0 |
| A | disk3/Ünïcödé café.txt | +1 -0 |
| A | scripts/exfat-check.sh | +87 -0 |
| M | scripts/ext2-check.sh | +3 -1 |
| M | scripts/run-headless.sh | +19 -1 |
| M | scripts/screenshot.sh | +7 -7 |
| M | src/block/partition.rs | +9 -0 |
| A | src/fs/exfat.rs | +993 -0 |
| M | src/fs/mod.rs | +15 -3 |
| M | src/syscall.rs | +1 -1 |
| A | tests/exfat.rs | +302 -0 |
| M | tests/ext2.rs | +7 -6 |
| M | tests/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. -+ ## 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() && §or[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(§or_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);