Skip to main content
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(())
}
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))
        }
    }
}
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

Build docs developers (and LLMs) love