How to Implement a File System in Rust (FUSE)

You implement a custom file system in Rust by using the `rust-fuse` crate, which provides safe bindings to the Linux FUSE kernel module, allowing you to define file system operations in Rust that the kernel mounts as a virtual directory.

When the disk doesn't have the answer

You have a configuration file that looks like a mess of nested JSON, but your legacy tool expects a directory full of text files. Or you're building a secure vault where files decrypt on the fly and never touch the disk. You don't want to write a kernel module in C and risk a segfault that bricks the machine. You want to write the logic in Rust, mount it like a USB drive, and let the operating system handle the rest.

That's where FUSE comes in. FUSE stands for Filesystem in Userspace. It lets you write a file system as a normal user-space program. The kernel talks to your program over a socket. Your program answers questions like "what's in this folder?" or "read this file." Rust is perfect for this because you get memory safety while doing low-level system work. You avoid kernel panics. You get the borrow checker. You still get the power of a custom file system.

The building manager analogy

Think of the kernel as a strict building manager. The manager controls the lobby and the elevators. Tenants live in the apartments. When a visitor asks to see Apartment 4B, the manager doesn't go inside. The manager calls you on the intercom. You check your ledger. You decide if the visitor gets in. You hand the visitor a key or a brochure.

The manager never sees the data inside the apartment. You control the logic. The kernel handles the plumbing. If you tell the manager "Apartment 4B doesn't exist," the manager turns the visitor away. If you hand the manager a broken key, the visitor gets stuck. You are responsible for the integrity of the answers you send back. The kernel trusts you. Don't lie to it.

Minimal example: a single virtual file

The rust-fuse crate provides safe bindings to the FUSE kernel module. You define a struct that implements the Filesystem trait. The trait has methods for every operation the kernel might ask for. You implement the ones you care about. The rest can return errors.

Here is a minimal file system that serves a single file named hello.txt.

use std::os::unix::io::RawFd;
use std::sync::Arc;
use rust_fuse::Filesystem;
use rust_fuse::mount::Mount;
use rust_fuse::fuse::FileAttr;

/// A minimal file system that serves a single virtual file.
struct MyFs;

impl Filesystem for MyFs {
    /// Return metadata for a path.
    /// The kernel calls this to check permissions, size, and type.
    fn getattr(&self, path: &str, _fh: Option<RawFd>) -> Result<FileAttr, i32> {
        if path == "/" {
            // Root directory attributes.
            // Mode 0o40755 means directory with rwxr-xr-x permissions.
            Ok(FileAttr {
                ino: 1,
                mode: 0o40755,
                nlink: 2,
                uid: 0,
                gid: 0,
                rdev: 0,
                size: 0,
                blksize: 512,
                blocks: 0,
                atime: 0,
                mtime: 0,
                ctime: 0,
                crtime: 0,
                flags: 0,
                generation: 0,
            })
        } else if path == "/hello.txt" {
            // File attributes.
            // Mode 0o100644 means regular file with rw-r--r-- permissions.
            Ok(FileAttr {
                ino: 2,
                mode: 0o100644,
                nlink: 1,
                uid: 0,
                gid: 0,
                rdev: 0,
                size: 17,
                blksize: 512,
                blocks: 8,
                atime: 0,
                mtime: 0,
                ctime: 0,
                crtime: 0,
                flags: 0,
                generation: 0,
            })
        } else {
            // Path not found.
            // libc::ENOENT is the standard POSIX error code.
            Err(libc::ENOENT)
        }
    }

    /// Read the contents of a file.
    /// The kernel provides a buffer and asks for bytes.
    fn read(&self, path: &str, _fh: RawFd, buffer: &mut [u8], _offset: u64, _flags: i32) -> Result<usize, i32> {
        if path == "/hello.txt" {
            // The virtual content.
            let data = b"Hello from Rust FUSE";
            // Copy as much as fits into the kernel's buffer.
            let len = data.len().min(buffer.len());
            buffer[..len].copy_from_slice(&data[..len]);
            // Return the number of bytes written.
            Ok(len)
        } else {
            Err(libc::ENOENT)
        }
    }

    /// List directory entries.
    /// This is complex; we return empty for non-root to keep it simple.
    fn readdir(&self, path: &str, _fh: Option<RawFd>, _offset: u64, _buffer: &mut [u8]) -> Result<usize, i32> {
        if path == "/" {
            // Real implementations must pack directory entries into the buffer.
            // Returning 0 here means empty directory for this demo.
            Ok(0)
        } else {
            Err(libc::ENOENT)
        }
    }
}

fn main() {
    // Wrap the filesystem in Arc for shared ownership.
    let fs = Arc::new(MyFs);
    // Create the mount point configuration.
    let mount = Mount::new("/mnt/my_fuse_fs", fs);
    
    // Start the mount loop.
    // This blocks until the user unmounts or an error occurs.
    match mount.mount() {
        Ok(_) => println!("Mounted successfully"),
        Err(e) => eprintln!("Mount failed: {:?}", e),
    }
}

Run this with cargo run --release. You need a mount point. Create it with sudo mkdir -p /mnt/my_fuse_fs. Once running, cat /mnt/my_fuse_fs/hello.txt prints the content. Unmount with fusermount3 -u /mnt/my_fuse_fs.

What happens under the hood

When you run this, Mount::new creates a communication channel with the kernel. The call to mount() starts a loop that waits for requests. The kernel doesn't know your file system exists yet. It just knows there's a FUSE device at /mnt/my_fuse_fs.

When you run ls /mnt/my_fuse_fs, the kernel sends a getattr request for the root path. Your getattr implementation checks the path. If it's /, it returns directory metadata. The kernel sees the mode bits indicate a directory and proceeds to ask for contents via readdir.

When you run cat /mnt/my_fuse_fs/hello.txt, the kernel asks for attributes first to verify it's a file. Then it calls read. Your code copies the bytes into the buffer. The kernel returns those bytes to cat.

If you ask for /missing.txt, getattr returns ENOENT. The kernel translates that to "No such file or directory" for the user. The loop runs until you unmount. Unmounting sends a signal that breaks the loop and cleans up the socket.

The FileAttr struct carries important data. The ino field is the inode number. It must be unique for each file. The kernel uses it for caching. If you change the content of a file but keep the same inode, the kernel might serve stale data. Update the mtime and ino when content changes. The mode field uses octal notation. The leading digit sets the file type. 0o4 is a directory. 0o10 is a regular file. The remaining three digits set permissions. 755 means owner can read/write/execute, others can read/execute.

Realistic example: stateful file system

Real file systems hold state. You might back your FS with a database, a cloud bucket, or an in-memory cache. Here's a sketch of a file system backed by a HashMap. You need to handle concurrency because the kernel might send requests from multiple threads.

use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::os::unix::io::RawFd;
use rust_fuse::Filesystem;
use rust_fuse::fuse::FileAttr;

/// A file system backed by a shared hash map.
struct MapFs {
    /// The data store, protected by a read-write lock.
    /// RwLock allows multiple readers or one writer.
    data: Arc<RwLock<HashMap<String, Vec<u8>>>>,
}

impl Filesystem for MapFs {
    fn getattr(&self, path: &str, _fh: Option<RawFd>) -> Result<FileAttr, i32> {
        // Lock the map for reading.
        // Unwrap is safe here; a poisoned lock means a bug in our code.
        let map = self.data.read().unwrap();
        if path == "/" {
            Ok(FileAttr {
                ino: 1,
                mode: 0o40755,
                nlink: 2,
                uid: 0,
                gid: 0,
                rdev: 0,
                size: 0,
                blksize: 512,
                blocks: 0,
                atime: 0,
                mtime: 0,
                ctime: 0,
                crtime: 0,
                flags: 0,
                generation: 0,
            })
        } else if map.contains_key(path) {
            let content = &map[path];
            Ok(FileAttr {
                ino: 2,
                mode: 0o100644,
                nlink: 1,
                uid: 0,
                gid: 0,
                rdev: 0,
                size: content.len() as u64,
                blksize: 512,
                blocks: 8,
                atime: 0,
                mtime: 0,
                ctime: 0,
                crtime: 0,
                flags: 0,
                generation: 0,
            })
        } else {
            Err(libc::ENOENT)
        }
    }

    fn read(&self, path: &str, _fh: RawFd, buffer: &mut [u8], _offset: u64, _flags: i32) -> Result<usize, i32> {
        let map = self.data.read().unwrap();
        if let Some(content) = map.get(path) {
            let len = content.len().min(buffer.len());
            buffer[..len].copy_from_slice(&content[..len]);
            Ok(len)
        } else {
            Err(libc::ENOENT)
        }
    }
}

The RwLock is the convention here. FUSE handlers read far more than they write. RwLock allows multiple concurrent readers. A Mutex would serialize all reads and hurt performance. The community expects RwLock for read-heavy shared state in FUSE.

Pitfalls and compiler errors

The biggest trap is readdir. The kernel gives you a raw byte buffer. You have to pack directory entries into it using a specific binary format. If you write past the end, you corrupt kernel memory. Rust's bounds checking helps, but you still need to calculate offsets carefully. Use a helper crate or library function to pack entries. Don't roll your own buffer packing unless you enjoy debugging segfaults.

Another pitfall is blocking the mount thread. If your read implementation makes a slow network request, you might stall the kernel. FUSE supports async, but the default loop is synchronous. If you do heavy I/O, consider spawning a thread or using the async support in rust-fuse.

Compiler errors often bite you on shared state. If you try to use Rc instead of Arc for the shared state, you might hit E0277 (trait bound not satisfied). FUSE handlers run on threads. Rc is not Send. The compiler will reject passing Rc where Send is needed. Use Arc for shared state in FUSE.

If you forget to return an error for a missing path and return Ok with garbage data, the kernel might crash or behave unpredictably. Always return ENOENT for unknown paths. The kernel expects POSIX semantics. Deviating from them breaks tools like ls, cp, and mv.

Debugging FUSE can be tricky. When things go wrong, check dmesg. The kernel logs errors from FUSE handlers. You'll see messages like "FUSE: device not found" or "FUSE: bad request". Use strace to see the system calls your program makes. This helps verify that requests are reaching your code.

Decision matrix

Use rust-fuse when you are starting a new project and want modern async support, safe bindings, and active maintenance. Use fuse-rs only when you are maintaining a legacy codebase that already depends on it; the API is older and lacks some safety guarantees. Use a kernel module when you need absolute minimum latency and can afford the risk of crashing the system on a bug; this is rarely necessary for user-space logic. Use tmpfs or a standard directory when you just need temporary storage and don't need custom logic like encryption or remote fetching.

Treat the buffer as a minefield. Measure twice, copy once.

Where to go next