rust-kernel
Rustbelt: a hobby x86_64 kernel written in Rust
| author | Tyler Fowler <tyler@localhost> | 2026-10-30 02:48:54 -0700 |
|---|---|---|
| commit | a04245092f08984a55a2bde7ce284a8e9797fb66 | |
| parent | 856182a546a770c0e1f776ded343d91ae316343c | |
| tree | browse files at this commit | |
Stage 10: read-only btrfs driver (0.10.0)
| M | Cargo.lock | +24 -1 |
| M | Cargo.toml | +19 -3 |
| M | README.md | +380 -55 |
| M | boot.png | +0 -0 |
| M | build.rs | +158 -2 |
| M | disk/scripts/selftest.sh | +15 -0 |
| A | disk4/Music/Road Trip/playlist.txt | +3 -0 |
| A | disk4/archive/2025-notes.txt | +2 -0 |
| A | disk4/docs/btrfs/chunks.txt | +3 -0 |
| A | disk4/docs/btrfs/cow.txt | +4 -0 |
| A | disk4/hello.txt | +1 -0 |
| A | disk4/projects/README.txt | +3 -0 |
| A | disk4/projects/rustbelt/stage10.txt | +2 -0 |
| A | disk4/readme.txt | +12 -0 |
| A | scripts/btrfs-check.sh | +92 -0 |
| M | scripts/exfat-check.sh | +1 -0 |
| M | scripts/ext2-check.sh | +1 -0 |
| M | scripts/run-headless.sh | +22 -4 |
| M | scripts/screenshot.sh | +9 -7 |
| A | src/fs/btrfs.rs | +1192 -0 |
| A | src/fs/lzo.rs | +123 -0 |
| M | src/fs/mod.rs | +20 -5 |
| M | src/lib.rs | +2 -1 |
| M | src/syscall.rs | +1 -1 |
| A | tests/btrfs.rs | +317 -0 |
| M | tests/exfat.rs | +27 -27 |
| M | tests/ext2.rs | +8 -7 |
| M | tests/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. -+ ## 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() && §or[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, §or).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);