RustForge

rust-kernel

Rustbelt: a hobby x86_64 kernel written in Rust

authorTyler Fowler <tyler@localhost>2026-10-30 02:48:54 -0700
commita04245092f08984a55a2bde7ce284a8e9797fb66
parent856182a546a770c0e1f776ded343d91ae316343c
treebrowse files at this commit
Stage 10: read-only btrfs driver (0.10.0)
MCargo.lock+24 -1
MCargo.toml+19 -3
MREADME.md+380 -55
Mboot.png+0 -0
Mbuild.rs+158 -2
Mdisk/scripts/selftest.sh+15 -0
Adisk4/Music/Road Trip/playlist.txt+3 -0
Adisk4/archive/2025-notes.txt+2 -0
Adisk4/docs/btrfs/chunks.txt+3 -0
Adisk4/docs/btrfs/cow.txt+4 -0
Adisk4/hello.txt+1 -0
Adisk4/projects/README.txt+3 -0
Adisk4/projects/rustbelt/stage10.txt+2 -0
Adisk4/readme.txt+12 -0
Ascripts/btrfs-check.sh+92 -0
Mscripts/exfat-check.sh+1 -0
Mscripts/ext2-check.sh+1 -0
Mscripts/run-headless.sh+22 -4
Mscripts/screenshot.sh+9 -7
Asrc/fs/btrfs.rs+1192 -0
Asrc/fs/lzo.rs+123 -0
Msrc/fs/mod.rs+20 -5
Msrc/lib.rs+2 -1
Msrc/syscall.rs+1 -1
Atests/btrfs.rs+317 -0
Mtests/exfat.rs+27 -27
Mtests/ext2.rs+8 -7
Mtests/shell.rs+1 -1

28 files changed, 2442 insertions, 114 deletions

@@ -3,6 +3,12 @@ version = 4  [[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]] name = "bit_field" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -129,6 +135,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"  [[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+]++[[package]] name = "pc-keyboard" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -160,7 +175,7 @@ dependencies = [  [[package]] name = "rustbelt"-version = "0.9.0"+version = "0.10.0" dependencies = [  "bootloader",  "conquer-once",@@ -169,8 +184,10 @@ dependencies = [  "futures-util",  "lazy_static",  "linked_list_allocator",+ "miniz_oxide",  "pc-keyboard",  "pic8259",+ "ruzstd",  "spin 0.5.2",  "uart_16550",  "volatile 0.2.7",@@ -184,6 +201,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"  [[package]]+name = "ruzstd"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"++[[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1,6 +1,6 @@ [package] name = "rustbelt"-version = "0.9.0"+version = "0.10.0" edition = "2024"  [dependencies]@@ -28,6 +28,11 @@ futures-util = { version = "0.3.4", default-features = false, features = ["alloc # an exact commit so builds are reproducible. Only `alloc` + long file names. fatfs = { git = "https://github.com/rafalh/rust-fatfs", rev = "38b3a7950bf0e765e34286d1dfe76c519ee347e2", default-features = false, features = ["alloc", "lfn"] } +# Decompressors for btrfs's compressed extents (stage 10). Only decompression+# is used; the btrfs driver itself is hand-written. Both work without std.+miniz_oxide = { version = "0.8", default-features = false, features = ["with-alloc"] } # zlib (DEFLATE)+ruzstd = { version = "0.8", default-features = false }                                # zstd+ [dependencies.lazy_static] version = "1.0" features = ["spin_no_std"]@@ -81,6 +86,8 @@ run-args = [     "-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",+    # Fifth disk: the btrfs image (target/btrfs.img), drive E: (read-only).+    "-drive", "file=target/btrfs.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 ]@@ -92,11 +99,20 @@ test-args = [     "-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",-    # 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/btrfs.img,format=raw,if=virtio,snapshot=on",+    # More images just for the tests, after the ones `cargo run` has so the+    # drive letters B: to E: are the same in both: ext2 with 4 KiB blocks+    # (/mnt/disk4), ext2 revision 0 (/mnt/disk5), exFAT with 512-byte+    # clusters (/mnt/disk6), btrfs with lzo (/mnt/disk7), zstd (/mnt/disk8),+    # converted from ext2 (/mnt/disk9), and one with sha256 checksums that+    # must be refused (so it gets no /mnt/diskN).     "-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",+    "-drive", "file=target/btrfs-lzo.img,format=raw,if=virtio,snapshot=on",+    "-drive", "file=target/btrfs-zstd.img,format=raw,if=virtio,snapshot=on",+    "-drive", "file=target/btrfs-convert.img,format=raw,if=virtio,snapshot=on",+    "-drive", "file=target/btrfs-sha256.img,format=raw,if=virtio,snapshot=on",     "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04",     "-serial", "stdio",     "-display", "none",
@@ -5,8 +5,8 @@ the 2nd edition of Philipp Oppermann's [Writing an OS in Rust](https://os.phil-opp.com/) and keeps going past the end of it. It boots in QEMU straight into **its own shell**: a user-mode program that has a Unix feel with DOS touches. It offers `ls`, `cat`, pipes and-redirection, plus `dir`, `type`, `ver`, and drive letters like `A:`, `B:`-and `C:`.+redirection, plus `dir`, `type`, `ver`, and drive letters from `A:` to+`E:`.  Under the shell: - **VGA text and serial output.** The kernel prints to the VGA text screen and@@ -26,11 +26,14 @@ Under the shell:   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`, 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.+  disk at `/mnt/disk1`, an **exFAT** disk at `/mnt/disk2` and a **btrfs**+  disk at `/mnt/disk3`. 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 btrfs driver, also+  hand-written, is **read-only**: copy-on-write B-trees, chunk mapping+  (single and DUP), crc32c checksums on every node and data block,+  zlib/lzo/zstd compression, holes, symlinks and subvolumes.   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.@@ -42,9 +45,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 and exFAT drivers.+partitions, FAT32, the shell, and the ext2, exFAT and btrfs drivers. -![Rustbelt shell session on the exFAT drive D:: drives, ls -l with long, mixed-case and Unicode names, cp from the ext2 drive C: to D:, then type with the name in other case](boot.png)+![Rustbelt shell session on the btrfs drive E:: ls -l with a symlink and two subvolumes, cat of an inline file through a symlink and of a compressed file, cp from E: to D:, and a write refused with "Read-only file system"](boot.png)  ## What's installed (on this box) @@ -57,6 +60,8 @@ partitions, FAT32, the shell, and the ext2 and exFAT drivers. | `bootimage` (cargo subcommand) | 0.10.5 | links kernel + bootloader into a bootable disk image | | QEMU (`qemu-system-x86`, `qemu-system-gui`) | 10.0.13 | the emulator we boot in (`-gui` adds the GTK/SDL window) | | `dosfstools`, `mtools`, `fdisk` | Debian packages | `build.rs` makes the FAT32 test disk with `sfdisk` + `mkfs.vfat` + `mmd`/`mcopy` |+| `e2fsprogs`, `exfatprogs` | Debian packages | the ext2 and exFAT images, and `e2fsck` / `fsck.exfat` to check Rustbelt's writes |+| `btrfs-progs` | 6.14 | `mkfs.btrfs --rootdir` and `btrfs-convert` make the btrfs images; `btrfs check --readonly` checks them |  `rust-toolchain.toml` pins the project to nightly with the two components, so `cargo` inside this directory picks them up automatically. It also installs@@ -66,7 +71,9 @@ Crates: `bootloader` 0.9.35 (BIOS bootloader used by the 2nd edition), `volatile` 0.2, `spin` 0.5, `x86_64` 0.15, `uart_16550` 0.3, `lazy_static` 1.5, `pic8259` 0.11 (interrupt controller), `pc-keyboard` 0.7 (scancode decoding), `linked_list_allocator` 0.9 (fallback heap allocator), `fatfs` (git, `no_std` +-`alloc`; FAT12/16/32 reading and writing).+`alloc`; FAT12/16/32 reading and writing), `miniz_oxide` 0.8 (zlib inflate)+and `ruzstd` 0.8 (zstd decoding) for btrfs's compressed extents; LZO is+hand-written (`src/fs/lzo.rs`).  Fresh setup on another machine: @@ -74,7 +81,7 @@ Fresh setup on another machine: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh   # rustup rustup toolchain install nightly -c rust-src -c llvm-tools-preview -t x86_64-unknown-none cargo install bootimage-sudo apt install qemu-system-x86 qemu-system-gui dosfstools mtools fdisk+sudo apt install qemu-system-x86 qemu-system-gui dosfstools mtools fdisk e2fsprogs exfatprogs btrfs-progs ```  ## Build and run@@ -87,7 +94,8 @@ 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, or `ls -l D:/` for the exFAT one. See+for the ext2 disk, `ls -l D:/` for the exFAT one, or `ls -l E:/` for the+read-only btrfs 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.@@ -106,9 +114,12 @@ Headless (no window, good for SSH / scripts): ./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+BTRFS_IMG=other.img ./scripts/run-headless.sh # another btrfs image as E: (always snapshot=on) ./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)")+./scripts/btrfs-check.sh                  # btrfs check --readonly on the images, then every file Rustbelt+                                          # reads is compared with the host's copy (see "btrfs (stage 10)") 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@@ -137,10 +148,10 @@ only, so the VGA screen shows just the shell session (and kernel messages such as a killed process).  ```-Rustbelt 0.9.0 -- a hobby x86_64 kernel in Rust+Rustbelt 0.10.0 -- a hobby x86_64 kernel in Rust [pci] 00:00.0 8086:1237 host bridge-... (9 PCI devices)-[pci] 9 devices on the PCI bus+... (10 PCI devices)+[pci] 10 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"@@ -148,24 +159,29 @@ Rustbelt 0.9.0 -- a hobby x86_64 kernel in Rust [disk] vdb: no partition table (NoSignature) [disk] vdc: virtio-blk, 65536 sectors (32 MiB) [disk] vdc: no partition table (WholeDiskExfat)+[disk] vdd: virtio-blk, 262144 sectors (128 MiB)+[disk] vdd: no partition table (NoSignature) [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+[disk] vdd: btrfs volume "TYLERBTRFS" (16 KiB nodes, metadata DUP, data single, 2 subvolumes, read-only) mounted at /mnt/disk3 [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+[vfs] mounted btrfs at /mnt/disk3 [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.9.0 (stage 9: exFAT) x86_64+Rustbelt 0.10.0 (stage 10: btrfs read-only) x86_64 A:/ $ drives A:  /            ramfs B:  /mnt/disk0   fat32 C:  /mnt/disk1   ext2 D:  /mnt/disk2   exfat+E:  /mnt/disk3   btrfs A:/ $ pwd / A:/ $ cd B:@@ -345,42 +361,121 @@ D:/ $ ls -l D:/        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...+D:/ $ cd E:+E:/ $ ls -l+[process] started pid 38 (ls), entry 0x8000401880+drwxr-xr-x      <DIR>  Music/+drwxr-xr-x      <DIR>  archive/+-rw-r--r--    2160000  big.txt+drwxr-xr-x      <DIR>  docs/+-rw-r--r--          0  empty.txt+lrwxrwxrwx             hello-link -> hello.txt+-rw-r--r--         78  hello.txt+drwxr-xr-x      <DIR>  projects/+-rw-r--r--    1500000  random.bin+-rw-r--r--        637  readme.txt+-rw-r--r--    1048576  zeros.bin+[process] pid 38 (ls) exited with code 0+E:/ $ cat hello.txt+[process] started pid 39 (cat), entry 0x8000400260+Hello from btrfs! (an inline extent: these bytes live inside the B-tree leaf)+[process] pid 39 (cat) exited with code 0+E:/ $ cat hello-link+[process] started pid 40 (cat), entry 0x8000400260+Hello from btrfs! (an inline extent: these bytes live inside the B-tree leaf)+[process] pid 40 (cat) exited with code 0+E:/ $ cat docs/btrfs/cow.txt+[process] started pid 41 (cat), entry 0x8000400260+Copy-on-write: btrfs never overwrites a block in place. To change a leaf+it writes a new copy somewhere free, then a new copy of its parent that+points to it, and so on up to the root. The superblock is the last thing+written; until then the old tree is still complete on disk.+[process] pid 41 (cat) exited with code 0+E:/ $ wc big.txt+[process] started pid 42 (wc), entry 0x8000400260+ 120000  360000 2160000 big.txt+[process] pid 42 (wc) exited with code 0+E:/ $ grep "line 119999" big.txt+[process] started pid 43 (grep), entry 0x8000400610+btrfs line 119999+[process] pid 43 (grep) exited with code 0+E:/ $ cat projects/rustbelt/stage10.txt+[process] started pid 44 (cat), entry 0x8000400260+Stage 10: a read-only btrfs driver. Next: core polish (SMP, memory+cleanup, guard pages, a proper scheduler), then networking.+[process] pid 44 (cat) exited with code 0+E:/ $ cp docs/btrfs/cow.txt D:/cow.txt+[process] started pid 45 (cp), entry 0x80004006b0+[process] pid 45 (cp) exited with code 0+E:/ $ type D:/COW.TXT | wc -l+[process] started pid 46 (cat), entry 0x8000400260+[process] started pid 47 (wc), entry 0x8000400260+[process] pid 46 (cat) exited with code 0+4+[process] pid 47 (wc) exited with code 0+E:/ $ cp docs/btrfs/chunks.txt C:/chunks.txt+[process] started pid 48 (cp), entry 0x80004006b0+[process] pid 48 (cp) exited with code 0+E:/ $ cat C:/chunks.txt | wc -l+[process] started pid 49 (cat), entry 0x8000400260+[process] started pid 50 (wc), entry 0x8000400260+[process] pid 49 (cat) exited with code 0+3+[process] pid 50 (wc) exited with code 0+E:/ $ echo hi > new.txt+sh: new.txt: Read-only file system+E:/ $ rm hello.txt+[process] started pid 51 (rm), entry 0x8000400100+rm: hello.txt: Read-only file system+[process] pid 51 (rm) exited with code 1+[exit code 1]+E:/ $ mkdir newdir+[process] started pid 52 (mkdir), entry 0x8000400100+mkdir: newdir: Read-only file system+[process] pid 52 (mkdir) exited with code 1+[exit code 1]+E:/ $ run evil+[process] started pid 53 (evil), entry 0x80004000f0+[evil] pid 53: 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 38 (evil) killed: page fault+[process] pid 53 (evil) killed: page fault [exit code 139]-D:/ $ nosuchprogram+E:/ $ nosuchprogram sh: nosuchprogram: command not found (try `help`)-D:/ $ cd Q:+E:/ $ cd Q: cd: there is no drive Q: (see `drives`)-D:/ $ cd A:/tmp+E:/ $ 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] 30 frames in use-[run-headless] all 30+2 expected shell lines found+[memory] 40 frames in use+[run-headless] all 41+4 expected shell lines found [run-headless] kernel exited OK ``` -The run ends with `[memory] 30 frames in use`, even though 38 processes have+The run ends with `[memory] 40 frames in use`, even though 53 processes have come and gone. That's exactly what was in use right after boot (mostly the-three virtio disks' queues and buffers), so exited processes give their page-tables, code and stacks back.+four virtio disks' queues and buffers, 10 frames each), 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), 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) and one-more exFAT image (512-byte clusters).+**third**, `target/ext2.img` (raw ext2), a **fourth**,+`target/exfat.img` (raw exFAT), and a **fifth**, `target/btrfs.img` (raw+btrfs), 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 more images *after* those five, so the+drive letters B: to E: are the same in `cargo run` and `cargo test`: two+more ext2 images (4 KiB blocks, revision 0, `/mnt/disk4` and `disk5`), one+more exFAT image (512-byte clusters, `disk6`), and four more btrfs images+(lzo, zstd, converted from ext2: `disk7` to `disk9`; and one with sha256+checksums that is refused, so it gets no `/mnt/diskN`). Drive letters+follow mount order, so a new user-visible disk always goes right after the+last one `cargo run` attaches, never after the test-only ones.  ## Tests @@ -405,7 +500,7 @@ heap_allocation::simple_allocation...               [ok]  | Test | What it checks | |---|---|-| lib (`src/*.rs` `#[test_case]`s) | trivial assertion, breakpoint handler returns, VGA `println!` writes the right cells |+| lib (`src/*.rs` `#[test_case]`s) | trivial assertion, breakpoint handler returns, VGA `println!` writes the right cells; btrfs: crc32c check value and the `"default"` name hash, a hand-built prealloc extent and an explicit hole, an LZO literal run + overlapping match | | `tests/basic_boot.rs` | printing works straight after boot, before any `init()` | | `tests/heap_allocation.rs` | Box, big Vec, 100 K short-lived boxes (memory reuse), same with one long-lived box, >2 KiB fallback allocation | | `tests/should_panic.rs` | a failing `assert_eq!` reaches the panic handler (`harness = false`) |@@ -416,8 +511,9 @@ 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`, `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/ext2.rs` | the three ext2 images are mounted (`/mnt/disk1`, `disk4`, `disk5`); 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/disk6`), 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/btrfs.rs` | the four btrfs images are mounted (`/mnt/disk3`, `disk7`-`disk9`) and described (label, node size, DUP/single, subvolume count); **every file on every image is read and its size and CRC-32 compared with build.rs's manifest of the host's tree** (zlib, lzo with a two-level tree, zstd with 64 KiB nodes, and the converted image); reads at odd offsets across compressed and plain extent boundaries; holes in the converted `sparse.bin`; `ext2_saved/image` starts like an ext2 disk; hash lookups are exact (`HELLO.TXT` is `ENOENT`); symlinks, including one leaving a subvolume; subvolumes listed with ids and read-only flags; the sha256 image is refused with a reason; create/write/truncate/mkdir/remove give `EROFS`; and, last, damage on purpose: copy 0 of a DUP node (copy 1 is used), a data sector (`EIO` for that block only), then both copies (`EIO`). `scripts/btrfs-check.sh` runs `btrfs check --readonly` and compares the same kernel's output with the host's files | | `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]`@@ -469,7 +565,7 @@ 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, exFAT or FAT partitions/disks -> /mnt/diskN)+                          mount_disks (ext2, exFAT, btrfs 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@@ -477,10 +573,16 @@ src/fs/ext2.rs            hand-written ext2: superblock, groups, bitmaps, inodes                           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/btrfs.rs           hand-written read-only btrfs: superblock, chunk tree (single/DUP), root and FS+                          trees, crc32c on nodes and data, inline/regular/compressed extents, holes,+                          symlinks, subvolumes (the format is explained at the top)+src/fs/lzo.rs             LZO1X decompressor for btrfs's lzo extents 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 exFAT images target/exfat.img, exfat-512.img (mkfs.exfat + populator)+                          and the exFAT images target/exfat.img, exfat-512.img (mkfs.exfat + populator),+                          and the btrfs images target/btrfs*.img (mkfs.btrfs --rootdir, btrfs-convert)+                          with manifests of their files (target/btrfs-*.manifest) 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)@@ -488,6 +590,9 @@ disk2/                    files copied onto the ext2 disk (hello.txt, readme.txt                           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+disk4/                    files copied onto the btrfs disks (hello.txt, docs/, Music/, projects/ and+                          archive/ become subvolumes); build.rs adds big.txt, random.bin, zeros.bin,+                          empty.txt and symlinks 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)@@ -503,6 +608,7 @@ scripts/run-headless.sh   headless boot + shell self-test, timeout, exit-code an 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+scripts/btrfs-check.sh    btrfs check --readonly, then tests/btrfs.rs's file CRCs vs. the host's files boot.png                  screenshot of the VGA screen (a shell session) ``` @@ -772,7 +878,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.9.0", "stage 9: exFAT", "x86_64", "(none)" |+| 63 | `uname` | *utsname | Linux's struct: 6 NUL-padded 65-byte fields: "Rustbelt", "qemu", "0.10.0", "stage 10: btrfs read-only", "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 | |@@ -1166,6 +1272,11 @@ Drive letters are only shell syntax. The kernel keeps one Unix tree:   matching drive path wins, so `/mnt/disk0/bin` shows as `B:/bin`. - **Unknown letters**: these are left alone, so `cd Q:` says   `there is no drive Q:`.+- **Stable letters**: since letters follow mount order, `Cargo.toml` and+  the scripts attach the user-visible disks first (B: FAT32, C: ext2,+  D: exFAT, E: btrfs) and the test-only images after them. A disk that+  fails to mount (like the sha256 btrfs test image) gets no `/mnt/diskN`+  and no letter, so it can't shift the others either.  ### How the pieces fit @@ -1593,12 +1704,210 @@ 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. +## btrfs (stage 10)++`E:` is a **btrfs** volume, read by a hand-written, **read-only** driver+(`src/fs/btrfs.rs`, about 1 200 lines with comments; `src/fs/lzo.rs` for+LZO). btrfs is what most of Tyler's Linux machines will use, so this is+the file system Rustbelt most needs to *read*. Writing comes much later+(see below).++```+E:/ $ ls -l+drwxr-xr-x      <DIR>  Music/+drwxr-xr-x      <DIR>  archive/            <- a read-only subvolume (id 256)+-rw-r--r--    2160000  big.txt             <- 17 zlib-compressed extents+drwxr-xr-x      <DIR>  docs/+-rw-r--r--          0  empty.txt+lrwxrwxrwx             hello-link -> hello.txt+-rw-r--r--         78  hello.txt           <- an inline extent, inside a leaf+drwxr-xr-x      <DIR>  projects/           <- a subvolume (id 257)+-rw-r--r--    1500000  random.bin          <- incompressible: stored as is+-rw-r--r--        637  readme.txt+-rw-r--r--    1048576  zeros.bin+E:/ $ echo hi > new.txt+sh: new.txt: Read-only file system+```++### The ideas, for a learner++**Everything is a B-tree item.** ext2 and exFAT have fixed tables (inode+tables, a FAT, bitmaps) at fixed places. btrfs has almost none: just the+superblock at 64 KiB (magic `_BHRfS_M`). Everything else is an *item* in a+B-tree, sorted by a 17-byte key `(objectid, type, offset)`. A file is a+handful of items that sit next to each other because they share an+objectid (the inode number):++```+(257 INODE_ITEM 0)         size, mode, link count, times, flags+(257 INODE_REF 256)        "my name in directory 256 is docs"+(258 DIR_ITEM <hash>)      a directory entry, found by crc32c(name)+(258 DIR_INDEX <n>)        the same entry again, in creation order (readdir)+(259 EXTENT_DATA 0)        file bytes 0.. : inline data, or "see disk extent X"+(259 EXTENT_DATA 131072)   file bytes 131072.. : the next extent+```++Leaves hold items; internal nodes hold `(key, child pointer)` pairs.+Looking something up is "walk down from the root to the last key <= the+one you want" (`Volume::search_le`); listing a directory or a file's+extents is a range walk (`Volume::range`). `lookup` uses DIR_ITEM (one+search by name hash), and `ls` uses DIR_INDEX (a range, in creation order).++**Copy-on-write B-trees.** btrfs never overwrites a live block. To change+a leaf it writes a *new* copy elsewhere, then a new copy of the parent+pointing to it, and so on up to the root; the superblock is written last+and switches everything over at once. Until then the old tree is still+complete, so a crash just leaves the old version. The same trick makes+snapshots nearly free: a snapshot is a second root pointing at the same+nodes. For a *reader* it means every node is immutable once written, and+each one records the transaction (generation) that wrote it.++**Chunks: logical addresses.** Tree pointers and extent addresses are+*logical* byte numbers, not disk offsets. The **chunk tree** maps logical+ranges to (device, physical offset) "stripes". With **single** there is one+stripe; with **DUP** (the default for metadata on one disk) there are two+copies on the same disk, and the driver reads the second when the first+fails its checksum. RAID profiles would spread stripes over several+devices; the driver refuses those with a message. But the chunk tree is+itself stored at logical addresses, so how do you read it? The superblock+carries a small bootstrap copy of the SYSTEM chunks (`sys_chunk_array`),+just enough to find the chunk tree. Open order: superblock, then+sys_chunk_array, then chunk tree, then root tree, then FS tree.++**Trees of trees.** The **root tree** lists every other tree with a+ROOT_ITEM (where its root node is, its level). Tree 5 is the top-level+**FS tree** (files and directories); tree 7 is the **csum tree**+(checksums of data blocks); others (extent tree, device tree...) only+matter for writing. The "default" entry in the root tree's directory 6+says which subvolume to show; the driver honours it.++**Subvolumes** are separate FS trees (ids 256 and up) with their own inode+numbers, so `(subvolume, inode)` names a file (`Loc` in the driver). A+directory entry that points at a subvolume has a ROOT_ITEM key instead of+an INODE_ITEM key; following it moves to that tree's top directory. The+driver lists them from the root tree's ROOT_REF items with their+read-only flag (`BtrfsFs::subvolumes`; on E: `archive` is 256, read-only,+and `projects` is 257), and `cd E:/projects` just walks into one. Snapshots+are subvolumes too, so they would work the same way.++**Checksums everywhere.** Every node starts with a checksum of the rest of+the node, plus its own logical address and the file system's id, so a+misplaced or stale block is caught as well as a corrupt one. Data blocks+are checksummed per sector in the csum tree, keyed by logical address. The+driver checks both (crc32c: the Castagnoli CRC, a few lines in software);+a bad block is `EIO` with a log line, and with DUP it tries the other copy+first. Newer btrfs can use xxhash64, sha256 or blake2b instead; those are+refused at mount time: `btrfs, but can't mount it: checksum type 2 (sha256)+is not supported, only crc32c`. Directory names are found by+`crc32c(name)` too, with seed `~1` and no final inversion (the "default"+hash 2378154706 is a unit test).++**Extents.** A file's data comes as EXTENT_DATA items, each covering a+range of the file:++| Kind | What it means | Driver |+|---|---|---|+| inline | the bytes are inside the item, in the leaf (small files, symlink targets) | copied, or decompressed |+| regular | `disk_bytenr` + `offset` + `num_bytes`: a slice of an on-disk extent, which may be shared with other files or snapshots | read + checksummed |+| compressed | a regular or inline extent with zlib, lzo or zstd: the whole extent is decompressed, then sliced | `miniz_oxide`, `src/fs/lzo.rs`, `ruzstd` |+| prealloc | reserved by `fallocate`, never written | zeros |+| hole | `disk_bytenr` 0, or simply no item (the `no_holes` feature) | zeros |++btrfs's lzo is not plain LZO: it's a 4-byte total length, then per segment+a 4-byte length plus an LZO1X block, and a segment header never crosses a+4 KiB sector boundary (padding instead). zstd extents can be followed by+zero padding, so the driver decodes frame by frame instead of "all+input".++### Why read-only first, and what writing would take++Reading btrfs only needs the trees as they are on disk. Writing needs+everything copy-on-write implies:++- **Allocation**: the extent tree and free-space tree say which logical+  ranges are used, with *reference counts* (an extent can be shared by+  files, snapshots and reflinks). Every new node or data block needs a+  free range, and every freed one a decrement.+- **COW updates**: changing one item means new copies of the leaf and of+  every node up to the root, in each affected tree (FS tree, extent tree,+  csum tree, root tree...), plus splitting and merging nodes to stay+  balanced.+- **Transactions**: batch all of that, write it, flush, then write the+  superblock (all copies) with the new generation, in the right order+  for crash safety. Also the log tree for `fsync`.+- **Checksums and compression on the way out**, inode/dir item updates,+  back-references (INODE_REF, ROOT_BACKREF...).++That's a few thousand careful lines where a bug corrupts the volume, versus+about a thousand for reading. Read-only first gets Tyler's disks readable,+and it builds the parts writing needs anyway (key order, tree walking,+chunk mapping, checksums). A first write step could be overwriting+existing data blocks in place on a NODATACOW file.++### What the driver does and doesn't do++- Does: superblock (crc32c, feature flags, single device), sys_chunk_array+  + chunk tree with single and DUP, root tree, the default subvolume, FS+  and csum trees; node checks (checksum, bytenr, fsid, level); inode items,+  INODE_REF, DIR_ITEM (hash lookup), DIR_INDEX (readdir); inline, regular,+  prealloc and hole extents, zlib/lzo/zstd, shared extents (slices into a+  bigger extent, as btrfs-convert makes them); data checksums; symlinks;+  subvolumes (listed, entered). A 256 KiB node cache and a one-extent+  decompression cache keep `cat big.txt` from re-reading and re-inflating.+- Doesn't: write anything (`EROFS` for write, truncate, create, mkdir,+  rm); RAID profiles or multiple devices; checksum types other than crc32c;+  encryption; extended attributes, hard-link counts and timestamps aren't+  shown; the extent tree isn't read (not needed to read files).++### The test images (`build.rs`)++`build.rs` copies `disk4/` to a staging tree, adds `big.txt` (120 000 lines,+2.16 MB), `random.bin` (1.5 MB of noise), `zeros.bin`, `empty.txt` and three+symlinks, and makes 128 MiB images with `mkfs.btrfs --rootdir`:++| Image | Options | Why |+|---|---|---|+| `btrfs.img` (E:) | `--compress zlib -u projects -u ro:archive`, defaults: 16 KiB nodes, metadata DUP | the main one |+| `btrfs-lzo.img` | `--compress lzo -n 4096 -m single` | lzo; small nodes make a 2-level FS tree |+| `btrfs-zstd.img` | `--compress zstd -n 65536` | zstd; 64 KiB nodes |+| `btrfs-convert.img` | `mke2fs -d` (4 KiB blocks), then `btrfs-convert` | real holes and shared extents, an `ext2_saved` subvolume |+| `btrfs-sha256.img` | `--csum sha256` | must be refused |++`mkfs.btrfs --rootdir` never makes holes or prealloc extents (sparse+files are stored as data), so holes come from the converted image and+prealloc from a hand-made item in a unit test. Each tree gets a manifest+(`target/btrfs-tree.manifest`, `btrfs-convert.manifest`): one line per+directory, file (size + CRC-32) and symlink (target).++### Checking against the host++`./scripts/btrfs-check.sh` (after `cargo test --test btrfs`):++```+== btrfs check --readonly target/btrfs.img+  found 2056192 bytes used, no error found+... (all five images)+== run the tests/btrfs.rs kernel+  11 btrfs tests ok+== compare what Rustbelt read with the host's files (zlib.crc32)+  /mnt/disk3: 22 entries (12 files, 4710275 bytes) match+  /mnt/disk7: 22 entries (12 files, 4710275 bytes) match+  /mnt/disk8: 22 entries (12 files, 4710275 bytes) match+  /mnt/disk9: 7 entries (4 files, 5442941 bytes) match+== all good+```++The test kernel itself already compares every file with the manifests; the+script checks again independently, from the host's files with Python's+`zlib.crc32`, using the `[btrfs-file]` lines the kernel prints.+ ## Roadmap  Done: all 12 chapters of the 2nd edition, plus preemptive kernel threads, user mode + syscalls, ELF loading, a VFS with a ramfs, PCI, virtio-blk, MBR/GPT partitions, FAT32, blocking threads, pipes, spawn/wait, process-memory cleanup, the shell, and a hand-written ext2 driver.+memory cleanup, the shell, and hand-written ext2, exFAT and (read-only)+btrfs drivers.  - [x] Freestanding binary — [phil-opp](https://os.phil-opp.com/freestanding-rust-binary/) - [x] Minimal kernel booting in QEMU — [phil-opp](https://os.phil-opp.com/minimal-rust-kernel/), [OSDev: Bare Bones](https://wiki.osdev.org/Bare_Bones)@@ -1628,6 +1937,7 @@ memory cleanup, the shell, and a hand-written ext2 driver. - [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)+- [x] btrfs, hand-written, read-only: chunk tree (single/DUP), root/FS/csum trees, crc32c everywhere, zlib/lzo/zstd, holes, subvolumes, checked against `btrfs check` and the host's files — [btrfs on-disk format](https://btrfs.readthedocs.io/en/latest/dev/On-disk-format.html)  ### Filesystem roadmap (Tyler's plan) @@ -1654,26 +1964,31 @@ whose `root()` returns `Inode`s; mounting it is one `fs::mount` call.    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.+7. **btrfs, read-only** — done (stage 10, `src/fs/btrfs.rs`): because most+   of Tyler's Linux systems will use btrfs going forward, so this kernel+   can read their disks. Copy-on-write B-trees, the chunk tree (logical ->+   physical, single and DUP), root/FS/csum trees, crc32c checksums,+   zlib/lzo/zstd, holes, subvolumes. Still to do: writing (see "Why+   read-only first"), other checksum types, RAID/multi-device, xattrs.    [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:+Next, 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).+   data, locks that work across cores), a memory cleanup (a real physical+   frame allocator instead of the boot-time walk plus free list, a bigger+   or growable heap), guard pages under kernel and thread stacks, and a+   proper scheduler (priorities, sleeping without polling, per-CPU run+   queues). 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).+4. **Running Linux software**: `fork`, `exec`, `mmap` and signals, then a+   [musl](https://musl.libc.org/) port, so ordinary C programs recompiled+   against Rustbelt's syscalls (numbered like Linux's already) can run.  Out of scope for now: **drivers for physical hardware** (real disks, NICs, USB). Rustbelt targets QEMU's virtio devices. A **Raspberry Pi / ARM port**@@ -1687,7 +2002,7 @@ Other ideas, any time:   (that needs signals or a kill syscall). - **More programs**: `more`/`less` (needs raw mode and screen size), an editor. -Small exercises along the way: guard pages under thread stacks, a timer-based+Small exercises along the way: a timer-based async `sleep` future, speed up the PIT ([OSDev: PIT](https://wiki.osdev.org/Programmable_Interval_Timer)), add handlers for divide error / general protection fault, make `BootInfoFrameAllocator` O(1), add a@@ -1716,8 +2031,8 @@ 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/`, `disk3/`, `build/` or-  `build.rs` change and rebuilds `target/disk.img` and the ext2 and exFAT images. QEMU opens them with+- `build.rs` reruns when `user/`, `disk/`, `disk2/`, `disk3/`, `disk4/`, `build/` or+  `build.rs` change and rebuilds `target/disk.img` and the ext2, exFAT and btrfs 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` and   `scripts/exfat-check.sh`).@@ -1734,6 +2049,16 @@ and for a UEFI/framebuffer setup later, the newer [`bootloader` 0.11](https://gi   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.+- btrfs-progs: `sudo apt install btrfs-progs` (6.14 here; `mkfs.btrfs`,+  `btrfs`, `btrfs-convert` are in `/usr/bin`). `mkfs.btrfs --rootdir`+  writes sparse files as real data (no holes, no prealloc) and numbers+  `-u` subvolumes in its own order (`-u projects -u ro:archive` gives+  `archive` 256 and `projects` 257). Compressed extents are at most+  128 KiB, uncompressed ones 1 MiB, and data that doesn't compress is+  stored plain. `btrfs-convert` refuses ext2 with 1 KiB blocks and fails+  *silently* on a 64 MiB image (256 MiB works). `btrfs inspect-internal+  dump-tree` and `dump-super` on an image show exactly what the driver+  parses; they were the main debugging tool. - 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.

Binary file changed.

@@ -41,9 +41,26 @@ //!    Mounted at /mnt/disk2 (drive D:). exfat-512.img (512-byte clusters, //!    so everything spans many clusters) is for `cargo test` only. //!+//! 5. Build the fifth QEMU disk, `target/btrfs.img` (128 MiB): a btrfs file+//!    system made by `mkfs.btrfs --rootdir`, which (like `mke2fs -d`) copies+//!    a directory tree in. The tree is disk4/ plus generated files: big.txt+//!    (2 MiB of text: many compressed extents), random.bin (incompressible,+//!    so stored uncompressed), zeros.bin, empty.txt and symlinks.+//!    `--compress zlib` compresses it; `-u projects -u ro:archive` turns+//!    those two directories into subvolumes. Mounted at /mnt/disk3 (E:).+//!    For `cargo test` only: the same tree with lzo and 4 KiB B-tree nodes+//!    (deeper trees), with zstd and 64 KiB nodes, a btrfs made by+//!    `btrfs-convert` from an ext2 image (the only way to get real holes+//!    and extents shared by several files without mounting anything), and+//!    one with sha256 checksums, which the driver must refuse.+//!    It also writes manifests (target/btrfs-*.manifest): every path with+//!    its size and CRC-32, which tests/btrfs.rs and scripts/btrfs-check.sh+//!    compare with what Rustbelt reads.+//! //! Needs dosfstools (mkfs.vfat), mtools (mmd, mcopy), fdisk (sfdisk),-//! e2fsprogs (mke2fs) and exfatprogs (mkfs.exfat):-//!     sudo apt install dosfstools mtools fdisk e2fsprogs exfatprogs+//! e2fsprogs (mke2fs), exfatprogs (mkfs.exfat) and btrfs-progs+//! (mkfs.btrfs, btrfs-convert):+//!     sudo apt install dosfstools mtools fdisk e2fsprogs exfatprogs btrfs-progs  use std::{env, fs, io::Write, path::{Path, PathBuf}, process::{Command, Stdio}}; @@ -70,6 +87,7 @@ fn main() {     println!("cargo:rerun-if-changed=disk");     println!("cargo:rerun-if-changed=disk2");     println!("cargo:rerun-if-changed=disk3");+    println!("cargo:rerun-if-changed=disk4");     println!("cargo:rerun-if-changed=build");     println!("cargo:rerun-if-changed=build.rs"); @@ -79,6 +97,7 @@ fn main() {     build_disk(&manifest, &out_dir);     build_ext2_disks(&manifest, &out_dir);     build_exfat_disks(&manifest, &out_dir);+    build_btrfs_disks(&manifest, &out_dir); }  fn build_program(user_dir: &Path, out_dir: &Path, program: &str) {@@ -287,3 +306,140 @@ fn build_exfat_disks(manifest: &Path, out_dir: &Path) {     mkfs(&target_dir.join("exfat.img"), 32 << 20, "4K", "TYLEREXFAT", &tree);     mkfs(&target_dir.join("exfat-512.img"), 8 << 20, "512", "SMALLCLUST", &small); }++// --- btrfs (stage 10) --------------------------------------------------------++/// CRC-32 (the zlib/PNG one), for the manifests. tests/btrfs.rs has its own+/// copy; scripts/btrfs-check.sh uses Python's zlib.crc32.+fn crc32(data: &[u8]) -> u32 {+    let mut crc = !0u32;+    for &b in data {+        crc ^= b as u32;+        for _ in 0..8 {+            crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };+        }+    }+    !crc+}++/// One line per path under `dir`, sorted: `D\tpath`, `F\tpath\tsize\tcrc32`+/// or `L\tpath\ttarget`. Paths are relative, with `/` separators.+fn manifest_lines(dir: &Path, prefix: &str, out: &mut Vec<String>) {+    for entry in fs::read_dir(dir).unwrap() {+        let entry = entry.unwrap();+        let name = entry.file_name().into_string().unwrap();+        let rel = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };+        let meta = fs::symlink_metadata(entry.path()).unwrap();+        if meta.file_type().is_symlink() {+            out.push(format!("L\t{rel}\t{}", fs::read_link(entry.path()).unwrap().display()));+        } else if meta.is_dir() {+            out.push(format!("D\t{rel}"));+            manifest_lines(&entry.path(), &rel, out);+        } else {+            let data = fs::read(entry.path()).unwrap();+            out.push(format!("F\t{rel}\t{}\t{:08x}", data.len(), crc32(&data)));+        }+    }+}++fn write_manifest(dir: &Path, extra: &[String], to: &Path) {+    let mut lines = Vec::new();+    manifest_lines(dir, "", &mut lines);+    lines.extend_from_slice(extra);+    lines.sort_by(|a, b| a.split('\t').nth(1).cmp(&b.split('\t').nth(1)));+    fs::write(to, lines.join("\n") + "\n").unwrap();+}++/// Pseudo-random bytes (a fixed LCG, so every build makes the same file).+fn noise(len: usize) -> Vec<u8> {+    let mut x: u64 = 0x2545_F491_4F6C_DD1D;+    (0..len)+        .map(|_| {+            x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);+            (x >> 33) as u8+        })+        .collect()+}++const BTRFS_LINES: usize = 120_000; // big.txt: 120 000 x 18 bytes = 2.16 MB++fn build_btrfs_disks(manifest: &Path, out_dir: &Path) {+    use std::io::{Seek, SeekFrom};+    use std::os::unix::fs::symlink;+    let target_dir = env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| manifest.join("target"));++    // The main tree: disk4/ + generated files and symlinks.+    let tree = out_dir.join("btrfs-tree");+    let _ = fs::remove_dir_all(&tree);+    copy_tree(&manifest.join("disk4"), &tree);+    let big: String = (0..BTRFS_LINES).map(|i| format!("btrfs line {i:06}\n")).collect();+    fs::write(tree.join("big.txt"), big).unwrap();+    fs::write(tree.join("random.bin"), noise(1_500_000)).unwrap();+    fs::write(tree.join("zeros.bin"), vec![0u8; 1 << 20]).unwrap();+    fs::write(tree.join("empty.txt"), b"").unwrap();+    symlink("hello.txt", tree.join("hello-link")).unwrap();+    symlink("btrfs/cow.txt", tree.join("docs/cow-link")).unwrap();+    // From inside a subvolume back out to the volume root.+    symlink("../hello.txt", tree.join("projects/hello-link")).unwrap();+    write_manifest(&tree, &[], &target_dir.join("btrfs-tree.manifest"));++    // mkfs.btrfs wants an existing file (or -b SIZE); 128 MiB is roomy.+    let mkfs = |img: &Path, args: &[&str], src: &Path| {+        let _ = fs::remove_file(img);+        fs::File::create(img).unwrap().set_len(128 << 20).unwrap();+        run(Command::new(tool("mkfs.btrfs")).arg("-q").args(args).arg("--rootdir").arg(src).arg(img), None);+    };+    let subvols = ["-u", "projects", "-u", "ro:archive"];+    let with = |a: &[&'static str]| -> Vec<&'static str> { [a, &subvols[..]].concat() };+    mkfs(&target_dir.join("btrfs.img"), &with(&["-L", "TYLERBTRFS", "--compress", "zlib"]), &tree);+    mkfs(&target_dir.join("btrfs-lzo.img"), &with(&["-L", "LZO4K", "--compress", "lzo", "-n", "4096", "-m", "single"]), &tree);+    mkfs(&target_dir.join("btrfs-zstd.img"), &with(&["-L", "ZSTD64K", "--compress", "zstd", "-n", "65536"]), &tree);++    // A btrfs with a checksum type the driver doesn't do (it must say so).+    let tiny = out_dir.join("btrfs-tiny");+    let _ = fs::remove_dir_all(&tiny);+    fs::create_dir_all(&tiny).unwrap();+    fs::write(tiny.join("hello.txt"), b"sha256 checksums\n").unwrap();+    mkfs(&target_dir.join("btrfs-sha256.img"), &["-L", "SHA256", "--csum", "sha256"], &tiny);++    // btrfs-convert: an ext2 image (4 KiB blocks) turned into btrfs in place.+    // mkfs.btrfs writes zeros as real data, but here the ext2 holes become+    // btrfs holes, and the converter's extents are shared: each file points+    // into one big extent that also backs ext2_saved/image (the old ext2).+    let ctree = out_dir.join("btrfs-convert-tree");+    let _ = fs::remove_dir_all(&ctree);+    fs::create_dir_all(ctree.join("sub/dir")).unwrap();+    fs::write(ctree.join("small.txt"), b"converted from ext2\n").unwrap();+    fs::write(ctree.join("sub/dir/deep.txt"), b"a nested file in a converted file system\n").unwrap();+    fs::write(ctree.join("medium.bin"), noise(200_000)).unwrap();+    symlink("sub/dir/deep.txt", ctree.join("deep-link")).unwrap();+    {+        // sparse.bin: 5 MiB, data only at 0, 1 MiB and the very end.+        let mut f = fs::File::create(ctree.join("sparse.bin")).unwrap();+        f.set_len(5 << 20).unwrap();+        for (at, text) in [(0u64, &b"start of sparse.bin"[..]), (1 << 20, b"one MiB in"), ((5 << 20) - 7, b"the end")] {+            f.seek(SeekFrom::Start(at)).unwrap();+            f.write_all(text).unwrap();+        }+    }+    let conv = target_dir.join("btrfs-convert.img");+    let conv_size: u64 = 256 << 20; // btrfs-convert silently fails on 64 MiB+    let _ = fs::remove_file(&conv);+    run(+        Command::new(tool("mke2fs"))+            .args(["-q", "-F", "-t", "ext2", "-b", "4096", "-L", "CONVERTED", "-d"])+            .arg(&ctree)+            .arg(&conv)+            .arg(format!("{}M", conv_size >> 20)),+        None,+    );+    run(Command::new(tool("btrfs-convert")).arg(&conv), None);+    // lost+found came from mke2fs; ext2_saved/image is the old ext2 (only+    // its size is listed: reading 256 MiB in a test would take too long).+    let extra = [+        String::from("D\tlost+found"),+        String::from("D\text2_saved"),+        format!("S\text2_saved/image\t{conv_size}"),+    ];+    write_manifest(&ctree, &extra, &target_dir.join("btrfs-convert.manifest"));+}
@@ -45,6 +45,21 @@ md "New Folder" echo hi from exFAT > "New Folder/Note.txt" type "new folder/NOTE.TXT" ls -l D:/+cd E:+ls -l+cat hello.txt+cat hello-link+cat docs/btrfs/cow.txt+wc big.txt+grep "line 119999" big.txt+cat projects/rustbelt/stage10.txt+cp docs/btrfs/cow.txt D:/cow.txt+type D:/COW.TXT | wc -l+cp docs/btrfs/chunks.txt C:/chunks.txt+cat C:/chunks.txt | wc -l+echo hi > new.txt+rm hello.txt+mkdir newdir run evil nosuchprogram cd Q:
@@ -0,0 +1,3 @@+1. Rust Belt Blues+2. Copy on Write (Radio Edit)+3. B-Tree Ballad
@@ -0,0 +1,2 @@+archive/ is a read-only subvolume (made with mkfs.btrfs -u ro:archive).+Even Linux can't change it until its read-only flag is cleared.
@@ -0,0 +1,3 @@+Chunks: every pointer in btrfs is a "logical" address. The chunk tree+maps logical ranges to places on the devices. Metadata is usually DUP+(two copies on one disk) and data is "single" (one copy).
@@ -0,0 +1,4 @@+Copy-on-write: btrfs never overwrites a block in place. To change a leaf+it writes a new copy somewhere free, then a new copy of its parent that+points to it, and so on up to the root. The superblock is the last thing+written; until then the old tree is still complete on disk.
@@ -0,0 +1 @@+Hello from btrfs! (an inline extent: these bytes live inside the B-tree leaf)
@@ -0,0 +1,3 @@+projects/ is a btrfs subvolume: its own file tree with its own inode+numbers (both this directory and the volume root are inode 256). It can+be snapshotted or mounted on its own.
@@ -0,0 +1,2 @@+Stage 10: a read-only btrfs driver. Next: core polish (SMP, memory+cleanup, guard pages, a proper scheduler), then networking.
@@ -0,0 +1,12 @@+This is drive E:, a btrfs file system. Rustbelt reads it with its own+driver (src/fs/btrfs.rs) but never writes to it: btrfs is read-only here.++btrfs keeps everything in copy-on-write B-trees. A file's bytes live in+"extents": small files (like this one) sit right inside the tree as+inline extents, bigger ones in data blocks elsewhere on the disk. This+image was made with --compress zlib, so most extents are compressed.++Things to try:+  ls -l E:/            cat E:/big.txt | wc       cat E:/hello-link+  cd E:/projects       (a subvolume: a separate tree of its own)+  cp E:/hello.txt C:/  echo hi > E:/new.txt  (fails: read-only)
@@ -0,0 +1,92 @@+#!/usr/bin/env bash+# Check Rustbelt's read-only btrfs driver against the host's own tools.+#+#   1. `btrfs check --readonly` every image build.rs made, so we know the+#      images themselves are sound (made by mkfs.btrfs / btrfs-convert).+#   2. Boot the tests/btrfs.rs kernel on them (snapshot=on: its last test+#      damages a disk on purpose). It prints one line per file it read:+#        [btrfs-file] /mnt/diskN  F  path  size  crc32+#   3. Independently of the manifests that kernel compares against, walk+#      the host's copies of the trees (build.rs's staging directories) with+#      Python's zlib.crc32 and compare every file, symlink and directory+#      with what Rustbelt printed.+#+# Usage: ./scripts/btrfs-check.sh   (after `cargo test --test btrfs`, which+#        builds the images and the test kernel)+set -euo pipefail+cd "$(dirname "$0")/.."+export PATH="$HOME/.cargo/bin:$PATH:/usr/sbin:/sbin"+for tool in btrfs qemu-system-x86_64 python3; do+    command -v $tool >/dev/null || { echo "need $tool (apt install btrfs-progs qemu-system-x86)"; exit 1; }+done+status=0++for img in btrfs btrfs-lzo btrfs-zstd btrfs-convert btrfs-sha256; do+    echo "== btrfs check --readonly target/$img.img"+    btrfs check --readonly target/$img.img 2>&1 | grep -E "^(found|no error|ERROR)" | sed 's/^/  /' \+        || true+    btrfs check --readonly target/$img.img >/dev/null 2>&1 || { echo "!! btrfs check found problems"; status=1; }+done++echo "== run the tests/btrfs.rs kernel"+KERNEL=$(ls -t target/x86_64-rust_kernel/debug/build/rustbelt/*/out/bootimage-btrfs-*.bin 2>/dev/null | head -1 || true)+[ -n "$KERNEL" ] || { echo "no test kernel: run 'cargo test --test btrfs' first"; exit 1; }+LOG=target/btrfs-check.log+drives=()+for img in disk ext2 exfat btrfs ext2-4k ext2-rev0 exfat-512 btrfs-lzo btrfs-zstd btrfs-convert btrfs-sha256; do+    drives+=(-drive "file=target/$img.img,format=raw,if=virtio,snapshot=on")+done+set +e+timeout 120 qemu-system-x86_64 -drive format=raw,file="$KERNEL" -no-reboot "${drives[@]}" \+    -device isa-debug-exit,iobase=0xf4,iosize=0x04 -serial stdio -display none > $LOG+code=$?+set -e+echo "  $(grep -c '\[ok\]' $LOG) btrfs tests ok"+[ $code = 33 ] || { echo "!! test kernel failed (exit $code), see $LOG"; exit 1; }++echo "== compare what Rustbelt read with the host's files (zlib.crc32)"+OUT=$(dirname "$(ls -td target/x86_64-rust_kernel/debug/build/rustbelt/*/out/btrfs-tree | head -1)")+python3 - "$LOG" "$OUT" <<'PY' || status=1+import os, sys, zlib+log, out = sys.argv[1:]+def host(root, skip=()):+    """Every entry under root as the kernel prints it: {path: line}."""+    seen = {}+    for dirpath, dirs, files in os.walk(root):+        for name in dirs + files:+            full = os.path.join(dirpath, name)+            rel = os.path.relpath(full, root)+            if rel.split('/')[0] in skip:+                continue+            if os.path.islink(full):+                seen[rel] = f"L\t{rel}\t{os.readlink(full)}"+            elif os.path.isdir(full):+                seen[rel] = f"D\t{rel}"+            else:+                data = open(full, 'rb').read()+                seen[rel] = f"F\t{rel}\t{len(data)}\t{zlib.crc32(data):08x}"+    return seen+tree, conv = host(f"{out}/btrfs-tree"), host(f"{out}/btrfs-convert-tree")+want = {"/mnt/disk3": tree, "/mnt/disk7": tree, "/mnt/disk8": tree, "/mnt/disk9": conv}+got = {m: {} for m in want}+for line in open(log, errors='replace'):+    if "[btrfs-file] " in line:  # (the first may follow the test's name)+        mount, rest = line.split("[btrfs-file] ", 1)[1].rstrip('\n').split('\t', 1)+        got[mount][rest.split('\t')[1]] = rest+total_bad = 0+for mount, files in want.items():+    bad = 0+    # Only on the converted disk: made by mke2fs / btrfs-convert, not in the tree.+    extra = {p for p in got[mount] if p in ("lost+found", "ext2_saved") or p.startswith("ext2_saved/")}+    for p in sorted(set(files) | set(got[mount]) - extra):+        if files.get(p) != got[mount].get(p):+            print(f"  !! {mount} {p}: host {files.get(p)!r}, Rustbelt {got[mount].get(p)!r}"); bad += 1+    n = sum(1 for l in files.values() if l[0] == 'F')+    size = sum(int(l.split('\t')[2]) for l in files.values() if l[0] == 'F')+    print(f"  {mount}: {len(files)} entries ({n} files, {size} bytes) {'match' if not bad else 'DIFFER'}")+    total_bad += bad+sys.exit(1 if total_bad else 0)+PY++[ $status = 0 ] && echo "== all good" || echo "== problems found"+exit $status
@@ -50,6 +50,7 @@ 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/btrfs.img,format=raw,if=virtio,snapshot=on \     -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 \
@@ -46,6 +46,7 @@ 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/btrfs.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 \
@@ -16,6 +16,8 @@ #        EXFAT_IMG=copy.img ./scripts/run-headless.sh #            the same for the exFAT disk (scripts/exfat-check.sh runs #            fsck.exfat on it)+#        BTRFS_IMG=other.img ./scripts/run-headless.sh+#            use other.img as drive E: (read-only, so always snapshot=on) set -euo pipefail cd "$(dirname "$0")/.." @@ -37,6 +39,8 @@ else     EXFAT_DRIVE="file=target/exfat.img,format=raw,if=virtio,snapshot=on" fi +BTRFS_DRIVE="file=${BTRFS_IMG:-target/btrfs.img},format=raw,if=virtio,snapshot=on"+ LOG=target/headless-serial.log set +e timeout 90 qemu-system-x86_64 \@@ -44,6 +48,7 @@ timeout 90 qemu-system-x86_64 \     -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \     -drive "$EXT2_DRIVE" \     -drive "$EXFAT_DRIVE" \+    -drive "$BTRFS_DRIVE" \     -device isa-debug-exit,iobase=0xf4,iosize=0x04 \     -serial stdio -display none -no-reboot | tee "$LOG" code=${PIPESTATUS[0]}@@ -52,10 +57,11 @@ set -e # Lines the shell self-test must have printed (only for a plain boot). if [ -z "${1:-}" ] && [ "$code" = 33 ]; then     expect=(-        "Rustbelt 0.9.0 (stage 9: exFAT) x86_64"      # ver+        "Rustbelt 0.10.0 (stage 10: btrfs read-only) x86_64" # ver         "B:  /mnt/disk0   fat32"                       # drives         "C:  /mnt/disk1   ext2"         "D:  /mnt/disk2   exfat"+        "E:  /mnt/disk3   btrfs"         "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@@ -78,6 +84,16 @@ if [ -z "${1:-}" ] && [ "$code" = 33 ]; then         "       125  todo.txt"                         # cp B: -> D:, ls -l D:/         "     <DIR>  New Folder/"         "    340000  numbers.txt"+        "E:/ $ ls -l"                                  # the btrfs disk+        "lrwxrwxrwx             hello-link -> hello.txt"+        "Hello from btrfs! (an inline extent"          # inline, through a symlink+        "btrfs never overwrites a block in place"      # compressed inline extent+        " 120000  360000 2160000 big.txt"              # 17 zlib extents+        "btrfs line 119999"+        "Stage 10: a read-only btrfs driver"           # inside subvolume projects+        "sh: new.txt: Read-only file system"           # writes refused+        "rm: hello.txt: Read-only file system"+        "mkdir: newdir: Read-only file system"         "[exit code 139]"                              # run evil         "sh: nosuchprogram: command not found"         "cd: there is no drive Q:"@@ -87,11 +103,13 @@ if [ -z "${1:-}" ] && [ "$code" = 33 ]; then     for line in "${expect[@]}"; do         grep -qF -- "$line" "$LOG" || { echo "[run-headless] MISSING: $line"; missing=1; }     done-    # Pipe results: `... | grep -i EXT | wc -l` -> 1, `dir bin | wc -l` -> 15.-    for n in 1 15; do+    # Pipe results: `... | grep -i EXT | wc -l` -> 1, `dir bin | wc -l` -> 15,+    # `cat C:/chunks.txt | wc -l` (copied from E:) -> 3, and+    # `type D:/COW.TXT | wc -l` (copied from E:) -> 4.+    for n in 1 15 3 4; do         grep -qx -- "$n" "$LOG" || { echo "[run-headless] MISSING pipe result: $n"; missing=1; }     done-    [ $missing = 0 ] && echo "[run-headless] all ${#expect[@]}+2 expected shell lines found" || code=35+    [ $missing = 0 ] && echo "[run-headless] all ${#expect[@]}+4 expected shell lines found" || code=35 fi  # isa-debug-exit: QEMU exit status = (value << 1) | 1, so Success(0x10) -> 33.
@@ -25,15 +25,16 @@ if [ -z "$SESSION" ]; then     # and fixes it.     SESSION=$(mktemp /tmp/session.XXXXXX)     cat > "$SESSION" <<'TXT'-drives-cd d:+cd e: lx{BS}s -l-cp C:/hello.txt "From Ext2.txt"-type "FROM EXT2.TXT"+cat hello-link+cat docs/btrfs/chunks.txt+cp docs/btrfs/cow.txt D:/cow.txt+echo hi > new.txt TXT-    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!"+    set -- "E:/ $ ls -l" "-rw-r--r--    2160000  big.txt" "hello-link -> hello.txt" \+        "Hello from btrfs! (an inline extent" "E:/ $ cat docs/btrfs/chunks.txt" \+        "sh: new.txt: Read-only file system" fi SOCK=$(mktemp -u /tmp/qemu-mon.XXXXXX) SERIAL_LOG=$(mktemp /tmp/serial.XXXXXX)@@ -42,6 +43,7 @@ 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 \+    -drive file=target/btrfs.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
@@ -0,0 +1,1192 @@+//! btrfs, read-only, hand-written (no crate except the zlib and zstd+//! decompressors). btrfs ("B-tree file system", Chris Mason, 2007) is the+//! copy-on-write file system that Fedora, openSUSE and Synology use by+//! default. This driver reads it: enough to list, open and read every+//! file, follow symlinks and walk into subvolumes. Anything that would+//! change the disk fails with `EROFS`.+//!+//! # The big idea: everything is a B-tree, and nothing is overwritten+//!+//! ext2 has fixed places for things (inode tables, bitmaps). btrfs has+//! almost nothing at a fixed place: just the **superblock** at 64 KiB.+//! Everything else lives in **B-trees** made of fixed-size **nodes**+//! (16 KiB by default), and the trees point at each other.+//!+//! * A **leaf** (level 0) holds **items**, sorted by a 17-byte **key**+//!   `(objectid u64, type u8, offset u64)`. The item headers sit at the+//!   front of the leaf and the item data at the back:+//!+//!   ```text+//!   | header 101 B | item 0 | item 1 | ...  free space ...  | data 1 | data 0 |+//!                    key + offset + size (25 bytes each)  ^+//!   ```+//! * An **internal node** (level 1+) holds `(key, child pointer)` pairs.+//!   The child holds keys from its key up to the next pair's key.+//! * Every node starts with a header: a checksum of the rest of the node,+//!   the file system's UUID, the node's own address, and its level. So a+//!   misdirected or torn write is caught. We check all of that on every+//!   node we read.+//!+//! **Copy-on-write**: to change a leaf, btrfs writes a changed *copy*+//! somewhere free, then a copy of its parent pointing at the new leaf, and+//! so on up to the root, then finally the superblock. Until that last+//! write, the old tree is untouched. A crash can't leave it half-updated,+//! and a **snapshot** is just a second pointer to an old root.+//!+//! # Logical addresses and chunks+//!+//! Tree pointers are **logical** addresses, not disk offsets. The **chunk+//! tree** maps logical ranges (chunks, 8 MiB to 1 GiB) to places on the+//! device(s), which is how btrfs does RAID and DUP:+//!+//! ```text+//! logical 30408704 +32 MiB  METADATA|DUP  -> device 1 @ 38797312  and  @ 72351744+//! logical 13631488 +8 MiB   DATA (single) -> device 1 @ 13631488+//! ```+//!+//! **DUP** keeps two copies on the same disk (the default for metadata on+//! one disk); if one copy's checksum is bad we read the other. The chunk+//! tree is itself a B-tree at a logical address. To read it you'd need the+//! chunk tree, so the superblock carries a few chunk items of its own (the+//! `sys_chunk_array`) for the SYSTEM chunks that hold the chunk tree.+//!+//! # The trees we read+//!+//! ```text+//! superblock -> root tree      ROOT_ITEMs: where every other tree's root is+//!            -> chunk tree     CHUNK_ITEMs: logical -> physical+//! root tree  -> FS tree (5)    the files of the top-level subvolume+//!            -> subvolume 256, 257...  one FS tree each+//!            -> csum tree (7)  a crc32c for every 4 KiB of file data+//! ```+//!+//! In an FS tree, a file or directory is an **inode number** (objectid) and+//! its items, in key order:+//!+//! ```text+//! (257 INODE_ITEM 0)         size, mode, link count, times, flags+//! (257 INODE_REF 256)        "my name in directory 256 is docs"+//! (257 DIR_ITEM <hash>)      directory entry, found by a crc32c hash of the name+//! (257 DIR_INDEX <n>)        the same entry again, keyed by creation order+//! (258 EXTENT_DATA <offset>) "file bytes from <offset> are in this extent"+//! ```+//!+//! A directory's entries are stored twice. DIR_ITEMs, keyed by name hash,+//! make a lookup one tree search. DIR_INDEXes, keyed by a counter, make a+//! listing come out in creation order.+//!+//! **File data** is a list of EXTENT_DATA items:+//! * **inline**: the bytes are right in the leaf (small files and symlinks);+//! * **regular**: `disk_bytenr` + `disk_num_bytes` say where the extent is.+//!   The file uses `num_bytes` of it starting `offset` bytes in, so several+//!   files (or snapshots) can share pieces of one extent. `disk_bytenr` 0 is+//!   a hole;+//! * **prealloc**: reserved by `fallocate`, never written: reads as zeros.+//!+//! Any of them can be **compressed** (zlib, lzo or zstd): then the extent is+//! decompressed whole (up to 128 KiB) and `offset`/`num_bytes` index the+//! *decompressed* bytes. With the NO_HOLES feature (the default), holes have+//! no item at all: a gap between extents reads as zeros.+//!+//! A **subvolume** is a separate FS tree. Its parent directory has an entry+//! whose "location" is a ROOT_ITEM key instead of an inode, so a lookup+//! steps from one tree into another. ROOT_REF items in the root tree list+//! which subvolume sits in which directory.+//!+//! # Checksums+//!+//! crc32c everywhere: the superblock and every node (over everything after+//! the 32-byte checksum field), and every 4 KiB of file data (stored in+//! the csum tree, checked here on every read). A name hash is crc32c too,+//! with seed `!1` and no final inversion. Other checksum types (xxhash64,+//! sha256, blake2b) are refused at mount with a clear message.+//!+//! # Why read-only+//!+//! Reading needs: superblock, chunks, B-tree search, a handful of item+//! types, decompression. Writing needs everything *else*. You'd COW every+//! node from leaf to root on each change, rebalance (split and merge+//! nodes), and allocate space from block groups via the extent tree,+//! keeping its back-references and reference counts right. You'd also have+//! to update the csum tree and the free-space tree, and commit transactions+//! in the right order with the superblock last. That's a much bigger+//! project than this whole driver; see the README.++use super::{DirEntry, FileSystem, Inode, InodeKind};+use crate::block::{BlockDevice, SECTOR_SIZE};+use crate::errno::Errno;+use alloc::{collections::BTreeMap, format, string::String, sync::Arc, vec, vec::Vec};+use spin::Mutex;++// ---------------------------------------------------------------------------+// On-disk constants+// ---------------------------------------------------------------------------++const SUPER_OFFSET: u64 = 0x10000; // the primary superblock: 64 KiB in+const SUPER_SIZE: usize = 4096;+const MAGIC: &[u8; 8] = b"_BHRfS_M";++// Superblock fields (byte offsets).+const SB_FSID: usize = 0x20;+const SB_MAGIC: usize = 0x40;+const SB_GENERATION: usize = 0x48;+const SB_ROOT: usize = 0x50; // logical address of the root tree+const SB_CHUNK_ROOT: usize = 0x58;+const SB_TOTAL_BYTES: usize = 0x70;+const SB_BYTES_USED: usize = 0x78;+const SB_NUM_DEVICES: usize = 0x88;+const SB_SECTORSIZE: usize = 0x90;+const SB_NODESIZE: usize = 0x94;+const SB_SYS_CHUNK_ARRAY_SIZE: usize = 0xa0;+const SB_INCOMPAT: usize = 0xbc;+const SB_CSUM_TYPE: usize = 0xc4;+const SB_ROOT_LEVEL: usize = 0xc6;+const SB_CHUNK_ROOT_LEVEL: usize = 0xc7;+const SB_DEV_ITEM: usize = 0xc9; // our device; its id (u64) comes first+const SB_LABEL: usize = 0x12b; // 256 bytes, NUL-padded+const SB_METADATA_UUID: usize = 0x23b;+const SB_SYS_CHUNK_ARRAY: usize = 0x32b;++// Incompat feature bits we can read. Anything else: refuse to mount.+const INCOMPAT_METADATA_UUID: u64 = 0x400;+const INCOMPAT_OK: u64 = 0x1 // mixed back-references+    | 0x2      // a default subvolume other than 5+    | 0x4      // data and metadata in the same block groups+    | 0x8 | 0x10 // lzo, zstd were used+    | 0x20     // nodes bigger than 4 KiB+    | 0x40     // extended inode refs (lots of hard links)+    | 0x100    // skinny extent-tree metadata items+    | 0x200    // NO_HOLES: holes have no items+    | INCOMPAT_METADATA_UUID+    | 0x800    // raid1c3/raid1c4 exist (irrelevant on one device)+    | 0x10000; // simple quotas++// Node header (101 bytes), then items (leaf) or key pointers (internal).+const HDR_FSID: usize = 0x20;+const HDR_BYTENR: usize = 0x30;+const HDR_NRITEMS: usize = 0x60;+const HDR_LEVEL: usize = 0x64;+const HEADER_SIZE: usize = 0x65;+const ITEM_SIZE: usize = 25; // key (17) + data offset u32 + data size u32+const KEY_PTR_SIZE: usize = 33; // key (17) + child address u64 + generation u64++// Item types (the middle field of a key).+const INODE_ITEM: u8 = 1;+const INODE_REF: u8 = 12;+const DIR_ITEM: u8 = 84;+const DIR_INDEX: u8 = 96;+const EXTENT_DATA: u8 = 108;+const EXTENT_CSUM: u8 = 128;+const ROOT_ITEM: u8 = 132;+const ROOT_REF: u8 = 156;+const CHUNK_ITEM: u8 = 228;++// Well-known objectids.+const FS_TREE: u64 = 5; // the top-level subvolume+const ROOT_TREE_DIR: u64 = 6; // holds the "default" subvolume entry+const CSUM_TREE: u64 = 7;+const FIRST_CHUNK_TREE: u64 = 256;+const CSUM_OBJECTID: u64 = -10i64 as u64; // EXTENT_CSUM items' objectid++// Chunk type bits: what it holds, and how it's laid out.+const BG_DATA: u64 = 1;+const BG_SYSTEM: u64 = 2;+const BG_METADATA: u64 = 4;+const BG_DUP: u64 = 0x20;+const BG_PROFILES: u64 = 0x8 | 0x10 | 0x20 | 0x40 | 0x80 | 0x100 | 0x200 | 0x400;++// Inode item fields (160 bytes, also the first part of a ROOT_ITEM).+const INODE_SIZE: usize = 16;+const INODE_MODE: usize = 52;+const INODE_FLAGS: usize = 64;+const INODE_NODATASUM: u64 = 1;+const S_IFMT: u32 = 0o170000;+const S_IFDIR: u32 = 0o040000;+const S_IFLNK: u32 = 0o120000;+// ROOT_ITEM fields after its inode item.+const ROOT_DIRID: usize = 168; // inode number of the subvolume's top directory+const ROOT_BYTENR: usize = 176;+const ROOT_FLAGS: usize = 208;+const ROOT_LEVEL: usize = 238;+const ROOT_RDONLY: u64 = 1;++// EXTENT_DATA fields.+const EXT_RAM_BYTES: usize = 8; // size once decompressed+const EXT_COMPRESSION: usize = 16;+const EXT_ENCRYPTION: usize = 17;+const EXT_OTHER_ENCODING: usize = 18;+const EXT_TYPE: usize = 20; // 0 inline, 1 regular, 2 prealloc+const EXT_INLINE_DATA: usize = 21;+const EXT_DISK_BYTENR: usize = 21;+const EXT_DISK_NUM_BYTES: usize = 29;+const EXT_OFFSET: usize = 37;+const EXT_NUM_BYTES: usize = 45;+const EXT_REGULAR_SIZE: usize = 53;++/// How much node data each volume keeps cached, in bytes.+const NODE_CACHE_BYTES: usize = 256 * 1024;++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())+}++// ---------------------------------------------------------------------------+// crc32c (Castagnoli), table-driven; the table is built at compile time+// ---------------------------------------------------------------------------++const CRC32C_TABLE: [u32; 256] = {+    let mut table = [0u32; 256];+    let mut i = 0;+    while i < 256 {+        let mut c = i as u32;+        let mut bit = 0;+        while bit < 8 {+            c = if c & 1 != 0 { (c >> 1) ^ 0x82F6_3B78 } else { c >> 1 };+            bit += 1;+        }+        table[i] = c;+        i += 1;+    }+    table+};++/// The raw crc32c update: no inversion at the start or end.+fn crc32c_update(mut crc: u32, data: &[u8]) -> u32 {+    for &b in data {+        crc = CRC32C_TABLE[((crc ^ b as u32) & 0xff) as usize] ^ (crc >> 8);+    }+    crc+}++/// The standard crc32c, as stored for the superblock, nodes and data.+pub fn crc32c(data: &[u8]) -> u32 {+    !crc32c_update(!0, data)+}++/// The DIR_ITEM key offset for a name: crc32c with seed `!1`, not inverted.+pub fn name_hash(name: &[u8]) -> u64 {+    crc32c_update(!1, name) as u64+}++/// Is there a btrfs superblock on this device?+pub fn probe(dev: &dyn BlockDevice) -> bool {+    let mut sector = [0u8; SECTOR_SIZE];+    if dev.sector_count() < (SUPER_OFFSET + SUPER_SIZE as u64) / SECTOR_SIZE as u64 {+        return false;+    }+    dev.read_sectors(SUPER_OFFSET / SECTOR_SIZE as u64, &mut sector).is_ok() && &sector[SB_MAGIC..SB_MAGIC + 8] == MAGIC+}++// ---------------------------------------------------------------------------+// Keys and trees+// ---------------------------------------------------------------------------++/// A B-tree key. The derived ordering (objectid, then type, then offset) is+/// exactly btrfs's.+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]+pub struct Key {+    pub objectid: u64,+    pub kind: u8,+    pub offset: u64,+}++impl Key {+    const fn new(objectid: u64, kind: u8, offset: u64) -> Key {+        Key { objectid, kind, offset }+    }+    fn parse(b: &[u8], at: usize) -> Key {+        Key { objectid: get64(b, at), kind: b[at + 8], offset: get64(b, at + 9) }+    }+}++/// Where a tree's root node is, and its level (0 = the root is a leaf).+#[derive(Debug, Clone, Copy)]+struct Tree {+    bytenr: u64,+    level: u8,+}++/// One chunk: `length` bytes from `logical` are stored at each physical+/// offset in `copies` (one for single, two for DUP).+struct Chunk {+    logical: u64,+    length: u64,+    kind: u64,+    copies: Vec<u64>,+}++/// A file or directory: which FS tree (subvolume) and which inode in it.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+struct Loc {+    root: u64,+    ino: u64,+}++/// The parts of an INODE_ITEM we use.+struct InodeItem {+    size: u64,+    mode: u32,+    flags: u64,+}++/// One entry of a DIR_ITEM or DIR_INDEX item.+struct DirItem {+    location: Key, // (inode, INODE_ITEM, 0) or (subvolume id, ROOT_ITEM, ...)+    name: Vec<u8>,+    file_type: u8, // 1 file, 2 directory, 7 symlink+}++/// A subvolume, for the boot log and tests.+#[derive(Debug, Clone, PartialEq, Eq)]+pub struct Subvolume {+    pub id: u64,+    pub path: String, // from the top-level subvolume, e.g. "projects"+    pub read_only: bool,+}++/// One EXTENT_DATA item.+enum Extent<'a> {+    Inline { compression: u8, data: &'a [u8], ram_bytes: u64 },+    Regular { compression: u8, disk_bytenr: u64, disk_num_bytes: u64, offset: u64, num_bytes: u64, ram_bytes: u64 },+    Prealloc { num_bytes: u64 },+}++impl<'a> Extent<'a> {+    fn parse(d: &'a [u8]) -> Result<Extent<'a>, Errno> {+        if d.len() < EXT_INLINE_DATA {+            return Err(Errno::EIO);+        }+        if d[EXT_ENCRYPTION] != 0 || get16(d, EXT_OTHER_ENCODING) != 0 {+            crate::kprintln!("[btrfs] encrypted extents are not supported");+            return Err(Errno::EIO);+        }+        let compression = d[EXT_COMPRESSION];+        let ram_bytes = get64(d, EXT_RAM_BYTES);+        match d[EXT_TYPE] {+            0 => Ok(Extent::Inline { compression, data: &d[EXT_INLINE_DATA..], ram_bytes }),+            t @ (1 | 2) if d.len() >= EXT_REGULAR_SIZE => {+                let num_bytes = get64(d, EXT_NUM_BYTES);+                if t == 2 {+                    return Ok(Extent::Prealloc { num_bytes });+                }+                Ok(Extent::Regular {+                    compression,+                    disk_bytenr: get64(d, EXT_DISK_BYTENR),+                    disk_num_bytes: get64(d, EXT_DISK_NUM_BYTES),+                    offset: get64(d, EXT_OFFSET),+                    num_bytes,+                    ram_bytes,+                })+            }+            _ => Err(Errno::EIO),+        }+    }++    /// How many bytes of the file this extent covers.+    fn len(&self) -> u64 {+        match self {+            Extent::Inline { ram_bytes, .. } => *ram_bytes,+            Extent::Regular { num_bytes, .. } | Extent::Prealloc { num_bytes } => *num_bytes,+        }+    }+}++/// Decompress a whole extent (`compression`: 1 zlib, 2 lzo, 3 zstd) to+/// `ram_bytes`, padding with zeros if the stream is shorter.+fn decompress(compression: u8, src: &[u8], ram_bytes: usize, sectorsize: usize) -> Result<Vec<u8>, Errno> {+    let fail = |what: &str| {+        crate::kprintln!("[btrfs] {}", what);+        Errno::EIO+    };+    let mut out = match compression {+        1 => miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(src, ram_bytes).map_err(|_| fail("bad zlib data"))?,+        2 => {+            // btrfs's lzo framing: a u32 total length, then segments of+            // (u32 length, LZO1X stream) that each decompress to at most one+            // sector. A segment header never straddles a sector boundary:+            // if fewer than 4 bytes are left in the sector, skip to the next.+            if src.len() < 4 {+                return Err(fail("bad lzo data"));+            }+            let total = (get32(src, 0) as usize).min(src.len());+            let (mut pos, mut out) = (4, Vec::with_capacity(ram_bytes));+            while pos < total && out.len() < ram_bytes {+                if sectorsize - pos % sectorsize < 4 {+                    pos = pos.next_multiple_of(sectorsize);+                }+                if pos + 4 > total {+                    break;+                }+                let len = get32(src, pos) as usize;+                let seg = src.get(pos + 4..pos + 4 + len).ok_or_else(|| fail("bad lzo segment"))?;+                super::lzo::decompress(seg, &mut out, sectorsize).map_err(|e| fail(e))?;+                pos += 4 + len;+            }+            out+        }+        3 => {+            // One zstd frame, followed by zero padding up to the sector.+            let mut decoder = ruzstd::decoding::FrameDecoder::new();+            let mut input = src;+            decoder.init(&mut input).map_err(|_| fail("bad zstd frame header"))?;+            decoder+                .decode_blocks(&mut input, ruzstd::decoding::BlockDecodingStrategy::All)+                .map_err(|_| fail("bad zstd data"))?;+            decoder.collect().unwrap_or_default()+        }+        n => return Err(fail(&format!("unknown compression type {}", n))),+    };+    out.resize(ram_bytes, 0);+    Ok(out)+}++// ---------------------------------------------------------------------------+// The volume: superblock, chunks, and B-tree search+// ---------------------------------------------------------------------------++struct Volume {+    dev: Arc<dyn BlockDevice>,+    sectorsize: usize,+    nodesize: usize,+    metadata_fsid: [u8; 16], // what every node header must carry+    label: String,+    generation: u64,+    total_bytes: u64,+    bytes_used: u64,+    chunks: Vec<Chunk>,+    root_tree: Tree,+    csum_tree: Option<Tree>,+    default_subvol: u64,+    fs_trees: BTreeMap<u64, (Tree, u64)>, // subvolume id -> (tree, top dir inode)+    node_cache: Vec<(u64, Arc<Vec<u8>>)>, // most recently used last+    extent_cache: Option<(u64, Arc<Vec<u8>>)>, // last decompressed extent+}++impl Volume {+    fn open(dev: Arc<dyn BlockDevice>) -> Result<Volume, String> {+        let mut sb = vec![0u8; SUPER_SIZE];+        dev.read_sectors(SUPER_OFFSET / SECTOR_SIZE as u64, &mut sb).map_err(|e| format!("can't read the superblock: {:?}", e))?;+        if &sb[SB_MAGIC..SB_MAGIC + 8] != MAGIC {+            return Err(String::from("no btrfs magic"));+        }+        let csum_type = get16(&sb, SB_CSUM_TYPE);+        if csum_type != 0 {+            let name = ["crc32c", "xxhash64", "sha256", "blake2b"].get(csum_type as usize).copied().unwrap_or("unknown");+            return Err(format!("checksum type {} ({}) is not supported, only crc32c", csum_type, name));+        }+        let (stored, computed) = (get32(&sb, 0), crc32c(&sb[0x20..]));+        if stored != computed {+            return Err(format!("bad superblock checksum ({:#010x}, should be {:#010x})", stored, computed));+        }+        let incompat = get64(&sb, SB_INCOMPAT);+        if incompat & !INCOMPAT_OK != 0 {+            return Err(format!("unsupported feature flags {:#x}", incompat & !INCOMPAT_OK));+        }+        if get64(&sb, SB_NUM_DEVICES) != 1 {+            return Err(String::from("multi-device file systems are not supported"));+        }+        let sectorsize = get32(&sb, SB_SECTORSIZE) as usize;+        let nodesize = get32(&sb, SB_NODESIZE) as usize;+        if !sectorsize.is_power_of_two() || sectorsize < SECTOR_SIZE || !nodesize.is_power_of_two() || nodesize < sectorsize {+            return Err(format!("odd sizes: sector {} node {}", sectorsize, nodesize));+        }+        let fsid_at = if incompat & INCOMPAT_METADATA_UUID != 0 { SB_METADATA_UUID } else { SB_FSID };+        let label_bytes = &sb[SB_LABEL..SB_LABEL + 256];+        let label_len = label_bytes.iter().position(|&b| b == 0).unwrap_or(256);+        let mut vol = Volume {+            dev,+            sectorsize,+            nodesize,+            metadata_fsid: sb[fsid_at..fsid_at + 16].try_into().unwrap(),+            label: String::from_utf8_lossy(&label_bytes[..label_len]).into_owned(),+            generation: get64(&sb, SB_GENERATION),+            total_bytes: get64(&sb, SB_TOTAL_BYTES),+            bytes_used: get64(&sb, SB_BYTES_USED),+            chunks: Vec::new(),+            root_tree: Tree { bytenr: get64(&sb, SB_ROOT), level: sb[SB_ROOT_LEVEL] },+            csum_tree: None,+            default_subvol: FS_TREE,+            fs_trees: BTreeMap::new(),+            node_cache: Vec::new(),+            extent_cache: None,+        };+        let devid = get64(&sb, SB_DEV_ITEM);++        // 1. Bootstrap: the SYSTEM chunks listed in the superblock itself+        //    (disk key + chunk item, repeated), enough to read the chunk tree.+        let array_len = (get32(&sb, SB_SYS_CHUNK_ARRAY_SIZE) as usize).min(2048);+        let array = &sb[SB_SYS_CHUNK_ARRAY..SB_SYS_CHUNK_ARRAY + array_len];+        let mut at = 0;+        while at + 17 + 48 <= array.len() {+            let key = Key::parse(array, at);+            let size = 48 + 32 * get16(array, at + 17 + 44) as usize;+            if key.kind != CHUNK_ITEM || at + 17 + size > array.len() {+                return Err(String::from("bad sys_chunk_array"));+            }+            vol.add_chunk(key.offset, &array[at + 17..at + 17 + size], devid)?;+            at += 17 + size;+        }++        // 2. The whole chunk tree: every chunk, DATA and METADATA too.+        let chunk_tree = Tree { bytenr: get64(&sb, SB_CHUNK_ROOT), level: sb[SB_CHUNK_ROOT_LEVEL] };+        let all = Key::new(FIRST_CHUNK_TREE, CHUNK_ITEM, 0)..=Key::new(FIRST_CHUNK_TREE, CHUNK_ITEM, u64::MAX);+        let items = vol.range(chunk_tree, *all.start(), *all.end()).map_err(|e| format!("can't read the chunk tree: {:?}", e))?;+        vol.chunks.clear();+        for (key, data) in items {+            vol.add_chunk(key.offset, &data, devid)?;+        }++        // 3. The root tree tells us where the csum tree is, and (through the+        //    "default" entry in directory 6) which subvolume to show.+        vol.csum_tree = vol.root_of(CSUM_TREE).ok().map(|(tree, _)| tree);+        let default = Key::new(ROOT_TREE_DIR, DIR_ITEM, name_hash(b"default"));+        if let Ok(Some(data)) = vol.get(vol.root_tree, default) {+            if let Some(entry) = parse_dir_items(&data).into_iter().find(|e| e.name == b"default") {+                vol.default_subvol = entry.location.objectid;+            }+        }+        vol.fs_tree(vol.default_subvol).map_err(|e| format!("can't find the default subvolume: {:?}", e))?;+        Ok(vol)+    }++    /// Remember one chunk item (`data`: the chunk item with its stripes).+    fn add_chunk(&mut self, logical: u64, data: &[u8], devid: u64) -> Result<(), String> {+        let kind = get64(data, 24);+        let profile = kind & BG_PROFILES;+        if profile != 0 && profile != BG_DUP {+            return Err(format!("chunk at {} uses a RAID profile ({:#x}); only single and DUP are supported", logical, profile));+        }+        let stripes = get16(data, 44) as usize;+        let mut copies = Vec::new();+        for s in 0..stripes {+            let at = 48 + 32 * s;+            if get64(data, at) != devid {+                return Err(format!("chunk at {} is on another device", logical));+            }+            copies.push(get64(data, at + 8));+        }+        self.chunks.push(Chunk { logical, length: get64(data, 0), kind, copies });+        Ok(())+    }++    /// The chunk holding `logical`, and how far into it that is.+    fn chunk(&self, logical: u64) -> Result<(&Chunk, u64), Errno> {+        self.chunks+            .iter()+            .find(|c| logical >= c.logical && logical - c.logical < c.length)+            .map(|c| (c, logical - c.logical))+            .ok_or(Errno::EIO)+    }++    /// Read `len` bytes at a logical address from copy number `copy`+    /// (0 for single; 0 or 1 for DUP). The range must not cross a chunk+    /// (nodes and extents never do).+    fn read_logical(&self, logical: u64, len: usize, copy: usize) -> Result<Vec<u8>, Errno> {+        let (chunk, into) = self.chunk(logical)?;+        if into + len as u64 > chunk.length {+            return Err(Errno::EIO);+        }+        let physical = chunk.copies.get(copy).ok_or(Errno::EIO)? + into;+        // Our reads are sector-aligned; round out just in case.+        let skip = (physical % SECTOR_SIZE as u64) as usize;+        let mut buf = vec![0u8; (skip + len).next_multiple_of(SECTOR_SIZE)];+        self.dev.read_sectors(physical / SECTOR_SIZE as u64, &mut buf)?;+        if skip != 0 || buf.len() != len {+            buf = buf[skip..skip + len].to_vec();+        }+        Ok(buf)+    }++    fn copies(&self, logical: u64) -> usize {+        self.chunk(logical).map(|(c, _)| c.copies.len()).unwrap_or(1)+    }++    /// Read and check one tree node. A copy with a bad checksum or a wrong+    /// header is skipped in favor of the next copy (DUP metadata).+    fn read_node(&mut self, bytenr: u64) -> Result<Arc<Vec<u8>>, Errno> {+        if let Some(i) = self.node_cache.iter().position(|(b, _)| *b == bytenr) {+            let entry = self.node_cache.remove(i);+            let node = entry.1.clone();+            self.node_cache.push(entry);+            return Ok(node);+        }+        for copy in 0..self.copies(bytenr) {+            let node = self.read_logical(bytenr, self.nodesize, copy)?;+            let (stored, computed) = (get32(&node, 0), crc32c(&node[0x20..]));+            let problem = if stored != computed {+                format!("bad checksum {:#010x}, should be {:#010x}", stored, computed)+            } else if get64(&node, HDR_BYTENR) != bytenr {+                format!("header says it's node {}", get64(&node, HDR_BYTENR))+            } else if node[HDR_FSID..HDR_FSID + 16] != self.metadata_fsid {+                String::from("it belongs to another file system")+            } else {+                if copy > 0 {+                    crate::kprintln!("[btrfs] node {}: copy 0 was bad, copy {} is good", bytenr, copy);+                }+                let node = Arc::new(node);+                if self.node_cache.len() >= (NODE_CACHE_BYTES / self.nodesize).max(2) {+                    self.node_cache.remove(0);+                }+                self.node_cache.push((bytenr, node.clone()));+                return Ok(node);+            };+            crate::kprintln!("[btrfs] node {} copy {}: {}", bytenr, copy, problem);+        }+        Err(Errno::EIO)+    }++    /// Every item with `min <= key <= max`, in key order, with a copy of+    /// its data.+    fn range(&mut self, tree: Tree, min: Key, max: Key) -> Result<Vec<(Key, Vec<u8>)>, Errno> {+        let mut out = Vec::new();+        self.walk(tree.bytenr, tree.level, min, max, &mut out)?;+        Ok(out)+    }++    fn walk(&mut self, bytenr: u64, level: u8, min: Key, max: Key, out: &mut Vec<(Key, Vec<u8>)>) -> Result<(), Errno> {+        let node = self.read_node(bytenr)?;+        if node[HDR_LEVEL] != level {+            crate::kprintln!("[btrfs] node {} has level {}, expected {}", bytenr, node[HDR_LEVEL], level);+            return Err(Errno::EIO);+        }+        let n = get32(&node, HDR_NRITEMS) as usize;+        if level == 0 {+            for i in 0..n.min((self.nodesize - HEADER_SIZE) / ITEM_SIZE) {+                let at = HEADER_SIZE + i * ITEM_SIZE;+                let key = Key::parse(&node, at);+                if key > max {+                    break;+                }+                if key >= min {+                    let (off, size) = (get32(&node, at + 17) as usize, get32(&node, at + 21) as usize);+                    let data = node.get(HEADER_SIZE + off..HEADER_SIZE + off + size).ok_or(Errno::EIO)?;+                    out.push((key, data.to_vec()));+                }+            }+            return Ok(());+        }+        // Child i holds keys from key[i] up to (not including) key[i + 1].+        let n = n.min((self.nodesize - HEADER_SIZE) / KEY_PTR_SIZE);+        for i in 0..n {+            let at = HEADER_SIZE + i * KEY_PTR_SIZE;+            if Key::parse(&node, at) > max {+                break;+            }+            if i + 1 < n && Key::parse(&node, at + KEY_PTR_SIZE) <= min {+                continue; // everything in this child is below `min`+            }+            self.walk(get64(&node, at + 17), level - 1, min, max, out)?;+        }+        Ok(())+    }++    /// The item with the largest key `<= key`, if any.+    fn search_le(&mut self, tree: Tree, key: Key) -> Result<Option<(Key, Vec<u8>)>, Errno> {+        let (mut bytenr, mut level) = (tree.bytenr, tree.level);+        loop {+            let node = self.read_node(bytenr)?;+            if node[HDR_LEVEL] != level {+                return Err(Errno::EIO);+            }+            let n = get32(&node, HDR_NRITEMS) as usize;+            let size = if level == 0 { ITEM_SIZE } else { KEY_PTR_SIZE };+            // The last slot whose key is <= key (a binary search would do too).+            let Some(i) = (0..n.min((self.nodesize - HEADER_SIZE) / size))+                .take_while(|&i| Key::parse(&node, HEADER_SIZE + i * size) <= key)+                .last()+            else {+                return Ok(None);+            };+            let at = HEADER_SIZE + i * size;+            if level == 0 {+                let (off, len) = (get32(&node, at + 17) as usize, get32(&node, at + 21) as usize);+                let data = node.get(HEADER_SIZE + off..HEADER_SIZE + off + len).ok_or(Errno::EIO)?;+                return Ok(Some((Key::parse(&node, at), data.to_vec())));+            }+            bytenr = get64(&node, at + 17);+            level -= 1;+        }+    }++    /// The data of the item with exactly this key.+    fn get(&mut self, tree: Tree, key: Key) -> Result<Option<Vec<u8>>, Errno> {+        Ok(self.search_le(tree, key)?.filter(|(k, _)| *k == key).map(|(_, d)| d))+    }++    /// A tree's root and (for subvolumes) top directory, from its ROOT_ITEM.+    /// A ROOT_ITEM's key offset is 0 or a transaction number: take the last.+    fn root_of(&mut self, id: u64) -> Result<(Tree, u64), Errno> {+        let (key, item) = self.search_le(self.root_tree, Key::new(id, ROOT_ITEM, u64::MAX))?.ok_or(Errno::ENOENT)?;+        if key.objectid != id || key.kind != ROOT_ITEM || item.len() <= ROOT_LEVEL {+            return Err(Errno::ENOENT);+        }+        Ok((Tree { bytenr: get64(&item, ROOT_BYTENR), level: item[ROOT_LEVEL] }, get64(&item, ROOT_DIRID)))+    }++    fn fs_tree(&mut self, subvol: u64) -> Result<(Tree, u64), Errno> {+        if let Some(t) = self.fs_trees.get(&subvol) {+            return Ok(*t);+        }+        let t = self.root_of(subvol)?;+        self.fs_trees.insert(subvol, t);+        Ok(t)+    }++    // ---- files and directories -----------------------------------------++    fn inode(&mut self, loc: Loc) -> Result<InodeItem, Errno> {+        let (tree, _) = self.fs_tree(loc.root)?;+        let d = self.get(tree, Key::new(loc.ino, INODE_ITEM, 0))?.ok_or(Errno::ENOENT)?;+        if d.len() < 160 {+            return Err(Errno::EIO);+        }+        Ok(InodeItem { size: get64(&d, INODE_SIZE), mode: get32(&d, INODE_MODE), flags: get64(&d, INODE_FLAGS) })+    }++    /// Where a directory entry points: an inode in the same tree, or the+    /// top directory of another subvolume.+    fn follow(&mut self, dir: Loc, entry: &DirItem) -> Result<Loc, Errno> {+        if entry.location.kind == ROOT_ITEM {+            let (_, top) = self.fs_tree(entry.location.objectid)?;+            Ok(Loc { root: entry.location.objectid, ino: top })+        } else {+            Ok(Loc { root: dir.root, ino: entry.location.objectid })+        }+    }++    /// Look a name up with one search: DIR_ITEMs are keyed by the name's+    /// hash (an item holds several entries if their hashes collide).+    fn lookup(&mut self, dir: Loc, name: &str) -> Result<Loc, Errno> {+        let (tree, _) = self.fs_tree(dir.root)?;+        let data = self.get(tree, Key::new(dir.ino, DIR_ITEM, name_hash(name.as_bytes())))?.ok_or(Errno::ENOENT)?;+        let entry = parse_dir_items(&data).into_iter().find(|e| e.name == name.as_bytes()).ok_or(Errno::ENOENT)?;+        self.follow(dir, &entry)+    }++    /// A directory's entries in creation order (its DIR_INDEX items).+    fn list(&mut self, dir: Loc) -> Result<Vec<DirItem>, Errno> {+        let (tree, _) = self.fs_tree(dir.root)?;+        let items = self.range(tree, Key::new(dir.ino, DIR_INDEX, 0), Key::new(dir.ino, DIR_INDEX, u64::MAX))?;+        Ok(items.iter().flat_map(|(_, d)| parse_dir_items(d)).collect())+    }++    /// Read file bytes at `pos` into `buf`; returns the count (0 at EOF).+    fn read_file(&mut self, loc: Loc, pos: u64, buf: &mut [u8]) -> Result<usize, Errno> {+        let inode = self.inode(loc)?;+        if pos >= inode.size {+            return Ok(0);+        }+        let n = (buf.len() as u64).min(inode.size - pos) as usize;+        let buf = &mut buf[..n];+        buf.fill(0); // holes (no extent at all) read as zeros+        let verify = inode.flags & INODE_NODATASUM == 0;+        let (tree, _) = self.fs_tree(loc.root)?;+        // The extent that starts at or before `pos`, and all up to the end.+        let first = match self.search_le(tree, Key::new(loc.ino, EXTENT_DATA, pos))? {+            Some((k, _)) if k.objectid == loc.ino && k.kind == EXTENT_DATA => k.offset,+            _ => 0,+        };+        let end = pos + n as u64;+        for (key, data) in self.range(tree, Key::new(loc.ino, EXTENT_DATA, first), Key::new(loc.ino, EXTENT_DATA, end - 1))? {+            let ext = Extent::parse(&data)?;+            // The overlap of this extent's file range and the request.+            let (from, to) = (key.offset.max(pos), (key.offset + ext.len()).min(end));+            if from >= to {+                continue;+            }+            let dst = &mut buf[(from - pos) as usize..(to - pos) as usize];+            let skip = (from - key.offset) as usize; // bytes into the extent's file range+            match ext {+                Extent::Prealloc { .. } => {} // reserved, never written: zeros+                Extent::Inline { compression: 0, data, .. } => {+                    let src = data.get(skip..skip + dst.len()).ok_or(Errno::EIO)?;+                    dst.copy_from_slice(src);+                }+                Extent::Inline { compression, data, ram_bytes } => {+                    let plain = decompress(compression, data, ram_bytes as usize, self.sectorsize)?;+                    dst.copy_from_slice(&plain[skip..skip + dst.len()]);+                }+                Extent::Regular { disk_bytenr: 0, .. } => {} // an explicit hole+                Extent::Regular { compression: 0, disk_bytenr, offset, .. } => {+                    self.read_data(disk_bytenr + offset + skip as u64, dst, verify)?;+                }+                Extent::Regular { compression, disk_bytenr, disk_num_bytes, offset, ram_bytes, .. } => {+                    let plain = self.decompressed(compression, disk_bytenr, disk_num_bytes, ram_bytes, verify)?;+                    let at = offset as usize + skip;+                    dst.copy_from_slice(plain.get(at..at + dst.len()).ok_or(Errno::EIO)?);+                }+            }+        }+        Ok(n)+    }++    /// A compressed extent, read, checked and decompressed whole. The last+    /// one is cached: `cat` reads a 128 KiB extent in many small pieces.+    fn decompressed(&mut self, compression: u8, bytenr: u64, disk_len: u64, ram: u64, verify: bool) -> Result<Arc<Vec<u8>>, Errno> {+        if let Some((b, data)) = &self.extent_cache {+            if *b == bytenr {+                return Ok(data.clone());+            }+        }+        let mut raw = vec![0u8; disk_len as usize];+        self.read_data(bytenr, &mut raw, verify)?;+        let plain = Arc::new(decompress(compression, &raw, ram as usize, self.sectorsize)?);+        self.extent_cache = Some((bytenr, plain.clone()));+        Ok(plain)+    }++    /// Read file data at a logical address, whole sectors at a time, and+    /// check each sector against the csum tree.+    fn read_data(&mut self, logical: u64, out: &mut [u8], verify: bool) -> Result<(), Errno> {+        let ss = self.sectorsize as u64;+        let start = logical / ss * ss;+        let len = (logical + out.len() as u64).next_multiple_of(ss) - start;+        let mut last_err = Errno::EIO;+        for copy in 0..self.copies(start) {+            let data = self.read_logical(start, len as usize, copy)?;+            match if verify { self.verify_data(start, &data) } else { Ok(()) } {+                Ok(()) => {+                    let skip = (logical - start) as usize;+                    out.copy_from_slice(&data[skip..skip + out.len()]);+                    return Ok(());+                }+                Err(e) => last_err = e,+            }+        }+        Err(last_err)+    }++    /// Compare each sector of `data` (at logical `start`) with its crc32c+    /// in the csum tree. EXTENT_CSUM items are keyed by the logical address+    /// of their first sector and hold one u32 per sector. Sectors without a+    /// checksum (NODATASUM files) are taken as they are.+    fn verify_data(&mut self, start: u64, data: &[u8]) -> Result<(), Errno> {+        let Some(tree) = self.csum_tree else { return Ok(()) };+        let ss = self.sectorsize;+        let mut item: Option<(u64, Vec<u8>)> = None;+        for (i, sector) in data.chunks(ss).enumerate() {+            let at = start + (i * ss) as u64;+            let covers = |it: &Option<(u64, Vec<u8>)>| matches!(it, Some((s, d)) if at >= *s && at < s + (d.len() / 4 * ss) as u64);+            if !covers(&item) {+                item = match self.search_le(tree, Key::new(CSUM_OBJECTID, EXTENT_CSUM, at))? {+                    Some((k, d)) if k.objectid == CSUM_OBJECTID && k.kind == EXTENT_CSUM => Some((k.offset, d)),+                    _ => None,+                };+                if !covers(&item) {+                    continue; // no checksum for this sector+                }+            }+            let (s, d) = item.as_ref().unwrap();+            let want = get32(d, ((at - s) / ss as u64) as usize * 4);+            let got = crc32c(sector);+            if got != want {+                crate::kprintln!("[btrfs] data at {}: checksum {:#010x}, should be {:#010x}", at, got, want);+                return Err(Errno::EIO);+            }+        }+        Ok(())+    }++    // ---- subvolumes and the boot log -------------------------------------++    /// The path of inode `ino` in subvolume `root`, from its INODE_REFs.+    fn path_in(&mut self, root: u64, mut ino: u64) -> Result<String, Errno> {+        let (tree, top) = self.fs_tree(root)?;+        let mut parts = Vec::new();+        while ino != top {+            let refs = self.range(tree, Key::new(ino, INODE_REF, 0), Key::new(ino, INODE_REF, u64::MAX))?;+            let (key, d) = refs.first().ok_or(Errno::EIO)?;+            // INODE_REF: index u64, name_len u16, name.+            let len = get16(d, 8) as usize;+            parts.push(String::from_utf8_lossy(d.get(10..10 + len).ok_or(Errno::EIO)?).into_owned());+            ino = key.offset; // the parent directory+        }+        parts.reverse();+        Ok(parts.join("/"))+    }++    /// Every subvolume below the top level, from the root tree's ROOT_REFs+    /// `(parent ROOT_REF child)`: dirid u64, sequence u64, name_len u16, name.+    fn subvolumes(&mut self) -> Result<Vec<Subvolume>, Errno> {+        let all = self.range(self.root_tree, Key::new(0, 0, 0), Key::new(u64::MAX, u8::MAX, u64::MAX))?;+        let refs: Vec<(u64, u64, u64, String)> = all+            .iter()+            .filter(|(k, d)| k.kind == ROOT_REF && d.len() >= 18)+            .map(|(k, d)| {+                let len = get16(d, 16) as usize;+                (k.objectid, k.offset, get64(d, 0), String::from_utf8_lossy(&d[18..(18 + len).min(d.len())]).into_owned())+            })+            .collect();+        let mut out = Vec::new();+        for (parent, child, dirid, name) in &refs {+            let mut path = self.path_in(*parent, *dirid)?;+            // Prefix the parent subvolume's own path (nested subvolumes).+            let mut p = *parent;+            while let Some((pp, _, pdir, pname)) = refs.iter().find(|r| r.1 == p) {+                let above = self.path_in(*pp, *pdir)?;+                path = [above, pname.clone(), path].iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join("/");+                p = *pp;+            }+            let full = if path.is_empty() { name.clone() } else { format!("{}/{}", path, name) };+            let read_only = all+                .iter()+                .rev()+                .find(|(k, _)| k.objectid == *child && k.kind == ROOT_ITEM)+                .map(|(_, d)| d.len() > ROOT_FLAGS && get64(d, ROOT_FLAGS) & ROOT_RDONLY != 0)+                .unwrap_or(false);+            out.push(Subvolume { id: *child, path: full, read_only });+        }+        Ok(out)+    }++    fn profile(&self, what: u64) -> &'static str {+        match self.chunks.iter().find(|c| c.kind & what != 0) {+            Some(c) if c.kind & BG_DUP != 0 => "DUP",+            Some(_) => "single",+            None => "none",+        }+    }+}++/// A DIR_ITEM or DIR_INDEX item's entries: location key (17), transid u64,+/// data_len u16, name_len u16, type u8, then the name (and xattr data).+fn parse_dir_items(mut d: &[u8]) -> Vec<DirItem> {+    let mut out = Vec::new();+    while d.len() >= 30 {+        let (data_len, name_len) = (get16(d, 25) as usize, get16(d, 27) as usize);+        let Some(name) = d.get(30..30 + name_len) else { break };+        out.push(DirItem { location: Key::parse(d, 0), name: name.to_vec(), file_type: d[29] });+        d = d.get(30 + name_len + data_len..).unwrap_or(&[]);+    }+    out+}++fn kind_of_mode(mode: u32) -> InodeKind {+    match mode & S_IFMT {+        S_IFDIR => InodeKind::Directory,+        S_IFLNK => InodeKind::Symlink,+        _ => InodeKind::File,+    }+}++// ---------------------------------------------------------------------------+// The VFS side+// ---------------------------------------------------------------------------++pub struct BtrfsFs {+    vol: Arc<Mutex<Volume>>,+    root: Loc,+}++impl BtrfsFs {+    /// Mount, or say in words why not (bad checksum, sha256, RAID...).+    pub fn mount(dev: Arc<dyn BlockDevice>) -> Result<Arc<BtrfsFs>, String> {+        let mut vol = Volume::open(dev)?;+        let (_, top) = vol.fs_tree(vol.default_subvol).map_err(|e| format!("{:?}", e))?;+        let root = Loc { root: vol.default_subvol, ino: top };+        Ok(Arc::new(BtrfsFs { vol: Arc::new(Mutex::new(vol)), root }))+    }++    /// For the boot log, e.g. `btrfs volume "TYLERBTRFS" (16 KiB nodes,+    /// metadata DUP, data single, 2 subvolumes)`.+    pub fn describe(&self) -> String {+        let mut v = self.vol.lock();+        let subvols = v.subvolumes().map(|s| s.len()).unwrap_or(0);+        format!(+            "btrfs volume \"{}\" ({} KiB nodes, metadata {}, data {}, {} subvolume{}, read-only)",+            v.label,+            v.nodesize / 1024,+            v.profile(BG_METADATA | BG_SYSTEM),+            v.profile(BG_DATA),+            subvols,+            if subvols == 1 { "" } else { "s" }+        )+    }++    pub fn label(&self) -> String {+        self.vol.lock().label.clone()+    }++    /// (sector size, node size, generation, total bytes, bytes used).+    pub fn geometry(&self) -> (usize, usize, u64, u64, u64) {+        let v = self.vol.lock();+        (v.sectorsize, v.nodesize, v.generation, v.total_bytes, v.bytes_used)+    }++    pub fn subvolumes(&self) -> Result<Vec<Subvolume>, Errno> {+        self.vol.lock().subvolumes()+    }++    // ---- for tests/btrfs.rs, which damages bytes on purpose to check+    //      that the checksums catch it ------------------------------------++    /// Byte offsets on the disk of every copy of a logical address.+    pub fn physical(&self, logical: u64) -> Vec<u64> {+        let v = self.vol.lock();+        v.chunk(logical).map(|(c, into)| c.copies.iter().map(|p| p + into).collect()).unwrap_or_default()+    }++    /// The logical address of the default subvolume's FS tree root node.+    pub fn fs_tree_root(&self) -> u64 {+        let mut v = self.vol.lock();+        let id = v.default_subvol;+        v.fs_tree(id).map(|(t, _)| t.bytenr).unwrap_or(0)+    }++    /// The logical address of a file's first on-disk extent (`path` is+    /// relative to the volume's root, no symlinks).+    pub fn first_extent(&self, path: &str) -> Result<u64, Errno> {+        let mut v = self.vol.lock();+        let mut loc = self.root;+        for name in path.split('/').filter(|n| !n.is_empty()) {+            loc = v.lookup(loc, name)?;+        }+        let (tree, _) = v.fs_tree(loc.root)?;+        for (_, data) in v.range(tree, Key::new(loc.ino, EXTENT_DATA, 0), Key::new(loc.ino, EXTENT_DATA, u64::MAX))? {+            if let Extent::Regular { disk_bytenr, .. } = Extent::parse(&data)? {+                if disk_bytenr != 0 {+                    return Ok(disk_bytenr);+                }+            }+        }+        Err(Errno::ENOENT)+    }+}++impl FileSystem for BtrfsFs {+    fn name(&self) -> &'static str {+        "btrfs"+    }+    fn root(&self) -> Arc<dyn Inode> {+        Arc::new(BtrfsInode { vol: self.vol.clone(), loc: self.root, kind: InodeKind::Directory })+    }+}++/// A file, directory or symlink: (subvolume, inode number).+pub struct BtrfsInode {+    vol: Arc<Mutex<Volume>>,+    loc: Loc,+    kind: InodeKind,+}++impl BtrfsInode {+    fn dir_only(&self) -> Result<(), Errno> {+        if self.kind == InodeKind::Directory { Ok(()) } else { Err(Errno::ENOTDIR) }+    }+}++impl Inode for BtrfsInode {+    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().inode(self.loc).map(|i| i.size as usize).unwrap_or(0)+    }++    fn mode(&self) -> u32 {+        self.vol.lock().inode(self.loc).map(|i| i.mode & 0o7777).unwrap_or(0)+    }++    fn readlink(&self) -> Result<String, Errno> {+        if self.kind != InodeKind::Symlink {+            return Err(Errno::EINVAL);+        }+        // A symlink's target is its file data (an inline extent).+        let mut vol = self.vol.lock();+        let size = vol.inode(self.loc)?.size as usize;+        let mut buf = vec![0u8; size];+        let n = vol.read_file(self.loc, 0, &mut buf)?;+        String::from_utf8(buf[..n].to_vec()).map_err(|_| Errno::EINVAL)+    }++    fn read_at(&self, offset: usize, buf: &mut [u8]) -> Result<usize, Errno> {+        if self.kind == InodeKind::Directory {+            return Err(Errno::EISDIR);+        }+        self.vol.lock().read_file(self.loc, offset as u64, buf)+    }++    fn lookup(&self, name: &str) -> Result<Arc<dyn Inode>, Errno> {+        self.dir_only()?;+        let mut vol = self.vol.lock();+        let loc = vol.lookup(self.loc, name)?;+        let kind = kind_of_mode(vol.inode(loc)?.mode);+        Ok(Arc::new(BtrfsInode { vol: self.vol.clone(), loc, kind }))+    }++    fn list(&self) -> Result<Vec<DirEntry>, Errno> {+        self.dir_only()?;+        let entries = self.vol.lock().list(self.loc)?;+        Ok(entries+            .into_iter()+            .map(|e| DirEntry {+                name: String::from_utf8_lossy(&e.name).into_owned(),+                kind: match e.file_type {+                    2 => InodeKind::Directory,+                    7 => InodeKind::Symlink,+                    _ => InodeKind::File,+                },+            })+            .collect())+    }++    // Read-only: everything that would change the disk.+    fn write_at(&self, _offset: usize, _buf: &[u8]) -> Result<usize, Errno> {+        Err(Errno::EROFS)+    }+    fn truncate(&self) -> Result<(), Errno> {+        Err(Errno::EROFS)+    }+    fn create(&self, _name: &str, _kind: InodeKind) -> Result<Arc<dyn Inode>, Errno> {+        Err(Errno::EROFS)+    }+    fn remove(&self, _name: &str) -> Result<(), Errno> {+        Err(Errno::EROFS)+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test_case]+    fn crc32c_known_answers() {+        assert_eq!(crc32c(b"123456789"), 0xE306_9283); // the standard check value+        assert_eq!(name_hash(b"default"), 2378154706); // seen in mkfs.btrfs's root tree+    }++    #[test_case]+    fn prealloc_and_hole_extents_read_as_nothing() {+        // mkfs.btrfs never makes prealloc extents, so build one by hand:+        // generation, ram_bytes, compression, encryption, other_encoding,+        // type 2, disk_bytenr, disk_num_bytes, offset, num_bytes.+        let mut item = vec![0u8; EXT_REGULAR_SIZE];+        item[EXT_TYPE] = 2;+        item[EXT_NUM_BYTES..EXT_NUM_BYTES + 8].copy_from_slice(&8192u64.to_le_bytes());+        assert!(matches!(Extent::parse(&item), Ok(Extent::Prealloc { num_bytes: 8192 })));+        item[EXT_TYPE] = 1; // regular with disk_bytenr 0: an explicit hole+        assert!(matches!(Extent::parse(&item), Ok(Extent::Regular { disk_bytenr: 0, .. })));+        item[EXT_ENCRYPTION] = 1;+        assert!(Extent::parse(&item).is_err());+    }++    #[test_case]+    fn lzo_literals_and_overlapping_match() {+        // "abc" as a first-byte literal run (17 + 3), then an M2 match of+        // length 6 at distance 3 (t = (6-1)<<5 | (3-1)<<2 = 0xA8, next byte 0),+        // then the end marker 0x11 0x00 0x00.+        let mut out = Vec::new();+        super::super::lzo::decompress(&[20, b'a', b'b', b'c', 0xA8, 0, 0x11, 0, 0], &mut out, 100).unwrap();+        assert_eq!(&out, b"abcabcabc");+    }+}
@@ -0,0 +1,123 @@+//! LZO1X decompression, hand-written (btrfs's "lzo" compression).+//!+//! LZO (Markus Oberhumer, 1996) is an LZ77-style compressor built for speed.+//! The compressed stream is a sequence of instructions, each either+//! "copy the next N bytes as they are" (a **literal run**) or "copy N bytes+//! from D bytes back in the output" (a **match**, which may overlap itself,+//! so `abcabcabc` is "abc" plus one match of 6 at distance 3).+//!+//! An instruction starts with one byte `t`; its range says what it is:+//!+//! ```text+//! t >= 64      M2 match: length 3-8, distance up to 2 KiB (one more byte)+//! 32 <= t < 64 M3 match: length from t (or a run of zero bytes), distance+//!              up to 16 KiB in the next two bytes+//! 16 <= t < 32 M4 match: distance 16-48 KiB; distance 16 KiB exactly+//!              means "end of stream"+//! t < 16       depends on what came before (the `state`):+//!              after a match with no trailing literals: a literal run;+//!              after 1-3 trailing literals: a 2-byte match, distance <= 1 KiB;+//!              after a long literal run: a 3-byte match, distance 2-3 KiB+//! ```+//!+//! The low 2 bits of most match instructions say how many literals (0-3)+//! follow the match, so short literal runs cost no extra byte. Long lengths+//! are written as a 0 (meaning "more"), then zero bytes worth 255 each,+//! then a final byte that's added.+//!+//! This follows the Linux kernel's `lzo1x_decompress_safe`, with every+//! read and back-reference checked: bad input gives `Err`, never a panic.++use alloc::vec::Vec;++/// Decompress one LZO1X stream, appending to `out`. `limit` is the most+/// bytes this stream may produce (btrfs: one sector).+pub fn decompress(src: &[u8], out: &mut Vec<u8>, limit: usize) -> Result<(), &'static str> {+    let start = out.len();+    let mut ip = 0usize;+    let byte = |ip: &mut usize| -> Result<usize, &'static str> {+        let b = *src.get(*ip).ok_or("truncated")?;+        *ip += 1;+        Ok(b as usize)+    };+    // A length continued in zero bytes: each 0 adds 255, then the final byte.+    let long_len = |ip: &mut usize, base: usize| -> Result<usize, &'static str> {+        let mut n = base;+        loop {+            let b = byte(ip)?;+            if b != 0 {+                return Ok(n + b);+            }+            n += 255;+        }+    };+    let literals = |ip: &mut usize, n: usize, out: &mut Vec<u8>| -> Result<(), &'static str> {+        let bytes = src.get(*ip..*ip + n).ok_or("truncated literal run")?;+        out.extend_from_slice(bytes);+        *ip += n;+        Ok(())+    };++    // `state`: 0 = last instruction was a match without trailing literals,+    // 1-3 = it had that many trailing literals, 4 = a literal run of 4+.+    let mut state;+    // The first byte may be a literal run of 18+ (t - 17) bytes.+    if *src.first().ok_or("empty")? > 17 {+        let n = byte(&mut ip)? - 17;+        literals(&mut ip, n, out)?;+        state = if n < 4 { n } else { 4 };+    } else {+        state = 0;+    }++    loop {+        if out.len() - start > limit {+            return Err("output too long");+        }+        let t = byte(&mut ip)?;+        let (len, dist, next);+        if t < 16 {+            if state == 0 {+                // Literal run of t + 3 bytes (t == 0: a long length).+                let n = if t == 0 { long_len(&mut ip, 15)? } else { t };+                literals(&mut ip, n + 3, out)?;+                state = 4;+                continue;+            }+            let b = byte(&mut ip)?;+            next = t & 3;+            if state < 4 {+                (len, dist) = (2, 1 + (t >> 2) + (b << 2));+            } else {+                (len, dist) = (3, 1 + 0x800 + (t >> 2) + (b << 2));+            }+        } else if t >= 64 {+            let b = byte(&mut ip)?;+            next = t & 3;+            (len, dist) = ((t >> 5) + 1, 1 + ((t >> 2) & 7) + (b << 3));+        } else if t >= 32 {+            len = if t & 31 == 0 { long_len(&mut ip, 31)? } else { t & 31 } + 2;+            let v = byte(&mut ip)? | (byte(&mut ip)? << 8);+            next = v & 3;+            dist = 1 + (v >> 2);+        } else {+            len = if t & 7 == 0 { long_len(&mut ip, 7)? } else { t & 7 } + 2;+            let v = byte(&mut ip)? | (byte(&mut ip)? << 8);+            next = v & 3;+            let d = ((t & 8) << 11) + (v >> 2);+            if d == 0 {+                return Ok(()); // the end marker (0x11 0x00 0x00)+            }+            dist = d + 0x4000;+        }+        // Copy the match byte by byte: it may overlap what it's producing.+        if dist > out.len() - start {+            return Err("match before the start");+        }+        for _ in 0..len {+            out.push(out[out.len() - dist]);+        }+        literals(&mut ip, next, out)?;+        state = next;+    }+}
@@ -4,8 +4,8 @@ //! //! * [`Inode`] -- one file or directory *inside some file system*. Knows how to //!   read/write its bytes at an offset and (for directories) look up, create,-//!   list and remove children. ramfs, FAT32 and ext2 implement it today;-//!   exFAT and btrfs will later.+//!   list and remove children. ramfs, FAT32, ext2, exFAT and (read-only)+//!   btrfs implement it. //! * [`FileSystem`] -- a mounted instance (e.g. "the FAT32 volume on vda1"); //!   gives you its root inode. //! * the **mount table** -- which file system is attached at which path ("/",@@ -28,7 +28,9 @@ //! 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 btrfs; pub mod exfat;+pub mod lzo; pub mod ext2; pub mod fat; pub mod file;@@ -293,7 +295,7 @@ pub fn init() {     hello.write_at(0, b"Hello from the ramfs! This is /hello.txt.\n").unwrap();     let etc = dir.create("etc", InodeKind::Directory).unwrap();     let motd = etc.create("motd", InodeKind::File).unwrap();-    motd.write_at(0, b"Welcome to Tyler's kernel. Next up: ext2, exFAT, btrfs.\n").unwrap();+    motd.write_at(0, b"Welcome to Tyler's kernel. Next up: SMP, then networking.\n").unwrap();     dir.create("tmp", InodeKind::Directory).unwrap(); // mount point     dir.create("mnt", InodeKind::Directory).unwrap(); // disks go to /mnt/diskN     mount("/", root).unwrap();@@ -306,8 +308,10 @@ 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); 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).+/// sector; btrfs has "_BHRfS_M" at byte 64 of its superblock (at 64 KiB);+/// anything else is tried as FAT (fatfs checks the boot sector itself).+/// A disk whose driver refuses it (say, a btrfs with sha256 checksums)+/// gets a log line saying why, and no /mnt/diskN. pub fn mount_disks() -> Vec<String> {     let mut log = Vec::new();     let devices = crate::block::devices();@@ -341,6 +345,17 @@ pub fn mount_disks() -> Vec<String> {                     continue;                 }             }+        } else if btrfs::probe(&**dev) {+            match btrfs::BtrfsFs::mount(dev.clone()) {+                Ok(fs) => {+                    let d = fs.describe();+                    (fs, d)+                }+                Err(e) => {+                    log.push(format!("{}: btrfs, but can't mount it: {}", name, e));+                    continue;+                }+            }         } else {             match fat::FatFs::mount(dev.clone()) {                 Ok(fs) => {
@@ -144,8 +144,9 @@ pub fn test_panic_handler(info: &PanicInfo) -> ! { bootloader::entry_point!(test_kernel_main);  #[cfg(test)]-fn test_kernel_main(_boot_info: &'static bootloader::BootInfo) -> ! {+fn test_kernel_main(boot_info: &'static bootloader::BootInfo) -> ! {     init();+    init_memory(boot_info); // a heap, for unit tests that use Vec (btrfs, lzo)     test_main();     hlt_loop(); }
@@ -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 9: exFAT",               // version+        "stage 10: btrfs read-only",    // version         "x86_64",                       // machine         "(none)",                       // domainname     ];
@@ -0,0 +1,317 @@+//! btrfs (read-only) through the VFS. Cargo.toml's test-args attach these+//! images that build.rs makes (snapshot=on: the corruption test at the end+//! damages a disk on purpose, and that vanishes when QEMU exits):+//!+//!   vdd  target/btrfs.img          /mnt/disk3  zlib, 16 KiB nodes, metadata DUP+//!   vdh  target/btrfs-lzo.img      /mnt/disk7  lzo, 4 KiB nodes (a 2-level tree)+//!   vdi  target/btrfs-zstd.img     /mnt/disk8  zstd, 64 KiB nodes+//!   vdj  target/btrfs-convert.img  /mnt/disk9  btrfs-convert'ed ext2: holes,+//!                                              shared extents, ext2_saved+//!   vdk  target/btrfs-sha256.img   (refused)   sha256 checksums+//!+//! The first three hold the same tree (disk4/ plus big.txt, random.bin...)+//! with subvolumes `archive` (256, read-only) and `projects` (257).+//!+//! The main test reads *every* file and compares its size and CRC-32 with+//! the manifests build.rs wrote from the host's copy. It also prints one+//! `[btrfs-file]` line per file, which scripts/btrfs-check.sh compares+//! again, independently, against Python's zlib.crc32 of the host files.+#![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::btrfs::{BtrfsFs, Subvolume};+use rustbelt::fs::{self, InodeKind};+use rustbelt::serial_println;++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 E: &str = "/mnt/disk3";+const LZO: &str = "/mnt/disk7";+const ZSTD: &str = "/mnt/disk8";+const CONVERT: &str = "/mnt/disk9";++const TREE: &str = include_str!("../target/btrfs-tree.manifest");+const CONVERT_TREE: &str = include_str!("../target/btrfs-convert.manifest");++/// CRC-32 (IEEE, as zlib and the manifests use), bit by bit: slow but+/// obviously right, and independent of the driver's crc32c.+fn crc32(crc: u32, data: &[u8]) -> u32 {+    let mut crc = !crc;+    for &b in data {+        crc ^= b as u32;+        for _ in 0..8 {+            crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };+        }+    }+    !crc+}++/// build.rs's random.bin generator.+fn noise(len: usize) -> Vec<u8> {+    let mut x: u64 = 0x2545_F491_4F6C_DD1D;+    (0..len)+        .map(|_| {+            x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);+            (x >> 33) as u8+        })+        .collect()+}++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+}++/// (size, CRC-32) of a file, read in 64 KiB pieces like `cat` would.+fn checksum(path: &str) -> (usize, u32) {+    let inode = fs::lookup(path).unwrap();+    let (mut at, mut crc, mut buf) = (0, 0, vec![0u8; 65536]);+    loop {+        let n = inode.read_at(at, &mut buf).unwrap();+        if n == 0 {+            return (at, crc);+        }+        crc = crc32(crc, &buf[..n]);+        at += n;+    }+}++/// Walk a mounted volume and describe it in the manifest's format. Paths+/// the manifest lists as `S` (size only) are not read.+fn walk(mount: &str, rel: &str, manifest: &str, out: &mut Vec<String>) {+    let dir = if rel.is_empty() { String::from(mount) } else { format!("{}/{}", mount, rel) };+    for entry in fs::lookup(&dir).unwrap().list().unwrap() {+        let path = if rel.is_empty() { entry.name.clone() } else { format!("{}/{}", rel, entry.name) };+        let full = format!("{}/{}", mount, path);+        let line = match entry.kind {+            InodeKind::Directory => {+                walk(mount, &path, manifest, out);+                format!("D\t{}", path)+            }+            InodeKind::Symlink => format!("L\t{}\t{}", path, fs::readlink(&full).unwrap()),+            _ if manifest.lines().any(|l| l == format!("S\t{}\t{}", path, l.rsplit('\t').next().unwrap())) => {+                format!("S\t{}\t{}", path, fs::lookup(&full).unwrap().size())+            }+            _ => {+                let (size, crc) = checksum(&full);+                assert_eq!(size, fs::lookup(&full).unwrap().size(), "{}", full);+                format!("F\t{}\t{}\t{:08x}", path, size, crc)+            }+        };+        serial_println!("[btrfs-file] {}\t{}", mount, line);+        out.push(line);+    }+}++fn matches_manifest(mount: &str, manifest: &str) {+    let mut got = Vec::new();+    walk(mount, "", manifest, &mut got);+    // Both sides sorted by path (the second field), like build.rs does.+    let path = |l: &str| String::from(l.split('\t').nth(1).unwrap());+    got.sort_by_key(|l| path(l));+    let mut want: Vec<&str> = manifest.lines().collect();+    want.sort_by_key(|l| path(l));+    assert_eq!(got, want, "{} differs from the host's tree", mount);+}++#[test_case]+fn mounted_as_btrfs() {+    let mounts = fs::mounts();+    for path in [E, LZO, ZSTD, CONVERT] {+        assert!(mounts.iter().any(|(p, name)| p == path && *name == "btrfs"), "{} not btrfs", path);+    }+    // vdk (sha256) was refused, so nothing got /mnt/disk10.+    assert!(!mounts.iter().any(|(p, _)| p == "/mnt/disk10"));+    let describe = |dev: &str| BtrfsFs::mount(block::get(dev).unwrap()).unwrap().describe();+    assert_eq!(describe("vdd"), "btrfs volume \"TYLERBTRFS\" (16 KiB nodes, metadata DUP, data single, 2 subvolumes, read-only)");+    assert_eq!(describe("vdh"), "btrfs volume \"LZO4K\" (4 KiB nodes, metadata single, data single, 2 subvolumes, read-only)");+    assert_eq!(describe("vdi"), "btrfs volume \"ZSTD64K\" (64 KiB nodes, metadata DUP, data single, 2 subvolumes, read-only)");+    assert_eq!(describe("vdj"), "btrfs volume \"CONVERTED\" (16 KiB nodes, metadata single, data single, 1 subvolume, read-only)");+    let (sector, node, _, total, used) = BtrfsFs::mount(block::get("vdd").unwrap()).unwrap().geometry();+    assert_eq!((sector, node, total), (4096, 16384, 128 << 20));+    // random.bin (1.5 MB) is stored as it is; the rest compresses well.+    assert!(used > 1_500_000 && used < 8_000_000, "bytes used {}", used);+}++#[test_case]+fn every_file_matches_the_host_zlib() {+    matches_manifest(E, TREE);+}++#[test_case]+fn every_file_matches_the_host_lzo() {+    matches_manifest(LZO, TREE);+}++#[test_case]+fn every_file_matches_the_host_zstd() {+    matches_manifest(ZSTD, TREE);+}++#[test_case]+fn every_file_matches_the_host_converted() {+    matches_manifest(CONVERT, CONVERT_TREE);+}++#[test_case]+fn holes_and_the_saved_ext2_image() {+    // sparse.bin: three bits of text in 5 MiB of holes.+    let path = format!("{}/sparse.bin", CONVERT);+    assert_eq!(read(&path, 0, 19), b"start of sparse.bin");+    assert_eq!(read(&path, 19, 4096), [0u8; 4096]); // rest of the first block+    assert_eq!(read(&path, (1 << 20) - 3, 13), b"\0\0\0one MiB in");+    assert_eq!(read(&path, 3 << 20, 8), [0u8; 8]); // deep inside a hole+    assert_eq!(read(&path, (5 << 20) - 10, 100), b"\0\0\0the end"); // short read at EOF+    // ext2_saved/image is the old ext2 file system, sharing its extents+    // with the converted files: the ext2 magic is where it should be.+    let image = format!("{}/ext2_saved/image", CONVERT);+    assert_eq!(read(&image, 1024 + 56, 2), [0x53, 0xEF]);+    let saved = BtrfsFs::mount(block::get("vdj").unwrap()).unwrap().subvolumes().unwrap();+    assert_eq!(saved.len(), 1);+    assert_eq!((saved[0].path.as_str(), saved[0].read_only), ("ext2_saved", false));+}++#[test_case]+fn reads_across_extent_boundaries() {+    // big.txt is line i = "btrfs line {i:06}\n" (18 bytes) at 18 * i, in+    // 128 KiB compressed extents; random.bin is stored as 1 MiB plain extents.+    let big = |at: usize, len: usize| -> Vec<u8> {+        let lines: String = (at / 18..(at + len) / 18 + 1).map(|i| format!("btrfs line {:06}\n", i)).collect();+        lines.as_bytes()[at % 18..at % 18 + len].to_vec()+    };+    let random = noise(1_500_000);+    for dir in [E, LZO, ZSTD] {+        for (at, len) in [(0, 18), (131072 - 5, 40), (2 * 131072 - 1, 3), (1_000_000, 70000), (2_160_000 - 18, 18)] {+            assert_eq!(read(&format!("{}/big.txt", dir), at, len), big(at, len), "{} big.txt at {}", dir, at);+        }+        for (at, len) in [(1 << 20) - 100, 4096 * 3 + 1, 1_499_990].into_iter().zip([200, 5000, 10]) {+            assert_eq!(read(&format!("{}/random.bin", dir), at, len), random[at..at + len], "{} random.bin at {}", dir, at);+        }+        assert_eq!(read(&format!("{}/zeros.bin", dir), 500_000, 1000), [0u8; 1000]);+        assert_eq!(read(&format!("{}/empty.txt", dir), 0, 10), b"");+    }+}++#[test_case]+fn lookups_symlinks_and_subvolumes() {+    // DIR_ITEM lookups are by crc32c hash: exact names only.+    assert!(read(&format!("{}/hello.txt", E), 0, 100).starts_with(b"Hello from btrfs!"));+    assert_eq!(fs::lookup(&format!("{}/HELLO.TXT", E)).err(), Some(Errno::ENOENT));+    assert_eq!(fs::lookup(&format!("{}/Music/Road Trip/playlist.txt", E)).unwrap().size(), 66);+    assert_eq!(fs::lookup(&format!("{}/hello.txt/x", E)).err(), Some(Errno::ENOTDIR));+    // Symlinks, including one from inside a subvolume back out of it.+    assert_eq!(fs::readlink(&format!("{}/docs/cow-link", E)).unwrap(), "btrfs/cow.txt");+    assert_eq!(read(&format!("{}/hello-link", E), 0, 17), b"Hello from btrfs!");+    assert_eq!(read(&format!("{}/projects/hello-link", E), 0, 17), b"Hello from btrfs!");+    assert_eq!(fs::lookup_nofollow(&format!("{}/hello-link", E)).unwrap().kind(), InodeKind::Symlink);+    // Into the subvolumes: separate trees, each with its own inode 256.+    assert!(read(&format!("{}/projects/rustbelt/stage10.txt", E), 0, 200).len() == 127);+    assert!(read(&format!("{}/archive/2025-notes.txt", E), 0, 200).len() == 136);+    let names = |p: &str| {+        let mut v: Vec<String> = fs::lookup(p).unwrap().list().unwrap().into_iter().map(|e| e.name).collect();+        v.sort();+        v+    };+    assert_eq!(names(&format!("{}/projects", E)), ["README.txt", "hello-link", "rustbelt"]);+    let want = vec![+        // mkfs.btrfs numbers them in its own order, not the command line's.+        Subvolume { id: 256, path: "archive".into(), read_only: true },+        Subvolume { id: 257, path: "projects".into(), read_only: false },+    ];+    for dev in ["vdd", "vdh", "vdi"] {+        assert_eq!(BtrfsFs::mount(block::get(dev).unwrap()).unwrap().subvolumes().unwrap(), want);+    }+    // Directory permissions come through (mkfs copies the host's).+    assert_eq!(fs::lookup(&format!("{}/docs", E)).unwrap().kind(), InodeKind::Directory);+}++#[test_case]+fn sha256_volume_is_refused_with_a_reason() {+    let err = BtrfsFs::mount(block::get("vdk").unwrap()).err().unwrap();+    assert_eq!(err, "checksum type 2 (sha256) is not supported, only crc32c");+}++#[test_case]+fn writes_are_refused() {+    let hello = format!("{}/hello.txt", E);+    let before = fs::read_all(&hello).unwrap();+    let file = fs::lookup(&hello).unwrap();+    assert_eq!(file.write_at(0, b"overwritten"), Err(Errno::EROFS));+    assert_eq!(file.truncate(), Err(Errno::EROFS));+    assert_eq!(fs::create(&format!("{}/new.txt", E), InodeKind::File).err(), Some(Errno::EROFS));+    assert_eq!(fs::create(&format!("{}/docs/newdir", E), InodeKind::Directory).err(), Some(Errno::EROFS));+    assert_eq!(fs::remove(&hello, false), Err(Errno::EROFS));+    assert_eq!(fs::remove(&format!("{}/docs/btrfs", E), true), Err(Errno::EROFS));+    assert_eq!(fs::read_all(&hello).unwrap(), before);+}++/// Flip one byte on the disk, under the driver's feet.+fn damage(dev: &str, at: u64) {+    let dev = block::get(dev).unwrap();+    let mut sector = [0u8; 512];+    dev.read_sectors(at / 512, &mut sector).unwrap();+    sector[(at % 512) as usize] ^= 0xFF;+    dev.write_sectors(at / 512, &sector).unwrap();+}++/// Last (alphabetically, so last to run): it damages vdi (zstd, metadata+/// DUP), which no other test reads afterwards.+#[test_case]+fn zz_checksums_catch_damage() {+    let fresh = || BtrfsFs::mount(block::get("vdi").unwrap()).unwrap(); // no cached nodes+    let read_via = |fs: &BtrfsFs, path: &str, at: usize, len: usize| -> Result<Vec<u8>, Errno> {+        use rustbelt::fs::FileSystem;+        let mut inode = fs.root();+        for name in path.split('/') {+            inode = inode.lookup(name)?;+        }+        let mut buf = vec![0u8; len];+        let n = inode.read_at(at, &mut buf)?;+        buf.truncate(n);+        Ok(buf)+    };+    let fs = fresh();+    let leaf = fs.fs_tree_root();+    let copies = fs.physical(leaf);+    assert_eq!(copies.len(), 2, "metadata should be DUP");+    let data = fs.physical(fs.first_extent("random.bin").unwrap());+    assert_eq!(data.len(), 1, "data should be single");++    // 1. Damage copy 0 of the FS tree's root: copy 1 still works.+    damage("vdi", copies[0] + 300);+    assert_eq!(read_via(&fresh(), "hello.txt", 0, 17).unwrap(), b"Hello from btrfs!");+    // 2. A damaged data sector: that sector fails with EIO, others don't.+    damage("vdi", data[0] + 100);+    let fs = fresh();+    assert_eq!(read_via(&fs, "random.bin", 0, 10), Err(Errno::EIO));+    assert_eq!(read_via(&fs, "random.bin", 8192, 10).unwrap(), noise(8202)[8192..]);+    // 3. Both copies of the node damaged: nothing to fall back to.+    damage("vdi", copies[1] + 300);+    assert_eq!(read_via(&fresh(), "hello.txt", 0, 17).err(), Some(Errno::EIO));+}++#[panic_handler]+fn panic(info: &PanicInfo) -> ! {+    rustbelt::test_panic_handler(info)+}
@@ -2,7 +2,7 @@ //! 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+//!   vdg  target/exfat-512.img  /mnt/disk6  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.@@ -33,11 +33,11 @@ fn main(boot_info: &'static BootInfo) -> ! { }  const DISK: &str = "/mnt/disk2";-const SMALL: &str = "/mnt/disk5";+const SMALL: &str = "/mnt/disk6"; /// 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";+const SMALL_DIR: &str = "/mnt/disk6/Photos";  fn read(path: &str, offset: usize, len: usize) -> Vec<u8> {     let mut buf = vec![0u8; len];@@ -87,7 +87,7 @@ fn mounted_as_exfat() {     // 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();+    let d = ExfatFs::mount(block::get("vdg").unwrap()).unwrap().describe();     assert!(d.starts_with("exFAT volume \"SMALLCLUST\" (512 byte clusters"), "{}", d); } @@ -194,65 +194,65 @@ fn write_4k_clusters() {  #[test_case] fn write_512_byte_clusters() {-    write_round_trip(SMALL_DIR, "vdf");+    write_round_trip(SMALL_DIR, "vdg"); }  #[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();+    let before = usage("vdg");+    let a = fs::create("/mnt/disk6/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();+    let b = fs::create("/mnt/disk6/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();+    let all = fs::read_all("/mnt/disk6/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);+    assert_eq!(fs::read_all("/mnt/disk6/Photos/b.bin").unwrap(), [b'b'; 1500]);+    fs::remove("/mnt/disk6/Photos/a.bin", false).unwrap();+    fs::remove("/mnt/disk6/Photos/b.bin", false).unwrap();+    assert_eq!(usage("vdg"), 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();+    let before = usage("vdg");+    fs::create("/mnt/disk6/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();+        let f = fs::create(&format!("/mnt/disk6/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");+    assert_eq!(fs::lookup("/mnt/disk6/Photos/many").unwrap().size(), 200);+    assert_eq!(read("/mnt/disk6/PHOTOS/MANY/file number 123.TXT", 0, 10), b"123\n");+    assert_eq!(read("/mnt/disk6/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(&format!("/mnt/disk6/Photos/many/file number {:03}.txt", i), false).unwrap();     }-    fs::remove("/mnt/disk5/Photos/many", true).unwrap();-    assert_eq!(usage("vdf"), before);+    fs::remove("/mnt/disk6/Photos/many", true).unwrap();+    assert_eq!(usage("vdg"), 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");+    let (used, _) = usage("vdg");     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_eq!(read("/mnt/disk6/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+    assert_eq!(usage("vdg").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");+    assert_eq!(usage("vdg").0, used + 1); // the root keeps its new cluster+    assert_eq!(read("/mnt/disk6/numbers.txt", 17, 17), b"exfat line 00001\n"); }  #[test_case]
@@ -2,10 +2,11 @@ //! that build.rs makes (all snapshot=on, so writes vanish after the test): //! //!   vdb  target/ext2.img       /mnt/disk1  1 KiB blocks, revision 1-//!   vdd  target/ext2-4k.img    /mnt/disk3  4 KiB blocks-//!   vde  target/ext2-rev0.img  /mnt/disk4  revision 0 (no file types in+//!   vde  target/ext2-4k.img    /mnt/disk4  4 KiB blocks+//!   vdf  target/ext2-rev0.img  /mnt/disk5  revision 0 (no file types in //!                                          directory entries)-//! (vdc, /mnt/disk2, is the exFAT disk: see tests/exfat.rs.)+//! (vdc, /mnt/disk2, is the exFAT disk: see tests/exfat.rs; vdd, /mnt/disk3,+//! and vdh-vdk are btrfs: see tests/btrfs.rs.) //! //! scripts/ext2-check.sh runs this kernel on *writable* copies and then //! checks them with the host's e2fsck.@@ -65,7 +66,7 @@ fn names(path: &str) -> Vec<String> { #[test_case] fn ext2_volumes_are_mounted() {     let mounts = fs::mounts();-    for path in ["/mnt/disk1", "/mnt/disk3", "/mnt/disk4"] {+    for path in ["/mnt/disk1", "/mnt/disk4", "/mnt/disk5"] {         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@@ -144,7 +145,7 @@ fn fast_and_slow_symlinks() {  #[test_case] fn four_k_blocks_and_revision_0() {-    for disk in ["/mnt/disk3", "/mnt/disk4"] {+    for disk in ["/mnt/disk4", "/mnt/disk5"] {         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");@@ -213,12 +214,12 @@ fn write_1k_blocks() {  #[test_case] fn write_4k_blocks() {-    write_round_trip("/mnt/disk3", "vdd");+    write_round_trip("/mnt/disk4", "vde"); }  #[test_case] fn write_revision_0() {-    write_round_trip("/mnt/disk4", "vde");+    write_round_trip("/mnt/disk5", "vdf"); }  #[test_case]
@@ -175,7 +175,7 @@ fn scripted_shell_session() {         "piped text\n",         "wc -l\n2\n",         "saved\nmore\n",-        "Rustbelt 0.9.0",+        "Rustbelt 0.10.0",         "B:/ $ exit 3\n",     ] {         assert!(out.contains(expected), "missing {:?} in:\n{}", expected, out);