-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add write support to virtual file system #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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_atwith 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.
| 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
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| 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)), | |
| } | |
| } | |
| } |
| // 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)), | ||
| } | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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| /// 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(()) | ||
| } | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| #[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!"); | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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)
}|
|
||
| // Reset window if needed | ||
| if now.duration_since(self.window_start) >= window_duration { | ||
| self.window_start = now; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| self.window_start = now; | |
| // Move window forward by exactly one window to prevent window skipping | |
| self.window_start += window_duration; |
| // File exists, handle EXCLUSIVE flag | ||
| if open_flags.contains(OpenFlags::EXCLUSIVE) { | ||
| return Err(FsError::trap(ErrorCode::Exist)); | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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:
- Creating a file with
CREATE | EXCLUSIVEsucceeds when the file doesn't exist - Opening an existing file with
CREATE | EXCLUSIVEreturns anErrorCode::Existerror
This is important to ensure the exclusive creation semantics work correctly.
| 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)), | ||
| } | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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
}| /// 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)), | ||
| } | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
| #[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!"); | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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");
}
}| // 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)) | ||
| } |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
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.
Closes #133
Describe your proposed changes here.