Skip to content

Commit

Permalink
feat(virtualfs): Add NFS server and improve overlayfs impl (#141)
Browse files Browse the repository at this point in the history
* feat(virtualfs): Add NFS server implementation

- Add NFS server implementation with full NFSv3 protocol support
- Implement path-to-fileid mapping and bidirectional lookups
- Add Unix-specific metadata support (uid/gid/permissions)
- Update MemoryFileSystem to handle empty paths and directory removal
- Add example showing NFS server usage
- Add workspace-level dependencies for nfsserve, intaglio and users crates

* refactor(virtualfs): improve directory handling and OCI compatibility

- Add recursive directory copy-up functionality for renames
- Enhance directory listing to properly handle whiteouts and opaque markers
- Update documentation to clarify OCI whiteout compatibility
- Add comprehensive tests for directory operations with markers
- Improve metadata preservation during directory operations

The changes make the overlay filesystem more robust when handling
directories, especially during rename operations, while maintaining
compatibility with OCI-style whiteouts and opaque markers.

* refactor: cargo fmt
  • Loading branch information
appcypher authored Feb 17, 2025
1 parent a7e2ee7 commit c4775ff
Show file tree
Hide file tree
Showing 15 changed files with 2,553 additions and 226 deletions.
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls"] }
regex = "1.10"
async-recursion = "1.1"
cfg-if = "1.0"
nfsserve = "0.10"
intaglio = "1.10"
users = "0.11"
151 changes: 76 additions & 75 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions monofs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ pretty-error-debug.workspace = true
async-trait.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
nfsserve = "0.10"
intaglio = "1.10"
nfsserve.workspace = true
intaglio.workspace = true
hex.workspace = true
tempfile.workspace = true
clap.workspace = true
Expand Down
8 changes: 8 additions & 0 deletions virtualfs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ chrono.workspace = true
getset.workspace = true
cfg-if.workspace = true
async-recursion.workspace = true
nfsserve.workspace = true
intaglio.workspace = true
users.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

[dev-dependencies]
clap.workspace = true
68 changes: 68 additions & 0 deletions virtualfs/examples/overlaynfs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! This example demonstrates running a simple NFS server using monofs.
//!
//! The example shows how to:
//! - Set up and configure an NFS server
//! - Serve a monofs filesystem over NFS
//! - Handle server configuration options
//!
//! Operations demonstrated:
//! 1. Parsing command line arguments for server configuration
//! 2. Setting up the NFS server
//! 3. Binding to a specified port
//! 4. Serving the filesystem
//!
//! To run the example:
//! ```bash
//! cargo run --example nfs -- /path/to/store --port 2049
//! ```
use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
use virtualfs::{MemoryFileSystem, VirtualFileSystemServer, DEFAULT_HOST, DEFAULT_NFS_PORT};

//--------------------------------------------------------------------------------------------------
// Types
//--------------------------------------------------------------------------------------------------

/// Simple NFS server that serves the monofs filesystem.
#[derive(Parser, Debug)]
#[command(author, long_about = None)]
struct Args {
/// Paths to the layers to overlay
layers: Vec<PathBuf>,

/// Host address to bind to
#[arg(short = 'H', long, default_value = DEFAULT_HOST)]
host: String,

/// Port to listen on
#[arg(short = 'P', long, default_value_t = DEFAULT_NFS_PORT)]
port: u32,
}

//--------------------------------------------------------------------------------------------------
// Functions: main
//--------------------------------------------------------------------------------------------------

#[tokio::main]
async fn main() -> Result<()> {
// Parse command line arguments
let args = Args::parse();

// Initialize logging
tracing_subscriber::fmt::init();

// Create and start the server
// let fs = OverlayFileSystem::new()?;
let fs = MemoryFileSystem::new();
let server = VirtualFileSystemServer::new(fs, args.host, args.port);
tracing::info!(
"Starting NFS server on {}:{}",
server.get_host(),
server.get_port()
);

server.start().await?;
Ok(())
}
9 changes: 9 additions & 0 deletions virtualfs/lib/defaults.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//--------------------------------------------------------------------------------------------------
// Constants
//--------------------------------------------------------------------------------------------------

/// The default host address to bind to.
pub const DEFAULT_HOST: &str = "127.0.0.1";

/// The default NFS port number to use.
pub const DEFAULT_NFS_PORT: u32 = 2049;
28 changes: 28 additions & 0 deletions virtualfs/lib/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub enum VfsError {
#[error("path is not a symlink: {0}")]
NotASymlink(PathBuf),

/// The directory is not empty
#[error("directory is not empty: {0}")]
NotEmpty(PathBuf),

/// Invalid offset for read/write operation
#[error("invalid offset {offset} for path: {path}")]
InvalidOffset {
Expand Down Expand Up @@ -140,3 +144,27 @@ impl Display for AnyError {
}

impl Error for AnyError {}

impl From<VfsError> for nfsserve::nfs::nfsstat3 {
fn from(error: VfsError) -> Self {
use nfsserve::nfs::nfsstat3;
match error {
VfsError::ParentDirectoryNotFound(_) => nfsstat3::NFS3ERR_NOENT,
VfsError::AlreadyExists(_) => nfsstat3::NFS3ERR_EXIST,
VfsError::NotFound(_) => nfsstat3::NFS3ERR_NOENT,
VfsError::NotADirectory(_) => nfsstat3::NFS3ERR_NOTDIR,
VfsError::NotAFile(_) => nfsstat3::NFS3ERR_INVAL,
VfsError::NotASymlink(_) => nfsstat3::NFS3ERR_INVAL,
VfsError::NotEmpty(_) => nfsstat3::NFS3ERR_NOTEMPTY,
VfsError::InvalidOffset { .. } => nfsstat3::NFS3ERR_INVAL,
VfsError::PermissionDenied(_) => nfsstat3::NFS3ERR_PERM,
VfsError::ReadOnlyFilesystem => nfsstat3::NFS3ERR_ROFS,
VfsError::InvalidSymlinkTarget(_) => nfsstat3::NFS3ERR_INVAL,
VfsError::EmptyPathSegment => nfsstat3::NFS3ERR_INVAL,
VfsError::InvalidPathComponent(_) => nfsstat3::NFS3ERR_INVAL,
VfsError::Io(_) => nfsstat3::NFS3ERR_IO,
VfsError::OverlayFileSystemRequiresAtLeastOneLayer => nfsstat3::NFS3ERR_INVAL,
VfsError::Custom(_) => nfsstat3::NFS3ERR_IO,
}
}
}
125 changes: 87 additions & 38 deletions virtualfs/lib/implementations/memoryfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,10 @@ impl MemoryFileReader {
#[async_trait]
impl VirtualFileSystem for MemoryFileSystem {
async fn exists(&self, path: &Path) -> VfsResult<bool> {
if path == Path::new("") {
return Ok(true);
}

let result = self.root_dir.read().await.find(path)?.is_some();
Ok(result)
}
Expand Down Expand Up @@ -617,8 +621,11 @@ impl VirtualFileSystem for MemoryFileSystem {
}

async fn get_metadata(&self, path: &Path) -> VfsResult<Metadata> {
// Find the entity
let root = self.root_dir.read().await;
if path == Path::new("") {
return Ok(root.get_metadata().clone());
}

let entity = root
.find(path)?
.ok_or_else(|| VfsError::NotFound(path.to_path_buf()))?;
Expand Down Expand Up @@ -676,6 +683,9 @@ impl VirtualFileSystem for MemoryFileSystem {
// Write the data at the specified offset
content[offset..offset + buffer.len()].copy_from_slice(&buffer);

// Update the file's metadata size to reflect the new content length
file.metadata.set_size(content.len() as u64);

Ok(())
}

Expand All @@ -686,16 +696,18 @@ impl VirtualFileSystem for MemoryFileSystem {
let parent_dir = MemoryFileSystem::get_parent_dir(&mut root, parent)?;

match parent_dir.get(&key) {
Some(entity) => match entity {
Entity::Dir(_) => return Err(VfsError::NotAFile(path.to_path_buf())),
_ => {
parent_dir.entries.remove(&key);
Some(entity) => {
// Check if it's a non-empty directory
if let Entity::Dir(dir) = entity {
if !dir.entries.is_empty() {
return Err(VfsError::NotEmpty(path.to_path_buf()));
}
}
},
None => return Err(VfsError::NotFound(path.to_path_buf())),
};

Ok(())
parent_dir.entries.remove(&key);
Ok(())
}
None => Err(VfsError::NotFound(path.to_path_buf())),
}
}

async fn rename(&self, old_path: &Path, new_path: &Path) -> VfsResult<()> {
Expand Down Expand Up @@ -782,8 +794,12 @@ impl VirtualFileSystem for MemoryFileSystem {
}

async fn set_metadata(&self, path: &Path, metadata: Metadata) -> VfsResult<()> {
// Find the entity and get a mutable reference
let mut root = self.root_dir.write().await;
if path == Path::new("") {
root.metadata = metadata;
return Ok(());
}

let entity = root
.find_mut(path)?
.ok_or_else(|| VfsError::NotFound(path.to_path_buf()))?;
Expand Down Expand Up @@ -1594,9 +1610,9 @@ mod tests {
Entity::File(File::new()),
)
.unwrap();
// Create a directory
// Create an empty directory
root.put(
PathSegment::try_from("dir").unwrap(),
PathSegment::try_from("empty_dir").unwrap(),
Entity::Dir(Dir::new()),
)
.unwrap();
Expand All @@ -1606,7 +1622,7 @@ mod tests {
Entity::Symlink(Symlink::new(PathBuf::from("target"))),
)
.unwrap();
// Create a nested file
// Create a non-empty directory
let mut subdir = Dir::new();
subdir
.put(
Expand All @@ -1615,38 +1631,44 @@ mod tests {
)
.unwrap();
root.put(
PathSegment::try_from("subdir").unwrap(),
PathSegment::try_from("non_empty_dir").unwrap(),
Entity::Dir(subdir),
)
.unwrap();
}

// Test removing a file
assert!(fs.remove(Path::new("file.txt")).await.is_ok());
assert!(matches!(
fs.read_file(Path::new("file.txt"), 0, 1).await,
Err(VfsError::NotFound(_))
));
assert!(!fs.exists(Path::new("file.txt")).await.unwrap());

// Test removing a symlink
assert!(fs.remove(Path::new("link")).await.is_ok());
assert!(matches!(
fs.read_symlink(Path::new("link")).await,
Err(VfsError::NotFound(_))
));
assert!(!fs.exists(Path::new("link")).await.unwrap());

// Test removing a directory (should fail)
assert!(matches!(
fs.remove(Path::new("dir")).await,
Err(VfsError::NotAFile(_))
));
// Test removing an empty directory
assert!(fs.remove(Path::new("empty_dir")).await.is_ok());
assert!(!fs.exists(Path::new("empty_dir")).await.unwrap());

// Test removing a nested file
assert!(fs.remove(Path::new("subdir/nested.txt")).await.is_ok());
// Test attempting to remove a non-empty directory (should fail)
assert!(matches!(
fs.read_file(Path::new("subdir/nested.txt"), 0, 1).await,
Err(VfsError::NotFound(_))
fs.remove(Path::new("non_empty_dir")).await,
Err(VfsError::NotEmpty(_))
));
// Verify directory and its contents still exist
assert!(fs.exists(Path::new("non_empty_dir")).await.unwrap());
assert!(fs
.exists(Path::new("non_empty_dir/nested.txt"))
.await
.unwrap());

// Remove the nested file first
assert!(fs
.remove(Path::new("non_empty_dir/nested.txt"))
.await
.is_ok());
// Now removing the directory should succeed
assert!(fs.remove(Path::new("non_empty_dir")).await.is_ok());
assert!(!fs.exists(Path::new("non_empty_dir")).await.unwrap());

// Test removing non-existent file
assert!(matches!(
Expand All @@ -1659,6 +1681,39 @@ mod tests {
fs.remove(Path::new("nonexistent/file.txt")).await,
Err(VfsError::ParentDirectoryNotFound(_))
));

// Test removing with invalid path components
for invalid_path in [".", "..", "/"] {
assert!(matches!(
fs.remove(Path::new(invalid_path)).await,
Err(VfsError::InvalidPathComponent(_))
));
}

// Test concurrent removal
{
let mut root = fs.root_dir.write().await;
root.put(
PathSegment::try_from("concurrent.txt").unwrap(),
Entity::File(File::new()),
)
.unwrap();
}

let fs2 = fs.clone();
let handle1 = tokio::spawn(async move { fs.remove(Path::new("concurrent.txt")).await });
let handle2 = tokio::spawn(async move { fs2.remove(Path::new("concurrent.txt")).await });

let (result1, result2) = tokio::join!(handle1, handle2);
let results = vec![result1.unwrap(), result2.unwrap()];
assert!(results.iter().filter(|r| r.is_ok()).count() == 1);
assert!(
results
.iter()
.filter(|r| matches!(r, Err(VfsError::NotFound(_))))
.count()
== 1
);
}

#[tokio::test]
Expand Down Expand Up @@ -1708,12 +1763,6 @@ mod tests {
assert!(fs.exists(Path::new("renamed.txt")).await.unwrap());
assert!(!fs.exists(Path::new("file.txt")).await.unwrap());

// Verify content is preserved
let mut buf = Vec::new();
let mut reader = fs.read_file(Path::new("renamed.txt"), 0, 3).await.unwrap();
tokio::io::copy(&mut reader, &mut buf).await.unwrap();
assert_eq!(buf, vec![1, 2, 3]);

// Test 2: Move file to different directory
assert!(fs
.rename(Path::new("renamed.txt"), Path::new("dir1/moved.txt"))
Expand Down
Loading

0 comments on commit c4775ff

Please sign in to comment.