The Virtual Filesystem (VFS) provides a complete in-memory Unix-like filesystem with optional host directory mounts. All Nash commands access files through this API — never touching the host filesystem directly except through explicit mounts.
Module Structure
vfs/
├── mod.rs # Core VFS operations (read, write, mkdir, rm, list)
├── node.rs # FsNode enum (File | Directory)
├── path.rs # Path manipulation (normalize, join, parent, basename)
└── mount.rs # Host directory binding support
Design Overview
The VFS is a flat hash map from absolute paths to nodes:
// From src/vfs/mod.rs:19-27
pub struct Vfs {
nodes : HashMap < String , FsNode >, // "/home/user/file.txt" → FsNode::File(...)
mounts : Vec < MountPoint >, // Host directory bindings
}
Unlike a traditional tree structure, Nash stores nodes in a flat map keyed by absolute path. This simplifies operations like recursive delete and directory listing.
FsNode Structure
From src/vfs/node.rs:5-13:
pub enum FsNode {
/// A regular file holding raw bytes
File ( Vec < u8 >),
/// A directory (children tracked via path map)
Directory ( HashMap < String , ()>),
}
Files store raw bytes. Directories exist as markers — children are discovered by scanning the path map.
Directory Entry
From src/vfs/node.rs:15-20:
pub struct DirEntry {
pub name : String , // Filename or directory name
pub is_dir : bool , // Type flag
}
Returned by list_dir() to enumerate directory contents.
Core Operations
Initialization
From src/vfs/mod.rs:30-42:
impl Vfs {
pub fn new () -> Self {
let mut vfs = Vfs {
nodes : HashMap :: new (),
mounts : Vec :: new (),
};
// Bootstrap standard directories
for dir in & [ "/" , "/bin" , "/usr" , "/home" , "/tmp" , "/lib" , "/etc" , "/var" ] {
vfs . nodes . insert ( dir . to_string (), FsNode :: Directory ( HashMap :: new ()));
}
vfs
}
}
Nash starts with a standard Unix skeleton: /bin, /usr, /home, /tmp, etc.
Existence Check
From src/vfs/mod.rs:60-75:
pub fn exists ( & self , path : & str ) -> bool {
let p = VfsPath :: normalize ( path );
// Check in-memory nodes
if self . nodes . contains_key ( & p ) {
return true ;
}
// Check host mount
if let Some ( mp ) = self . find_mount ( & p ) {
let rel = p . strip_prefix ( & mp . vfs_path) . unwrap_or ( "" ) . trim_start_matches ( '/' );
let host = format! ( "{}/{}" , mp . host_path, rel );
return std :: path :: Path :: new ( & host ) . exists ();
}
false
}
The VFS checks in-memory nodes first, then falls back to host mounts if the path is under a mounted directory.
Read Operations
Read File
From src/vfs/mod.rs:94-116:
pub fn read ( & self , path : & str ) -> Result < Vec < u8 >> {
let p = VfsPath :: normalize ( path );
// Try in-memory node first
if let Some ( node ) = self . nodes . get ( & p ) {
match node {
FsNode :: File ( data ) => return Ok ( data . clone ()),
FsNode :: Directory ( _ ) => bail! ( "is a directory: {}" , path ),
}
}
// Try host mount
if let Some ( mp ) = self . find_mount ( & p ) {
let rel = p . strip_prefix ( & mp . vfs_path) . unwrap_or ( "" ) . trim_start_matches ( '/' );
let host = format! ( "{}/{}" , mp . host_path, rel );
return std :: fs :: read ( & host ) . context ( format! ( "cannot read {}" , path ));
}
bail! ( "no such file: {}" , path )
}
Read as UTF-8 String
From src/vfs/mod.rs:118-122:
pub fn read_to_string ( & self , path : & str ) -> Result < String > {
let bytes = self . read ( path ) ? ;
String :: from_utf8 ( bytes ) . map_err ( | _ | anyhow! ( "not valid UTF-8: {}" , path ))
}
Write Operations
Write File
From src/vfs/mod.rs:124-149:
pub fn write ( & mut self , path : & str , data : Vec < u8 >) -> Result <()> {
let p = VfsPath :: normalize ( path );
self . check_write_allowed ( & p ) ? ; // Reject if read-only mount
// Ensure parent directory exists
let parent = VfsPath :: parent ( & p );
if ! parent . is_empty () && ! self . is_dir ( & parent ) {
bail! ( "no such directory: {}" , parent );
}
// Try host mount first
if let Some ( mp ) = self . find_mount_mut ( & p ) {
let host_path = mp . host_path . clone ();
let vfs_path = mp . vfs_path . clone ();
let rel = p . strip_prefix ( & vfs_path ) . unwrap_or ( "" ) . trim_start_matches ( '/' );
let host = format! ( "{}/{}" , host_path , rel );
return std :: fs :: write ( & host , & data ) . context ( format! ( "cannot write {}" , path ));
}
// Write to in-memory node
self . nodes . insert ( p , FsNode :: File ( data ));
Ok (())
}
Append to File
From src/vfs/mod.rs:156-186:
pub fn append ( & mut self , path : & str , data : Vec < u8 >) -> Result <()> {
let p = VfsPath :: normalize ( path );
self . check_write_allowed ( & p ) ? ;
// Handle host mount
if let Some ( mp ) = self . find_mount ( & p ) {
let rel = p . strip_prefix ( & mp . vfs_path) . unwrap_or ( "" ) . trim_start_matches ( '/' );
let host = format! ( "{}/{}" , mp . host_path, rel );
use std :: io :: Write ;
let mut f = std :: fs :: OpenOptions :: new ()
. create ( true )
. append ( true )
. open ( & host )
. context ( format! ( "cannot open {} for append" , path )) ? ;
f . write_all ( & data ) ? ;
return Ok (());
}
// Append to in-memory node
let entry = self . nodes . entry ( p ) . or_insert_with ( || FsNode :: File ( Vec :: new ()));
match entry {
FsNode :: File ( existing ) => existing . extend_from_slice ( & data ),
_ => bail! ( "is a directory: {}" , path ),
}
Ok (())
}
Directory Operations
Create Directory (with parents)
From src/vfs/mod.rs:188-209:
pub fn mkdir_p ( & mut self , path : & str ) -> Result <()> {
let p = VfsPath :: normalize ( path );
let mut current = String :: new ();
// Build path component by component
for component in p . split ( '/' ) {
if component . is_empty () {
current . push ( '/' );
continue ;
}
if current == "/" {
current . push_str ( component );
} else {
current . push ( '/' );
current . push_str ( component );
}
if ! self . nodes . contains_key ( & current ) {
self . nodes . insert ( current . clone (), FsNode :: Directory ( HashMap :: new ()));
}
}
Ok (())
}
Example: mkdir -p /home/alice/projects
Initial state: { "/": Directory, "/home": Directory }
Iterations:
1. current = "/home/alice" → insert Directory
2. current = "/home/alice/projects" → insert Directory
Final state: {
"/": Directory,
"/home": Directory,
"/home/alice": Directory,
"/home/alice/projects": Directory
}
List Directory
From src/vfs/mod.rs:280-335:
pub fn list_dir ( & self , path : & str ) -> Result < Vec < DirEntry >> {
let p = VfsPath :: normalize ( path );
if ! self . is_dir ( & p ) {
bail! ( "not a directory: {}" , path );
}
let mut entries : Vec < DirEntry > = Vec :: new ();
let prefix = if p == "/" { "/" . to_string () } else { format! ( "{}/" , p ) };
// Scan in-memory children
for key in self . nodes . keys () {
if key == & p { continue ; }
if key . starts_with ( & prefix ) {
let rest = & key [ prefix . len () .. ];
if ! rest . contains ( '/' ) { // Direct child only
let is_dir = matches! ( self . nodes . get ( key ), Some ( FsNode :: Directory ( _ )));
entries . push ( DirEntry {
name : rest . to_string (),
is_dir ,
});
}
}
}
// Add host mount children
if let Some ( mp ) = self . find_mount ( & p ) {
let rel = p . strip_prefix ( & mp . vfs_path) . unwrap_or ( "" ) . trim_start_matches ( '/' );
let host = if rel . is_empty () {
mp . host_path . clone ()
} else {
format! ( "{}/{}" , mp . host_path, rel )
};
if let Ok ( rd ) = std :: fs :: read_dir ( & host ) {
for entry in rd . flatten () {
let name = entry . file_name () . to_string_lossy () . to_string ();
let is_dir = entry . file_type () . map ( | t | t . is_dir ()) . unwrap_or ( false );
if ! entries . iter () . any ( | e | e . name == name ) {
entries . push ( DirEntry { name , is_dir });
}
}
}
}
entries . sort_by ( | a , b | a . name . cmp ( & b . name));
Ok ( entries )
}
Remove Operations
Remove Single File/Dir
From src/vfs/mod.rs:238-254:
pub fn remove ( & mut self , path : & str ) -> Result <()> {
let p = VfsPath :: normalize ( path );
self . check_write_allowed ( & p ) ? ;
if let Some ( node ) = self . nodes . get ( & p ) {
match node {
FsNode :: Directory ( children ) if ! children . is_empty () => {
bail! ( "directory not empty: {}" , path );
}
_ => {}
}
self . nodes . remove ( & p );
return Ok (());
}
bail! ( "no such file or directory: {}" , path )
}
Remove Recursively
From src/vfs/mod.rs:256-277:
pub fn remove_recursive ( & mut self , path : & str ) -> Result <()> {
let p = VfsPath :: normalize ( path );
self . check_write_allowed ( & p ) ? ;
if ! self . exists ( & p ) {
bail! ( "no such file or directory: {}" , path );
}
// Collect all keys under path
let to_remove : Vec < String > = self
. nodes
. keys ()
. filter ( | k | * k == & p || k . starts_with ( & format! ( "{}/" , p )))
. cloned ()
. collect ();
for key in to_remove {
self . nodes . remove ( & key );
}
Ok (())
}
Path Operations
From src/vfs/path.rs:3-68:
pub struct VfsPath ;
impl VfsPath {
/// Normalize a path: resolve `.` and `..`, ensure leading `/`
pub fn normalize ( path : & str ) -> String {
let mut components : Vec < & str > = Vec :: new ();
for part in path . split ( '/' ) {
match part {
"" | "." => {} // Skip empty and current dir
".." => { components . pop (); } // Go up one level
other => components . push ( other ),
}
}
let result = format! ( "/{}" , components . join ( "/" ));
if result . is_empty () { "/" . to_string () } else { result }
}
/// Return parent directory
pub fn parent ( path : & str ) -> String {
let p = Self :: normalize ( path );
if p == "/" { return "/" . to_string (); }
match p . rfind ( '/' ) {
Some ( 0 ) => "/" . to_string (),
Some ( i ) => p [ .. i ] . to_string (),
None => "/" . to_string (),
}
}
/// Return final path component
pub fn basename ( path : & str ) -> String {
let p = Self :: normalize ( path );
match p . rfind ( '/' ) {
Some ( i ) => p [ i + 1 .. ] . to_string (),
None => p ,
}
}
/// Join base path with relative component
pub fn join ( base : & str , rel : & str ) -> String {
if rel . starts_with ( '/' ) {
Self :: normalize ( rel )
} else {
Self :: normalize ( & format! ( "{}/{}" , base , rel ))
}
}
}
Path Normalization Examples
VfsPath::normalize("/home/./user") // → "/home/user"
VfsPath::normalize("/home/user/../other") // → "/home/other"
VfsPath::normalize("home/user") // → "/home/user" (adds leading /)
VfsPath::normalize("/") // → "/"
VfsPath::parent("/home/user/file.txt") // → "/home/user"
VfsPath::parent("/home") // → "/"
VfsPath::parent("/") // → "/"
VfsPath::basename("/home/user/file.txt") // → "file.txt"
VfsPath::basename("/") // → ""
VfsPath::join("/home/user", "docs") // → "/home/user/docs"
VfsPath::join("/home/user", "/etc") // → "/etc" (absolute takes precedence)
VfsPath::join("/home/user", "../other") // → "/home/other"
Mount Points
From src/vfs/mount.rs:3-18:
pub struct MountOptions {
pub read_only : bool , // Reject writes if true
}
pub struct MountPoint {
pub host_path : String , // Absolute path on host filesystem
pub vfs_path : String , // Absolute VFS path where host appears
pub opts : MountOptions ,
}
Mounting Host Directories
From src/vfs/mod.rs:46-56:
pub fn mount ( & mut self , host_path : String , vfs_path : String , opts : MountOptions ) -> Result <()> {
// Ensure VFS mount point exists
self . ensure_dir ( & vfs_path ) ? ;
self . mounts . push ( MountPoint {
host_path ,
vfs_path ,
opts ,
});
Ok (())
}
Example:
nash --bind ./project:/workspace
Creates a mount:
MountPoint {
host_path : "/real/path/to/project" ,
vfs_path : "/workspace" ,
opts : MountOptions { read_only : false },
}
Now:
cat /workspace/file.txt reads from /real/path/to/project/file.txt
echo data > /workspace/new.txt writes to /real/path/to/project/new.txt
Read-Only Mounts
From src/vfs/mod.rs:390-397:
fn check_write_allowed ( & self , vfs_path : & str ) -> Result <()> {
if let Some ( mp ) = self . find_mount ( vfs_path ) {
if mp . opts . read_only {
bail! ( "filesystem is read-only: {}" , vfs_path );
}
}
Ok (())
}
Example:
nash --bind-ro ./config:/config
cat /config/app.json # ✓ Allowed
echo x > /config/new.txt # ✗ Error: filesystem is read-only
Mount Precedence
If multiple mounts overlap, the most specific (longest matching prefix) wins:
mounts: [
{ vfs_path: "/data", host_path: "/host/data" },
{ vfs_path: "/data/sensitive", host_path: "/host/secure" },
]
/data/file.txt → /host/data/file.txt
/data/sensitive/key.txt → /host/secure/key.txt (more specific mount wins)
Testing
The VFS includes comprehensive tests (src/vfs/mod.rs:400-463):
#[test]
fn test_write_and_read () {
let mut vfs = Vfs :: new ();
vfs . write_str ( "/tmp/hello.txt" , "hello world \n " ) . unwrap ();
let content = vfs . read_to_string ( "/tmp/hello.txt" ) . unwrap ();
assert_eq! ( content , "hello world \n " );
}
#[test]
fn test_list_dir () {
let mut vfs = Vfs :: new ();
vfs . write_str ( "/tmp/a.txt" , "" ) . unwrap ();
vfs . write_str ( "/tmp/b.txt" , "" ) . unwrap ();
vfs . mkdir ( "/tmp/subdir" ) . unwrap ();
let entries = vfs . list_dir ( "/tmp" ) . unwrap ();
let names : Vec < _ > = entries . iter () . map ( | e | e . name . as_str ()) . collect ();
assert! ( names . contains ( & "a.txt" ));
assert! ( names . contains ( & "b.txt" ));
assert! ( names . contains ( & "subdir" ));
}
Design Decisions
1. Flat Hash Map vs Tree
Nash uses a flat map instead of a tree structure:
Advantages:
Simple to implement
Fast lookups by absolute path
Easy recursive operations (filter keys by prefix)
Trade-offs:
More memory for long paths
Directory listing requires scanning all keys
2. Host Mount Overlay
Host mounts are checked after in-memory nodes:
1. Check nodes map
2. If not found, check mounts
3. If mounted, delegate to std::fs
This allows in-memory files to “shadow” host files at the same path.
3. Path Normalization
All paths are normalized before storage:
Ensures /home/user and /home/./user refer to the same node
Prevents .. escaping sandbox boundaries
Simplifies parent/child relationships
Next Steps