Building a tiny FUSE filesystem<br>Building a tiny FUSE filesystem<br>June 10, 2026<br>11 min read
Contents<br>Try it first<br>Filesystems as a request loop<br>Metadata and names<br>File contents as local blobs<br>Write is not sync<br>Caching and stale metadata<br>Shortcomings I kept
Lately I have been working around sandboxing, storage, and networking, and a lot of that work keeps coming back to files, which makes sense since Unix has organized itself around everything is a file for over fifty years. Your terminal and random number generator are device files you can open and read (/dev/tty, /dev/urandom), and even network sockets, which are created with their own system call rather than opened by path, are read and written through the same interface afterwards.<br>For this post, I built a small filesystem with a real backing store, enough metadata to behave like a filesystem, and a few deliberate omissions so the code is still readable.<br>magicfs mounts at /magic, but it keeps its own local backing store next to it, with names and inode numbers in metadata.json, while file contents live as plain local files under blobs/. Calling that directory a blob store is a little grandiose, because the blobs are just files with allocated names like blob-000000000001, but keeping metadata separate from file contents lets the example cover name lookup, inode stability, write ordering, kernel caching, and what fsync() is asking the filesystem to do.<br>The full sample code is at github.com/shayonj/magicfs, and if you have Docker, you can run the filesystem with FUSE enabled.<br>Try it first<br>docker run -it --rm --device /dev/fuse --cap-add SYS_ADMIN shayonj/magicfs<br>$ ls /magic<br>hello.txt notes.txt
$ cat /magic/hello.txt<br>Hello from a tiny FUSE filesystem.
$ echo "remember the milk" > /magic/notes.txt<br>$ cat /magic/notes.txt<br>remember the milk<br>Inside that shell, the mount point is the interface applications use, while the store directory is private state owned by the filesystem process, so the shell sees an ordinary directory even though the data behind it is a metadata file plus a couple of local blobs.<br>$ find /tmp/magicfs-store -type f<br>/tmp/magicfs-store/metadata.json<br>/tmp/magicfs-store/blobs/blob-000000000001<br>/tmp/magicfs-store/blobs/blob-000000000002<br>In the store directory, the metadata file stands in for a tiny inode table and a tiny directory tree, recording the name, inode number, size, mode bits, and blob IDs for each file.<br>"next_inode": 4,<br>"entries": {<br>"hello.txt": {<br>"ino": 2,<br>"mode": 420,<br>"size": 36,<br>"blobs": [<br>"blob": "blob-000000000001",<br>"offset": 0,<br>"len": 36<br>},<br>"notes.txt": {<br>"ino": 3,<br>"mode": 420,<br>"size": 18,<br>"blobs": [<br>"blob": "blob-000000000002",<br>"offset": 0,<br>"len": 18<br>The path notes.txt is not where the bytes live, it is the name that gets you to inode 3, and the metadata for inode 3 points at a blob file under blobs/, so renaming notes.txt changes the directory metadata, while rewriting it creates a new blob and updates the metadata pointer.<br>Filesystems as a request loop<br>When you run cat /magic/hello.txt, cat does not know that JSON metadata and blob files are involved, because all it does is call open() and read(), after which the kernel resolves the path through the VFS, and the operation eventually lands on the filesystem mounted at /magic.<br>With FUSE, the code that answers those filesystem requests runs in userspace, where the kernel driver sends request messages over /dev/fuse, the userspace process replies, and the application that made the system call keeps waiting until the kernel has an answer, while the kernel FUSE documentation covers the protocol, and the fuser crate exposes the same operations as Rust trait methods.<br>The path for a read looks roughly like this:<br>flowchart LR<br>A["cat /magic/hello.txt"] --> B["Linux VFS"]<br>B --> C["FUSE kernel driver"]<br>C --> D["magicfs userspace process"]<br>D --> E["metadata.json + local blobs"]<br>E --> D<br>D --> C<br>C --> B<br>B --> A<br>In the request log, LOOKUP asks whether a name exists in a directory and which inode it maps to, GETATTR asks for the metadata associated with an inode, READ asks for bytes at an offset, and WRITE sends bytes at an offset, while later in the lifetime of an open file, FLUSH, FSYNC, and RELEASE show up and make the write path less like a simple callback that copies bytes.<br>Here is the log from writing notes.txt, trimmed to the requests involved in opening, truncating, writing, flushing, and releasing the file:<br>[magicfs] READDIR ino=1<br>[magicfs] LOOKUP notes.txt -> ino=3<br>[magicfs] OPEN notes.txt ino=3 flags=0x8001<br>[magicfs] SETATTR ino=3 size=0 staged=true<br>[magicfs] WRITE notes.txt ino=3 offset=0 len=18 staged=true<br>[magicfs] FLUSH notes.txt ino=3<br>[magicfs] COMMIT notes.txt ino=3 size=18 blobs=1<br>[magicfs] COMMIT metadata entries=2<br>[magicfs] RELEASE notes.txt ino=3 flags=0x8001 flush=true<br>In this log, ls triggers READDIR, while a direct cat /magic/hello.txt can walk the path without listing the directory first. Shell redirection with > opens the file for writing and truncation, so the kernel...