use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
pub type WasiFd = u32;
#[allow(dead_code)]
pub struct WasiFilesystem {
mounts: Arc<RwLock<HashMap<String, PathBuf>>>,
fd_table: Arc<RwLock<HashMap<WasiFd, OpenFile>>>,
next_fd: Arc<RwLock<WasiFd>>,
config: WasiConfig,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct WasiConfig {
pub allow_host_access: bool,
pub read_only: bool,
pub max_file_size: usize,
}
impl Default for WasiConfig {
fn default() -> Self {
Self {
allow_host_access: true,
read_only: false,
max_file_size: 100 * 1024 * 1024, }
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
struct OpenFile {
path: PathBuf,
flags: OpenFlags,
offset: usize,
virtual_path: String,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub struct OpenFlags {
pub read: bool,
pub write: bool,
pub append: bool,
pub create: bool,
pub truncate: bool,
}
impl Default for OpenFlags {
fn default() -> Self {
Self {
read: true,
write: false,
append: false,
create: false,
truncate: false,
}
}
}
impl Default for WasiFilesystem {
fn default() -> Self {
Self::new()
}
}
#[allow(dead_code)]
impl WasiFilesystem {
pub fn new() -> Self {
Self::with_config(WasiConfig::default())
}
pub fn with_config(config: WasiConfig) -> Self {
Self {
mounts: Arc::new(RwLock::new(HashMap::new())),
fd_table: Arc::new(RwLock::new(HashMap::new())),
next_fd: Arc::new(RwLock::new(3)), config,
}
}
pub fn mount(&self, guest_path: &str, host_path: impl AsRef<Path>) -> Result<()> {
let host_path = host_path.as_ref();
if !host_path.exists() {
anyhow::bail!(
"Cannot mount non-existent directory: {}",
host_path.display()
);
}
if !host_path.is_dir() {
anyhow::bail!("Cannot mount non-directory path: {}", host_path.display());
}
let canonical = host_path.canonicalize()?;
let mut mounts = self.mounts.write().unwrap();
mounts.insert(guest_path.to_string(), canonical);
Ok(())
}
pub fn unmount(&self, guest_path: &str) -> Option<PathBuf> {
let mut mounts = self.mounts.write().unwrap();
mounts.remove(guest_path)
}
pub fn list_mounts(&self) -> Vec<(String, PathBuf)> {
let mounts = self.mounts.read().unwrap();
mounts.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
}
pub fn path_open(&self, virtual_path: &str, flags: OpenFlags) -> Result<WasiFd> {
if self.config.read_only && (flags.write || flags.create) {
anyhow::bail!("Filesystem is in read-only mode");
}
let host_path = self.resolve_path(virtual_path)?;
if flags.create && !host_path.exists() {
if let Some(parent) = host_path.parent() {
fs::create_dir_all(parent)?;
}
fs::File::create(&host_path)?;
}
if !host_path.exists() {
anyhow::bail!("File not found: {virtual_path}");
}
if flags.truncate && host_path.is_file() {
fs::File::create(&host_path)?;
}
let fd = {
let mut next_fd = self.next_fd.write().unwrap();
let fd = *next_fd;
*next_fd += 1;
fd
};
let mut fd_table = self.fd_table.write().unwrap();
fd_table.insert(
fd,
OpenFile {
path: host_path,
flags,
offset: 0, virtual_path: virtual_path.to_string(),
},
);
Ok(fd)
}
pub fn fd_read(&self, fd: WasiFd, count: usize) -> Result<Vec<u8>> {
let mut fd_table = self.fd_table.write().unwrap();
let open_file = fd_table
.get_mut(&fd)
.ok_or_else(|| anyhow::anyhow!("Invalid file descriptor: {fd}"))?;
if !open_file.flags.read {
anyhow::bail!("File not open for reading");
}
let data = fs::read(&open_file.path)?;
let start = open_file.offset.min(data.len());
let end = (start + count).min(data.len());
let result = data[start..end].to_vec();
open_file.offset = end;
Ok(result)
}
pub fn fd_write(&self, fd: WasiFd, data: &[u8]) -> Result<usize> {
if self.config.read_only {
anyhow::bail!("Filesystem is in read-only mode");
}
if data.len() > self.config.max_file_size {
anyhow::bail!("File size exceeds maximum allowed size");
}
let mut fd_table = self.fd_table.write().unwrap();
let open_file = fd_table
.get_mut(&fd)
.ok_or_else(|| anyhow::anyhow!("Invalid file descriptor: {fd}"))?;
if !open_file.flags.write {
anyhow::bail!("File not open for writing");
}
if open_file.flags.append {
let mut file = fs::OpenOptions::new().append(true).open(&open_file.path)?;
std::io::Write::write_all(&mut file, data)?;
open_file.offset += data.len();
} else {
let mut content = if open_file.path.exists() {
fs::read(&open_file.path)?
} else {
Vec::new()
};
if open_file.offset + data.len() > content.len() {
content.resize(open_file.offset + data.len(), 0);
}
content[open_file.offset..open_file.offset + data.len()].copy_from_slice(data);
fs::write(&open_file.path, &content)?;
open_file.offset += data.len();
}
Ok(data.len())
}
pub fn fd_close(&self, fd: WasiFd) -> Result<()> {
let mut fd_table = self.fd_table.write().unwrap();
fd_table
.remove(&fd)
.ok_or_else(|| anyhow::anyhow!("Invalid file descriptor: {fd}"))?;
Ok(())
}
pub fn fd_seek(&self, fd: WasiFd, offset: i64, whence: SeekWhence) -> Result<usize> {
let mut fd_table = self.fd_table.write().unwrap();
let open_file = fd_table
.get_mut(&fd)
.ok_or_else(|| anyhow::anyhow!("Invalid file descriptor: {fd}"))?;
let file_size = fs::metadata(&open_file.path)?.len() as usize;
let new_offset = match whence {
SeekWhence::Start => offset.max(0) as usize,
SeekWhence::Current => {
let current = open_file.offset as i64;
(current + offset).max(0) as usize
}
SeekWhence::End => {
let end = file_size as i64;
(end + offset).max(0) as usize
}
};
open_file.offset = new_offset;
Ok(new_offset)
}
pub fn path_create_directory(&self, virtual_path: &str) -> Result<()> {
if self.config.read_only {
anyhow::bail!("Filesystem is in read-only mode");
}
let host_path = self.resolve_path(virtual_path)?;
fs::create_dir_all(&host_path)
.with_context(|| format!("Failed to create directory: {virtual_path}"))?;
Ok(())
}
pub fn path_remove_directory(&self, virtual_path: &str) -> Result<()> {
if self.config.read_only {
anyhow::bail!("Filesystem is in read-only mode");
}
let host_path = self.resolve_path(virtual_path)?;
fs::remove_dir(&host_path)
.with_context(|| format!("Failed to remove directory: {virtual_path}"))?;
Ok(())
}
pub fn path_unlink_file(&self, virtual_path: &str) -> Result<()> {
if self.config.read_only {
anyhow::bail!("Filesystem is in read-only mode");
}
let host_path = self.resolve_path(virtual_path)?;
fs::remove_file(&host_path)
.with_context(|| format!("Failed to unlink file: {virtual_path}"))?;
Ok(())
}
pub fn path_readdir(&self, virtual_path: &str) -> Result<Vec<DirEntry>> {
let host_path = self.resolve_path(virtual_path)?;
let entries: Result<Vec<DirEntry>> = fs::read_dir(&host_path)?
.map(|entry| {
let entry = entry?;
let metadata = entry.metadata()?;
Ok(DirEntry {
name: entry.file_name().to_string_lossy().to_string(),
is_dir: metadata.is_dir(),
is_file: metadata.is_file(),
size: metadata.len(),
})
})
.collect();
entries
}
pub fn path_filestat_get(&self, virtual_path: &str) -> Result<FileStats> {
let host_path = self.resolve_path(virtual_path)?;
let metadata = fs::metadata(&host_path)?;
Ok(FileStats {
is_file: metadata.is_file(),
is_dir: metadata.is_dir(),
size: metadata.len(),
accessed: metadata.accessed().ok(),
modified: metadata.modified().ok(),
created: metadata.created().ok(),
})
}
pub fn path_exists(&self, virtual_path: &str) -> bool {
self.resolve_path(virtual_path)
.map(|p| p.exists())
.unwrap_or(false)
}
pub fn read_file(&self, virtual_path: &str) -> Result<Vec<u8>> {
let host_path = self.resolve_path(virtual_path)?;
let data = fs::read(&host_path)?;
if data.len() > self.config.max_file_size {
anyhow::bail!("File size exceeds maximum allowed size");
}
Ok(data)
}
pub fn write_file(&self, virtual_path: &str, data: &[u8]) -> Result<()> {
if self.config.read_only {
anyhow::bail!("Filesystem is in read-only mode");
}
if data.len() > self.config.max_file_size {
anyhow::bail!("File size exceeds maximum allowed size");
}
let host_path = self.resolve_path(virtual_path)?;
if let Some(parent) = host_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&host_path, data)?;
Ok(())
}
pub fn get_stats(&self) -> WasiFilesystemStats {
let mounts = self.mounts.read().unwrap();
let total_mounts = mounts.len();
let total_size: u64 = mounts
.values()
.filter_map(|path| Self::calculate_dir_size(path).ok())
.sum();
let fd_table = self.fd_table.read().unwrap();
let open_fds = fd_table.len();
WasiFilesystemStats {
total_mounts,
total_size,
open_fds,
mounts: mounts
.iter()
.map(|(k, v)| MountInfo {
guest_path: k.clone(),
host_path: v.clone(),
size: Self::calculate_dir_size(v).unwrap_or(0),
})
.collect(),
}
}
fn resolve_path(&self, virtual_path: &str) -> Result<PathBuf> {
let mounts = self.mounts.read().unwrap();
let best_match = mounts
.iter()
.filter(|(guest_path, _)| {
virtual_path == guest_path.as_str()
|| virtual_path.starts_with(&format!("{}/", guest_path.trim_end_matches('/')))
|| guest_path.as_str() == "/"
})
.max_by_key(|(guest_path, _)| guest_path.len());
let (guest_path, host_path) =
best_match.ok_or_else(|| anyhow::anyhow!("Path not mounted: {virtual_path}"))?;
let relative = virtual_path
.strip_prefix(guest_path)
.unwrap_or(virtual_path)
.trim_start_matches('/');
let resolved = host_path.join(relative);
let canonical_mount = host_path.canonicalize()?;
let canonical_resolved = if let Ok(canon) = resolved.canonicalize() {
canon
} else if let Some(parent) = resolved.parent() {
parent.canonicalize()?
} else {
anyhow::bail!("Invalid path: {virtual_path}");
};
if !canonical_resolved.starts_with(&canonical_mount) {
anyhow::bail!("Path escapes mount point: {virtual_path}");
}
Ok(resolved)
}
fn calculate_dir_size(path: &Path) -> Result<u64> {
let mut total = 0u64;
if path.is_file() {
return Ok(path.metadata()?.len());
}
if !path.is_dir() {
return Ok(0);
}
for entry in fs::read_dir(path)? {
let entry = entry?;
let metadata = entry.metadata()?;
if metadata.is_file() {
total += metadata.len();
} else if metadata.is_dir() {
total += Self::calculate_dir_size(&entry.path())?;
}
}
Ok(total)
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeekWhence {
Start,
Current,
End,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub is_file: bool,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileStats {
pub is_file: bool,
pub is_dir: bool,
pub size: u64,
pub accessed: Option<std::time::SystemTime>,
pub modified: Option<std::time::SystemTime>,
pub created: Option<std::time::SystemTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasiFilesystemStats {
pub total_mounts: usize,
pub total_size: u64,
pub open_fds: usize,
pub mounts: Vec<MountInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MountInfo {
pub guest_path: String,
pub host_path: PathBuf,
pub size: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_wasi_filesystem_creation() {
let fs = WasiFilesystem::new();
assert_eq!(fs.list_mounts().len(), 0);
}
#[test]
fn test_mount_directory() {
let fs = WasiFilesystem::new();
let temp = tempdir().unwrap();
fs.mount("/test", temp.path()).unwrap();
let mounts = fs.list_mounts();
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].0, "/test");
}
#[test]
fn test_mount_nonexistent_directory() {
let fs = WasiFilesystem::new();
let result = fs.mount("/test", "/nonexistent/path");
assert!(result.is_err());
}
#[test]
fn test_unmount() {
let fs = WasiFilesystem::new();
let temp = tempdir().unwrap();
fs.mount("/test", temp.path()).unwrap();
assert_eq!(fs.list_mounts().len(), 1);
let unmounted = fs.unmount("/test");
assert!(unmounted.is_some());
assert_eq!(fs.list_mounts().len(), 0);
}
#[test]
fn test_read_write_file() {
let fs = WasiFilesystem::new();
let temp = tempdir().unwrap();
fs.mount("/test", temp.path()).unwrap();
fs.write_file("/test/file.txt", b"Hello, WASI!").unwrap();
let content = fs.read_file("/test/file.txt").unwrap();
assert_eq!(content, b"Hello, WASI!");
}
#[test]
fn test_path_operations() {
let fs = WasiFilesystem::new();
let temp = tempdir().unwrap();
fs.mount("/test", temp.path()).unwrap();
fs.path_create_directory("/test/subdir").unwrap();
assert!(fs.path_exists("/test/subdir"));
fs.write_file("/test/subdir/file.txt", b"content").unwrap();
let stats = fs.path_filestat_get("/test/subdir/file.txt").unwrap();
assert!(stats.is_file);
assert_eq!(stats.size, 7);
let entries = fs.path_readdir("/test").unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "subdir");
assert!(entries[0].is_dir);
}
#[test]
fn test_fd_operations() {
let fs = WasiFilesystem::new();
let temp = tempdir().unwrap();
fs.mount("/test", temp.path()).unwrap();
fs.write_file("/test/test.txt", b"Hello, World!").unwrap();
let fd = fs
.path_open(
"/test/test.txt",
OpenFlags {
read: true,
..Default::default()
},
)
.unwrap();
let data = fs.fd_read(fd, 5).unwrap();
assert_eq!(data, b"Hello");
let data = fs.fd_read(fd, 7).unwrap();
assert_eq!(data, b", World");
fs.fd_close(fd).unwrap();
}
#[test]
fn test_readonly_mode() {
let config = WasiConfig {
read_only: true,
..Default::default()
};
let fs = WasiFilesystem::with_config(config);
let temp = tempdir().unwrap();
fs.mount("/test", temp.path()).unwrap();
let result = fs.write_file("/test/file.txt", b"data");
assert!(result.is_err());
}
}