Skip to content

Conversation

@Sl1mb0
Copy link
Contributor

@Sl1mb0 Sl1mb0 commented Dec 4, 2025

Closes #133

Describe your proposed changes here.

  • I've read the contributing section of the project CONTRIBUTING.md.
  • Signed CLA (if not already signed).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds write support to the virtual file system (VFS) used by WASM guests, transitioning from a read-only TAR-based system to one that supports file creation, modification, and truncation. The implementation introduces new resource limits (storage bytes, file size, write operation rate) with tracking mechanisms to prevent denial-of-service attacks.

Key changes:

  • Implements write operations (write, set_size, open_at with CREATE/TRUNCATE flags)
  • Adds storage tracking and rate limiting to prevent DoS attacks
  • Includes comprehensive test suite for write functionality, permissions, and limits

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 11 comments.

File Description
host/src/vfs/limits.rs Adds three new limit fields: max_storage_bytes, max_file_size, and max_write_ops_per_sec with sensible defaults (100MB total, 10MB per file, 1000 ops/sec)
host/src/vfs/mod.rs Implements write support including: storage allocation tracking, rate limiter for write operations, VfsOutputStream (unused), file creation/truncation logic in open_at, write and set_size methods, and comprehensive test coverage

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +440 to +512
struct VfsOutputStream {
/// File node being written to.
node: SharedVfsNode,
/// Current write offset.
offset: u64,
/// Buffer for collecting writes.
write_buffer: Vec<u8>,
}

impl VfsOutputStream {
/// Create a new VFS output stream.
fn new(node: SharedVfsNode, offset: u64) -> Self {
Self {
node,
offset,
write_buffer: Vec::new(),
}
}

/// Write data to the stream buffer.
fn write_data(&mut self, data: &[u8]) -> Result<u64, FsError> {
self.write_buffer.extend_from_slice(data);
Ok(data.len() as u64)
}

/// Flush buffered data to the file.
fn flush_to_file(&mut self, vfs_state: &mut VfsState) -> Result<(), FsError> {
if self.write_buffer.is_empty() {
return Ok(());
}

// Check rate limit
vfs_state.write_rate_limiter.check_write_allowed()?;

let mut node_guard = self.node.write().unwrap();
match &mut node_guard.kind {
VfsNodeKind::File { content } => {
let new_end = self.offset + self.write_buffer.len() as u64;

// Check file size limit
if new_end > vfs_state.limits.max_file_size {
return Err(FsError::trap(ErrorCode::InsufficientMemory));
}

// Calculate storage change
let old_size = content.len() as u64;
let new_size = new_end.max(old_size);
let size_change = new_size.saturating_sub(old_size);

// Check storage limit
if size_change > 0 {
vfs_state.storage_allocation.inc(size_change)?;
}

// Resize content if needed
if new_end as usize > content.len() {
content.resize(new_end as usize, 0);
}

// Write the data
let start_offset = self.offset as usize;
let end_offset = start_offset + self.write_buffer.len();
content[start_offset..end_offset].copy_from_slice(&self.write_buffer);

self.offset = new_end;
self.write_buffer.clear();
Ok(())
}
VfsNodeKind::Directory { .. } => Err(FsError::trap(ErrorCode::IsDirectory)),
}
}
}

Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The VfsOutputStream struct and its implementation (lines 438-511) appear to be unused dead code. The methods write_at and append_via_stream (lines 634-647) that would use this stream still return ReadOnly errors with comments indicating "For now, return ReadOnly until we fully implement stream-based writes."

Either remove this dead code or integrate it into the stream-based write methods. Dead code increases maintenance burden and can confuse future developers.

Suggested change
struct VfsOutputStream {
/// File node being written to.
node: SharedVfsNode,
/// Current write offset.
offset: u64,
/// Buffer for collecting writes.
write_buffer: Vec<u8>,
}
impl VfsOutputStream {
/// Create a new VFS output stream.
fn new(node: SharedVfsNode, offset: u64) -> Self {
Self {
node,
offset,
write_buffer: Vec::new(),
}
}
/// Write data to the stream buffer.
fn write_data(&mut self, data: &[u8]) -> Result<u64, FsError> {
self.write_buffer.extend_from_slice(data);
Ok(data.len() as u64)
}
/// Flush buffered data to the file.
fn flush_to_file(&mut self, vfs_state: &mut VfsState) -> Result<(), FsError> {
if self.write_buffer.is_empty() {
return Ok(());
}
// Check rate limit
vfs_state.write_rate_limiter.check_write_allowed()?;
let mut node_guard = self.node.write().unwrap();
match &mut node_guard.kind {
VfsNodeKind::File { content } => {
let new_end = self.offset + self.write_buffer.len() as u64;
// Check file size limit
if new_end > vfs_state.limits.max_file_size {
return Err(FsError::trap(ErrorCode::InsufficientMemory));
}
// Calculate storage change
let old_size = content.len() as u64;
let new_size = new_end.max(old_size);
let size_change = new_size.saturating_sub(old_size);
// Check storage limit
if size_change > 0 {
vfs_state.storage_allocation.inc(size_change)?;
}
// Resize content if needed
if new_end as usize > content.len() {
content.resize(new_end as usize, 0);
}
// Write the data
let start_offset = self.offset as usize;
let end_offset = start_offset + self.write_buffer.len();
content[start_offset..end_offset].copy_from_slice(&self.write_buffer);
self.offset = new_end;
self.write_buffer.clear();
Ok(())
}
VfsNodeKind::Directory { .. } => Err(FsError::trap(ErrorCode::IsDirectory)),
}
}
}

Copilot uses AI. Check for mistakes.
Comment on lines 970 to 981
// Handle TRUNCATE flag
if open_flags.contains(OpenFlags::TRUNCATE) && flags.contains(DescriptorFlags::WRITE) {
let mut node_guard = node.write().unwrap();
match &mut node_guard.kind {
VfsNodeKind::File { content } => {
content.clear();
content.shrink_to_fit();
}
VfsNodeKind::Directory { .. } => return Err(FsError::trap(ErrorCode::IsDirectory)),
}
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When TRUNCATE flag is used (line 975), the file content is cleared but storage_allocation is not decremented to reflect the freed storage. This is inconsistent with how storage is tracked elsewhere and will cause the VFS to incorrectly report storage usage.

The truncate operation should update storage tracking:

let old_size = content.len() as u64;
content.clear();
content.shrink_to_fit();
// Need to decrement storage_allocation by old_size

Copilot uses AI. Check for mistakes.
Comment on lines +261 to 301
/// Rate limiter for write operations to prevent DoS attacks.
#[derive(Debug)]
struct WriteRateLimiter {
/// Window start time.
window_start: Instant,
/// Number of operations in current window.
ops_count: u32,
/// Maximum operations per second.
max_ops_per_sec: u32,
}

impl WriteRateLimiter {
/// Create a new rate limiter.
fn new(max_ops_per_sec: u32) -> Self {
Self {
window_start: Instant::now(),
ops_count: 0,
max_ops_per_sec,
}
}

/// Check if a write operation is allowed.
fn check_write_allowed(&mut self) -> Result<(), FsError> {
let now = Instant::now();
let window_duration = Duration::from_secs(1);

// Reset window if needed
if now.duration_since(self.window_start) >= window_duration {
self.window_start = now;
self.ops_count = 0;
}

// Check rate limit
if self.ops_count >= self.max_ops_per_sec {
return Err(FsError::trap(ErrorCode::Busy));
}

self.ops_count += 1;
Ok(())
}
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WriteRateLimiter stores mutable state (window_start, ops_count) but is not wrapped in any synchronization primitive. The check_write_allowed method takes &mut self, which means it requires exclusive access. However, VfsState contains the rate limiter and is accessed through &mut references in the VfsCtxView.

While this may work in the current single-threaded context, it's fragile and could lead to race conditions if the VFS is ever accessed concurrently. Consider documenting the single-threaded assumption or adding proper synchronization if concurrent access is possible.

Copilot uses AI. Check for mistakes.
Comment on lines 1407 to 1529
#[tokio::test]
async fn test_vfs_limits() {
use filesystem::types::HostDescriptor;

// Create VFS with very small limits for testing
let limits = VfsLimits {
inodes: 10,
max_path_length: 255,
max_path_segment_size: 50,
max_storage_bytes: 100, // Very small storage limit
max_file_size: 50, // Small file size limit
max_write_ops_per_sec: 2, // Low rate limit
};
let mut vfs_state = VfsState::new(limits);
let mut resource_table = ResourceTable::new();

let mut vfs_ctx = VfsCtxView {
table: &mut resource_table,
vfs_state: &mut vfs_state,
};

let root_desc = VfsDescriptor {
node: Arc::clone(&vfs_ctx.vfs_state.root),
flags: DescriptorFlags::READ | DescriptorFlags::WRITE,
};
let root_resource = vfs_ctx.table.push(root_desc).unwrap();
let root_descriptor: Resource<Descriptor> = root_resource.cast();

// Create a file
let file_descriptor = vfs_ctx
.open_at(
root_descriptor,
PathFlags::empty(),
"test_limits.txt".to_string(),
OpenFlags::CREATE,
DescriptorFlags::READ | DescriptorFlags::WRITE,
)
.await
.expect("Failed to create file");

// Try to write data exceeding file size limit
let large_data = vec![b'X'; 100]; // Larger than max_file_size (50)
let write_result = vfs_ctx.write(file_descriptor, large_data, 0).await;

match write_result {
Err(err) => {
println!("Expected error for file size limit: {:?}", err);
}
Ok(_) => panic!("Write should have failed due to file size limit"),
}

println!("VFS limits tests passed!");
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test validates max_file_size but doesn't test the max_storage_bytes limit, which is distinct. The storage limit should be tested by creating multiple files that individually are under max_file_size but collectively exceed max_storage_bytes.

Example test case:

// Create multiple small files that exceed total storage
for i in 0..3 {
    let result = vfs_ctx.open_at(..., format!("file{}.txt", i), ...).await;
    let write_result = vfs_ctx.write(file_desc, vec![b'X'; 40], 0).await;
    // Should fail on 3rd iteration: 3 * 40 = 120 > max_storage_bytes(100)
}

Copilot uses AI. Check for mistakes.

// Reset window if needed
if now.duration_since(self.window_start) >= window_duration {
self.window_start = now;
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rate limiter resets the window immediately when duration_since(window_start) >= window_duration (line 288). This creates a timing vulnerability where an attacker could perform max_ops_per_sec operations at the very end of one window, then immediately perform another max_ops_per_sec operations at the start of the next window, effectively doubling the allowed rate.

Consider using a sliding window or token bucket algorithm instead, or at minimum, increment window_start by window_duration rather than resetting it to now to prevent window skipping.

Suggested change
self.window_start = now;
// Move window forward by exactly one window to prevent window skipping
self.window_start += window_duration;

Copilot uses AI. Check for mistakes.
Comment on lines +909 to +912
// File exists, handle EXCLUSIVE flag
if open_flags.contains(OpenFlags::EXCLUSIVE) {
return Err(FsError::trap(ErrorCode::Exist));
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OpenFlags::EXCLUSIVE flag behavior is implemented (line 910) but there's no test coverage for it. Add a test case that verifies:

  1. Creating a file with CREATE | EXCLUSIVE succeeds when the file doesn't exist
  2. Opening an existing file with CREATE | EXCLUSIVE returns an ErrorCode::Exist error

This is important to ensure the exclusive creation semantics work correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +677 to 713
async fn set_size(&mut self, self_: Resource<Descriptor>, size: Filesize) -> FsResult<()> {
// Check if descriptor has write permissions
let desc = self.get_descriptor(self_)?;
if !desc.flags.contains(DescriptorFlags::WRITE) {
return Err(FsError::trap(ErrorCode::BadDescriptor));
}

let node = Arc::clone(&desc.node);

// Check rate limit
self.vfs_state.write_rate_limiter.check_write_allowed()?;

let mut node_guard = node.write().unwrap();
match &mut node_guard.kind {
VfsNodeKind::File { content } => {
// Check file size limit
if size > self.vfs_state.limits.max_file_size {
return Err(FsError::trap(ErrorCode::InsufficientMemory));
}

let old_size = content.len() as u64;
let new_size = size;

if new_size > old_size {
// Growing the file - check storage limit
let size_increase = new_size - old_size;
self.vfs_state.storage_allocation.inc(size_increase)?;
}

// Resize the content
content.resize(size as usize, 0);

Ok(())
}
VfsNodeKind::Directory { .. } => Err(FsError::trap(ErrorCode::IsDirectory)),
}
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storage allocation is not properly tracked when shrinking files. When set_size reduces the file size (line 707), the storage is freed but storage_allocation is never decremented. This leads to a memory leak in the allocation tracking where the VFS will eventually report it has hit storage limits even though actual storage has been freed.

Consider adding a decrement operation when shrinking:

if new_size < old_size {
    // Shrinking the file - release storage
    let size_decrease = old_size - new_size;
    // Need to add a `dec` method to Allocation or track decrease
}

Copilot uses AI. Check for mistakes.
Comment on lines +555 to +599
/// Create a new file at the given path.
fn create_node_at(&mut self, res: Resource<Descriptor>, path: &str) -> FsResult<SharedVfsNode> {
let parent = self.node(res)?;
let (is_root, directions) = PathTraversal::parse(path, &self.vfs_state.limits)?;

let mut directions = directions.collect::<Vec<_>>();

let name = match directions
.pop()
.ok_or_else(|| FsError::trap(ErrorCode::Invalid))??
{
PathTraversal::Down(segment) => segment,
_ => return Err(FsError::trap(ErrorCode::Invalid)),
};

let parent_start = if is_root {
Arc::clone(&self.vfs_state.root)
} else {
parent
};
let parent_node = VfsNode::traverse(parent_start, directions.into_iter())?;

// Check if file already exists
let mut parent_guard = parent_node.write().unwrap();
match &mut parent_guard.kind {
VfsNodeKind::Directory { children } => {
if children.contains_key(&name) {
return Err(FsError::trap(ErrorCode::Exist));
}

let file_node = Arc::new(RwLock::new(VfsNode {
kind: VfsNodeKind::File {
content: Vec::new(),
},
parent: Some(Arc::downgrade(&parent_node)),
}));

self.vfs_state.inodes_allocation.inc(1)?;

children.insert(name, Arc::clone(&file_node));
Ok(file_node)
}
VfsNodeKind::File { .. } => Err(FsError::trap(ErrorCode::NotDirectory)),
}
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The create_node_at helper function (lines 555-599) appears to be unused. The open_at method (lines 891-987) contains duplicated logic for file creation rather than calling this helper. This code duplication violates DRY principles.

Either remove this unused function or refactor open_at to use it, eliminating the duplicated file creation logic on lines 922-962.

Copilot uses AI. Check for mistakes.
Comment on lines 1407 to 1529
#[tokio::test]
async fn test_vfs_limits() {
use filesystem::types::HostDescriptor;

// Create VFS with very small limits for testing
let limits = VfsLimits {
inodes: 10,
max_path_length: 255,
max_path_segment_size: 50,
max_storage_bytes: 100, // Very small storage limit
max_file_size: 50, // Small file size limit
max_write_ops_per_sec: 2, // Low rate limit
};
let mut vfs_state = VfsState::new(limits);
let mut resource_table = ResourceTable::new();

let mut vfs_ctx = VfsCtxView {
table: &mut resource_table,
vfs_state: &mut vfs_state,
};

let root_desc = VfsDescriptor {
node: Arc::clone(&vfs_ctx.vfs_state.root),
flags: DescriptorFlags::READ | DescriptorFlags::WRITE,
};
let root_resource = vfs_ctx.table.push(root_desc).unwrap();
let root_descriptor: Resource<Descriptor> = root_resource.cast();

// Create a file
let file_descriptor = vfs_ctx
.open_at(
root_descriptor,
PathFlags::empty(),
"test_limits.txt".to_string(),
OpenFlags::CREATE,
DescriptorFlags::READ | DescriptorFlags::WRITE,
)
.await
.expect("Failed to create file");

// Try to write data exceeding file size limit
let large_data = vec![b'X'; 100]; // Larger than max_file_size (50)
let write_result = vfs_ctx.write(file_descriptor, large_data, 0).await;

match write_result {
Err(err) => {
println!("Expected error for file size limit: {:?}", err);
}
Ok(_) => panic!("Write should have failed due to file size limit"),
}

println!("VFS limits tests passed!");
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test sets max_write_ops_per_sec: 2 but only performs a single write operation, so the rate limiting functionality is never actually tested. Add a test that performs multiple writes in quick succession to verify the rate limiter correctly rejects operations when the limit is exceeded.

Example:

// Perform 3 writes quickly (limit is 2)
for i in 0..3 {
    let result = vfs_ctx.write(file_desc, vec![b'X'; 10], i * 10).await;
    if i >= 2 {
        assert!(result.is_err(), "Should hit rate limit");
    }
}

Copilot uses AI. Check for mistakes.
Comment on lines +637 to 647
// For now, return ReadOnly until we fully implement stream-based writes
Err(FsError::trap(ErrorCode::ReadOnly))
}

fn append_via_stream(
&mut self,
_self_: Resource<Descriptor>,
) -> FsResult<Resource<OutputStream>> {
// For now, return ReadOnly until we fully implement stream-based writes
Err(FsError::trap(ErrorCode::ReadOnly))
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The comment says "For now, return ReadOnly until we fully implement stream-based writes" but this appears to be misleading. The PR title is "add write support to virtual file system" and the write method (line 749) is fully implemented. These comments suggest the stream-based API was intentionally left unimplemented as a separate concern from the descriptor-based write method.

Consider clarifying these comments to indicate that stream-based writes are intentionally deferred and explaining the difference between stream-based and descriptor-based writes, or create a TODO/issue reference for future work.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Virtal File System: Write Support

2 participants