rust-kernel
Rustbelt: a hobby x86_64 kernel written in Rust
| author | Tyler Fowler <tyler@localhost> | 2026-10-30 01:52:32 -0700 |
|---|---|---|
| commit | 3b489faecb168e0e2b623eaa8eaff1ec5edd7a5a | |
| parent | d3dbcfaf7fae0c452e63f201fbc709209f8810ed | |
| tree | browse 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
| M | Cargo.lock | +1 -1 |
| M | Cargo.toml | +7 -1 |
| M | README.md | +334 -57 |
| M | boot.png | +0 -0 |
| M | build.rs | +102 -3 |
| M | disk/scripts/selftest.sh | +10 -0 |
| A | disk2/docs/deep/er/nested.txt | +1 -0 |
| A | disk2/docs/layout.txt | +4 -0 |
| A | disk2/hello.txt | +2 -0 |
| A | disk2/readme.txt | +4 -0 |
| A | scripts/ext2-check.sh | +67 -0 |
| M | scripts/run-headless.sh | +20 -1 |
| M | scripts/screenshot.sh | +10 -8 |
| M | src/errno.rs | +8 -4 |
| A | src/fs/ext2.rs | +1092 -0 |
| M | src/fs/fat.rs | +5 -2 |
| M | src/fs/file.rs | +5 -1 |
| M | src/fs/mod.rs | +101 -24 |
| M | src/fs/pipe.rs | +2 -2 |
| M | src/fs/ramfs.rs | +1 -0 |
| M | src/syscall.rs | +15 -3 |
| M | src/tty.rs | +1 -1 |
| A | tests/ext2.rs | +255 -0 |
| M | tests/shell.rs | +1 -1 |
| M | tests/vfs.rs | +1 -1 |
| M | user/ls.rs | +46 -8 |
| M | user/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. -+ ## 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", } }