C++ Virtual File System Project
A filesystem inside a file: directories, files, and blocks you manage yourself.
What you will build
Create a disk image in one host file, then implement the filesystem on top of it: an inode table, a directory tree, block allocation, and a mini shell (mkdir, cd, ls, cat, rm) to use it. It's the capstone of systems projects — operating-system concepts made tangible.
What you will learn
- Inode-based file metadata
- Block allocation with a free list or bitmap
- Directory trees and path resolution
- Serialising structures to a disk image
- Writing a small command shell over your own FS
Feature checklist
- Create/format a disk image file
- Commands: mkdir, cd, ls, touch, cat, write, rm
- Files span multiple blocks when large
- Free-block tracking; deleting reclaims space
- Path resolution with . and ..
- Image persists: reopen and your files are still there
Starter code
#include <array>
#include <cstring>
#include <string>
#include <vector>
const int BLOCK = 512, NBLOCKS = 1024, NINODES = 128;
struct Inode {
bool used = false;
bool isDir = false;
int size = 0;
int blocks[12]; // direct blocks (12 * 512 = 6 KB max file)
Inode() { std::memset(blocks, -1, sizeof(blocks)); }
};
struct DirEntry { char name[28]; int inode; }; // -1 = empty slot
class VFS {
std::array<Inode, NINODES> inodes_;
std::vector<bool> blockFree_ = std::vector<bool>(NBLOCKS, true);
int cwd_ = 0; // inode 0 = root directory
public:
int allocBlock() {
for (int i = 0; i < NBLOCKS; ++i)
if (blockFree_[i]) { blockFree_[i] = false; return i; }
return -1; // disk full
}
int allocInode(bool dir) {
for (int i = 0; i < NINODES; ++i)
if (!inodes_[i].used) {
inodes_[i] = Inode{};
inodes_[i].used = true; inodes_[i].isDir = dir;
return i;
}
return -1;
}
// resolve(path): walk DirEntry lists from root or cwd_
};Full walkthrough
Step 1: The disk is one file, divided into blocks
A VFS formats a single host file (say 4 MiB) as a grid of fixed blocks (4 KiB each → 1024 blocks). Layout: block 0 = superblock (magic, block size, counts), then a block bitmap (which blocks are free), an inode table region, then data blocks. Every offset in the system is blockNumber × blockSize — one multiplication is the whole storage engine's addressing scheme.
Step 2: Inodes: metadata separated from content
struct Inode { bool used; bool isDir; uint32_t size; uint32_t blocks[12]; } — an inode describes a file but is not the file: it points at up to 12 data blocks (12 × 4 KiB = 48 KiB max file — a stated limit, fine for learning). struct DirEntry { char name[28]; uint32_t inode; } — directories are just files whose content is DirEntry records. That 'directories are files' insight is the Unix idea, arriving in your own code.
Step 3: allocBlock() and allocInode(): the resource heart
allocBlock scans the bitmap for a clear bit, sets it, returns the index; freeBlock clears it. allocInode does the same over the inode table. These two functions are the ONLY places resources are granted, so 'deleting reclaims space' reduces to: free the inode's blocks in the bitmap, free the inode, remove the directory entry. No leaks are possible anywhere else because nowhere else can allocate.
int VFS::allocBlock() {
for (int i = 0; i < numBlocks; ++i)
if (!bitmap[i]) { bitmap[i] = true; return i; }
return -1; // disk full
}Step 4: Path resolution with . and ..
Split '/docs/notes/a.txt' into components and walk from the root inode: for each component, search the current directory's entries for the name, move to that inode; '.' stays, '..' goes to the parent (store parent links or track the walk stack — the stack is simpler and always consistent). Resolution failure at any hop = 'no such file or directory', naming the component that failed.
Step 5: Commands as thin wrappers: mkdir, touch, write, cat, ls, rm
Each shell command resolves paths then does one thing: mkdir allocates an inode + a block holding '.' and '..' entries; write copies bytes into the file's blocks (allocating as it grows, refusing past 12 blocks or a full disk); cat streams blocks back in inode order; ls prints a directory's entries with sizes; rm frees everything via the allocators' mirrors. The REPL is the mini shell's loop with built-ins only.
Step 6: Persistence is the default state
The host file IS the disk: every mutation writes through to it (fseek/fwrite on the ofstream, or memory-map it), and reopening the image re-reads the superblock and tables. There is no save command because there is nothing outside the disk to save TO — run the program twice and ls in the second run proves the first run's work survived.
Step 7: Test the resource lifecycle
Format an image, mkdir /docs, write a 10 KiB file (3 blocks), check free blocks dropped by exactly the inode's usage (3 data + directory effects), cat it back byte-identical, write until the disk reports full honestly, rm the file and confirm the free count returns EXACTLY to the post-format number — any discrepancy is a block leak with your name on it.
Common mistakes
- Storing file contents inside the inode struct, so 'files' cannot span blocks and sizes are capped by a struct definition.
- Deleting a file's directory entry without freeing its blocks and inode — the space is gone forever, one leak per rm.
- Path resolution that treats '..' as a literal name, stranding users in subdirectories with no way up.
Compile and run
g++ -std=c++17 -o vfs vfs.cppSample run
$ ./vfs
No image found — format a new one? (y/n):
> y
Formatted disk.img: 1024 blocks x 4 KiB (4 MiB) · 1019 blocks free
vfs:/$
> mkdir docs
vfs:/$
> cd docs
vfs:/docs$
> write hello.txt
> Hello from inside a virtual disk.
> This file lives in blocks, not in a real folder.
> .
Wrote hello.txt — 1 block used (1017 free)
vfs:/docs$
> ls
hello.txt 83 bytes
> cat hello.txt
Hello from inside a virtual disk.
This file lives in blocks, not in a real folder.
> cd ..
> exitTry it in your browser
No browser demo is included for this project. Build the C++ version below — that is the real program.