Skip to content

Toy Filesystem Project — FUSE-Based Persistent Filesystem

Overview

This project implements a minimal but persistent POSIX-compatible filesystem using FUSE (Filesystem in Userspace). FUSE lets user-space programs service kernel VFS (Virtual Filesystem Switch) calls by registering a fuse_operations struct. The kernel routes open(), read(), write(), and related syscalls from the kernel's VFS layer down through /dev/fuse to your daemon. You never write kernel code, yet the filesystem appears as a first-class mount point to any standard tool.

The design is deliberately ext2-inspired but shrunk to fit in approximately 200 lines of core logic per phase, making it readable and modifiable. The completed project supports ls, cp, cat, mkdir, rm, mv, and most other standard tools without modification.


Phase 1 — On-Disk Format Design

Block Layout

The disk image is divided into fixed-size blocks (default: 4096 bytes). Blocks are organized in this order from the start of the image:

Block 0:     Superblock
Block 1:     Inode bitmap  (1 bit per inode, covers up to 4096*8 = 32768 inodes)
Block 2:     Block bitmap  (1 bit per data block)
Blocks 3–N:  Inode table   (one block per 16 inodes, each inode = 256 bytes)
Block N+1…:  Data blocks

Superblock (Block 0)

#define FS_MAGIC  0x54455354  /* "TEST" — change to your own */
#define BLOCK_SIZE  4096

typedef struct {
    uint32_t magic;
    uint32_t block_size;
    uint32_t total_blocks;   /* total blocks in image */
    uint32_t inode_count;    /* maximum number of inodes */
    uint32_t data_start;     /* first data block index */
    uint32_t free_blocks;    /* updated on alloc/free */
    uint32_t free_inodes;    /* updated on alloc/free */
    uint8_t  padding[4096 - 28];   /* pad to exactly one block */
} Superblock;

The magic number is checked on mount to reject non-filesystem images. Keep the superblock exactly one block so that all offset calculations are integer multiples of BLOCK_SIZE.

Inode (256 bytes, 16 per block)

#define DIRECT_BLOCKS  12

typedef struct {
    uint16_t mode;         /* file type + permissions (same bit layout as stat.st_mode) */
    uint16_t uid;
    uint16_t gid;
    uint16_t nlink;        /* hard link count */
    uint32_t size;         /* file size in bytes */
    uint32_t atime;
    uint32_t mtime;
    uint32_t ctime;
    uint32_t blocks[DIRECT_BLOCKS];  /* direct data block numbers (0 = unused) */
    uint32_t indirect;               /* singly-indirect block (0 = unused) */
    uint8_t  padding[256 - 52];      /* pad to 256 bytes */
} Inode;

Each inode can directly address up to 12 × 4096 = 49,152 bytes. The indirect block is a 4096-byte block whose contents are 1024 uint32_t block pointers, extending maximum file size to (12 + 1024) × 4096 = 4,243,456 bytes — adequate for the project scope.

Inode 0 is reserved (the "null" inode). Inode 1 is the root directory.

Directory Entry

Directories are stored as a sequence of fixed-size directory entries in their data blocks:

#define NAME_MAX_LEN  56

typedef struct {
    uint32_t inode;           /* 0 = deleted / empty slot */
    char     name[NAME_MAX_LEN];
} DirEntry;

Each entry is 60 bytes, so one block holds 68 directory entries — sufficient for a toy filesystem. A directory's size field in its inode reflects the number of populated entries × sizeof(DirEntry).

mkfs Utility

The mkfs.toyfs utility initializes a disk image:

int mkfs(const char *path, uint32_t size_mb) {
    int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644);
    uint32_t total_blocks = (size_mb * 1024 * 1024) / BLOCK_SIZE;

    /* Write zeroed image */
    uint8_t zero[BLOCK_SIZE] = {0};
    for (uint32_t i = 0; i < total_blocks; i++) write(fd, zero, BLOCK_SIZE);

    /* Write superblock */
    Superblock sb = {
        .magic        = FS_MAGIC,
        .block_size   = BLOCK_SIZE,
        .total_blocks = total_blocks,
        .inode_count  = 1024,
        .data_start   = 3 + (1024 * 256 / BLOCK_SIZE),  /* after inode table */
        .free_blocks  = total_blocks - sb.data_start,
        .free_inodes  = 1023,   /* inode 0 reserved, inode 1 = root */
    };
    pwrite(fd, &sb, sizeof(Superblock), 0);

    /* Mark inode 0 and 1 as used in bitmap */
    uint8_t inode_bm[BLOCK_SIZE] = {0};
    inode_bm[0] = 0x03;   /* bits 0 and 1 set */
    pwrite(fd, inode_bm, BLOCK_SIZE, BLOCK_SIZE);

    /* Write root directory inode (inode 1) */
    Inode root = {
        .mode  = S_IFDIR | 0755,
        .nlink = 2,   /* "." and parent */
        .size  = 0,
        .mtime = time(NULL),
    };
    off_t inode_table_off = 2 * BLOCK_SIZE;  /* block 2 = inode table start */
    pwrite(fd, &root, sizeof(Inode), inode_table_off + 1 * sizeof(Inode));

    close(fd);
    return 0;
}

Create the disk image and format it:

dd if=/dev/zero bs=1M count=100 of=disk.img
./mkfs.toyfs disk.img

Phase 2 — FUSE Interface

Skeleton Structure

#include <fuse.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>

static int disk_fd;  /* global file descriptor for the disk image */

/* Helpers: inode_read, inode_write, block_alloc, block_free, inode_alloc */

static struct fuse_operations toyfs_ops = {
    .getattr  = toyfs_getattr,
    .readdir  = toyfs_readdir,
    .open     = toyfs_open,
    .read     = toyfs_read,
    .write    = toyfs_write,
    .create   = toyfs_create,
    .mkdir    = toyfs_mkdir,
    .unlink   = toyfs_unlink,
    .rename   = toyfs_rename,
    .init     = toyfs_init,
    .destroy  = toyfs_destroy,
};

int main(int argc, char *argv[]) {
    /* argv[1] = disk image path, rest passed to fuse_main */
    disk_fd = open(argv[1], O_RDWR);
    if (disk_fd < 0) { perror("open disk"); return 1; }
    /* shift argv to remove disk image arg before passing to fuse_main */
    return fuse_main(argc - 1, argv + 1, &toyfs_ops, NULL);
}

Compile with pkg-config:

gcc $(pkg-config --cflags fuse) -Wall -O2 -g toyfs.c -o toyfs \
    $(pkg-config --libs fuse)

Key Operations

getattr — called by stat(), ls, and nearly every VFS operation:

static int toyfs_getattr(const char *path, struct stat *st) {
    int ino = path_to_inode(path);   /* walk directory tree */
    if (ino < 0) return -ENOENT;
    Inode inode;
    inode_read(ino, &inode);
    memset(st, 0, sizeof(struct stat));
    st->st_ino   = ino;
    st->st_mode  = inode.mode;
    st->st_nlink = inode.nlink;
    st->st_size  = inode.size;
    st->st_mtime = inode.mtime;
    return 0;
}

readdir — called by ls, find:

static int toyfs_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
                         off_t offset, struct fuse_file_info *fi) {
    int ino = path_to_inode(path);
    if (ino < 0) return -ENOENT;
    Inode dir;
    inode_read(ino, &dir);
    filler(buf, ".",  NULL, 0);
    filler(buf, "..", NULL, 0);
    DirEntry ent;
    for (uint32_t off = 0; off < dir.size; off += sizeof(DirEntry)) {
        dir_entry_read(ino, off, &ent);
        if (ent.inode != 0)
            filler(buf, ent.name, NULL, 0);
    }
    return 0;
}

write — the most complex operation; handles block allocation and indirect block expansion:

The write implementation reads the inode, determines which data block(s) the write spans, allocates new blocks as needed (setting bits in the block bitmap and updating the inode's block pointers), writes the data, and updates inode.size and inode.mtime. Always write the updated inode back to disk before returning.


Phase 3 — Persistence and Mount

Mounting the Filesystem

Create a mount point and mount:

mkdir -p /tmp/toyfs_mount
./toyfs disk.img /tmp/toyfs_mount -f -d   # -f = foreground, -d = debug output

In a second terminal, use the filesystem like any other:

ls /tmp/toyfs_mount
echo "hello persistent world" > /tmp/toyfs_mount/hello.txt
cat /tmp/toyfs_mount/hello.txt
mkdir /tmp/toyfs_mount/subdir
cp /bin/ls /tmp/toyfs_mount/subdir/

All writes go to disk.img via pwrite() calls in your FUSE daemon. Unmount:

fusermount -u /tmp/toyfs_mount   # or: umount /tmp/toyfs_mount

Re-mount the same image and verify data persists:

./toyfs disk.img /tmp/toyfs_mount
cat /tmp/toyfs_mount/hello.txt   # should still print "hello persistent world"

The -d Debug Flag

Running with -d causes FUSE to log every VFS call and its arguments to stderr. This is invaluable during development. A simple ls in the mounted directory will show a stream like:

getattr: /
readdir: /
getattr: /hello.txt

This tells you exactly which operations are being called and in what order.


Phase 4 — Testing and Validation

Standard Tool Verification

# Basic file operations
echo "test" > /tmp/toyfs_mount/a.txt
cp a.txt /tmp/toyfs_mount/b.txt
mv /tmp/toyfs_mount/b.txt /tmp/toyfs_mount/c.txt
rm /tmp/toyfs_mount/a.txt
ls -la /tmp/toyfs_mount/

# Directory operations
mkdir -p /tmp/toyfs_mount/x/y/z
find /tmp/toyfs_mount -type f

# Large file (tests indirect block allocation)
dd if=/dev/urandom bs=1M count=10 of=/tmp/toyfs_mount/bigfile
md5sum /tmp/toyfs_mount/bigfile   # record hash
# Unmount, remount, re-check hash

fsstress (Stress Testing)

fsstress from the xfstests suite hammers a filesystem with random operations:

sudo apt install xfstests
fsstress -d /tmp/toyfs_mount -n 1000 -p 4 -l 5

Run this and observe whether your filesystem crashes or produces inconsistencies. Common failure modes discovered by fsstress:

  • Off-by-one in block offset calculations
  • Forgetting to flush the inode after a write
  • Race conditions in block bitmap allocation (if you add threads)
  • Directory size not updated correctly on file creation

fsck Verification

After a crash or fsstress run, unmount and run a custom fsck.toyfs that checks:

  1. Superblock magic is valid.
  2. All referenced inode numbers are within range.
  3. All directory entries point to allocated inodes.
  4. Block reference counts match the block bitmap.
  5. Inode link counts match the number of directory entries pointing to each inode.

C Skeleton Summary

The minimal project is approximately this file structure:

toyfs/
  toyfs.c         — main FUSE daemon (fuse_operations, main)
  disk.c          — on-disk I/O: inode_read/write, block_alloc/free, dir ops
  disk.h          — Superblock, Inode, DirEntry typedefs, constants
  mkfs.c          — format utility
  fsck.c          — consistency checker
  Makefile

Key compile flags:

CFLAGS = -Wall -Wextra -O2 -g $(shell pkg-config --cflags fuse)
LDFLAGS = $(shell pkg-config --libs fuse)

Evaluation Criteria

Criterion Weight Description
Correct reads/writes 25% Files survive unmount-remount cycles
Directory operations 20% ls, mkdir, rm, mv work correctly
Indirect blocks 15% Files larger than 12 × 4096 bytes succeed
mkfs utility 10% Clean format; mount succeeds on fresh image
fsstress survival 15% No crashes after 1000 random operations
fsck correctness 15% Detects injected corruption

Extension Ideas

Journaling (ext3-style): Add a journal area before the data blocks. Write a transaction (superblock + modified inodes + modified blocks) atomically before committing it. On mount after a crash, replay the journal. This eliminates the risk of a partial write leaving the filesystem inconsistent.

Symbolic links: Add S_IFLNK inode type. Store the link target as the inode's data. Implement toyfs_symlink, toyfs_readlink. Test with ln -s.

Hard links: nlink > 1 is already in the inode struct. Implement toyfs_link (create a new directory entry pointing to the same inode) and verify that the file persists after one link is deleted.

Larger inodes: Extend to double-indirect and triple-indirect block pointers (same pattern as ext2) to support files up to ~4 GB. Requires extending the Inode struct and the block traversal logic in read/write.

In-memory block cache: Add an LRU cache of recently accessed blocks to avoid redundant pread/pwrite calls. Measure throughput improvement with fio.