RustForge

rust-kernel

Rustbelt: a hobby x86_64 kernel written in Rust

authorTyler Fowler <tyler@localhost>2026-10-30 01:52:32 -0700
commit3b489faecb168e0e2b623eaa8eaff1ec5edd7a5a
parentd3dbcfaf7fae0c452e63f201fbc709209f8810ed
treebrowse files at this commit
Stage 8: ext2 driver (0.8.0)

Hand-written ext2 (src/fs/ext2.rs, no crate) plugged into the VFS:
superblock, group descriptors, bitmaps, inodes with direct/single/
double/triple indirect blocks, directories with file types, fast and
slow symlinks; 1 KiB and 4 KiB blocks, revision 0 and 1. Writes:
create, write/append, truncate, mkdir, unlink, rmdir with bitmap
allocation and free counts in the superblock and group descriptors.

- VFS: symlink following (ELOOP after 8), lookup_nofollow, readlink;
  Stat gains mode; new readlink syscall (89); mount_disks probes ext2
  (magic 0xEF53) before FAT and mounts unpartitioned disks
- build.rs: mke2fs -d images from disk2/ (big.txt, sparse.bin,
  symlinks): ext2.img (raw, vdb -> /mnt/disk1 = C:), plus 4K-block
  and revision-0 images for tests
- ls -l shows permissions and symlink targets
- tests/ext2.rs (11 tests); scripts/ext2-check.sh runs Rustbelt on
  writable copies, then host e2fsck -fn and debugfs
- self-test and boot.png use drive C:; README ext2 section
MCargo.lock+1 -1
MCargo.toml+7 -1
MREADME.md+334 -57
Mboot.png+0 -0
Mbuild.rs+102 -3
Mdisk/scripts/selftest.sh+10 -0
Adisk2/docs/deep/er/nested.txt+1 -0
Adisk2/docs/layout.txt+4 -0
Adisk2/hello.txt+2 -0
Adisk2/readme.txt+4 -0
Ascripts/ext2-check.sh+67 -0
Mscripts/run-headless.sh+20 -1
Mscripts/screenshot.sh+10 -8
Msrc/errno.rs+8 -4
Asrc/fs/ext2.rs+1092 -0
Msrc/fs/fat.rs+5 -2
Msrc/fs/file.rs+5 -1
Msrc/fs/mod.rs+101 -24
Msrc/fs/pipe.rs+2 -2
Msrc/fs/ramfs.rs+1 -0
Msrc/syscall.rs+15 -3
Msrc/tty.rs+1 -1
Atests/ext2.rs+255 -0
Mtests/shell.rs+1 -1
Mtests/vfs.rs+1 -1
Muser/ls.rs+46 -8
Muser/ulib.rs+11 -0

27 files changed, 2106 insertions, 118 deletions

@@ -160,7 +160,7 @@ dependencies = [  [[package]] name = "rustbelt"-version = "0.7.0"+version = "0.8.0" dependencies = [  "bootloader",  "conquer-once",
@@ -1,6 +1,6 @@ [package] name = "rustbelt"-version = "0.7.0"+version = "0.8.0" edition = "2024"  [dependencies]@@ -77,6 +77,8 @@ run-args = [     # virtio-blk PCI device. snapshot=on: writes go to a temporary overlay,     # so the image is the same at every boot (and tests can't corrupt it).     "-drive", "file=target/disk.img,format=raw,if=virtio,snapshot=on",+    # Third disk: the ext2 image (target/ext2.img), also snapshot=on.+    "-drive", "file=target/ext2.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 ]@@ -86,6 +88,10 @@ test-args = [     # virtio-blk PCI device. snapshot=on: writes go to a temporary overlay,     # so the image is the same at every boot (and tests can't corrupt it).     "-drive", "file=target/disk.img,format=raw,if=virtio,snapshot=on",+    "-drive", "file=target/ext2.img,format=raw,if=virtio,snapshot=on",+    # Two more ext2 images just for tests/ext2.rs: 4 KiB blocks, revision 0.+    "-drive", "file=target/ext2-4k.img,format=raw,if=virtio,snapshot=on",+    "-drive", "file=target/ext2-rev0.img,format=raw,if=virtio,snapshot=on",     "-device", "isa-debug-exit,iobase=0xf4,iosize=0x04",     "-serial", "stdio",     "-display", "none",
@@ -5,7 +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:` and `B:`.+redirection, plus `dir`, `type`, `ver`, and drive letters like `A:`, `B:`+and `C:`.  Under the shell: - **VGA text and serial output.** The kernel prints to the VGA text screen and@@ -20,14 +21,16 @@ Under the shell:   **preemptive kernel threads**. - **User programs.** Small Rust ELF binaries run in **ring 3** as processes,   each with its own address space. Their memory is freed when they exit. They-  talk to the kernel through about 25 `syscall`s, numbered like Linux's:+  talk to the kernel through about 26 `syscall`s, numbered like Linux's:   `read`, `write`, `open`, `stat`, `getdents64`, `pipe`, `uname`... Two are   our own: `spawn` and `wait`. - **Files.** Files live behind a Linux-style **VFS**: a ramfs at `/` and-  `/tmp`, and a **FAT32** partition mounted at `/mnt/disk0`. That partition is-  on a second **virtio-blk** disk, found by **PCI** enumeration with its-  **GPT/MBR** partitions parsed. The shell and its programs are loaded from-  that disk.+  `/tmp`, a **FAT32** partition mounted at `/mnt/disk0`, and an **ext2**+  disk at `/mnt/disk1` with a hand-written driver that reads and writes it+  (permissions, symlinks, indirect blocks; checked with the real `e2fsck`).+  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.  It has a `cargo test` suite that runs inside QEMU, and a headless self-test that drives the shell with a script.@@ -36,9 +39,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 and the shell.+partitions, FAT32, the shell and the ext2 driver. -![Rustbelt shell session: ls, cat with a drive letter, a pipe, run --trace](boot.png)+![Rustbelt shell session on the ext2 drive C:: drives, ls -l with permissions and a symlink, cat through the symlink, cp from B: to C:](boot.png)  ## What's installed (on this box) @@ -80,7 +83,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`. See+for example `help`, `cd B:`, `ls` or `cat readme.txt | wc`, or `ls -l C:/`+for the ext2 disk. 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.@@ -97,6 +101,9 @@ Headless (no window, good for SSH / scripts):                                           # passes if the shell exits 0 AND the expected lines appear ./scripts/run-headless.sh stack-overflow  # deliberately overflow the stack; passes if the double-fault handler catches it ./scripts/run-headless.sh page-fault      # write to unmapped 0xdeadbeaf; passes if the page-fault handler catches it+EXT2_IMG=copy.img ./scripts/run-headless.sh  # use copy.img as the ext2 disk, writable (no snapshot)+./scripts/ext2-check.sh                   # Rustbelt writes to copies of the ext2 images, then the+                                          # host's e2fsck -fn checks them (see "ext2 (stage 8)") 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@@ -125,31 +132,36 @@ only, so the VGA screen shows just the shell session (and kernel messages such as a killed process).  ```-Rustbelt 0.7.0 -- a hobby x86_64 kernel in Rust+Rustbelt 0.8.0 -- a hobby x86_64 kernel in Rust [pci] 00:00.0 8086:1237 host bridge-... (7 PCI devices)-[pci] 7 devices on the PCI bus+... (8 PCI devices)+[pci] 8 devices on the PCI bus [disk] vda: virtio-blk, 131072 sectors (64 MiB) [disk] vda: GPT partition table, 1 partition(s) [disk] vda1: start 2048 sectors 126976 type Microsoft basic data (FAT/exFAT/NTFS) name "fat32"+[disk] vdb: virtio-blk, 65536 sectors (32 MiB)+[disk] vdb: no partition table (NoSignature) [disk] vda1: FAT32 volume "TYLERDISK" mounted at /mnt/disk0+[disk] vdb: ext2 volume "RUSTBELT" (rev 1, 1 KiB blocks, 4 groups) mounted at /mnt/disk1 [vfs] mounted ramfs at / [vfs] mounted ramfs at /tmp [vfs] mounted fat32 at /mnt/disk0+[vfs] mounted ext2 at /mnt/disk1 A:/ $ ver-Rustbelt 0.7.0 (stage 7: shell) x86_64+Rustbelt 0.8.0 (stage 8: ext2) x86_64 A:/ $ drives A:  /            ramfs B:  /mnt/disk0   fat32+C:  /mnt/disk1   ext2 A:/ $ pwd / A:/ $ cd B: B:/ $ ls-[process] started pid 2 (ls), entry 0x8000402c00+[process] started pid 2 (ls), entry 0x8000401880 bin/        notes/      readme.txt  scripts/ [process] pid 2 (ls) exited with code 0 B:/ $ ls -l notes-[process] started pid 3 (ls), entry 0x8000402c00+[process] started pid 3 (ls), entry 0x8000401880        125  todo.txt [process] pid 3 (ls) exited with code 0 B:/ $ type B:/readme.txt@@ -161,13 +173,13 @@ In the Rustbelt shell this disk is drive B: -- try `cd B:` and `ls`. B:/ $ cat b:notes/todo.txt | grep -i EXT | wc -l [process] started pid 5 (cat), entry 0x8000400260 [process] started pid 6 (grep), entry 0x8000400610-[process] pid 5 (cat) exited with code 0 [process] started pid 7 (wc), entry 0x8000400260+[process] pid 5 (cat) exited with code 0 [process] pid 6 (grep) exited with code 0 1 [process] pid 7 (wc) exited with code 0 B:/ $ dir bin | wc -l-[process] started pid 8 (ls), entry 0x8000402c00+[process] started pid 8 (ls), entry 0x8000401880 [process] started pid 9 (wc), entry 0x8000400260 [process] pid 8 (ls) exited with code 0 15@@ -183,13 +195,13 @@ B:/ $ md /tmp/work [process] started pid 11 (mkdir), entry 0x8000400100 [process] pid 11 (mkdir) exited with code 0 B:/ $ copy B:/notes/todo.txt /tmp/work-[process] started pid 12 (cp), entry 0x8000400670+[process] started pid 12 (cp), entry 0x80004006b0 [process] pid 12 (cp) exited with code 0 B:/ $ touch /tmp/work/empty.txt [process] started pid 13 (touch), entry 0x8000400100 [process] pid 13 (touch) exited with code 0 B:/ $ ls -l A:/tmp/work-[process] started pid 14 (ls), entry 0x8000402c00+[process] started pid 14 (ls), entry 0x8000401880          0  empty.txt        125  todo.txt [process] pid 14 (ls) exited with code 0@@ -202,7 +214,7 @@ In the Rustbelt shell this disk is drive B: -- try `cd B:` and `ls`. [process] pid 16 (grep) exited with code 0 B:/ $ alias ll="ls -l" B:/ $ ll A:/tmp-[process] started pid 17 (ls), entry 0x8000402c00+[process] started pid 17 (ls), entry 0x8000401880         23  out.txt [process] pid 17 (ls) exited with code 0 B:/ $ run --trace cat B:/notes/todo.txt@@ -224,37 +236,84 @@ B:/ $ jobs B:/ $ wait [process] pid 19 (sleep) exited with code 0 [done] pid 19 (start sleep 1), exit code 0-B:/ $ run evil-[process] started pid 20 (evil), entry 0x80004000f0-[evil] pid 20: reading kernel memory at 0x200000...+B:/ $ cd C:+C:/ $ ls+[process] started pid 20 (ls), entry 0x8000401880+big.txt      docs/        hello-link@  hello.txt    lost+found/  readme.txt+sparse.bin+[process] pid 20 (ls) exited with code 0+C:/ $ cat hello-link+[process] started pid 21 (cat), entry 0x8000400260+Hello from the ext2 disk! This file lives on a second virtio disk, in an+ext2 file system that Rustbelt reads and writes with its own driver.+[process] pid 21 (cat) exited with code 0+C:/ $ grep "line 12345" big.txt+[process] started pid 22 (grep), entry 0x8000400610+big.txt line 12345+[process] pid 22 (grep) exited with code 0+C:/ $ cp B:/notes/todo.txt C:/todo.txt+[process] started pid 23 (cp), entry 0x80004006b0+[process] pid 23 (cp) exited with code 0+C:/ $ cat C:/todo.txt | grep -i ext2+[process] started pid 24 (cat), entry 0x8000400260+[process] started pid 25 (grep), entry 0x8000400610+[process] pid 24 (cat) exited with code 0+Next: more file systems -- ext2, exFAT, btrfs.+[process] pid 25 (grep) exited with code 0+C:/ $ md C:/made-here+[process] started pid 26 (mkdir), entry 0x8000400100+[process] pid 26 (mkdir) exited with code 0+C:/ $ echo written by Rustbelt > C:/made-here/note.txt+C:/ $ type C:/made-here/note.txt+[process] started pid 27 (cat), entry 0x8000400260+written by Rustbelt+[process] pid 27 (cat) exited with code 0+C:/ $ ls -l C:/+[process] started pid 28 (ls), entry 0x8000401880+-rw-r--r--     304000  big.txt+drwxr-xr-x      <DIR>  docs/+lrwxrwxrwx             hello-link -> hello.txt+-rw-r--r--        142  hello.txt+drwx------      <DIR>  lost+found/+drwxr-xr-x      <DIR>  made-here/+-rw-r--r--        284  readme.txt+-rw-r--r--   69206016  sparse.bin+-rw-r--r--        125  todo.txt+[process] pid 28 (ls) exited with code 0+C:/ $ run evil+[process] started pid 29 (evil), entry 0x80004000f0+[evil] pid 29: reading kernel memory at 0x200000... [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 20 (evil) killed: page fault+[process] pid 29 (evil) killed: page fault [exit code 139]-B:/ $ nosuchprogram+C:/ $ nosuchprogram sh: nosuchprogram: command not found (try `help`)-B:/ $ cd Q:+C:/ $ cd Q: cd: there is no drive Q: (see `drives`)-B:/ $ cd A:/tmp+C:/ $ 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] 10 frames in use-[run-headless] all 14+2 expected shell lines found+[memory] 20 frames in use+[run-headless] all 22+2 expected shell lines found [run-headless] kernel exited OK ``` -The run ends with `[memory] 10 frames in use`, even though 20 processes have-come and gone. That shows exited processes give their page tables, code and-stacks back.+The run ends with `[memory] 20 frames in use`, even though 29 processes have+come and gone. That's exactly what was in use right after boot (mostly the+two virtio disks' queues and buffers), so exited processes give their page+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`, as-`-drive file=target/disk.img,format=raw,if=virtio,snapshot=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.+`Cargo.toml` attach a **second** disk, `target/disk.img` (GPT + FAT32), and a+**third**, `target/ext2.img` (raw ext2), 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).  ## Tests @@ -290,6 +349,7 @@ heap_allocation::simple_allocation...               [ok] | `tests/vfs.rs` | read `/hello.txt` to EOF, path normalisation (`.`, `..`, relative to a cwd), `ENOENT`/`ENOTDIR`/`EINVAL`/`EISDIR`/`EEXIST`, `/tmp` is a separate ramfs, longest-prefix mounts, sparse writes zero-fill, listing, remove (`ENOTEMPTY`, `EBUSY` on mount points), seek/stat/truncate, `getdents64` record layout, pipes pass bytes and report EOF / `EPIPE` | | `tests/block.rs` | PCI scan finds the virtio disk; sector 1 of the image is the GPT header; multi-sector reads equal single-sector reads; write + read back; out-of-range and partial-sector requests fail; the GPT partition is registered as `vda1` (with a FAT32 boot sector); MBR parsing on a RAM disk | | `tests/fat.rs` | the FAT32 volume is mounted at `/mnt/disk0` with the expected files (`bin`, `notes`, `readme.txt`, `scripts`); read `readme.txt`; every program in `bin/` is a valid ELF; create/write/read/truncate/remove a 10 KB file; directories with long names, `ENOTEMPTY`; the `BlockIo` adapter handles unaligned reads and writes |+| `tests/ext2.rs` | the three ext2 images are mounted (`/mnt/disk1-3`); permissions and case-sensitive names; `big.txt` line by line and in one read (direct, single and double indirect blocks); `sparse.bin`'s data at 0 / 40 MiB / 65 MiB (triple indirect) and its holes; fast and slow symlinks, `readlink`, removing a link keeps its target; 4 KiB blocks and revision 0; on each image: mkdir, create, a 300 KB write, append, overwrite across a block boundary, a write at 70 MiB (hole + triple indirect), truncate, `ENOTEMPTY`/`EISDIR`/`ENOTDIR`, unlink, rmdir, and the superblock's free counts are back where they started; 200 files grow a directory; copy from FAT to ext2. `scripts/ext2-check.sh` then checks the same kernel's writes with the host's `e2fsck` | | `tests/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]`@@ -340,14 +400,20 @@ src/errno.rs              Linux errno numbers src/tty.rs                console line discipline (cooked: echo + line editing; raw: every key, arrows                           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,-                          init (boot files), mount_disks (FAT partitions -> /mnt/diskN)+                          symlink following (ELOOP after 8), init (boot files),+                          mount_disks (ext2 or FAT partitions/disks -> /mnt/diskN) src/fs/file.rs            File trait (what an fd points to), InodeFile (inode + offset), Stat, getdents64 src/fs/ramfs.rs           in-memory filesystem src/fs/fat.rs             FAT32 via the fatfs crate: BlockIo adapter + FatFs/FatInode for the VFS+src/fs/ext2.rs            hand-written ext2: superblock, groups, bitmaps, inodes, indirect blocks,+                          directories, symlinks; read + write (the layout is explained at the top) src/fs/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) disk/                     files copied onto the FAT32 disk (readme.txt, notes/todo.txt,                           scripts/selftest.sh = the headless shell session)+disk2/                    files copied onto the ext2 disk (hello.txt, readme.txt, docs/...);+                          build.rs adds big.txt, sparse.bin and two symlinks 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)@@ -361,6 +427,7 @@ tests/                    integration tests, one QEMU-booted kernel each scripts/runner.sh         `cargo run`/`cargo test` runner: `bootimage runner` + stale-image fix scripts/run-headless.sh   headless boot + shell self-test, timeout, exit-code and output checks scripts/screenshot.sh     headless boot + a typed shell session + PNG screenshot via QEMU monitor+scripts/ext2-check.sh     Rustbelt writes to copies of the ext2 images, then host e2fsck -fn + debugfs boot.png                  screenshot of the VGA screen (a shell session) ``` @@ -620,7 +687,7 @@ Numbers are Linux's wherever Linux has the same call, so man pages and | 1 | `write` | fd, buf, len | console = VGA + serial; pipe: `EPIPE` once the reader is gone | | 2 | `open` | path, path_len, flags | `O_CREAT` 0o100, `O_TRUNC` 0o1000, `O_APPEND` 0o2000 (every write goes to the end: `>>`); directories can be opened (for `getdents64`) | | 3 | `close` | fd | |-| 4 | `stat` | path, path_len, *Stat | |+| 4 | `stat` | path, path_len, *Stat | follows symlinks | | 5 | `fstat` | fd, *Stat | | | 8 | `lseek` | fd, offset, whence | `SEEK_SET/CUR/END` = 0/1/2; `ESPIPE` on console/pipes | | 16 | `ioctl` | fd, request, arg | only `TTY_SET_RAW` (1): console raw mode on (arg 1) / off (0); returns the old mode, `ENOTTY` if fd isn't the console |@@ -630,12 +697,13 @@ 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.7.0", "stage 7: shell", "x86_64", "(none)" |+| 63 | `uname` | *utsname | Linux's struct: 6 NUL-padded 65-byte fields: "Rustbelt", "qemu", "0.8.0", "stage 8: ext2", "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 | | | 84 | `rmdir` | path, path_len | empty directories only (`ENOTEMPTY`); `EBUSY` on a mount point |-| 87 | `unlink` | path, path_len | files only (`EISDIR`) |+| 87 | `unlink` | path, path_len | files and symlinks (the link, not its target); directories give `EISDIR` |+| 89 | `readlink` | path, path_len, buf, len | a symlink's target (no NUL); returns its length, cut to `len` like Linux; `EINVAL` if not a symlink | | 217 | `getdents64` | fd, buf, len | real Linux `struct linux_dirent64` records (ino, off, reclen, type, name\0) | | 500 | `spawn` | path, path_len, argv, argv_len, *SpawnOptions | **ours**: start a program, returns its pid | | 501 | `mounts` | buf, len | **ours** (Linux reads `/proc/mounts`): the mount table as text, one `path fstype` line per mount, in mount order; returns the length, `ERANGE` if too small. The shell assigns drive letters from it |@@ -645,9 +713,10 @@ Deviations from Linux worth knowing: - **Paths are pointer + length**, not NUL-terminated C strings (easier to   validate, and Rust `&str`s aren't NUL-terminated). Relative paths start at   the process's cwd; `.` and `..` are resolved in the kernel (`fs::normalize`).-- **`Stat` is tiny**: `#[repr(C)] struct Stat { kind: u64, size: u64 }`, kind-  1 = file, 2 = directory, 3 = console, 4 = pipe. No inode numbers, owners,-  modes or times yet.+- **`Stat` is tiny**: `#[repr(C)] struct Stat { kind: u64, size: u64, mode: u64 }`,+  kind 1 = file, 2 = directory, 3 = console, 4 = pipe, 5 = symlink; `mode`+  is the permission bits (`0o644`), or 0 on file systems without them+  (ramfs, FAT). No inode numbers, owners or times yet. - **spawn instead of fork + exec.** `fork` copies the whole address space (or   needs copy-on-write page tables to be fast) and `exec` immediately throws the   copy away. `spawn` does the useful part in one step, like Windows@@ -745,7 +814,7 @@ counter before `.data`, not just the (empty, hence dropped) section.  Designed like Linux's so real filesystems can plug in later: -- `trait Inode` (`fs/mod.rs`): a file or directory: `kind`, `size`,+- `trait Inode` (`fs/mod.rs`): a file, directory or symlink: `kind`, `size`, `mode`, `readlink`,   `read_at(offset, buf)`, `write_at`, `truncate`, `lookup(name)`,   `create(name, kind)`, `list` (names + kinds), `remove(name)`.   Methods have default impls returning an error, so a read-only fs implements@@ -896,7 +965,7 @@ When it exits, the thread starts a new one, the way `init` respawns a login shell.  ```-Rustbelt shell. Type `help` for commands. Drives: A: = /, B: = /mnt/disk0+Rustbelt shell. Type `help` for commands. Drives: A: = /, B: = /mnt/disk0, C: = /mnt/disk1 A:/ $ cd b: B:/ $ ls bin/        notes/      readme.txt  scripts/@@ -926,7 +995,7 @@ B:/ $ run evil [process] pid 10 (evil) killed: page fault [exit code 139] B:/ $ ver-Rustbelt 0.7.0 (stage 7: shell) x86_64+Rustbelt 0.8.0 (stage 8: ext2) x86_64 B:/ $                      <- Enter on an empty line, a few seconds later [done] pid 9 (start sleep 5), exit code 0 B:/ $@@ -955,7 +1024,7 @@ Programs in `B:/bin` (`user/*.rs`, each 30-150 lines):  | Program | What it does | |---|---|-| `ls [-l] [path...]` | list a directory, sorted; in columns on the console, one name per line into a pipe or file (like real `ls`); `-l` shows sizes and `<DIR>` |+| `ls [-l] [path...]` | list a directory, sorted; in columns on the console, one name per line into a pipe or file (like real `ls`); symlinks get an `@`; `-l` shows sizes and `<DIR>`, plus permissions (`-rw-r--r--`) and `link -> target` on ext2 | | `cat [file...]` | print files, or stdin (also for `-`) | | `wc [-l] [file...]` | lines, words, bytes | | `grep [-i] [-v] [-n] text [file...]` | lines containing `text` (plain text, no regexes); exit code 0 = found, 1 = not found |@@ -1043,12 +1112,207 @@ Drive letters are only shell syntax. The kernel keeps one Unix tree:   - The `\x08` and `\x0c` console controls.   - A fix for the `|` key (see Notes). +## ext2 (stage 8)++The third disk, `target/ext2.img`, holds an **ext2** file system that+Rustbelt reads and writes with its own driver, `src/fs/ext2.rs` (about 1100+lines including a long explanation at the top, no crates). It is mounted at `/mnt/disk1`,+which is drive **`C:`** in the shell:++```+A:/ $ drives+A:  /            ramfs+B:  /mnt/disk0   fat32+C:  /mnt/disk1   ext2+A:/ $ cd c:+C:/ $ ls -l+-rw-r--r--     304000  big.txt+drwxr-xr-x      <DIR>  docs/+lrwxrwxrwx             hello-link -> hello.txt+-rw-r--r--        142  hello.txt+drwx------      <DIR>  lost+found/+-rw-r--r--        284  readme.txt+-rw-r--r--   69206016  sparse.bin+C:/ $ cat hello-link+Hello from the ext2 disk! This file lives on a second virtio disk, in an+ext2 file system that Rustbelt reads and writes with its own driver.+C:/ $ cp B:/notes/todo.txt C:/+C:/ $ cat todo.txt | grep -i ext2+Next: more file systems -- ext2, exFAT, btrfs.+```++### The on-disk layout, for a learner++ext2 (1993) was Linux's main file system through the 1990s, and ext3 and+ext4 grew out of it. It is the classic file system to learn from, because+everything on disk is an **array of fixed-size records** that you find with+plain arithmetic. You can follow along with `dumpe2fs target/ext2.img` and+`debugfs target/ext2.img` (both in `/usr/sbin`).++- **Blocks and groups.** The disk is cut into blocks of 1, 2 or 4 KiB, and+  the blocks into **block groups** (8192 blocks each with 1 KiB blocks).+  Our 32 MiB disk has 1 KiB blocks, so it has 4 groups. Each group keeps+  its own bitmaps and inode table, so a file's inode and its data usually+  sit close together.++  ```+  byte 0       1024          2048 (block 2)+  | boot block | superblock  | group descriptors | reserved | block bitmap | inode bitmap | inode table | data...   <- group 0+  | superblock + descriptor backups | block bitmap | inode bitmap | inode table | data...                         <- group 1+  ```+- **Superblock** (always at byte 1024, whatever the block size). It holds+  the totals: block size (`1024 << s_log_block_size`), blocks and inodes+  per group, the free counts, the magic number `0xEF53`, the revision and+  three sets of **feature flags**. `probe()` reads just that magic, and+  `mount_disks` tries ext2 first, then FAT.+- **Feature flags.** There are three sets, and each set tells an old driver+  what to do with a flag it doesn't know:+  - **compat**: ignore it.+  - **ro_compat**: mount read-only (Rustbelt then says `read-only` in the+    boot log, and writes give `EROFS`).+  - **incompat**: don't mount at all. ext4's extents are one, so Rustbelt+    refuses ext4 instead of misreading it.+- **Group descriptors** (32 bytes per group, in the block after the+  superblock). Each says where that group's block bitmap, inode bitmap and+  inode table are, and how many blocks and inodes are free there.+- **Bitmaps.** One bit per block or per inode, 1 = in use. Allocating is+  "find a 0 bit, set it, decrement the free count in the group descriptor+  *and* the superblock". Rustbelt allocates a file's blocks in the group of+  its inode first.+- **Inodes.** Inode N is at index `(N-1) % inodes_per_group` of group+  `(N-1) / inodes_per_group`'s table. Inode 2 is the root directory, and 11+  is `lost+found`. An inode has the mode (type + permissions), owner,+  size, times, link count and **15 block pointers**:++  ```+  i_block[0..12]  data blocks 0-11              1 KiB blocks: the first 12 KiB+  i_block[12]     -> 256 block numbers          single indirect, up to 268 KiB+  i_block[13]     -> 256 single-indirect blocks double indirect, up to ~64 MiB+  i_block[14]     -> 256 double-indirect blocks triple indirect, up to ~16 GiB+  ```++  `Volume::bmap` turns "block 40 960 of this file" into a disk block by+  working out which level it falls in and walking down the pointers. A 0+  pointer is a **hole**, which reads as zeros. That is how `sparse.bin`+  is 66 MiB while using only a few blocks. With 4 KiB blocks, each indirect+  block holds 1024 numbers.+- **Directories** are files full of variable-length entries:+  `inode (u32), rec_len (u16), name_len (u8), file_type (u8), name`.+  `rec_len` is the distance to the next entry. To delete an entry,+  Rustbelt adds its length to the previous entry's `rec_len`. To add one,+  it looks for an entry with spare room behind it, and if there is none,+  it gives the directory a new block. Revision 0 has no `file_type` byte,+  so the driver reads the inode to tell files from directories.+- **Symlinks.** A target shorter than 60 bytes is stored right inside+  `i_block` (a *fast* symlink, like `hello-link`). Longer targets get a+  data block (a *slow* symlink, like `docs/long-link`, 74 bytes). The VFS+  follows up to 8 links in a path, then gives `ELOOP`. `stat` follows+  them, `rm` doesn't, and the new `readlink` syscall (89) returns the+  target, which `ls -l` prints as `name -> target`.++### What the driver does and doesn't do++- **Reads:**+  - The revision 0 and revision 1 formats, with 1 KiB and 4 KiB blocks+    (2 KiB should work too, but isn't tested).+  - Direct, single, double and triple indirect blocks, and holes.+  - Fast and slow symlinks.+  - Directories with `dir_index` hash trees. The index hides inside+    entries that plain ext2 code skips.+- **Writes:**+  - Creating files and directories (mode 0644 and 0755), writing,+    appending, writing past the end (which leaves a hole), truncating to 0,+    `unlink` and `rmdir`.+  - The bitmaps, the free counts and the parent's link count stay+    consistent, so `e2fsck` is happy. Changing a hash-tree directory+    clears its index flag, like Linux's ext2 does.+- **Design:**+  - No cache: every operation re-reads what it needs, and every write goes+    straight to the disk.+  - One `Mutex` per volume.+  - An `Ext2Inode` is just *(volume, inode number, kind)*.+- **Not yet:**+  - Creating symlinks, hard links, rename, and `chmod`/`chown`.+  - Deleting a file that has an extended-attribute block (gives+    `EOPNOTSUPP`).+  - Writes past 2 GiB.+  - Real timestamps: there is no clock yet, so new files get the time the+    image was made.+  - The backup superblocks are never updated (Linux doesn't either), and+    there is no journal: a crash in the middle of an operation leaves work+    for `e2fsck`, just like real ext2.++### The test images (`build.rs`)++`build.rs` copies `disk2/` (text files, nested directories) to+`target/ext2-tree/` and adds a few generated files:+- `big.txt`: 16 000 numbered lines, 304 000 bytes, which reaches the double+  indirect blocks.+- `sparse.bin`: 66 MiB with data only at 0, 40 MiB and 65 MiB, so the+  last one sits in a **triple** indirect block.+- The two symlinks.++Then `mke2fs -t ext2 -d <tree>` builds the images without root or mounting:++| Image | Size | Block size | Notes | Attached as |+|---|---|---|---|---|+| `target/ext2.img` | 32 MiB | 1 KiB | revision 1, label RUSTBELT, the full tree | `vdb` -> `/mnt/disk1` = `C:` (run and tests) |+| `target/ext2-4k.img` | 8 MiB | 4 KiB | label FOURK, small tree | `vdc` -> `/mnt/disk2`, tests only |+| `target/ext2-rev0.img` | 4 MiB | 1 KiB | **revision 0** (`-E revision=0`), small tree | `vdd` -> `/mnt/disk3`, tests only |++**Raw, not partitioned.** The FAT disk has a GPT because that's what real+disks have. The ext2 image is the bare file system, with no partition table,+so the host's `e2fsck`, `debugfs` and `dumpe2fs` work on the file directly.+`mount_disks` mounts a whole disk if it has no partitions, and its+partitions if it does. The disks are numbered in device order: `vda1` =+disk0, `vdb` = disk1.++### Checking Rustbelt's writes with the real e2fsck++All QEMU disks normally use `snapshot=on`, so nothing reaches the image+files. `scripts/ext2-check.sh` does the opposite. It copies the images to+`target/fsck/` and boots Rustbelt on the **writable** copies twice:+- once for the shell self-test (`EXT2_IMG=... ./scripts/run-headless.sh`),+  which does `cp B: -> C:`, `md` and `echo >` on C:.+- once for the `tests/ext2.rs` kernel, which creates, writes, truncates+  and deletes on all three images.++Then it runs `e2fsck -fn` on every copy (`-f` = check even if marked clean,+`-n` = read-only, change nothing) and shows the root directory with+`debugfs`:++```sh+cargo test --test ext2 && ./scripts/ext2-check.sh+```++```+== e2fsck -fn target/fsck/selftest.img (after Rustbelt wrote to it)+e2fsck 1.47.2 (1-Jan-2025)+Pass 1: Checking inodes, blocks, and sizes+Pass 2: Checking directory structure+Pass 3: Checking directory connectivity+Pass 4: Checking reference counts+Pass 5: Checking group summary information+RUSTBELT: 25/8192 files (0.0% non-contiguous), 2777/32768 blocks+...+== debugfs: ls -l / on the self-test copy+     23  100644 (1)      0      0     125 30-Oct-2026 01:45 todo.txt+     24   40755 (2)      0      0    1024 30-Oct-2026 01:45 made-here+== debugfs: cat /made-here/note.txt+written by Rustbelt+== all images are clean+```++Pass 5 is the one that catches allocation bugs: it recounts every bitmap+and compares the result with the free counts in the group descriptors and+the superblock.+ ## 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, and the shell.+memory cleanup, the shell, and a hand-written ext2 driver.  - [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)@@ -1076,6 +1340,7 @@ memory cleanup, and the shell. - [x] Blocking threads + wait queues, pipes, `spawn`/`wait`, cwd, `stat`/`lseek`/`getdents64` — [OSDev: Blocking Process](https://wiki.osdev.org/Blocking_Process) - [x] Shell as a user program: line editing, builtins, PATH, pipes, redirection, background jobs, drive letters, `run --trace` — [OSDev: Shell](https://wiki.osdev.org/Shell) - [x] Process cleanup: frame free list, address spaces freed on reap+- [x] ext2, hand-written: read + write, indirect blocks, symlinks, 1/4 KiB blocks, revision 0/1, checked with `e2fsck` — [OSDev: Ext2](https://wiki.osdev.org/Ext2), [The Second Extended File System (Dave Poirier)](https://www.nongnu.org/ext2-doc/ext2.html)  ### Filesystem roadmap (Tyler's plan) @@ -1089,17 +1354,19 @@ whose `root()` returns `Inode`s; mounting it is one `fs::mount` call. 3. **Partition tables** — done: MBR + GPT (no CRC checks yet). 4. **FAT32** — done via the `fatfs` crate (`src/fs/fat.rs`). A good exercise    later: a hand-written read-only FAT32 to see what the crate does.-5. **ext2** — written by hand: superblock, block groups, inode table, direct/indirect-   blocks, directory entries. The classic "learn how Unix filesystems work" step,-   and the base for understanding ext4 later. [OSDev: Ext2](https://wiki.osdev.org/Ext2),+5. **ext2** — done (stage 8, `src/fs/ext2.rs`): written by hand, read and+   write: superblock, block groups, bitmaps, inode table, direct/indirect+   blocks, directory entries, symlinks. Still to do: symlink creation,+   rename, hard links, a block cache, real timestamps once there's an RTC+   driver. Later, ext4's extents would be a nice follow-up. [OSDev: Ext2](https://wiki.osdev.org/Ext2),    [The Second Extended File System (Dave Poirier)](https://www.nongnu.org/ext2-doc/ext2.html)-6. **End goals:**-   - **exFAT** — because it works on literally anything (cameras, phones,+6. **Next, in this order:**+   - **exFAT** (stage 9) — because it works on literally anything (cameras, phones,      Windows, macOS, Linux), so a kernel that speaks exFAT can share USB sticks      and SD cards with every other machine. Allocation bitmap + FAT-like cluster      chains + directory entry sets with checksums.      [Microsoft exFAT specification](https://learn.microsoft.com/en-us/windows/win32/fileio/exfat-specification)-   - **btrfs** (read-only first) — because most of Tyler's Linux systems will use+   - **btrfs**, read-only (after exFAT) — because most of Tyler's Linux systems will use      btrfs going forward, so this kernel could read their disks. Much bigger:      copy-on-write B-trees, the chunk tree (logical -> physical mapping), the      root/fs trees, checksums, compression (zlib/lzo/zstd). Read-only means@@ -1144,9 +1411,19 @@ 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/` or `build.rs` change and rebuilds-  `target/disk.img`. QEMU opens it with `snapshot=on`, so the kernel's writes-  (the tests create and delete files) never reach the file on disk.+- `build.rs` reruns when `user/`, `disk/`, `disk2/` or `build.rs` change and+  rebuilds `target/disk.img` and the ext2 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`).+- e2fsprogs: `sudo apt install e2fsprogs`. `mke2fs`, `e2fsck`, `debugfs` and+  `dumpe2fs` live in `/usr/sbin`, which isn't on a normal user's `PATH`+  (`build.rs` and the script look there). Newer `mke2fs` dropped `-r 0`;+  revision 0 is `-E revision=0`. `mke2fs -d` stores all-zero blocks as+  holes, so `sparse.bin`'s data chunks are deliberately non-zero.+- `docs/long-link` points through lots of `..` and `.`, but along a path+  that also works on Linux (`cat` it after `debugfs -R "rdump /docs /tmp/x"`).+  The VFS resolves `..` lexically, so a target like `file.txt/..` would+  work in Rustbelt but not on Linux; the test image avoids that. - Debug builds use a lot of stack: deep call chains (syscall -> VFS -> fatfs   -> ELF loader) needed more than 16 KiB. If you see a weird ring-0 fault at   address 0 or "kernel stack overflow in thread", suspect the stack first.

Binary file changed.

@@ -19,8 +19,21 @@ //!    QEMU opens it with `snapshot=on`, so the kernel's writes never change //!    this file (every boot and every test starts from the same disk). //!-//! Needs dosfstools (mkfs.vfat), mtools (mmd, mcopy) and fdisk (sfdisk):-//!     sudo apt install dosfstools mtools fdisk+//! 3. Build the third QEMU disk, `target/ext2.img` (32 MiB): a whole-disk+//!    ext2 file system (no partition table) made by `mke2fs -d`, which+//!    copies a directory tree into the new file system. The tree is disk2/+//!    plus generated files: big.txt (big enough for double-indirect blocks),+//!    sparse.bin (66 MiB of mostly holes, reaching the triple-indirect+//!    blocks), and two symlinks (one short = "fast", one long = "slow").+//!    Mounted at /mnt/disk1 (drive C: in the shell).+//!    Two more small images are for `cargo test` only (see Cargo.toml+//!    test-args): ext2-4k.img (4 KiB blocks) and ext2-rev0.img (the original+//!    1993 revision 0 format: fixed 128-byte inodes, no file types in+//!    directory entries).+//!+//! Needs dosfstools (mkfs.vfat), mtools (mmd, mcopy), fdisk (sfdisk) and+//! e2fsprogs (mke2fs):+//!     sudo apt install dosfstools mtools fdisk e2fsprogs  use std::{env, fs, io::Write, path::{Path, PathBuf}, process::{Command, Stdio}}; @@ -42,12 +55,14 @@ fn main() {     let user_dir = manifest.join("user");     println!("cargo:rerun-if-changed=user");     println!("cargo:rerun-if-changed=disk");+    println!("cargo:rerun-if-changed=disk2");     println!("cargo:rerun-if-changed=build.rs");      for program in PROGRAMS {         build_program(&user_dir, &out_dir, program);     }     build_disk(&manifest, &out_dir);+    build_ext2_disks(&manifest, &out_dir); }  fn build_program(user_dir: &Path, out_dir: &Path, program: &str) {@@ -75,7 +90,7 @@ fn tool(name: &str) -> PathBuf {             return candidate;         }     }-    panic!("`{name}` not found: install it with `sudo apt install dosfstools mtools fdisk`");+    panic!("`{name}` not found: install it with `sudo apt install dosfstools mtools fdisk e2fsprogs`"); }  fn run(cmd: &mut Command, stdin: Option<&str>) {@@ -136,3 +151,87 @@ fn build_disk(manifest: &Path, out_dir: &Path) {     file.seek(SeekFrom::Start(PART_START * 512)).unwrap();     file.write_all(&fat).unwrap(); }++/// The lines of big.txt: line i is `big.txt line {i:05}` (19 bytes with the+/// newline), so tests can check any offset. 16000 lines = 304000 bytes:+/// with 1 KiB blocks that's past the 12 direct + 256 single-indirect blocks.+pub const BIG_LINES: usize = 16000;++/// sparse.bin: 66 MiB, all holes except these 1 KiB chunks of text. With+/// 1 KiB blocks, 40 MiB is in the double-indirect range and 65 MiB in the+/// triple-indirect range (which starts at 12 + 256 + 256*256 KiB ~ 64.3 MiB).+pub const SPARSE_SIZE: u64 = 66 * 1024 * 1024;+pub const SPARSE_MARKS: &[u64] = &[0, 40 * 1024 * 1024, 65 * 1024 * 1024];++/// Copy a directory tree (files and directories only).+fn copy_tree(from: &Path, to: &Path) {+    fs::create_dir_all(to).unwrap();+    for entry in fs::read_dir(from).unwrap() {+        let path = entry.unwrap().path();+        let dest = to.join(path.file_name().unwrap());+        if path.is_dir() {+            copy_tree(&path, &dest);+        } else {+            fs::copy(&path, &dest).unwrap();+        }+    }+}++fn build_ext2_disks(manifest: &Path, out_dir: &Path) {+    use std::os::unix::fs::symlink;+    let target_dir = env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| manifest.join("target"));++    // The tree to copy in: disk2/ + generated files.+    let tree = out_dir.join("ext2-tree");+    let _ = fs::remove_dir_all(&tree);+    copy_tree(&manifest.join("disk2"), &tree);+    let big: String = (0..BIG_LINES).map(|i| format!("big.txt line {i:05}\n")).collect();+    fs::write(tree.join("big.txt"), &big).unwrap();+    {+        use std::io::{Seek, SeekFrom};+        let mut f = fs::File::create(tree.join("sparse.bin")).unwrap();+        f.set_len(SPARSE_SIZE).unwrap(); // a file of holes...+        for &at in SPARSE_MARKS {+            // ...with a few 1 KiB blocks of text (mke2fs -d keeps all-zero+            // blocks as holes, so the image stays small).+            let text = format!("sparse.bin data at offset {at}\n");+            let mut block = text.repeat(1024 / text.len()).into_bytes();+            block.resize(1024, b'.');+            f.seek(SeekFrom::Start(at)).unwrap();+            f.write_all(&block).unwrap();+        }+    }+    // A "fast" symlink: the target (< 60 bytes) fits inside the inode.+    symlink("hello.txt", tree.join("hello-link")).unwrap();+    // A "slow" symlink: the target is too long, so it gets a data block.+    symlink("./deep/../deep/er/../er/./../../../docs/./deep/er/../../deep/er/nested.txt", tree.join("docs/long-link")).unwrap();++    // -t ext2: no journal, no extents. -b 1024: 1 KiB blocks. -m 0: no+    // blocks reserved for root. -E root_owner: the root directory belongs to+    // root (the other files keep their owner from the build machine).+    // -F: it's a file, not a block device; just do it.+    let mke2fs = |img: &Path, size: &str, extra: &[&str], src: &Path| {+        let _ = fs::remove_file(img);+        run(+            Command::new(tool("mke2fs"))+                .args(["-q", "-F", "-t", "ext2", "-m", "0"])+                .args(extra)+                .arg("-d")+                .arg(src)+                .arg(img)+                .arg(size),+            None,+        );+    };+    mke2fs(&target_dir.join("ext2.img"), "32M", &["-b", "1024", "-L", "RUSTBELT", "-E", "root_owner=0:0"], &tree);++    // Smaller tree for the test-only images.+    let small = out_dir.join("ext2-small");+    let _ = fs::remove_dir_all(&small);+    copy_tree(&manifest.join("disk2/docs"), &small.join("docs"));+    fs::copy(manifest.join("disk2/hello.txt"), small.join("hello.txt")).unwrap();+    fs::write(small.join("big.txt"), &big).unwrap();+    symlink("hello.txt", small.join("hello-link")).unwrap();+    mke2fs(&target_dir.join("ext2-4k.img"), "8M", &["-b", "4096", "-L", "FOURK", "-E", "root_owner=0:0"], &small);+    mke2fs(&target_dir.join("ext2-rev0.img"), "4M", &["-b", "1024", "-E", "root_owner=0:0,revision=0"], &small);+}
@@ -24,6 +24,16 @@ run --trace cat B:/notes/todo.txt start sleep 1 jobs wait+cd C:+ls+cat hello-link+grep "line 12345" big.txt+cp B:/notes/todo.txt C:/todo.txt+cat C:/todo.txt | grep -i ext2+md C:/made-here+echo written by Rustbelt > C:/made-here/note.txt+type C:/made-here/note.txt+ls -l C:/ run evil nosuchprogram cd Q:
@@ -0,0 +1 @@+You found the deeply nested file.
@@ -0,0 +1,4 @@+ext2 in one breath: the disk is cut into blocks; blocks are grouped into+block groups; each group has a block bitmap, an inode bitmap and an inode+table; an inode lists the blocks of one file; a directory is a file of+(inode number, name) records. The superblock at byte 1024 describes it all.
@@ -0,0 +1,2 @@+Hello from the ext2 disk! This file lives on a second virtio disk, in an+ext2 file system that Rustbelt reads and writes with its own driver.
@@ -0,0 +1,4 @@+This is drive C: in the Rustbelt shell (/mnt/disk1): an ext2 file system.+Try: ls -l C:/   cat C:/hello-link   cp B:/notes/todo.txt C:/+Files made by build.rs: big.txt (indirect blocks), sparse.bin (a 66 MiB+file that is mostly holes), and the symlinks hello-link and docs/long-link.
@@ -0,0 +1,67 @@+#!/usr/bin/env bash+# Check Rustbelt's ext2 writes with the host's own tools (e2fsprogs).+#+#   1. e2fsck the pristine images build.rs made (sanity check of the build).+#   2. Copy them to target/fsck/ and boot Rustbelt on the *writable* copies+#      (no snapshot=on), so whatever it writes really lands in the files:+#        - the shell self-test (cp B: -> C:, mkdir, echo > file)+#        - the tests/ext2.rs kernel (create/write/truncate/unlink/rmdir on+#          all three images: 1 KiB blocks, 4 KiB blocks, revision 0)+#   3. `e2fsck -fn` every copy: -f = check even if marked clean, -n = open+#      read-only and answer "no" to every question (report, don't fix).+#      Any complaint means the driver wrote something inconsistent.+#   4. Show the root directory with debugfs, to see the new files.+#+# Usage: ./scripts/ext2-check.sh   (after `cargo build` and+#        `cargo test --test ext2`, which build the images and the kernels)+set -euo pipefail+cd "$(dirname "$0")/.."+export PATH="$HOME/.cargo/bin:$PATH:/usr/sbin:/sbin"+for tool in e2fsck debugfs qemu-system-x86_64; do+    command -v $tool >/dev/null || { echo "need $tool (apt install e2fsprogs qemu-system-x86)"; exit 1; }+done++fsck() { # fsck <image> <title>+    echo "== e2fsck -fn $1 ($2)"+    e2fsck -fn "$1" || { echo "!! e2fsck found problems in $1"; status=1; }+}+status=0++for img in target/ext2.img target/ext2-4k.img target/ext2-rev0.img; do+    fsck $img "pristine, as built"+done++mkdir -p target/fsck+cp target/ext2.img target/fsck/selftest.img+for img in ext2 ext2-4k ext2-rev0; do cp target/$img.img target/fsck/tests-$img.img; done++echo "== boot the shell self-test on target/fsck/selftest.img"+EXT2_IMG=target/fsck/selftest.img ./scripts/run-headless.sh >/dev/null && echo "self-test OK"++echo "== run the tests/ext2.rs kernel on target/fsck/tests-*.img"+KERNEL=$(ls -t target/x86_64-rust_kernel/debug/build/rustbelt/*/out/bootimage-ext2-*.bin 2>/dev/null | head -1 || true)+[ -n "$KERNEL" ] || { echo "no test kernel: run 'cargo test --test ext2' first"; exit 1; }+set +e+timeout 120 qemu-system-x86_64 -drive format=raw,file="$KERNEL" -no-reboot \+    -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \+    -drive file=target/fsck/tests-ext2.img,format=raw,if=virtio \+    -drive file=target/fsck/tests-ext2-4k.img,format=raw,if=virtio \+    -drive file=target/fsck/tests-ext2-rev0.img,format=raw,if=virtio \+    -device isa-debug-exit,iobase=0xf4,iosize=0x04 \+    -serial stdio -display none > target/fsck/tests.log+code=$?+set -e+grep -c "\[ok\]" target/fsck/tests.log | sed 's/$/ ext2 tests ok/'+[ $code = 33 ] || { echo "!! test kernel failed (exit $code), see target/fsck/tests.log"; exit 1; }++for img in target/fsck/selftest.img target/fsck/tests-*.img; do+    fsck "$img" "after Rustbelt wrote to it"+done++echo "== debugfs: ls -l / on the self-test copy"+debugfs -R "ls -l /" target/fsck/selftest.img 2>/dev/null+echo "== debugfs: cat /made-here/note.txt"+debugfs -R "cat /made-here/note.txt" target/fsck/selftest.img 2>/dev/null++[ $status = 0 ] && echo "== all images are clean" || echo "== e2fsck found problems"+exit $status
@@ -9,6 +9,10 @@ # # Usage: ./scripts/run-headless.sh                 # boot + shell self-test #        ./scripts/run-headless.sh stack-overflow  # extra cargo features, comma separated+#        EXT2_IMG=copy.img ./scripts/run-headless.sh+#            use copy.img as the ext2 disk *without* snapshot=on, so the+#            self-test's writes land in that file (scripts/ext2-check.sh+#            then runs e2fsck on it) set -euo pipefail cd "$(dirname "$0")/.." @@ -18,11 +22,18 @@ touch target/x86_64-rust_kernel/debug/rustbelt cargo bootimage --features "headless${1:+,$1}" IMG=target/x86_64-rust_kernel/debug/bootimage-rustbelt.bin +if [ -n "${EXT2_IMG:-}" ]; then+    EXT2_DRIVE="file=$EXT2_IMG,format=raw,if=virtio"+else+    EXT2_DRIVE="file=target/ext2.img,format=raw,if=virtio,snapshot=on"+fi+ LOG=target/headless-serial.log set +e timeout 90 qemu-system-x86_64 \     -drive format=raw,file="$IMG" \     -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \+    -drive "$EXT2_DRIVE" \     -device isa-debug-exit,iobase=0xf4,iosize=0x04 \     -serial stdio -display none -no-reboot | tee "$LOG" code=${PIPESTATUS[0]}@@ -31,8 +42,9 @@ set -e # Lines the shell self-test must have printed (only for a plain boot). if [ -z "${1:-}" ] && [ "$code" = 33 ]; then     expect=(-        "Rustbelt 0.7.0 (stage 7: shell) x86_64"      # ver+        "Rustbelt 0.8.0 (stage 8: ext2) x86_64"       # ver         "B:  /mnt/disk0   fat32"                       # drives+        "C:  /mnt/disk1   ext2"         "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@@ -41,6 +53,13 @@ if [ -z "${1:-}" ] && [ "$code" = 33 ]; then         '[trace 18] open("/mnt/disk0/notes/todo.txt", 0o0) = 3'   # run --trace         "sleep started in the background"              # start         "[done] pid 19 (start sleep 1), exit code 0"   # wait+        "C:/ $ ls"                                     # the ext2 disk+        "Hello from the ext2 disk!"                    # cat through a symlink+        "big.txt line 12345"                           # double-indirect block+        "written by Rustbelt"                          # mkdir + create on ext2+        "-rw-r--r--        125  todo.txt"              # cp B: -> C:, ls -l+        "lrwxrwxrwx             hello-link -> hello.txt"+        "drwxr-xr-x      <DIR>  made-here/"         "[exit code 139]"                              # run evil         "sh: nosuchprogram: command not found"         "cd: there is no drive Q:"
@@ -25,21 +25,23 @@ if [ -z "$SESSION" ]; then     # and fixes it.     SESSION=$(mktemp /tmp/session.XXXXXX)     cat > "$SESSION" <<'TXT'-cd b:-lx{BS}s-cat B:/notes/todo.txt-type readme.txt | grep -i rustbelt-run --trace cat notes/todo.txt+drives+cd c:+lx{BS}s -l+cat hello-link+cp B:/notes/todo.txt C:/+cat todo.txt | grep -i ext2 TXT-    set -- "B:/ $ ls" "bin/        notes/" "Next: more file systems" \-        "In the Rustbelt shell this disk is drive B:" \-        '] open("notes/todo.txt", 0o0) = 3' "] exit(0)"+    set -- "C:  /mnt/disk1   ext2" "C:/ $ ls -l" "hello-link -> hello.txt" \+        "-rw-r--r--     304000  big.txt" "Hello from the ext2 disk!" \+        "Next: more file systems -- ext2" fi SOCK=$(mktemp -u /tmp/qemu-mon.XXXXXX) SERIAL_LOG=$(mktemp /tmp/serial.XXXXXX)  qemu-system-x86_64 -drive format=raw,file="$IMG" \     -drive file=target/disk.img,format=raw,if=virtio,snapshot=on \+    -drive file=target/ext2.img,format=raw,if=virtio,snapshot=on \     -serial file:"$SERIAL_LOG" -display none -monitor unix:"$SOCK",server,nowait & QPID=$! sleep 4   # boot until the shell prompt
@@ -21,22 +21,26 @@ pub enum Errno {     EMFILE = 24,       // too many open files     ENOSPC = 28,       // no space left on device     ENOTTY = 25,       // not a terminal (ioctl on a file)+    EFBIG = 27,        // file too large     ESPIPE = 29,       // can't seek (pipe, console)+    EROFS = 30,        // read-only file system     EPIPE = 32,        // writing to a pipe nobody reads     ERANGE = 34,       // result doesn't fit (getcwd buffer too small)     ENAMETOOLONG = 36, // path too long     ENOSYS = 38,       // no such system call     ENOTEMPTY = 39,    // directory not empty+    ELOOP = 40,        // too many symlinks in a row (a loop?)+    EOPNOTSUPP = 95,   // operation not supported (by this file system) }  impl Errno {     /// The errno for a negative syscall result like -2, if we know it.     pub fn from_ret(ret: i64) -> Option<Errno> {         use Errno::*;-        const ALL: [Errno; 23] = [-            ENOENT, EIO, E2BIG, ENOEXEC, EBADF, ECHILD, EAGAIN, ENOMEM, EFAULT, EBUSY, EEXIST, ENOTDIR,-            EISDIR, EINVAL, EMFILE, ENOTTY, ENOSPC, ESPIPE, EPIPE, ERANGE, ENAMETOOLONG, ENOSYS,-            ENOTEMPTY,+        const ALL: [Errno; 27] = [+            ENOENT, EIO, E2BIG, ENOEXEC, EBADF, ECHILD, EAGAIN, ENOMEM, EFAULT, EBUSY, EEXIST,+            ENOTDIR, EISDIR, EINVAL, EMFILE, ENOTTY, EFBIG, ENOSPC, ESPIPE, EROFS, EPIPE, ERANGE,+            ENAMETOOLONG, ENOSYS, ENOTEMPTY, ELOOP, EOPNOTSUPP,         ];         ALL.into_iter().find(|&e| -(e as i64) == ret)     }
@@ -0,0 +1,1092 @@+//! ext2, the "second extended file system" (Rémy Card, 1993): Linux's main+//! file system through the 1990s, and the base that ext3 and ext4 grew from.+//! Written by hand here, no crate. It's the classic file system to learn+//! from: everything on disk is an array of fixed-size records.+//!+//! # On-disk layout+//!+//! The disk is cut into **blocks** of 1, 2 or 4 KiB (`1024 << s_log_block_size`).+//! Blocks are numbered from 0 and grouped into **block groups** of+//! `s_blocks_per_group` blocks (8192 with 1 KiB blocks). Our 32 MiB disk has+//! 1 KiB blocks, so it has 4 groups:+//!+//! ```text+//! byte 0     1024          block 2 (with 1 KiB blocks)+//! | boot     | superblock  | group descriptor table | reserved GDT blocks |+//! | block bitmap | inode bitmap | inode table ...   | data blocks ...      |   <- group 0+//! | (superblock + GDT backup) | block bitmap | inode bitmap | inode table | data... |   <- group 1+//! ...+//! ```+//!+//! * **Superblock** (always at byte 1024, 1024 bytes): the totals.+//!   It records the block size, blocks and inodes per group, free+//!   counts, the magic number 0xEF53, the revision and feature flags.+//!   "sparse_super" keeps backup copies only in groups 0, 1 and powers of+//!   3, 5 and 7. We read and update only the primary copy, like Linux.+//! * **Group descriptor table** (the block after the superblock): 32 bytes per+//!   group, saying where that group's block bitmap, inode bitmap and inode+//!   table are, and its free block and inode counts.+//! * **Bitmaps**: one block each, one bit per block or inode in the group;+//!   1 = in use. Allocating means finding a 0 bit.+//! * **Inode table**: `s_inodes_per_group` inodes of `s_inode_size` bytes+//!   (128 in revision 0; usually 256 in revision 1, where only the first 128+//!   are classic ext2 fields). Inode numbers start at 1; inode N lives in+//!   group (N-1) / inodes_per_group. Inode 2 is the root directory, 1-10 are+//!   reserved, and 11 is usually lost+found.+//! * **An inode** describes one file. It holds the mode (type + permissions),+//!   size, link count and times, plus 15 block pointers:+//!+//!   ```text+//!   i_block[0..12]  -> data blocks 0..11 directly+//!   i_block[12]     -> a block of block numbers           (single indirect)+//!   i_block[13]     -> a block of single-indirect blocks  (double indirect)+//!   i_block[14]     -> a block of double-indirect blocks  (triple indirect)+//!   ```+//!+//!   With 1 KiB blocks an indirect block holds 256 numbers. Direct blocks+//!   cover 12 KiB, single indirect up to 268 KiB, double up to ~64 MiB, and+//!   triple up to ~16 GiB. A 0 pointer is a *hole*, which reads as zeros+//!   (sparse files).+//! * **A directory** is a file of variable-length entries:+//!   `inode u32, rec_len u16, name_len u8, file_type u8, name`. The entries+//!   are padded to 4 bytes and never cross a block. `rec_len` jumps to the+//!   next entry, so deleting just adds the entry's length to the previous+//!   one, and the last entry stretches to the end of the block. Revision 0+//!   has no file_type byte (name_len is 16 bits). Every directory starts+//!   with "." and "..".+//! * **Symlinks**: a target shorter than 60 bytes is stored right inside+//!   `i_block` (a "fast" symlink). Longer ones get a data block ("slow").+//!+//! # What we do+//!+//! * **Reading:** all of the above, including holes and both symlink kinds.+//!   Large "dir_index" (htree) directories also work: their hash index+//!   hides inside entries that plain ext2 code skips.+//! * **Writing:**+//!   - We create files and directories, write and append, truncate to 0,+//!     and unlink and rmdir.+//!   - Blocks and inodes come from the bitmaps. The free counts are kept+//!     right in the group descriptors and the superblock, so `e2fsck` finds+//!     a clean file system.+//!   - When we change an htree directory, we clear its index flag, like+//!     Linux's ext2 driver does.+//! * **Not supported:**+//!   - Creating symlinks or hard links, and rename.+//!   - Extended attribute blocks: deleting a file that has one gives+//!     EOPNOTSUPP.+//!   - Files bigger than 2 GiB on write.+//! * **Unknown features:**+//!   - A file system with an "incompat" feature we don't know refuses to+//!     mount (ext4's extents, for example).+//!   - An unknown "ro_compat" feature mounts read-only.+//!+//! Every operation reads what it needs straight from the disk: there's no+//! cache, and writes go straight through. One Mutex serializes the volume.+//! There's no journal (that's ext3), so a crash in the middle of an+//! operation can leave work for e2fsck, just like real ext2.++use super::{DirEntry, FileSystem, Inode, InodeKind};+use crate::block::{BlockDevice, SECTOR_SIZE};+use crate::errno::Errno;+use alloc::{format, string::String, sync::Arc, vec, vec::Vec};+use spin::Mutex;++const MAGIC: u16 = 0xEF53;+const ROOT_INO: u32 = 2;++// Superblock fields (byte offsets inside the 1024-byte superblock).+const SB_INODES_COUNT: usize = 0;+const SB_BLOCKS_COUNT: usize = 4;+const SB_FREE_BLOCKS: usize = 12;+const SB_FREE_INODES: usize = 16;+const SB_FIRST_DATA_BLOCK: usize = 20; // block holding the superblock: 1 with 1 KiB blocks, else 0+const SB_LOG_BLOCK_SIZE: usize = 24;+const SB_BLOCKS_PER_GROUP: usize = 32;+const SB_INODES_PER_GROUP: usize = 40;+const SB_WTIME: usize = 48;+const SB_MAGIC: usize = 56;+const SB_REV_LEVEL: usize = 76;+const SB_FIRST_INO: usize = 84; // revision 1++const SB_INODE_SIZE: usize = 88; // revision 1+ (u16)+const SB_FEATURE_INCOMPAT: usize = 96;+const SB_FEATURE_RO_COMPAT: usize = 100;+const SB_VOLUME_NAME: usize = 120; // 16 bytes++// Feature flags we understand.+const INCOMPAT_FILETYPE: u32 = 0x0002; // directory entries have a file type byte+const RO_COMPAT_SPARSE_SUPER: u32 = 0x0001; // fewer superblock backups+const RO_COMPAT_LARGE_FILE: u32 = 0x0002; // files > 2 GiB (we only read them)+const RO_COMPAT_BTREE_DIR: u32 = 0x0004; // never used by anyone++// Group descriptor fields (32 bytes per group).+const GD_SIZE: usize = 32;+const GD_BLOCK_BITMAP: usize = 0;+const GD_INODE_BITMAP: usize = 4;+const GD_INODE_TABLE: usize = 8;+const GD_FREE_BLOCKS: usize = 12; // u16+const GD_FREE_INODES: usize = 14; // u16+const GD_USED_DIRS: usize = 16; // u16++// Inode fields (the classic first 128 bytes).+const I_MODE: usize = 0; // u16+const I_SIZE: usize = 4;+const I_ATIME: usize = 8;+const I_CTIME: usize = 12;+const I_MTIME: usize = 16;+const I_DTIME: usize = 20; // deletion time (0 = in use)+const I_LINKS: usize = 26; // u16+const I_SECTORS: usize = 28; // "i_blocks": counted in 512-byte sectors!+const I_FLAGS: usize = 32;+const I_BLOCK: usize = 40; // 15 block pointers (60 bytes)+const I_FILE_ACL: usize = 104; // extended attribute block+const I_SIZE_HIGH: usize = 108; // upper 32 bits of the size (regular files)++// i_mode: the top 4 bits are the type, the low 12 the permissions.+const S_IFMT: u16 = 0o170000;+const S_IFREG: u16 = 0o100000;+const S_IFDIR: u16 = 0o040000;+const S_IFLNK: u16 = 0o120000;++// Directory entry file types (with INCOMPAT_FILETYPE).+const FT_REG: u8 = 1;+const FT_DIR: u8 = 2;++const INDEX_FL: u32 = 0x1000; // directory has an htree index++/// Biggest file we write (bigger needs the large_file feature handling).+const MAX_WRITE_SIZE: u64 = (1 << 31) - 1;++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], b[at + 1], b[at + 2], b[at + 3]])+}+fn put16(b: &mut [u8], at: usize, v: u16) {+    b[at..at + 2].copy_from_slice(&v.to_le_bytes());+}+fn put32(b: &mut [u8], at: usize, v: u32) {+    b[at..at + 4].copy_from_slice(&v.to_le_bytes());+}++/// Does `dev` hold an ext2 (or ext3/ext4) file system? Checks the magic+/// number in the superblock at byte 1024 (= sectors 2 and 3).+pub fn probe(dev: &dyn BlockDevice) -> bool {+    let mut sb = [0u8; 1024];+    dev.read_sectors(2, &mut sb).is_ok() && get16(&sb, SB_MAGIC) == MAGIC+}++// ---------------------------------------------------------------------------+// One inode, as on disk+// ---------------------------------------------------------------------------++/// An inode's bytes (all `s_inode_size` of them, so writing it back keeps+/// the fields we don't interpret), with accessors for the ones we use.+#[derive(Clone)]+struct DiskInode {+    ino: u32,+    raw: Vec<u8>,+}++impl DiskInode {+    fn mode(&self) -> u16 {+        get16(&self.raw, I_MODE)+    }+    fn kind(&self) -> InodeKind {+        match self.mode() & S_IFMT {+            S_IFDIR => InodeKind::Directory,+            S_IFLNK => InodeKind::Symlink,+            _ => InodeKind::File, // regular files (devices, fifos: treated as files)+        }+    }+    fn is_regular(&self) -> bool {+        self.mode() & S_IFMT == S_IFREG+    }+    fn size(&self) -> u64 {+        let high = if self.is_regular() { get32(&self.raw, I_SIZE_HIGH) as u64 } else { 0 };+        (high << 32) | get32(&self.raw, I_SIZE) as u64+    }+    fn set_size(&mut self, size: u64) {+        put32(&mut self.raw, I_SIZE, size as u32);+        if self.is_regular() {+            put32(&mut self.raw, I_SIZE_HIGH, (size >> 32) as u32);+        }+    }+    fn links(&self) -> u16 {+        get16(&self.raw, I_LINKS)+    }+    fn set_links(&mut self, n: u16) {+        put16(&mut self.raw, I_LINKS, n);+    }+    fn sectors(&self) -> u32 {+        get32(&self.raw, I_SECTORS)+    }+    fn add_sectors(&mut self, n: i64) {+        let sectors = (self.sectors() as i64 + n) as u32;+        put32(&mut self.raw, I_SECTORS, sectors);+    }+    fn block(&self, slot: usize) -> u32 {+        get32(&self.raw, I_BLOCK + 4 * slot)+    }+    fn set_block(&mut self, slot: usize, b: u32) {+        put32(&mut self.raw, I_BLOCK + 4 * slot, b);+    }+    fn file_acl(&self) -> u32 {+        get32(&self.raw, I_FILE_ACL)+    }+    fn set_times(&mut self, which: &[usize], t: u32) {+        which.iter().for_each(|&at| put32(&mut self.raw, at, t));+    }+}++/// One directory entry, as read from disk.+struct RawDirEntry {+    ino: u32,+    name: String,+    file_type: u8, // 0 = unknown (revision 0 file systems)+}++/// Size a directory entry with this name needs: 8-byte header + name,+/// rounded up to 4 bytes.+fn entry_size(name_len: usize) -> usize {+    (8 + name_len + 3) & !3+}++// ---------------------------------------------------------------------------+// The volume: superblock, group descriptors and all the disk work+// ---------------------------------------------------------------------------++struct Volume {+    dev: Arc<dyn BlockDevice>,+    sb: Vec<u8>,  // the 1024-byte superblock, as on disk+    gdt: Vec<u8>, // the group descriptor table, as on disk (whole blocks)+    block_size: usize,+    groups: u32,+    gdt_block: u32, // where the descriptor table starts+    inode_size: usize,+    first_ino: u32, // first inode for ordinary files (11)+    revision: u32,+    filetype: bool, // directory entries carry a file type+    writable: bool,+}++impl Volume {+    fn open(dev: Arc<dyn BlockDevice>) -> Result<Volume, Errno> {+        let mut sb = vec![0u8; 1024];+        dev.read_sectors(2, &mut sb)?;+        if get16(&sb, SB_MAGIC) != MAGIC {+            return Err(Errno::EINVAL);+        }+        let log = get32(&sb, SB_LOG_BLOCK_SIZE);+        if log > 6 {+            return Err(Errno::EINVAL); // > 64 KiB: corrupted+        }+        let block_size = 1024usize << log;+        let revision = get32(&sb, SB_REV_LEVEL);+        let (inode_size, first_ino, incompat, ro_compat) = if revision == 0 {+            (128, 11, 0, 0) // revision 0: fixed sizes, no feature flags+        } else {+            (+                get16(&sb, SB_INODE_SIZE) as usize,+                get32(&sb, SB_FIRST_INO),+                get32(&sb, SB_FEATURE_INCOMPAT),+                get32(&sb, SB_FEATURE_RO_COMPAT),+            )+        };+        if inode_size < 128 || !inode_size.is_power_of_two() || inode_size > block_size {+            return Err(Errno::EINVAL);+        }+        // "incompat" features change the format: if we don't know one, we+        // can't even read safely (ext4 extents, a journal needing recovery...).+        if incompat & !INCOMPAT_FILETYPE != 0 {+            return Err(Errno::EOPNOTSUPP);+        }+        // "ro_compat" features: reading is fine, writing could break them.+        let writable = ro_compat & !(RO_COMPAT_SPARSE_SUPER | RO_COMPAT_LARGE_FILE | RO_COMPAT_BTREE_DIR) == 0;++        let blocks_count = get32(&sb, SB_BLOCKS_COUNT);+        let first_data = get32(&sb, SB_FIRST_DATA_BLOCK);+        let per_group = get32(&sb, SB_BLOCKS_PER_GROUP);+        if per_group == 0 || get32(&sb, SB_INODES_PER_GROUP) == 0 || blocks_count <= first_data {+            return Err(Errno::EINVAL);+        }+        let groups = (blocks_count - first_data).div_ceil(per_group);+        let gdt_block = first_data + 1;+        let gdt_blocks = (groups as usize * GD_SIZE).div_ceil(block_size);+        let mut gdt = vec![0u8; gdt_blocks * block_size];+        dev.read_sectors(gdt_block as u64 * (block_size / SECTOR_SIZE) as u64, &mut gdt)?;+        Ok(Volume {+            dev,+            sb,+            gdt,+            block_size,+            groups,+            gdt_block,+            inode_size,+            first_ino,+            revision,+            filetype: incompat & INCOMPAT_FILETYPE != 0,+            writable,+        })+    }++    fn sb32(&self, at: usize) -> u32 {+        get32(&self.sb, at)+    }++    /// Timestamp for new and changed inodes. We have no clock driver yet,+    /// so we reuse the superblock's "last written" time (set by mke2fs).+    fn now(&self) -> u32 {+        self.sb32(SB_WTIME).max(1)+    }++    // ---- blocks ----------------------------------------------------------++    fn read_block(&self, block: u32) -> Result<Vec<u8>, Errno> {+        if block == 0 || block >= self.sb32(SB_BLOCKS_COUNT) {+            return Err(Errno::EIO); // a bad pointer: corrupted file system+        }+        let mut data = vec![0u8; self.block_size];+        self.dev.read_sectors(block as u64 * (self.block_size / SECTOR_SIZE) as u64, &mut data)?;+        Ok(data)+    }++    fn write_block(&self, block: u32, data: &[u8]) -> Result<(), Errno> {+        if block == 0 || block >= self.sb32(SB_BLOCKS_COUNT) {+            return Err(Errno::EIO);+        }+        self.dev.write_sectors(block as u64 * (self.block_size / SECTOR_SIZE) as u64, data)+    }++    // ---- group descriptors and counters ----------------------------------++    fn gd32(&self, group: u32, field: usize) -> u32 {+        get32(&self.gdt, group as usize * GD_SIZE + field)+    }+    fn gd16(&self, group: u32, field: usize) -> u16 {+        get16(&self.gdt, group as usize * GD_SIZE + field)+    }++    /// Change a group's free-block / free-inode / directory counts (and the+    /// superblock totals), and write both back to disk.+    fn adjust_counts(&mut self, group: u32, blocks: i32, inodes: i32, dirs: i32) -> Result<(), Errno> {+        let at = group as usize * GD_SIZE;+        for (field, delta) in [(GD_FREE_BLOCKS, blocks), (GD_FREE_INODES, inodes), (GD_USED_DIRS, dirs)] {+            let v = get16(&self.gdt, at + field) as i32 + delta;+            put16(&mut self.gdt, at + field, v as u16);+        }+        for (field, delta) in [(SB_FREE_BLOCKS, blocks), (SB_FREE_INODES, inodes)] {+            let v = get32(&self.sb, field) as i64 + delta as i64;+            put32(&mut self.sb, field, v as u32);+        }+        // Write the descriptor table block holding this group, then the+        // superblock (sectors 2-3, wherever block 0/1 boundaries are).+        let index = at / self.block_size;+        let bs = self.block_size;+        self.write_block(self.gdt_block + index as u32, &self.gdt[index * bs..(index + 1) * bs])?;+        self.dev.write_sectors(2, &self.sb)+    }++    // ---- bitmaps: allocating and freeing ---------------------------------++    /// Find a 0 bit among the first `bits` of group `group`'s bitmap (block+    /// or inode bitmap, by `field`), set it, and return its index.+    fn take_bit(&self, group: u32, field: usize, bits: u32) -> Result<Option<u32>, Errno> {+        let block = self.gd32(group, field);+        let mut map = self.read_block(block)?;+        for i in 0..bits.min(self.block_size as u32 * 8) {+            let (byte, mask) = ((i / 8) as usize, 1u8 << (i % 8));+            if map[byte] & mask == 0 {+                map[byte] |= mask;+                self.write_block(block, &map)?;+                return Ok(Some(i));+            }+        }+        Ok(None) // the counts said there was room: they were wrong+    }++    fn clear_bit(&self, group: u32, field: usize, i: u32) -> Result<(), Errno> {+        let block = self.gd32(group, field);+        let mut map = self.read_block(block)?;+        let (byte, mask) = ((i / 8) as usize, 1u8 << (i % 8));+        if map[byte] & mask == 0 {+            return Err(Errno::EIO); // freeing something free: corrupted+        }+        map[byte] &= !mask;+        self.write_block(block, &map)+    }++    /// Allocate a block, preferably in `goal` (the group of its inode, so a+    /// file's blocks stay near each other). The new block is zeroed.+    fn alloc_block(&mut self, goal: u32) -> Result<u32, Errno> {+        let per_group = self.sb32(SB_BLOCKS_PER_GROUP);+        let first_data = self.sb32(SB_FIRST_DATA_BLOCK);+        for k in 0..self.groups {+            let group = (goal + k) % self.groups;+            if self.gd16(group, GD_FREE_BLOCKS) == 0 {+                continue;+            }+            // The last group can be shorter than the others.+            let start = first_data + group * per_group;+            let bits = per_group.min(self.sb32(SB_BLOCKS_COUNT) - start);+            if let Some(i) = self.take_bit(group, GD_BLOCK_BITMAP, bits)? {+                self.adjust_counts(group, -1, 0, 0)?;+                let block = start + i;+                self.write_block(block, &vec![0u8; self.block_size])?;+                return Ok(block);+            }+        }+        Err(Errno::ENOSPC)+    }++    fn free_block(&mut self, block: u32) -> Result<(), Errno> {+        let first_data = self.sb32(SB_FIRST_DATA_BLOCK);+        if block < first_data || block >= self.sb32(SB_BLOCKS_COUNT) {+            return Err(Errno::EIO);+        }+        let per_group = self.sb32(SB_BLOCKS_PER_GROUP);+        let group = (block - first_data) / per_group;+        self.clear_bit(group, GD_BLOCK_BITMAP, (block - first_data) % per_group)?;+        self.adjust_counts(group, 1, 0, 0)+    }++    /// Allocate an inode number, preferably in group `goal`, and zero it.+    fn alloc_inode(&mut self, goal: u32, dir: bool) -> Result<DiskInode, Errno> {+        let per_group = self.sb32(SB_INODES_PER_GROUP);+        for k in 0..self.groups {+            let group = (goal + k) % self.groups;+            if self.gd16(group, GD_FREE_INODES) == 0 {+                continue;+            }+            if let Some(i) = self.take_bit(group, GD_INODE_BITMAP, per_group)? {+                let ino = group * per_group + i + 1; // inode numbers start at 1+                if ino < self.first_ino {+                    return Err(Errno::EIO); // reserved inodes must be marked used+                }+                self.adjust_counts(group, 0, -1, dir as i32)?;+                let inode = DiskInode { ino, raw: vec![0u8; self.inode_size] };+                self.write_inode(&inode)?;+                return Ok(inode);+            }+        }+        Err(Errno::ENOSPC)+    }++    fn free_inode(&mut self, ino: u32, dir: bool) -> Result<(), Errno> {+        let per_group = self.sb32(SB_INODES_PER_GROUP);+        let group = (ino - 1) / per_group;+        self.clear_bit(group, GD_INODE_BITMAP, (ino - 1) % per_group)?;+        self.adjust_counts(group, 0, 1, -(dir as i32))+    }++    // ---- inodes ----------------------------------------------------------++    /// Which block of the inode table holds inode `ino`, and where in it.+    fn inode_location(&self, ino: u32) -> Result<(u32, usize), Errno> {+        if ino == 0 || ino > self.sb32(SB_INODES_COUNT) {+            return Err(Errno::EIO);+        }+        let per_group = self.sb32(SB_INODES_PER_GROUP);+        let (group, index) = ((ino - 1) / per_group, (ino - 1) % per_group);+        let byte = index as usize * self.inode_size;+        let block = self.gd32(group, GD_INODE_TABLE) + (byte / self.block_size) as u32;+        Ok((block, byte % self.block_size))+    }++    fn read_inode(&self, ino: u32) -> Result<DiskInode, Errno> {+        let (block, at) = self.inode_location(ino)?;+        let data = self.read_block(block)?;+        Ok(DiskInode { ino, raw: data[at..at + self.inode_size].to_vec() })+    }++    fn write_inode(&self, inode: &DiskInode) -> Result<(), Errno> {+        let (block, at) = self.inode_location(inode.ino)?;+        let mut data = self.read_block(block)?;+        data[at..at + self.inode_size].copy_from_slice(&inode.raw);+        self.write_block(block, &data)+    }++    fn group_of(&self, ino: u32) -> u32 {+        (ino - 1) / self.sb32(SB_INODES_PER_GROUP)+    }++    // ---- file block mapping: direct and indirect blocks ------------------++    /// Which disk block holds block number `index` of the file? 0 = a hole.+    /// With `alloc`, holes are filled with new zeroed blocks (and any+    /// missing indirect blocks on the way); the caller then writes `inode`+    /// back, since its block pointers and sector count changed.+    fn bmap(&mut self, inode: &mut DiskInode, index: u64, alloc: bool) -> Result<u32, Errno> {+        let p = (self.block_size / 4) as u64; // pointers per indirect block+        // Which of the 15 pointers, how many indirect levels below it, and+        // the index inside that tree.+        let (slot, levels, mut rest) = if index < 12 {+            (index as usize, 0, 0)+        } else if index - 12 < p {+            (12, 1, index - 12)+        } else if index - 12 - p < p * p {+            (13, 2, index - 12 - p)+        } else if index - 12 - p - p * p < p * p * p {+            (14, 3, index - 12 - p - p * p)+        } else {+            return Err(Errno::EFBIG);+        };+        let goal = self.group_of(inode.ino);+        let sectors_per_block = (self.block_size / SECTOR_SIZE) as i64;+        let mut block = inode.block(slot);+        if block == 0 {+            if !alloc {+                return Ok(0);+            }+            block = self.alloc_block(goal)?;+            inode.set_block(slot, block);+            inode.add_sectors(sectors_per_block);+        }+        // Walk down the indirect blocks: at each level, pick the pointer for+        // our part of the tree (like digits of `rest` in base p).+        for level in (0..levels).rev() {+            let span = p.pow(level);+            let i = (rest / span) as usize;+            rest %= span;+            let mut table = self.read_block(block)?;+            let mut next = get32(&table, 4 * i);+            if next == 0 {+                if !alloc {+                    return Ok(0);+                }+                next = self.alloc_block(goal)?;+                put32(&mut table, 4 * i, next);+                self.write_block(block, &table)?;+                inode.add_sectors(sectors_per_block);+            }+            block = next;+        }+        Ok(block)+    }++    /// Free a block and, for indirect blocks (`levels` > 0), everything it+    /// points to.+    fn free_tree(&mut self, block: u32, levels: u32) -> Result<(), Errno> {+        if levels > 0 {+            let table = self.read_block(block)?;+            for i in 0..self.block_size / 4 {+                let next = get32(&table, 4 * i);+                if next != 0 {+                    self.free_tree(next, levels - 1)?;+                }+            }+        }+        self.free_block(block)+    }++    /// Free all of a file's data blocks: size 0, no blocks (truncate).+    fn free_data(&mut self, inode: &mut DiskInode) -> Result<(), Errno> {+        if !self.is_fast_symlink(inode) {+            for slot in 0..15 {+                let block = inode.block(slot);+                if block != 0 {+                    let levels = slot.saturating_sub(11) as u32; // 12 -> 1, 13 -> 2, 14 -> 3+                    self.free_tree(block, levels)?;+                    inode.set_block(slot, 0);+                }+            }+            // What's left in i_blocks is the extended attribute block, if any.+            let ea = if inode.file_acl() != 0 { self.block_size / SECTOR_SIZE } else { 0 };+            put32(&mut inode.raw, I_SECTORS, ea as u32);+        }+        inode.set_size(0);+        Ok(())+    }++    /// A symlink whose target is stored inside i_block (no data blocks).+    fn is_fast_symlink(&self, inode: &DiskInode) -> bool {+        let ea = if inode.file_acl() != 0 { (self.block_size / SECTOR_SIZE) as u32 } else { 0 };+        inode.kind() == InodeKind::Symlink && inode.sectors() == ea+    }++    // ---- file contents ---------------------------------------------------++    fn read_file(&mut self, inode: &DiskInode, offset: u64, buf: &mut [u8]) -> Result<usize, Errno> {+        let size = inode.size();+        if offset >= size {+            return Ok(0);+        }+        let len = buf.len().min((size - offset) as usize);+        let bs = self.block_size as u64;+        let mut inode = inode.clone(); // bmap wants &mut (it won't change it without alloc)+        let mut done = 0;+        while done < len {+            let pos = offset + done as u64;+            let within = (pos % bs) as usize;+            let n = (self.block_size - within).min(len - done);+            match self.bmap(&mut inode, pos / bs, false)? {+                0 => buf[done..done + n].fill(0), // a hole+                block => buf[done..done + n].copy_from_slice(&self.read_block(block)?[within..within + n]),+            }+            done += n;+        }+        Ok(done)+    }++    fn write_file(&mut self, inode: &mut DiskInode, offset: u64, buf: &[u8]) -> Result<usize, Errno> {+        let end = offset.checked_add(buf.len() as u64).ok_or(Errno::EFBIG)?;+        if end > MAX_WRITE_SIZE {+            return Err(Errno::EFBIG);+        }+        let bs = self.block_size as u64;+        let mut done = 0;+        let mut result = Ok(());+        while done < buf.len() {+            let pos = offset + done as u64;+            let within = (pos % bs) as usize;+            let n = (self.block_size - within).min(buf.len() - done);+            let step = (|| {+                let block = self.bmap(inode, pos / bs, true)?;+                // A whole block is simply overwritten; part of one is+                // read-modify-write.+                let mut data = if n == self.block_size { vec![0u8; n] } else { self.read_block(block)? };+                data[within..within + n].copy_from_slice(&buf[done..done + n]);+                self.write_block(block, &data)+            })();+            if let Err(e) = step {+                result = Err(e); // e.g. ENOSPC: keep what was written so far+                break;+            }+            done += n;+        }+        // Always save the inode: bmap may have allocated blocks even if a+        // later step failed.+        let new_end = offset + done as u64;+        if new_end > inode.size() {+            inode.set_size(new_end);+        }+        let now = self.now();+        inode.set_times(&[I_MTIME, I_CTIME], now);+        self.write_inode(inode)?;+        match result {+            Err(e) if done == 0 => Err(e),+            _ => Ok(done),+        }+    }++    // ---- directories -----------------------------------------------------++    /// Parse the entry at `at` in a directory block: (inode, rec_len, name_len, type).+    fn parse_entry(&self, data: &[u8], at: usize) -> Result<(u32, usize, usize, u8), Errno> {+        let rec_len = get16(data, at + 4) as usize;+        let (name_len, file_type) =+            if self.filetype { (data[at + 6] as usize, data[at + 7]) } else { (get16(data, at + 6) as usize, 0) };+        if rec_len < 8 || rec_len % 4 != 0 || at + rec_len > data.len() || 8 + name_len > rec_len {+            return Err(Errno::EIO); // corrupted directory+        }+        Ok((get32(data, at), rec_len, name_len, file_type))+    }++    fn write_entry(&self, data: &mut [u8], at: usize, ino: u32, rec_len: usize, name: &str, file_type: u8) {+        put32(data, at, ino);+        put16(data, at + 4, rec_len as u16);+        if self.filetype {+            data[at + 6] = name.len() as u8;+            data[at + 7] = file_type;+        } else {+            put16(data, at + 6, name.len() as u16);+        }+        data[at + 8..at + 8 + name.len()].copy_from_slice(name.as_bytes());+    }++    fn dir_blocks(&self, dir: &DiskInode) -> u64 {+        dir.size().div_ceil(self.block_size as u64)+    }++    /// All entries of a directory, "." and ".." included.+    fn read_dir(&mut self, dir: &DiskInode) -> Result<Vec<RawDirEntry>, Errno> {+        let mut out = Vec::new();+        let mut dir = dir.clone();+        for index in 0..self.dir_blocks(&dir) {+            let block = self.bmap(&mut dir, index, false)?;+            if block == 0 {+                continue;+            }+            let data = self.read_block(block)?;+            let mut at = 0;+            while at < data.len() {+                let (ino, rec_len, name_len, file_type) = self.parse_entry(&data, at)?;+                if ino != 0 {+                    let name = String::from_utf8_lossy(&data[at + 8..at + 8 + name_len]).into_owned();+                    out.push(RawDirEntry { ino, name, file_type });+                }+                at += rec_len;+            }+        }+        Ok(out)+    }++    fn find_entry(&mut self, dir: &DiskInode, name: &str) -> Result<Option<u32>, Errno> {+        Ok(self.read_dir(dir)?.into_iter().find(|e| e.name == name).map(|e| e.ino))+    }++    /// We don't keep the htree hash index of big directories up to date,+    /// so mark it gone when changing a directory (like Linux's ext2 does).+    fn drop_index(dir: &mut DiskInode) {+        let flags = get32(&dir.raw, I_FLAGS);+        put32(&mut dir.raw, I_FLAGS, flags & !INDEX_FL);+    }++    /// Add `name -> ino` to a directory: in the slack space after an+    /// existing entry if there is room, else in a new block at the end.+    fn add_entry(&mut self, dir: &mut DiskInode, name: &str, ino: u32, file_type: u8) -> Result<(), Errno> {+        let need = entry_size(name.len());+        Self::drop_index(dir);+        for index in 0..self.dir_blocks(dir) {+            let block = self.bmap(dir, index, false)?;+            if block == 0 {+                continue;+            }+            let mut data = self.read_block(block)?;+            let mut at = 0;+            while at < data.len() {+                let (e_ino, rec_len, name_len, _) = self.parse_entry(&data, at)?;+                let used = if e_ino == 0 { 0 } else { entry_size(name_len) };+                if rec_len - used >= need {+                    if e_ino == 0 {+                        // An unused entry (start of a block): take it over.+                        self.write_entry(&mut data, at, ino, rec_len, name, file_type);+                    } else {+                        // Shrink this entry to its real size; we get the rest.+                        put16(&mut data, at + 4, used as u16);+                        self.write_entry(&mut data, at + used, ino, rec_len - used, name, file_type);+                    }+                    self.write_block(block, &data)?;+                    return self.write_inode(dir);+                }+                at += rec_len;+            }+        }+        // No room anywhere: grow the directory by one block.+        let index = self.dir_blocks(dir);+        let block = self.bmap(dir, index, true)?;+        let mut data = vec![0u8; self.block_size];+        self.write_entry(&mut data, 0, ino, self.block_size, name, file_type);+        self.write_block(block, &data)?;+        dir.set_size((index + 1) * self.block_size as u64);+        self.write_inode(dir)+    }++    /// Remove `name` from a directory; returns its inode number. The entry+    /// is merged into the previous one (or, first in its block, marked+    /// unused with inode 0).+    fn remove_entry(&mut self, dir: &mut DiskInode, name: &str) -> Result<u32, Errno> {+        Self::drop_index(dir);+        for index in 0..self.dir_blocks(dir) {+            let block = self.bmap(dir, index, false)?;+            if block == 0 {+                continue;+            }+            let mut data = self.read_block(block)?;+            let (mut at, mut prev) = (0, None);+            while at < data.len() {+                let (e_ino, rec_len, name_len, _) = self.parse_entry(&data, at)?;+                if e_ino != 0 && &data[at + 8..at + 8 + name_len] == name.as_bytes() {+                    match prev {+                        Some(p) => {+                            let merged = get16(&data, p + 4) as usize + rec_len;+                            put16(&mut data, p + 4, merged as u16);+                        }+                        None => put32(&mut data, at, 0),+                    }+                    self.write_block(block, &data)?;+                    self.write_inode(dir)?;+                    return Ok(e_ino);+                }+                prev = Some(at);+                at += rec_len;+            }+        }+        Err(Errno::ENOENT)+    }++    // ---- the operations the VFS uses -------------------------------------++    fn create(&mut self, parent_ino: u32, name: &str, kind: InodeKind) -> Result<u32, Errno> {+        if !self.writable {+            return Err(Errno::EROFS);+        }+        if name.is_empty() || name == "." || name == ".." || name.contains('/') {+            return Err(Errno::EINVAL);+        }+        if name.len() > 255 {+            return Err(Errno::ENAMETOOLONG);+        }+        let mut parent = self.read_inode(parent_ino)?;+        if parent.kind() != InodeKind::Directory {+            return Err(Errno::ENOTDIR);+        }+        if self.find_entry(&parent, name)?.is_some() {+            return Err(Errno::EEXIST);+        }+        let dir = match kind {+            InodeKind::File => false,+            InodeKind::Directory => true,+            InodeKind::Symlink => return Err(Errno::EOPNOTSUPP), // no symlink syscall yet+        };+        let mut inode = self.alloc_inode(self.group_of(parent_ino), dir)?;+        let now = self.now();+        inode.set_times(&[I_ATIME, I_CTIME, I_MTIME], now);+        if dir {+            put16(&mut inode.raw, I_MODE, S_IFDIR | 0o755);+            inode.set_links(2); // the parent's entry + its own "."+        } else {+            put16(&mut inode.raw, I_MODE, S_IFREG | 0o644);+            inode.set_links(1);+        }+        let result = (|| {+            if dir {+                // A new directory holds "." and ".." (which takes the rest of the block).+                let block = self.bmap(&mut inode, 0, true)?;+                let mut data = vec![0u8; self.block_size];+                self.write_entry(&mut data, 0, inode.ino, 12, ".", FT_DIR);+                self.write_entry(&mut data, 12, parent_ino, self.block_size - 12, "..", FT_DIR);+                self.write_block(block, &data)?;+                inode.set_size(self.block_size as u64);+            }+            self.write_inode(&inode)?;+            self.add_entry(&mut parent, name, inode.ino, if dir { FT_DIR } else { FT_REG })?;+            if dir {+                parent.set_links(parent.links() + 1); // the new ".." points at it+                self.write_inode(&parent)?;+            }+            Ok(())+        })();+        if let Err(e) = result {+            // Undo (e.g. the parent directory couldn't grow: ENOSPC).+            let _ = self.free_data(&mut inode);+            let _ = self.free_inode(inode.ino, dir);+            return Err(e);+        }+        Ok(inode.ino)+    }++    fn remove(&mut self, parent_ino: u32, name: &str) -> Result<(), Errno> {+        if !self.writable {+            return Err(Errno::EROFS);+        }+        let mut parent = self.read_inode(parent_ino)?;+        let ino = self.find_entry(&parent, name)?.ok_or(Errno::ENOENT)?;+        let mut child = self.read_inode(ino)?;+        let is_dir = child.kind() == InodeKind::Directory;+        if is_dir && self.read_dir(&child)?.iter().any(|e| e.name != "." && e.name != "..") {+            return Err(Errno::ENOTEMPTY);+        }+        let last_link = is_dir || child.links() <= 1;+        if last_link && child.file_acl() != 0 {+            return Err(Errno::EOPNOTSUPP); // we don't do extended attribute blocks+        }+        self.remove_entry(&mut parent, name)?;+        if is_dir {+            parent.set_links(parent.links() - 1); // its ".." is gone+            self.write_inode(&parent)?;+        }+        if !last_link {+            child.set_links(child.links() - 1); // another hard link remains+            return self.write_inode(&child);+        }+        // Last link gone: free the data, mark the inode deleted, free it.+        self.free_data(&mut child)?;+        child.set_links(0);+        let now = self.now();+        child.set_times(&[I_DTIME, I_CTIME], now);+        self.write_inode(&child)?;+        self.free_inode(ino, is_dir)+    }++    fn truncate(&mut self, ino: u32) -> Result<(), Errno> {+        if !self.writable {+            return Err(Errno::EROFS);+        }+        let mut inode = self.read_inode(ino)?;+        self.free_data(&mut inode)?;+        let now = self.now();+        inode.set_times(&[I_MTIME, I_CTIME], now);+        self.write_inode(&inode)+    }++    fn readlink(&mut self, inode: &DiskInode) -> Result<String, Errno> {+        let size = inode.size() as usize;+        let bytes = if self.is_fast_symlink(inode) {+            inode.raw[I_BLOCK..I_BLOCK + size.min(60)].to_vec() // stored in i_block itself+        } else {+            let mut buf = vec![0u8; size];+            let n = self.read_file(inode, 0, &mut buf)?;+            buf.truncate(n);+            buf+        };+        String::from_utf8(bytes).map_err(|_| Errno::EINVAL)+    }++    fn label(&self) -> String {+        let raw = &self.sb[SB_VOLUME_NAME..SB_VOLUME_NAME + 16];+        let len = raw.iter().position(|&b| b == 0).unwrap_or(16);+        String::from_utf8_lossy(&raw[..len]).into_owned()+    }+}++// ---------------------------------------------------------------------------+// The VFS side+// ---------------------------------------------------------------------------++/// A mounted ext2 volume.+pub struct Ext2Fs {+    vol: Arc<Mutex<Volume>>,+}++impl Ext2Fs {+    pub fn mount(dev: Arc<dyn BlockDevice>) -> Result<Arc<Ext2Fs>, Errno> {+        let vol = Volume::open(dev)?;+        let root = vol.read_inode(ROOT_INO)?;+        if root.kind() != InodeKind::Directory {+            return Err(Errno::EIO);+        }+        Ok(Arc::new(Ext2Fs { vol: Arc::new(Mutex::new(vol)) }))+    }++    /// For the boot log, e.g. `ext2 volume "RUSTBELT" (rev 1, 1 KiB blocks, 4 groups)`.+    pub fn describe(&self) -> String {+        let v = self.vol.lock();+        format!(+            "ext2 volume \"{}\" (rev {}, {} KiB blocks, {} groups{})",+            v.label(),+            v.revision,+            v.block_size / 1024,+            v.groups,+            if v.writable { "" } else { ", read-only" }+        )+    }++    /// (free blocks, total blocks, free inodes, total inodes), from the superblock.+    pub fn counts(&self) -> (u32, u32, u32, u32) {+        let v = self.vol.lock();+        (v.sb32(SB_FREE_BLOCKS), v.sb32(SB_BLOCKS_COUNT), v.sb32(SB_FREE_INODES), v.sb32(SB_INODES_COUNT))+    }+}++impl FileSystem for Ext2Fs {+    fn name(&self) -> &'static str {+        "ext2"+    }+    fn root(&self) -> Arc<dyn Inode> {+        Arc::new(Ext2Inode { vol: self.vol.clone(), ino: ROOT_INO, kind: InodeKind::Directory })+    }+}++/// A file, directory or symlink on an ext2 volume: just its inode number.+/// Every operation reads the on-disk inode fresh.+pub struct Ext2Inode {+    vol: Arc<Mutex<Volume>>,+    ino: u32,+    kind: InodeKind,+}++impl Ext2Inode {+    fn child(&self, ino: u32, kind: InodeKind) -> Arc<dyn Inode> {+        Arc::new(Ext2Inode { vol: self.vol.clone(), ino, kind })+    }+    fn dir_only(&self) -> Result<(), Errno> {+        if self.kind == InodeKind::Directory { Ok(()) } else { Err(Errno::ENOTDIR) }+    }+}++impl Inode for Ext2Inode {+    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);+        }+        let vol = self.vol.lock();+        vol.read_inode(self.ino).map(|i| i.size() as usize).unwrap_or(0)+    }++    fn mode(&self) -> u32 {+        let vol = self.vol.lock();+        vol.read_inode(self.ino).map(|i| (i.mode() & 0o7777) as u32).unwrap_or(0)+    }++    fn readlink(&self) -> Result<String, Errno> {+        if self.kind != InodeKind::Symlink {+            return Err(Errno::EINVAL);+        }+        let mut vol = self.vol.lock();+        let inode = vol.read_inode(self.ino)?;+        vol.readlink(&inode)+    }++    fn read_at(&self, offset: usize, buf: &mut [u8]) -> Result<usize, Errno> {+        if self.kind == InodeKind::Directory {+            return Err(Errno::EISDIR);+        }+        let mut vol = self.vol.lock();+        let inode = vol.read_inode(self.ino)?;+        vol.read_file(&inode, offset as u64, buf)+    }++    fn write_at(&self, offset: usize, buf: &[u8]) -> Result<usize, Errno> {+        if self.kind == InodeKind::Directory {+            return Err(Errno::EISDIR);+        }+        let mut vol = self.vol.lock();+        if !vol.writable {+            return Err(Errno::EROFS);+        }+        let mut inode = vol.read_inode(self.ino)?;+        vol.write_file(&mut inode, offset as u64, buf)+    }++    fn truncate(&self) -> Result<(), Errno> {+        if self.kind == InodeKind::Directory {+            return Err(Errno::EISDIR);+        }+        self.vol.lock().truncate(self.ino)+    }++    fn lookup(&self, name: &str) -> Result<Arc<dyn Inode>, Errno> {+        self.dir_only()?;+        let mut vol = self.vol.lock();+        let dir = vol.read_inode(self.ino)?;+        let ino = vol.find_entry(&dir, name)?.ok_or(Errno::ENOENT)?;+        let kind = vol.read_inode(ino)?.kind();+        Ok(self.child(ino, kind))+    }++    fn create(&self, name: &str, kind: InodeKind) -> Result<Arc<dyn Inode>, Errno> {+        self.dir_only()?;+        let ino = self.vol.lock().create(self.ino, name, kind)?;+        Ok(self.child(ino, kind))+    }++    fn list(&self) -> Result<Vec<DirEntry>, Errno> {+        self.dir_only()?;+        let mut vol = self.vol.lock();+        let dir = vol.read_inode(self.ino)?;+        let mut out = Vec::new();+        for e in vol.read_dir(&dir)? {+            if e.name == "." || e.name == ".." {+                continue;+            }+            let kind = match e.file_type {+                1 => InodeKind::File,+                2 => InodeKind::Directory,+                7 => InodeKind::Symlink,+                _ => vol.read_inode(e.ino)?.kind(), // revision 0: no type in the entry+            };+            out.push(DirEntry { name: e.name, kind });+        }+        Ok(out)+    }++    fn remove(&self, name: &str) -> Result<(), Errno> {+        self.dir_only()?;+        self.vol.lock().remove(self.ino, name)+    }+}
@@ -252,7 +252,7 @@ impl Inode for FatInode {     fn size(&self) -> usize {         match self.kind {             InodeKind::File => self.with_file(|f| f.seek(SeekFrom::End(0)).map_err(errno)).unwrap_or(0) as usize,-            InodeKind::Directory => self.list().map(|l| l.len()).unwrap_or(0),+            _ => self.list().map(|l| l.len()).unwrap_or(0),         }     } @@ -322,10 +322,13 @@ impl Inode for FatInode {         if self.lookup(name).is_ok() {             return Err(Errno::EEXIST);         }+        if kind == InodeKind::Symlink {+            return Err(Errno::EOPNOTSUPP); // FAT has no symlinks+        }         self.with_dir(|dir| {             match kind {-                InodeKind::File => dir.create_file(name).map(|_| ()),                 InodeKind::Directory => dir.create_dir(name).map(|_| ()),+                _ => dir.create_file(name).map(|_| ()),             }             .map_err(errno)         })?;
@@ -19,20 +19,23 @@ use spin::Mutex; pub struct Stat {     pub kind: u64, // one of the KIND_* constants     pub size: u64, // bytes (files), entries (directories)+    pub mode: u64, // permission bits like 0o644 (0 = the file system has none) }  pub const KIND_FILE: u64 = 1; pub const KIND_DIR: u64 = 2; pub const KIND_CONSOLE: u64 = 3; pub const KIND_PIPE: u64 = 4;+pub const KIND_SYMLINK: u64 = 5;  impl Stat {     pub fn of(inode: &dyn Inode) -> Stat {         let kind = match inode.kind() {             InodeKind::File => KIND_FILE,             InodeKind::Directory => KIND_DIR,+            InodeKind::Symlink => KIND_SYMLINK,         };-        Stat { kind, size: inode.size() as u64 }+        Stat { kind, size: inode.size() as u64, mode: inode.mode() as u64 }     } } @@ -139,6 +142,7 @@ impl File for InodeFile {             rec[18] = match entry.kind {                 InodeKind::Directory => 4, // DT_DIR                 InodeKind::File => 8,      // DT_REG+                InodeKind::Symlink => 10,  // DT_LNK             };             rec[19..19 + entry.name.len()].copy_from_slice(entry.name.as_bytes());             used += reclen;
@@ -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 and FAT32 implement it today; ext2, exFAT-//!   and btrfs will later.+//!   list and remove children. ramfs, FAT32 and ext2 implement it today;+//!   exFAT and btrfs will later. //! * [`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 ("/",@@ -18,9 +18,17 @@ //! Paths: every path is first turned into a clean absolute path by //! [`normalize`] (relative paths are joined to the process's current //! directory; ".", ".." and "//" are resolved by plain string processing).-//! That makes ".." work across mount points for free -- real kernels track-//! parent dentries instead, which also handles symlinks (we have none).+//! That makes ".." work across mount points for free. Real kernels track+//! parent dentries instead.+//!+//! Symlinks (ext2 has them): [`lookup`] follows them. When a path component+//! is a symlink, its target is joined to the directory the link is in, the+//! rest of the path is appended, and the lookup starts over (at most 8+//! times, then ELOOP). Since ".." is handled as text, `link/..` is the+//! 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 ext2; pub mod fat; pub mod file; pub mod pipe;@@ -34,6 +42,8 @@ use spin::Mutex; pub enum InodeKind {     File,     Directory,+    /// A symbolic link: a small file holding a path ([`Inode::readlink`]).+    Symlink, }  /// One entry of a directory listing.@@ -52,6 +62,15 @@ pub trait Inode: Send + Sync {     fn kind(&self) -> InodeKind;     /// Size in bytes (files) or number of entries (directories).     fn size(&self) -> usize;+    /// Unix permission bits like 0o644, or 0 if the file system has none+    /// (FAT, ramfs).+    fn mode(&self) -> u32 {+        0+    }+    /// The target path of a symlink.+    fn readlink(&self) -> Result<String, Errno> {+        Err(Errno::EINVAL)+    }     /// Read bytes starting at `offset`; returns how many were read (0 = EOF).     fn read_at(&self, _offset: usize, _buf: &mut [u8]) -> Result<usize, Errno> {         Err(Errno::EISDIR)@@ -152,14 +171,32 @@ pub fn normalize(cwd: &str, path: &str) -> Result<String, Errno> {     Ok(result) } -/// Resolve an absolute path to an inode.+/// Resolve an absolute path to an inode, following symlinks. pub fn lookup(path: &str) -> Result<Arc<dyn Inode>, Errno> {+    walk(path, true, 0)+}++/// Like [`lookup`], but if the *last* component is a symlink, return the+/// link itself (for `readlink`, and for deleting a link, not its target).+pub fn lookup_nofollow(path: &str) -> Result<Arc<dyn Inode>, Errno> {+    walk(path, false, 0)+}++/// The target of the symlink at absolute `path`.+pub fn readlink(path: &str) -> Result<String, Errno> {+    lookup_nofollow(path)?.readlink()+}++/// How many symlinks one lookup may follow (Linux allows 40).+const MAX_SYMLINKS: usize = 8;++fn walk(path: &str, follow_last: bool, links_followed: usize) -> Result<Arc<dyn Inode>, Errno> {     if !path.starts_with('/') {         return Err(Errno::EINVAL); // callers resolve relative paths first     }     let path = normalize("/", path)?;     // Longest mount point that contains the path wins.-    let (root, rest) = {+    let (root, mount_path, rest) = {         let mounts = MOUNTS.lock();         let m = mounts             .iter()@@ -167,12 +204,29 @@ pub fn lookup(path: &str) -> Result<Arc<dyn Inode>, Errno> {             .max_by_key(|m| m.path.len())             .ok_or(Errno::ENOENT)?;         let rest = String::from(if m.path == "/" { &path[..] } else { &path[m.path.len()..] });-        (m.fs.root(), rest)+        (m.fs.root(), m.path.clone(), rest)     };     // Walk the remaining components ("a/b/c") from that file system's root.+    let names: Vec<&str> = rest.split('/').filter(|c| !c.is_empty()).collect();     let mut inode = root;-    for name in rest.split('/').filter(|c| !c.is_empty()) {-        inode = inode.lookup(name)?;+    let mut dir = mount_path; // absolute path of `inode` so far+    for (i, name) in names.iter().enumerate() {+        let child = inode.lookup(name)?;+        let last = i + 1 == names.len();+        if child.kind() == InodeKind::Symlink && (follow_last || !last) {+            if links_followed == MAX_SYMLINKS {+                return Err(Errno::ELOOP);+            }+            // Replace "dir/link/rest..." by "dir/<target>/rest..." and start over.+            let mut new_path = normalize(&dir, &child.readlink()?)?;+            for rest in &names[i + 1..] {+                new_path.push('/');+                new_path.push_str(rest);+            }+            return walk(&new_path, follow_last, links_followed + 1);+        }+        dir = if dir == "/" { format!("/{}", name) } else { format!("{}/{}", dir, name) };+        inode = child;     }     Ok(inode) }@@ -200,10 +254,11 @@ pub fn remove(path: &str, want_dir: bool) -> Result<(), Errno> {     if MOUNTS.lock().iter().any(|m| m.path == normalized) {         return Err(Errno::EBUSY); // can't delete a mount point     }-    let inode = lookup(&normalized)?;+    // Don't follow a symlink here: `rm link` deletes the link.+    let inode = lookup_nofollow(&normalized)?;     match (inode.kind(), want_dir) {         (InodeKind::Directory, false) => return Err(Errno::EISDIR),-        (InodeKind::File, true) => return Err(Errno::ENOTDIR),+        (InodeKind::File | InodeKind::Symlink, true) => return Err(Errno::ENOTDIR),         _ => {}     }     let (parent, name) = parent_of(&normalized)?;@@ -244,9 +299,14 @@ pub fn init() {     mount("/tmp", ramfs::RamFs::new()).unwrap(); } -/// Try to mount every registered partition (and unpartitioned disk) that-/// holds a FAT file system at /mnt/disk0, /mnt/disk1, ... Returns log lines.-/// (Later: also probe for ext2, exFAT, btrfs superblocks here.)+/// Try to mount every registered partition (and unpartitioned disk) at+/// /mnt/disk0, /mnt/disk1, ... Returns log lines.+///+/// Which driver? Like Linux's `mount` without `-t`, we look for each file+/// system's *magic number*: ext2 has 0xEF53 at byte 56 of its superblock+/// (which starts at byte 1024); anything else is tried as FAT (fatfs checks+/// the boot sector itself). Later: exFAT ("EXFAT   " at byte 3), btrfs+/// ("_BHRfS_M" at 64 KiB + 64). pub fn mount_disks() -> Vec<String> {     let mut log = Vec::new();     let devices = crate::block::devices();@@ -257,19 +317,36 @@ pub fn mount_disks() -> Vec<String> {         if has_partitions {             continue;         }-        match fat::FatFs::mount(dev.clone()) {-            Ok(fs) => {-                let path = format!("/mnt/disk{}", n);-                let _ = create(&path, InodeKind::Directory); // mount point (may exist)-                let label = fs.label();-                match mount(&path, fs) {-                    Ok(()) => log.push(format!("{}: FAT32 volume \"{}\" mounted at {}", name, label, path)),-                    Err(e) => log.push(format!("{}: mount at {} failed: {:?}", name, path, e)),+        let path = format!("/mnt/disk{}", n);+        let (fs, description): (Arc<dyn FileSystem>, String) = if ext2::probe(&**dev) {+            match ext2::Ext2Fs::mount(dev.clone()) {+                Ok(fs) => {+                    let d = fs.describe();+                    (fs, d)+                }+                Err(e) => {+                    log.push(format!("{}: ext2, but can't mount it: {:?}", name, e));+                    continue;+                }+            }+        } else {+            match fat::FatFs::mount(dev.clone()) {+                Ok(fs) => {+                    let d = format!("FAT32 volume \"{}\"", fs.label());+                    (fs, d)+                }+                Err(e) => {+                    log.push(format!("{}: no file system we know ({:?})", name, e));+                    continue;                 }-                n += 1;             }-            Err(e) => log.push(format!("{}: not a FAT file system ({:?})", name, e)),+        };+        let _ = create(&path, InodeKind::Directory); // mount point (may exist)+        match mount(&path, fs) {+            Ok(()) => log.push(format!("{}: {} mounted at {}", name, description, path)),+            Err(e) => log.push(format!("{}: mount at {} failed: {:?}", name, path, e)),         }+        n += 1;     }     log }
@@ -63,7 +63,7 @@ impl File for PipeReader {     }     fn stat(&self) -> Result<Stat, Errno> {         let size = without_interrupts(|| self.0.state.lock().buffer.len());-        Ok(Stat { kind: KIND_PIPE, size: size as u64 })+        Ok(Stat { kind: KIND_PIPE, size: size as u64, mode: 0 })     } } @@ -84,7 +84,7 @@ impl File for PipeWriter {         Ok(buf.len())     }     fn stat(&self) -> Result<Stat, Errno> {-        Ok(Stat { kind: KIND_PIPE, size: 0 })+        Ok(Stat { kind: KIND_PIPE, size: 0, mode: 0 })     } } 
@@ -86,6 +86,7 @@ impl Inode for RamInode {         let node = Arc::new(match kind {             InodeKind::File => RamInode::File(Mutex::new(Vec::new())),             InodeKind::Directory => RamInode::Dir(Mutex::new(BTreeMap::new())),+            InodeKind::Symlink => return Err(Errno::EOPNOTSUPP), // ramfs has no symlinks         });         entries.insert(String::from(name), node.clone());         Ok(node)
@@ -15,7 +15,7 @@ //! |   1 | write       | fd, buf, len                        | | //! |   2 | open        | path, path_len, flags               | O_CREAT 0o100, O_TRUNC 0o1000; directories too (for getdents) | //! |   3 | close       | fd                                  | |-//! |   4 | stat        | path, path_len, *Stat               | our own small `Stat {kind, size}` |+//! |   4 | stat        | path, path_len, *Stat               | our own small `Stat {kind, size, mode}`; follows symlinks | //! |   5 | fstat       | fd, *Stat                           | | //! |   8 | lseek       | fd, offset, whence                  | SEEK_SET/CUR/END = 0/1/2 | //! |  16 | ioctl       | fd, request, arg                    | only TTY_SET_RAW (ours): console raw/cooked mode |@@ -30,7 +30,8 @@ //! |  80 | chdir       | path, path_len                      | | //! |  83 | mkdir       | path, path_len                      | | //! |  84 | rmdir       | path, path_len                      | only empty directories |-//! |  87 | unlink      | path, path_len                      | files only |+//! |  87 | unlink      | path, path_len                      | files and symlinks (not what a symlink points to) |+//! |  89 | readlink    | path, path_len, buf, len            | a symlink's target; returns its length (cut to `len`, no NUL) | //! | 217 | getdents64  | fd, buf, len                        | Linux `struct linux_dirent64` records | //! | 500 | spawn       | path, path_len, argv, argv_len, *SpawnOptions | ours: start a program, returns its pid (see process.rs for why not fork/exec) | //! | 501 | mounts      | buf, len                            | ours: "path fstype\n" per mount (Linux: read /proc/mounts) |@@ -85,6 +86,7 @@ pub const SYS_CHDIR: u64 = 80; pub const SYS_MKDIR: u64 = 83; pub const SYS_RMDIR: u64 = 84; pub const SYS_UNLINK: u64 = 87;+pub const SYS_READLINK: u64 = 89; pub const SYS_GETDENTS64: u64 = 217; pub const SYS_SPAWN: u64 = 500; pub const SYS_MOUNTS: u64 = 501;@@ -119,6 +121,7 @@ pub fn name(nr: u64) -> &'static str {         SYS_MKDIR => "mkdir",         SYS_RMDIR => "rmdir",         SYS_UNLINK => "unlink",+        SYS_READLINK => "readlink",         SYS_GETDENTS64 => "getdents64",         SYS_SPAWN => "spawn",         SYS_MOUNTS => "mounts",@@ -255,6 +258,7 @@ extern "C" fn syscall_dispatch(nr: u64, a1: u64, a2: u64, a3: u64, a4: u64, a5:          SYS_MKDIR => user_path(a1, a2).and_then(|p| fs::create(&p, InodeKind::Directory)).map(|_| 0),         SYS_RMDIR => user_path(a1, a2).and_then(|p| fs::remove(&p, true)).map(|_| 0),         SYS_UNLINK => user_path(a1, a2).and_then(|p| fs::remove(&p, false)).map(|_| 0),+        SYS_READLINK => sys_readlink(a1, a2, a3, a4),         SYS_GETDENTS64 => sys_getdents64(a1, a2, a3),         SYS_SPAWN => sys_spawn(a1, a2, a3, a4, a5),         SYS_MOUNTS => sys_mounts(a1, a2),@@ -298,6 +302,7 @@ fn trace_line(nr: u64, a: [u64; 4], ret: i64) -> String {         SYS_WRITE => format!("{}, {}, {}", a[0], text(a[1], a[2]), a[2]),         SYS_OPEN => format!("{}, {:#o}", text(a[0], a[1]), a[2]),         SYS_STAT => format!("{}, {:#x}", text(a[0], a[1]), a[2]),+        SYS_READLINK => format!("{}, {:#x}, {}", text(a[0], a[1]), a[2], a[3]),         SYS_CHDIR | SYS_MKDIR | SYS_RMDIR | SYS_UNLINK => text(a[0], a[1]),         SYS_SPAWN => format!("{}, {}", text(a[0], a[1]), text(a[2], a[3])),         SYS_SCHED_YIELD | SYS_GETPID => String::new(),@@ -428,6 +433,13 @@ fn sys_chdir(path_ptr: u64, path_len: u64) -> Result<u64, Errno> {     Ok(0) } +fn sys_readlink(path_ptr: u64, path_len: u64, buf: u64, len: u64) -> Result<u64, Errno> {+    let target = fs::readlink(&user_path(path_ptr, path_len)?)?;+    let n = target.len().min(len as usize);+    user_slice_mut(buf, n as u64)?.copy_from_slice(&target.as_bytes()[..n]);+    Ok(n as u64)+}+ fn sys_getdents64(fd: u64, buf: u64, len: u64) -> Result<u64, Errno> {     let file = current()?.file(fd)?;     file.getdents(user_slice_mut(buf, len)?).map(|n| n as u64)@@ -481,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 7: shell",               // version+        "stage 8: ext2",                // version         "x86_64",                       // machine         "(none)",                       // domainname     ];
@@ -181,6 +181,6 @@ impl File for Console {     }      fn stat(&self) -> Result<Stat, Errno> {-        Ok(Stat { kind: KIND_CONSOLE, size: 0 })+        Ok(Stat { kind: KIND_CONSOLE, size: 0, mode: 0 })     } }
@@ -0,0 +1,255 @@+//! ext2 through the VFS. Cargo.toml's test-args attach three ext2 images+//! that build.rs makes (all snapshot=on, so writes vanish after the test):+//!+//!   vdb  target/ext2.img       /mnt/disk1  1 KiB blocks, revision 1+//!   vdc  target/ext2-4k.img    /mnt/disk2  4 KiB blocks+//!   vdd  target/ext2-rev0.img  /mnt/disk3  revision 0 (no file types in+//!                                          directory entries)+//!+//! scripts/ext2-check.sh runs this kernel on *writable* copies and then+//! checks them with the host's e2fsck.+#![no_std]+#![no_main]+#![feature(custom_test_frameworks)]+#![test_runner(rustbelt::test_runner)]+#![reexport_test_harness_main = "test_main"]++extern crate alloc;++use alloc::{format, string::String, vec, vec::Vec};+use bootloader::{BootInfo, entry_point};+use core::panic::PanicInfo;+use rustbelt::block;+use rustbelt::errno::Errno;+use rustbelt::fs::{self, InodeKind};++entry_point!(main);++fn main(boot_info: &'static BootInfo) -> ! {+    rustbelt::init();+    rustbelt::init_memory(boot_info);+    fs::init();+    rustbelt::init_storage();+    test_main();+    rustbelt::hlt_loop();+}++const DISK: &str = "/mnt/disk1";++/// Read `len` bytes at `offset` of a file.+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+}++/// (free blocks, free inodes), straight from the superblock on the disk+/// (byte 1024 = sector 2). The driver has no cache, so this is current.+fn free_counts(device: &str) -> (u32, u32) {+    let mut sb = [0u8; 1024];+    block::get(device).unwrap().read_sectors(2, &mut sb).unwrap();+    let get = |at: usize| u32::from_le_bytes(sb[at..at + 4].try_into().unwrap());+    (get(12), get(16))+}++fn names(path: &str) -> Vec<String> {+    let mut names: Vec<String> = fs::lookup(path).unwrap().list().unwrap().into_iter().map(|e| e.name).collect();+    names.sort();+    names+}++// --- reading -----------------------------------------------------------------++#[test_case]+fn ext2_volumes_are_mounted() {+    let mounts = fs::mounts();+    for path in ["/mnt/disk1", "/mnt/disk2", "/mnt/disk3"] {+        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+    // added files by now: check what build.rs put there.)+    let found = names(DISK);+    for name in ["big.txt", "docs", "hello.txt", "lost+found", "readme.txt", "sparse.bin"] {+        assert!(found.iter().any(|n| n == name), "{} missing", name);+    }+}++#[test_case]+fn read_small_file_and_permissions() {+    let hello = fs::lookup("/mnt/disk1/hello.txt").unwrap();+    assert_eq!(hello.kind(), InodeKind::File);+    assert_eq!(hello.size(), 142);+    assert_eq!(hello.mode(), 0o644);+    assert!(read("/mnt/disk1/hello.txt", 0, 200).starts_with(b"Hello from the ext2 disk!"));+    assert_eq!(fs::lookup(DISK).unwrap().mode(), 0o755);+    assert_eq!(fs::lookup("/mnt/disk1/lost+found").unwrap().mode(), 0o700);+    // ext2 names are case-sensitive (unlike FAT).+    assert_eq!(fs::lookup("/mnt/disk1/HELLO.TXT").err(), Some(Errno::ENOENT));+    assert_eq!(fs::lookup("/mnt/disk1/hello.txt/x").err(), Some(Errno::ENOTDIR));+    assert_eq!(read("/mnt/disk1/docs/deep/er/nested.txt", 0, 100), b"You found the deeply nested file.\n");+}++#[test_case]+fn big_file_through_indirect_blocks() {+    // big.txt: 16 000 lines of 19 bytes. With 1 KiB blocks the first 12 KiB+    // are direct blocks, up to 268 KiB single indirect, the rest double.+    let path = "/mnt/disk1/big.txt";+    assert_eq!(fs::lookup(path).unwrap().size(), 304_000);+    for line in [0usize, 100, 700, 5_000, 14_500, 15_999] {+        let expected = format!("big.txt line {:05}\n", line);+        assert_eq!(read(path, line * 19, 19), expected.as_bytes(), "line {}", line);+    }+    // One read spanning many blocks, across the single -> double boundary.+    let whole = fs::read_all(path).unwrap();+    assert_eq!(whole.len(), 304_000);+    assert!(whole.chunks(19).enumerate().all(|(i, l)| l == format!("big.txt line {:05}\n", i).as_bytes()));+}++#[test_case]+fn sparse_file_and_triple_indirect() {+    // sparse.bin is 66 MiB but only three 1 KiB chunks hold data: at 0+    // (direct), 40 MiB (double indirect) and 65 MiB (triple indirect).+    let path = "/mnt/disk1/sparse.bin";+    assert_eq!(fs::lookup(path).unwrap().size(), 66 << 20);+    for at in [0usize, 40 << 20, 65 << 20] {+        let expected = format!("sparse.bin data at offset {}\n", at);+        assert_eq!(read(path, at, expected.len()), expected.as_bytes());+    }+    // Holes read as zeros.+    assert!(read(path, 10 << 20, 4096).iter().all(|&b| b == 0));+    assert!(read(path, (66 << 20) - 100, 4096).iter().all(|&b| b == 0)); // last block, cut at EOF+    assert_eq!(read(path, (66 << 20) - 100, 4096).len(), 100);+}++#[test_case]+fn fast_and_slow_symlinks() {+    // Fast: the 9-byte target sits inside the inode's block pointers.+    assert_eq!(fs::readlink("/mnt/disk1/hello-link").unwrap(), "hello.txt");+    assert_eq!(fs::lookup_nofollow("/mnt/disk1/hello-link").unwrap().kind(), InodeKind::Symlink);+    let followed = fs::lookup("/mnt/disk1/hello-link").unwrap();+    assert_eq!((followed.kind(), followed.size()), (InodeKind::File, 142));+    // Slow: a 74-byte target lives in a data block.+    let target = fs::readlink("/mnt/disk1/docs/long-link").unwrap();+    assert_eq!(target.len(), 74);+    assert!(target.ends_with("/deep/er/nested.txt"));+    assert_eq!(read("/mnt/disk1/docs/long-link", 0, 100), b"You found the deeply nested file.\n");+    assert_eq!(fs::readlink("/mnt/disk1/hello.txt").err(), Some(Errno::EINVAL));+    // Removing a symlink removes the link, not the file.+    fs::remove("/mnt/disk1/hello-link", false).unwrap();+    assert_eq!(fs::lookup_nofollow("/mnt/disk1/hello-link").err(), Some(Errno::ENOENT));+    assert_eq!(fs::lookup("/mnt/disk1/hello.txt").unwrap().size(), 142);+}++#[test_case]+fn four_k_blocks_and_revision_0() {+    for disk in ["/mnt/disk2", "/mnt/disk3"] {+        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");+        assert_eq!(read(&format!("{}/hello-link", disk), 0, 9), b"Hello fro");+        // Revision 0 has no file type in directory entries: the driver+        // reads each inode to tell directories from files.+        let kinds: Vec<InodeKind> = fs::lookup(disk).unwrap().list().unwrap().into_iter().map(|e| e.kind).collect();+        assert!(kinds.contains(&InodeKind::Symlink) && kinds.contains(&InodeKind::Directory));+    }+}++// --- writing -----------------------------------------------------------------++/// Create, fill, check and delete a file on `disk`, inside a new directory.+/// Afterwards every free count is back where it started.+fn write_round_trip(disk: &str, device: &str) {+    let before = free_counts(device);+    let dir = format!("{}/scratch", disk);+    fs::create(&dir, InodeKind::Directory).unwrap();+    assert_eq!(fs::lookup(&dir).unwrap().mode(), 0o755);+    let path = format!("{}/data.bin", dir);+    let inode = fs::create(&path, InodeKind::File).unwrap();+    assert_eq!(fs::create(&path, InodeKind::File).err(), Some(Errno::EEXIST));++    // 300 000 bytes: with 1 KiB blocks that needs a double-indirect block.+    let data: Vec<u8> = (0..300_000u32).map(|i| (i * 31 % 251) as u8).collect();+    assert_eq!(inode.write_at(0, &data), Ok(data.len()));+    assert_eq!(fs::read_all(&path).unwrap(), data);+    // Append, overwrite across a block boundary.+    assert_eq!(inode.write_at(300_000, b"tail"), Ok(4));+    inode.write_at(1022, b"XYZW").unwrap();+    assert_eq!(read(&path, 1020, 8), [data[1020], data[1021], b'X', b'Y', b'Z', b'W', data[1026], data[1027]]);+    assert_eq!(read(&path, 299_998, 10), [data[299_998], data[299_999], b't', b'a', b'i', b'l']);+    // A write far past the end leaves a hole (zeros) and lands in the+    // triple-indirect range on 1 KiB disks.+    inode.write_at(70 << 20, b"far away").unwrap();+    assert_eq!(inode.size(), (70 << 20) + 8);+    assert!(read(&path, 50 << 20, 1024).iter().all(|&b| b == 0));+    assert_eq!(read(&path, 70 << 20, 100), b"far away");+    let used = free_counts(device);+    assert!(used.0 < before.0 && used.1 == before.1 - 2);++    // Truncate frees every data block (and the indirect blocks).+    inode.truncate().unwrap();+    assert_eq!(inode.size(), 0);+    assert_eq!(read(&path, 0, 10), b"");+    inode.write_at(0, b"short again\n").unwrap();++    // A second file and a subdirectory, then rmdir/unlink rules.+    fs::create(&format!("{}/sub", dir), InodeKind::Directory).unwrap();+    assert_eq!(fs::remove(&dir, true), Err(Errno::ENOTEMPTY));+    assert_eq!(fs::remove(&format!("{}/sub", dir), false), Err(Errno::EISDIR));+    assert_eq!(fs::remove(&path, true), Err(Errno::ENOTDIR));+    assert_eq!(names(&dir), vec!["data.bin", "sub"]);+    fs::remove(&format!("{}/sub", dir), true).unwrap();+    fs::remove(&path, false).unwrap();+    fs::remove(&dir, true).unwrap();+    assert_eq!(fs::lookup(&dir).err(), Some(Errno::ENOENT));+    assert_eq!(free_counts(device), before);+}++#[test_case]+fn write_1k_blocks() {+    write_round_trip("/mnt/disk1", "vdb");+}++#[test_case]+fn write_4k_blocks() {+    write_round_trip("/mnt/disk2", "vdc");+}++#[test_case]+fn write_revision_0() {+    write_round_trip("/mnt/disk3", "vdd");+}++#[test_case]+fn many_files_grow_a_directory() {+    // 200 entries do not fit in one 1 KiB directory block: the directory+    // grows, and every name is still found afterwards.+    let before = free_counts("vdb");+    fs::create("/mnt/disk1/many", InodeKind::Directory).unwrap();+    for i in 0..200 {+        let f = fs::create(&format!("/mnt/disk1/many/file-number-{:03}.txt", i), InodeKind::File).unwrap();+        f.write_at(0, format!("{}\n", i).as_bytes()).unwrap();+    }+    assert!(fs::lookup("/mnt/disk1/many").unwrap().size() == 200);+    assert_eq!(read("/mnt/disk1/many/file-number-123.txt", 0, 10), b"123\n");+    for i in 0..200 {+        fs::remove(&format!("/mnt/disk1/many/file-number-{:03}.txt", i), false).unwrap();+    }+    fs::remove("/mnt/disk1/many", true).unwrap();+    assert_eq!(free_counts("vdb"), before);+}++#[test_case]+fn copy_from_fat_to_ext2() {+    // What `cp B:/notes/todo.txt C:/` does.+    let todo = fs::read_all("/mnt/disk0/notes/todo.txt").unwrap();+    let copy = fs::create("/mnt/disk1/todo.txt", InodeKind::File).unwrap();+    copy.write_at(0, &todo).unwrap();+    assert_eq!(fs::read_all("/mnt/disk1/todo.txt").unwrap(), todo);+    assert_eq!(copy.mode(), 0o644);+}++#[panic_handler]+fn panic(info: &PanicInfo) -> ! {+    rustbelt::test_panic_handler(info)+}
@@ -175,7 +175,7 @@ fn scripted_shell_session() {         "piped text\n",         "wc -l\n2\n",         "saved\nmore\n",-        "Rustbelt 0.7.0",+        "Rustbelt 0.8.0",         "B:/ $ exit 3\n",     ] {         assert!(out.contains(expected), "missing {:?} in:\n{}", expected, out);
@@ -111,7 +111,7 @@ fn seek_stat_and_truncate() {     assert_eq!(file.seek(2, SEEK_SET), Ok(2));     assert_eq!(file.seek(1, SEEK_CUR), Ok(3));     assert_eq!(file.seek(-4, SEEK_CUR), Err(Errno::EINVAL)); // before the start-    assert_eq!(file.stat(), Ok(Stat { kind: KIND_FILE, size: 10 }));+    assert_eq!(file.stat(), Ok(Stat { kind: KIND_FILE, size: 10, mode: 0 }));     inode.truncate().unwrap();     assert_eq!(inode.size(), 0); }
@@ -2,7 +2,9 @@ //! //! On the console the names are printed in columns; into a pipe or file //! one per line (like the real `ls`, so `ls | wc -l` counts files).-//! -l adds sizes, DOS style: `<DIR>` or the size in bytes.+//! -l adds sizes, DOS style: `<DIR>` or the size in bytes. On file systems+//! with Unix permissions (ext2) it also shows them, like `-rw-r--r--`, and+//! where symlinks point: `hello-link -> hello.txt`. #![no_std] #![no_main] @@ -18,7 +20,10 @@ use ulib::*; struct Entry {     name: String,     dir: bool,+    link: bool,     size: u64,+    mode: u64,           // 0 = no permissions on this file system+    target: String,      // symlinks (with -l): where they point }  fn main() -> i64 {@@ -46,7 +51,8 @@ fn main() -> i64 {             continue;         }         if st.kind != KIND_DIR {-            print_entries(&[Entry { name: String::from(*path), dir: false, size: st.size }], long, columns);+            let entry = Entry { name: String::from(*path), dir: false, link: false, size: st.size, mode: st.mode, target: String::new() };+            print_entries(&[entry], long, columns);             continue;         }         if paths.len() > 1 {@@ -79,7 +85,8 @@ fn read_dir(path: &str, long: bool) -> Result<Vec<Entry>, i64> {         }         for_each_dirent(&buf, n as usize, |name, d_type| {             if name != "." && name != ".." {-                entries.push(Entry { name: String::from(name), dir: d_type == 4, size: 0 });+                let (dir, link) = (d_type == 4, d_type == 10); // DT_DIR, DT_LNK+                entries.push(Entry { name: String::from(name), dir, link, size: 0, mode: 0, target: String::new() });             }         });     }@@ -87,9 +94,17 @@ fn read_dir(path: &str, long: bool) -> Result<Vec<Entry>, i64> {     entries.sort_by(|a, b| a.name.cmp(&b.name));     if long {         let mut st = Stat::default();-        for e in entries.iter_mut().filter(|e| !e.dir) {-            if stat(&format!("{}/{}", path, e.name), &mut st) == 0 {+        for e in entries.iter_mut() {+            let full = format!("{}/{}", path, e.name);+            if e.link {+                let mut buf = [0u8; 256];+                let n = readlink(&full, &mut buf);+                if n >= 0 {+                    e.target = String::from(core::str::from_utf8(&buf[..n as usize]).unwrap_or("?"));+                }+            } else if stat(&full, &mut st) == 0 {                 e.size = st.size;+                e.mode = st.mode;             }         }     }@@ -99,15 +114,23 @@ fn read_dir(path: &str, long: bool) -> Result<Vec<Entry>, i64> { fn print_entries(entries: &[Entry], long: bool, columns: bool) {     if long {         for e in entries {+            // `drwxr-xr-x ` in front if the file system has permissions.+            let perms = if e.mode != 0 || e.link { format!("{} ", mode_string(e)) } else { String::new() };             if e.dir {-                println!("     <DIR>  {}/", e.name);+                println!("{}     <DIR>  {}/", perms, e.name);+            } else if e.link {+                println!("{}            {} -> {}", perms, e.name, e.target);             } else {-                println!("{:>10}  {}", e.size, e.name);+                println!("{}{:>10}  {}", perms, e.size, e.name);             }         }         return;     }-    let shown = |e: &Entry| if e.dir { format!("{}/", e.name) } else { e.name.clone() };+    let shown = |e: &Entry| match (e.dir, e.link) {+        (true, _) => format!("{}/", e.name),+        (_, true) => format!("{}@", e.name), // like `ls -F`+        _ => e.name.clone(),+    };     if !columns {         entries.iter().for_each(|e| println!("{}", shown(e)));         return;@@ -124,3 +147,18 @@ fn print_entries(entries: &[Entry], long: bool, columns: bool) {         }     } }++/// `-rw-r--r--` style: type, then rwx for owner, group and others.+fn mode_string(e: &Entry) -> String {+    if e.link {+        return String::from("lrwxrwxrwx"); // symlink permissions are never used+    }+    let mut s = String::from(if e.dir { "d" } else { "-" });+    for shift in [6, 3, 0] {+        let bits = (e.mode >> shift) & 7;+        s.push(if bits & 4 != 0 { 'r' } else { '-' });+        s.push(if bits & 2 != 0 { 'w' } else { '-' });+        s.push(if bits & 1 != 0 { 'x' } else { '-' });+    }+    s+}
@@ -37,6 +37,7 @@ pub const SYS_CHDIR: u64 = 80; pub const SYS_MKDIR: u64 = 83; pub const SYS_RMDIR: u64 = 84; pub const SYS_UNLINK: u64 = 87;+pub const SYS_READLINK: u64 = 89; pub const SYS_GETDENTS64: u64 = 217; pub const SYS_SPAWN: u64 = 500; pub const SYS_MOUNTS: u64 = 501;@@ -69,11 +70,13 @@ pub const ENOTEMPTY: i64 = 39; pub struct Stat {     pub kind: u64, // KIND_*     pub size: u64,+    pub mode: u64, // permission bits like 0o644; 0 = the file system has none } pub const KIND_FILE: u64 = 1; pub const KIND_DIR: u64 = 2; pub const KIND_CONSOLE: u64 = 3; pub const KIND_PIPE: u64 = 4;+pub const KIND_SYMLINK: u64 = 5;  /// Same layout as the kernel's `syscall::SpawnOptions`. #[repr(C)]@@ -209,6 +212,10 @@ pub fn mkdir(path: &str) -> i64 { pub fn rmdir(path: &str) -> i64 {     syscall3(SYS_RMDIR, path.as_ptr() as u64, path.len() as u64, 0) }+/// The target of a symlink, into `buf`; returns its length or -errno.+pub fn readlink(path: &str, buf: &mut [u8]) -> i64 {+    syscall5(SYS_READLINK, path.as_ptr() as u64, path.len() as u64, buf.as_mut_ptr() as u64, buf.len() as u64, 0)+} pub fn unlink(path: &str) -> i64 {     syscall3(SYS_UNLINK, path.as_ptr() as u64, path.len() as u64, 0) }@@ -324,13 +331,17 @@ pub fn strerror(errno: i64) -> &'static str {         22 => "Invalid argument",         24 => "Too many open files",         25 => "Not a terminal",+        27 => "File too large",         28 => "No space left on device",         29 => "Illegal seek",+        30 => "Read-only file system",         32 => "Broken pipe",         34 => "Result too large",         36 => "File name too long",         38 => "Function not implemented",         39 => "Directory not empty",+        40 => "Too many levels of symbolic links",+        95 => "Operation not supported",         _ => "Unknown error",     } }