All documentation
How it works
Filesystems
docs/filesystems.mdView on GitHubHow files work in nos: what gets mounted at boot and how the root is
chosen, the file API kernel code uses, what the ext2 driver does and does
not do, and how a root filesystem image is made and checked. The shell side
is in Shell commands.
What is mounted at boot
rust_mount_root_fs() (src/rust/fs/src/rootfs.rs) runs late in the boot, after the disks
that appear during rust_init (NVMe) have been probed for partitions, and
does what root= on the command line asks:
| Command line | What ends up on / |
|---|---|
| nothing | nothing; the VFS starts empty and filesystems are mounted from the shell |
root=auto |
the ext2 filesystem whose volume label is nos, read-write. Without one: a ramfs on /, the first ext2 found read-only on /boot, the first nanofs found read-write on /data (the layout the ISO had before there was a root on disk) |
root=vda1, root=nvme01 |
that block device, ext2 if it carries one, else nanofs |
root=LABEL=nos |
the ext2 filesystem with that label |
root=UUID=… |
the ext2 filesystem with that UUID, as blkid prints it |
ro mounts the root read-only. A procfs goes on /proc in every case (the
directory is created on the root if it is missing). If the named root is not
found the fallback layout is mounted and the boot continues, so a wrong
root= costs a shell without persistence rather than a box without a shell.
Both shipped configs say root=auto: the ISO (build/grub.cfg) so that any
disk labelled nos attached to QEMU becomes the root, and the disk image
(build/grub-disk.cfg), whose one partition carries that label. The label is
what makes the same command line work whatever bus the disk is on — virtio
on QEMU and the clouds, NVMe on the Hetzner boxes.
fstest=on runs the filesystem self-test (below) on / right after the
mount; the smoke tests boot with it and assert fstest: passed.
The file API
Rust reaches files through crate::vfs_instance() (src/rust/fs/src/vfs.rs),
and C++ through the kernel_vfs_* calls it exports -- there is no C++ view
of the layer, so a file that needs one declares the calls it makes where it
makes them (kernel/module.cpp reading a .ko, kernel/cmd.cpp for
crc32, sha256, wget and /etc/rc). Paths are absolute; the longest
mount prefix wins; . and .. resolve in the tree, and .. stops at a
mount root.
let file = Open::new(vfs, b"/lib/modules/foo.ko", OPEN_READ)?; // closed when it goes
let got = file.read(&mut buf)?; // 0 at end of file
An open file is a Handle: a slot in the VFS's table of open files and the
generation of what is in it. That is what crosses the C ABI as well -- the
RustFile* the C++ shell holds is that word and not an address -- so a
handle from anywhere is looked up rather than followed, and one that was
closed, or never opened, or whose filesystem has been unmounted at shutdown,
reads as no file instead of as whatever memory is there now.
open(path, flags)withOPEN_READ,OPEN_WRITE,OPEN_CREATE,OPEN_TRUNCATE,OPEN_APPEND(which puts every write at the end and impliesOPEN_WRITE);close,read,write,seek,tell,size. A write past the end extends the file; the gap reads as zeros.stat(type, size, inode),read_dir(path, index)to walk a directory by index,rename(a move within one filesystem; a directory cannot move under itself),truncate(either way),sync,remove(a directory goes with everything under it),create(path, directory).files.rsfor the whole-file cases:list_dirandread_filefor what the shell prints,replace_file/locatefor a configuration file that a full disk or a crash midway must not leave empty, and thekernel_file_*ABI over them that a module -- and the shell's/etc/rc-- reads and writes by.
An open file pins its vnode: remove and rename refuse a file (or a
directory containing one) that is open, and unmount refuses a filesystem
with open files. unmount_all at shutdown does not refuse — it is shutdown —
and the handles it abandons stop naming anything.
A mounted filesystem claims its block device (kernel_blockdev_claim_as) until
it is unmounted, and so do the disk log and a module writing to a device
direct (blkload's write tests) -- and the shell's format and diskwrite,
for as long as they write. A claim is refused while another overlaps
it — a stretch of the same disk: the same device, the disk a partition is on,
a partition of that disk, or another partition of it that the disk's table
has overlap (the table is whoever wrote the disk's to choose) — so a mount fails (vfs: the device is in use by a mounted filesystem
in dmesg) on the disk under a mounted partition, on the disk log's area, or
on a device a write test is running on; and those, in turn, keep off a
mounted one. Reads need no claim.
One mutex serialises every VFS call, so a multi-megabyte read holds up an
ls from the UDP shell for its duration. Everything it guards is inside it:
the mounts, each owning its filesystem, and the open files.
What a filesystem is
A filesystem is a type that implements vfs::FileSystem, mounted as a
Box<dyn FileSystem> the mount owns; unmounting drops it. The trait is the
whole seam: mount/unmount, root, lookup, load_dir, create_file,
create_dir, read, write, truncate, rename, remove, sync, and
name/info/device for what mounts prints and the device it claims.
Every call arrives with the VFS lock held, so a filesystem sees one call at
a time and does no locking of its own.
What a filesystem holds is a vnode::Tree: an arena of nodes -- a name, a
kind, a size, an inode number, the children of a directory in order -- named
by NodeIds, a slot and the generation of what is in it. Parents, children
and open files all refer to a node by that number, never by an address, so
the tree is ordinary owned data: a filesystem hands the VFS an id and the
VFS asks the filesystem's tree what it is. An id of a node that has been
freed finds nothing. The slots are taken from the allocator in chunks and
fallibly: an image says how many nodes there will be, and an image is not
to be trusted with that.
There is no unsafe in the VFS, the tree or any of the four filesystems.
ext2's on-disk structures are read and written whole through kcore::pod,
which is where the two pointer casts that takes are; what is left in the
crate is the C ABI at its edge, where a path or a buffer arrives as a
pointer and a length.
ext2
src/rust/fs/src/ext2.rs reads and writes ext2 rev 1 with 1, 2 or 4 KiB blocks:
direct, indirect and doubly-indirect blocks (a file can reach 4 GiB at 4 KiB
blocks; the triple-indirect block is not implemented and a file needing it is
refused), sparse files, directories of any size. Directories are read from
disk the first time a path walk enters them, not at mount, so the memory
cost is what is used, not what is on the disk -- all of a directory or none
of it: one whose read fails part way keeps nothing it read, and is read
again whole at the next look.
What it refuses, and why:
- a geometry that does not hold together: more blocks than the device has, an inode count that is not its groups' inodes, a group whose bitmaps or inode table lie outside the filesystem. Every number the driver works out from the superblock is then one that cannot overflow or lead off the device.
- any
incompatfeature other thanfiletype— ext3 with journal recovery pending, ext4 extents,64bit,meta_bg: same magic, different format. - an image without
filetype(rev 0): directory entries do not say what they name. - any
ro_compatfeature other thansparse_superandlarge_fileis mounted read-only:gdt_csumabove all, whose group checksums a write would silently break. - symlinks and device nodes are left out of the tree; a file over 4 GiB is skipped with a message.
- a write or a truncate past what the blocks map -- 64 MiB at 1 KiB blocks, 512 MiB at 2 KiB, 4 GiB at 4 KiB -- is refused whole, before anything is allocated. A write that fails part way (the disk full, a block that would not read) gives back what it allocated past the end of the file, and the file keeps the size it had; what it wrote inside that size stays.
- a name with a NUL in it: the VFS refuses one for every filesystem.
Writing keeps the on-disk structures consistent for e2fsck and for Linux, which can mount the same partition (that is how modules get onto a Hetzner box: copy them in under Ubuntu). The rules the driver follows, in place of the journal ext2 does not have:
- Order. Allocation bitmaps go down first, then the data and indirect blocks (plain writes) followed by a device flush, then the inode, then the free counts in the group descriptors and superblock. A crash at any point leaves at worst blocks marked used that nothing references — e2fsck reclaims them — and never a block both referenced and free. A create is the same: the bits, then the inode, then the name; and one undone after an error writes its inode deleted before freeing the bits, or, when that write fails, leaves them taken.
- A file's data pays for what it changes. A write that allocated
nothing — a disk image having its blocks written over, a guest's — moved
no pointer and changed no bit, so nothing is ordered against it: the
blocks go down plainly, and so does the inode, and only when the size or
the second of the mtime changed;
syncputs them on the medium, and a guest's flush is async. A write that allocated flushes the device once — the bitmap and the data it wrote then on the medium — and writes what points at them after that, plainly: each indirect block it filled in once, however many pointers of it the write set (they wait in its buffer for the flush; one written as it was set would have reached the disk before the bitmap), the inode, the counts. So what a write leaves in the device's cache reaches the medium at the next flush -- another write's that allocates, async, the unmount -- and a caller that needs it there sooner syncs, as the two-step replace,grubenvand a guest's flush do. Creates, renames and removes keep the FUA writes of their inodes and bitmaps. - Frees run the other way. A truncate cuts the tail off the block tree,
commits the inode without it, and only then clears the bits, in batches of
512 blocks so a large file does not hold a huge list in memory. A remove
takes the name out of its directory first, so a crash leaves an orphan for
lost+found, never a name leading nowhere; a directory goes deepest first, by a walk and not a recursion, as deep as the paths reach. - Renames add the new name before dropping the old one, so a crash leaves the file reachable twice, which e2fsck reduces to once.
- The
validstate bit is cleared at a read-write mount and set again at a clean unmount, as Linux does. A boot after a crash printswas not cleanly unmountedand mounts anyway; rune2fsckunder Linux when convenient. The unmount happens onpoweroffandreboot, before the soft IRQs stop (a block request completes through one). - Directories are modified linearly and the htree
indexflag is dropped from any directory the driver touches; images fornosare made with-O ^dir_indexso Linux never builds an index in the first place. - Only the primary superblock and group descriptors are updated; the
backups drift, which e2fsck tolerates (
-buses them for recovery only).
There is no cache of data: a file's blocks go to and from the device as
the call asks for them, a batch at a time -- up to 64 blocks, a page of a
256 KiB buffer each (Disk::write_pieces, read_pieces), in flight
together on NVMe and virtio-blk, where they used to go one synchronous
request after another. A batch of a write stays under one pointer block --
the inode's, the indirect block's, one of the doubly-indirect block's --
because moving on to the next puts the last one down after a flush, and the
blocks it points at have to be on the disk by then, not waiting in the
batch. A partial block is read in first, and a block the batch allocated is
written even when the batch stops short, so it never holds what the disk
had there before. Metadata -- inodes, bitmaps, directories, indirect
blocks -- still goes a block at a time. The inode table block, the indirect
and the doubly-indirect block last used are kept in memory, so a file read
or written a piece at a time does not read its inode back for every piece,
and a sequential pass does not re-read its indirect blocks per data block.
nanofs, ramfs, procfs
nanofs (src/rust/fs/src/nanofs.rs) is the kernel's own small checksummed filesystem:
1024 inodes, 64 MiB of data, files up to 1 MiB, every write copy-on-write
with the inode committed last. It predates ext2 write support and remains
for what it is good at — a small, self-verifying store (format nanofs,
mount nanofs, scripts/mkfs_nanofs.py) — and takes the whole file API,
including writes at an offset and truncates, by rewriting the file. The
mount reads the whole tree, by a walk with a stack of its own, so a tree as
deep as the inodes allow reads back whole; a read checks the file's data
against its checksum the first time after a mount or a write, not on every
piece. Mounted read-only it writes nothing, not even the bitmap repair the
mount makes of a reachable inode or block marked free. format and mount
refuse a device smaller than the layout (69 MB) before touching it.
ramfs (src/rust/fs/src/ramfs.rs) is the in-memory filesystem the fallback
layout puts on /, and what TestVfs exercises at every boot: a file is
one buffer that doubles as it grows. procfs (procfs.rs) is a ramfs whose
/proc/interrupts is regenerated on each lookup -- the VFS looks a file up
before it reads it or reports its size, so refreshing there keeps the two
consistent; /proc/version and /proc/cmdline are written once at mount.
Making a root filesystem
scripts/mkrootfs.sh root.img 256 rootdir # 256 MiB, populated from rootdir/
scripts/mkrootfs.sh root.img 64 "" 1024 # empty, 1 KiB blocks (what the smoke tests use)
This is mke2fs -L nos -O ^dir_index -d rootdir: the label root=auto
looks for, no directory index, populated from a directory without loop
devices or root privileges (inside the nos-builder Docker image on macOS,
where the copy goes through a directory in the container because a macOS
bind mount serves no extended attributes). Attach it to QEMU as a raw disk
and the ISO boot mounts it:
qemu-system-x86_64 ... -cdrom nos.iso -drive file=root.img,format=raw,if=virtio
scripts/build-disk.sh makes nos.qcow2 the same way — one MBR partition,
ext2 labelled nos, /boot/kernel64.elf and /boot/grub inside it, GRUB's
boot code in the boot sector, ROOTFS=dir to add content (modules under
lib/modules) — and runs e2fsck over the result. Nothing in it needs
--privileged.
On a machine that dual-boots Ubuntu (the Hetzner boxes) the root is an ext2
partition made under Ubuntu, mkfs.ext2 -L nos -O ^dir_index /dev/nvme0n1pN,
filled by mounting it there, and named on the nos command line by label or
UUID. After a nos session, e2fsck -f under Ubuntu is the check that the
driver left it as it should.
Checking it
fstest [dir] [size] from the shell (size at least 8 KiB), or fstest=on
at boot, runs
The self-test (src/rust/fs/src/selftest.rs) runs in a directory it makes and removes: write
and read back, append, write at an offset, truncate both ways with the gap
checked for zeros, rename, move into a subdirectory, readdir, the
operations that must fail, a file of size bytes (300 KiB by default)
written in 64 KiB pieces and read back in 12345-byte ones, patched in the
middle, cut short, grown back, and a recursive remove. fstest / 5M at
4 KiB blocks reaches the doubly-indirect blocks; the smoke tests get there at
300 KiB by using 1 KiB blocks. crc32 <path> checks a file against its
copy on the host.
TestVfs in kernel/test.cpp runs the same test on a ramfs at every boot,
before any disk is up.