diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index aac8dcb..05d082c 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -24,6 +24,8 @@ const PROJECT_PERSISTENCE_WRITE_ELF: &[u8] = include_bytes!( ); const PROJECT_PERSISTENCE_READ_ELF: &[u8] = include_bytes!("../../../guest-tests/project-persistence/project-persistence-read.aarch64.elf"); +const VFS_LIFECYCLE_ELF: &[u8] = + include_bytes!("../../../guest-tests/vfs-lifecycle/vfs-lifecycle.aarch64.elf"); /// Browser-safe result returned after a guest exits or stops diagnostically. #[wasm_bindgen] @@ -263,6 +265,7 @@ fn fixture(name: &str) -> Option<(&'static [u8], &'static [u8])> { "project-persistence-read" => { Some((PROJECT_PERSISTENCE_READ_ELF, b"/project-persistence-read")) } + "vfs-lifecycle" => Some((VFS_LIFECYCLE_ELF, b"/vfs-lifecycle")), _ => None, } } @@ -437,4 +440,24 @@ mod tests { assert_eq!(read.outcome, "exited"); assert_eq!(read.stdout, "persistent project data\n"); } + + #[test] + fn executes_the_vfs_lifecycle_fixture() { + let result = execute_fixture( + "vfs-lifecycle", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + &[], + ); + + assert_eq!(result.outcome, "exited"); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "updated\n"); + assert!(result.trace.contains("getdents64(fd=3")); + assert!(result.trace.contains("renameat(olddirfd=-100")); + assert!(result.trace.contains("unlinkat(dirfd=-100")); + } } diff --git a/crates/host-api/src/lib.rs b/crates/host-api/src/lib.rs index 93b0478..2787331 100644 --- a/crates/host-api/src/lib.rs +++ b/crates/host-api/src/lib.rs @@ -53,6 +53,7 @@ impl FileOpenFlags { pub const EXCLUSIVE: Self = Self(1 << 1); pub const TRUNCATE: Self = Self(1 << 2); pub const APPEND: Self = Self(1 << 3); + pub const DIRECTORY: Self = Self(1 << 4); #[must_use] pub const fn union(self, other: Self) -> Self { @@ -80,6 +81,20 @@ pub enum FileSeekFrom { End, } +/// Kind of an entry returned while enumerating a directory. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FileType { + Regular, + Directory, +} + +/// One host-independent directory entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DirectoryEntry { + pub name: Vec, + pub file_type: FileType, +} + /// Filesystem failures that map deterministically onto Linux errno values. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FileSystemError { @@ -91,6 +106,7 @@ pub enum FileSystemError { PermissionDenied, InvalidInput, NoSpace, + NotEmpty, Unsupported, } @@ -137,6 +153,43 @@ pub trait HostFileSystem { /// /// Returns [`FileSystemError::BadDescriptor`] when the handle is unknown. fn close(&mut self, handle: u64) -> Result<(), FileSystemError>; + + /// Create a directory at a normalized absolute path. + /// + /// # Errors + /// + /// Returns a stable filesystem error when the parent is missing or the + /// target already exists. + fn create_directory(&mut self, path: &[u8]) -> Result<(), FileSystemError>; + + /// Read directory entries from an opaque directory handle, advancing its + /// enumeration cursor. + /// + /// # Errors + /// + /// Returns a stable filesystem error when the handle is invalid or does + /// not refer to a directory. + fn read_directory( + &mut self, + handle: u64, + max_entries: usize, + ) -> Result, FileSystemError>; + + /// Atomically rename a regular file or directory within the VFS. + /// + /// # Errors + /// + /// Returns a stable filesystem error for invalid paths or conflicting + /// targets. + fn rename(&mut self, old_path: &[u8], new_path: &[u8]) -> Result<(), FileSystemError>; + + /// Remove a regular file or, when requested, an empty directory. + /// + /// # Errors + /// + /// Returns a stable filesystem error when the target is missing, has the + /// wrong kind, or is a non-empty directory. + fn remove(&mut self, path: &[u8], directory: bool) -> Result<(), FileSystemError>; } /// Filesystem used by callers that intentionally provide no file service. @@ -168,4 +221,24 @@ impl HostFileSystem for NullFileSystem { fn close(&mut self, _handle: u64) -> Result<(), FileSystemError> { Err(FileSystemError::BadDescriptor) } + + fn create_directory(&mut self, _path: &[u8]) -> Result<(), FileSystemError> { + Err(FileSystemError::Unsupported) + } + + fn read_directory( + &mut self, + _handle: u64, + _max_entries: usize, + ) -> Result, FileSystemError> { + Err(FileSystemError::BadDescriptor) + } + + fn rename(&mut self, _old_path: &[u8], _new_path: &[u8]) -> Result<(), FileSystemError> { + Err(FileSystemError::Unsupported) + } + + fn remove(&mut self, _path: &[u8], _directory: bool) -> Result<(), FileSystemError> { + Err(FileSystemError::Unsupported) + } } diff --git a/crates/linux-abi/src/lib.rs b/crates/linux-abi/src/lib.rs index bacb367..22ff0e7 100644 --- a/crates/linux-abi/src/lib.rs +++ b/crates/linux-abi/src/lib.rs @@ -4,8 +4,12 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u64)] pub enum Syscall { + Mkdirat = 34, + Unlinkat = 35, + Renameat = 38, Openat = 56, Close = 57, + Getdents64 = 61, Lseek = 62, Read = 63, Write = 64, @@ -27,8 +31,12 @@ impl Syscall { #[must_use] pub const fn from_number(number: u64) -> Option { match number { + 34 => Some(Self::Mkdirat), + 35 => Some(Self::Unlinkat), + 38 => Some(Self::Renameat), 56 => Some(Self::Openat), 57 => Some(Self::Close), + 61 => Some(Self::Getdents64), 62 => Some(Self::Lseek), 63 => Some(Self::Read), 73 => Some(Self::Ppoll), @@ -70,6 +78,7 @@ pub enum Errno { TooManyOpenFiles = 24, NoSpace = 28, NoSystemCall = 38, + DirectoryNotEmpty = 39, } impl Errno { @@ -86,8 +95,12 @@ mod tests { #[test] fn decodes_the_minimal_aarch64_table() { + assert_eq!(Syscall::from_number(34), Some(Syscall::Mkdirat)); + assert_eq!(Syscall::from_number(35), Some(Syscall::Unlinkat)); + assert_eq!(Syscall::from_number(38), Some(Syscall::Renameat)); assert_eq!(Syscall::from_number(56), Some(Syscall::Openat)); assert_eq!(Syscall::from_number(57), Some(Syscall::Close)); + assert_eq!(Syscall::from_number(61), Some(Syscall::Getdents64)); assert_eq!(Syscall::from_number(62), Some(Syscall::Lseek)); assert_eq!(Syscall::from_number(63), Some(Syscall::Read)); assert_eq!(Syscall::from_number(64), Some(Syscall::Write)); diff --git a/crates/linux-runtime/src/lib.rs b/crates/linux-runtime/src/lib.rs index bf60e4b..9b71c10 100644 --- a/crates/linux-runtime/src/lib.rs +++ b/crates/linux-runtime/src/lib.rs @@ -6,8 +6,8 @@ use std::collections::BTreeMap; use binarrow_aarch64::{Aarch64State, Interpreter, InterpreterInitializationError}; use binarrow_guest_memory::{AddressSpace, Permissions, RegionKind}; use binarrow_host_api::{ - FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, HostFileSystem, - HostTerminal, NullFileSystem, TerminalStream, + FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, FileType, + HostFileSystem, HostTerminal, NullFileSystem, TerminalStream, }; use binarrow_linux_abi::{Errno, Syscall}; use binarrow_loader::ProcessImage; @@ -24,8 +24,14 @@ const OPEN_CREATE: u64 = 0x40; const OPEN_EXCLUSIVE: u64 = 0x80; const OPEN_TRUNCATE: u64 = 0x200; const OPEN_APPEND: u64 = 0x400; +const OPEN_DIRECTORY: u64 = 0x1_0000; const SUPPORTED_OPEN_FLAGS: u64 = - OPEN_ACCESS_MASK | OPEN_CREATE | OPEN_EXCLUSIVE | OPEN_TRUNCATE | OPEN_APPEND; + OPEN_ACCESS_MASK | OPEN_CREATE | OPEN_EXCLUSIVE | OPEN_TRUNCATE | OPEN_APPEND | OPEN_DIRECTORY; +const AT_REMOVE_DIRECTORY: u64 = 0x200; +const DIRECTORY_ENTRY_HEADER_SIZE: usize = 19; +const DIRECTORY_ENTRY_ALIGNMENT: usize = 8; +const DIRECTORY_TYPE: u8 = 4; +const REGULAR_FILE_TYPE: u8 = 8; const LINUX_SIGNAL_COUNT: usize = 64; const KERNEL_SIGACTION_SIZE: usize = 32; const KERNEL_SIGNAL_SET_SIZE: u64 = 8; @@ -91,14 +97,13 @@ pub struct SyscallEvent { impl fmt::Display for SyscallEvent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match Syscall::from_number(self.number) { - Some(Syscall::Openat) => write!( - formatter, - "openat(dirfd={}, path={:#x}, flags={:#x}, mode={:#o})", - self.arguments[0].cast_signed(), - self.arguments[1], - self.arguments[2], - self.arguments[3], - )?, + Some( + syscall @ (Syscall::Mkdirat + | Syscall::Unlinkat + | Syscall::Renameat + | Syscall::Openat + | Syscall::Getdents64), + ) => format_vfs_syscall(formatter, syscall, self.arguments)?, Some(Syscall::Close) => write!(formatter, "close(fd={})", self.arguments[0])?, Some(Syscall::Lseek) => write!( formatter, @@ -188,6 +193,51 @@ impl fmt::Display for SyscallEvent { } } +fn format_vfs_syscall( + formatter: &mut fmt::Formatter<'_>, + syscall: Syscall, + arguments: [u64; 6], +) -> fmt::Result { + match syscall { + Syscall::Mkdirat => write!( + formatter, + "mkdirat(dirfd={}, path={:#x}, mode={:#o})", + arguments[0].cast_signed(), + arguments[1], + arguments[2], + ), + Syscall::Unlinkat => write!( + formatter, + "unlinkat(dirfd={}, path={:#x}, flags={:#x})", + arguments[0].cast_signed(), + arguments[1], + arguments[2], + ), + Syscall::Renameat => write!( + formatter, + "renameat(olddirfd={}, oldpath={:#x}, newdirfd={}, newpath={:#x})", + arguments[0].cast_signed(), + arguments[1], + arguments[2].cast_signed(), + arguments[3], + ), + Syscall::Openat => write!( + formatter, + "openat(dirfd={}, path={:#x}, flags={:#x}, mode={:#o})", + arguments[0].cast_signed(), + arguments[1], + arguments[2], + arguments[3], + ), + Syscall::Getdents64 => write!( + formatter, + "getdents64(fd={}, dirp={:#x}, count={})", + arguments[0], arguments[1], arguments[2], + ), + _ => unreachable!("only VFS syscalls are delegated to the VFS formatter"), + } +} + /// A failure outside normal Linux syscall return handling. #[derive(Debug)] pub enum ExecutionError { @@ -376,8 +426,12 @@ impl Process { output_bytes: self.output_bytes, })); } + Some(Syscall::Mkdirat) => self.dispatch_mkdirat(filesystem), + Some(Syscall::Unlinkat) => self.dispatch_unlinkat(filesystem), + Some(Syscall::Renameat) => self.dispatch_renameat(filesystem), Some(Syscall::Openat) => self.dispatch_openat(filesystem), Some(Syscall::Close) => self.dispatch_close(filesystem), + Some(Syscall::Getdents64) => self.dispatch_getdents64(filesystem), Some(Syscall::Lseek) => self.dispatch_lseek(filesystem), Some(Syscall::Read) => self.dispatch_read(filesystem), Some(Syscall::Ppoll) => self.set_return(0), @@ -444,6 +498,167 @@ impl Process { Ok(()) } + fn dispatch_mkdirat(&mut self, filesystem: &mut F) { + if self.register(0) != AT_FDCWD { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let path = match self.read_guest_path(GuestAddress::new(self.register(1))) { + Ok(path) if path.first() == Some(&b'/') => path, + Ok(_) => { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + Err(error) => { + self.set_return(error.return_value()); + return; + } + }; + match filesystem.create_directory(&path) { + Ok(()) => self.set_return(0), + Err(error) => self.set_return(filesystem_error_return(error)), + } + } + + fn dispatch_unlinkat(&mut self, filesystem: &mut F) { + let flags = self.register(2); + if self.register(0) != AT_FDCWD || !matches!(flags, 0 | AT_REMOVE_DIRECTORY) { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let path = match self.read_guest_path(GuestAddress::new(self.register(1))) { + Ok(path) if path.first() == Some(&b'/') => path, + Ok(_) => { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + Err(error) => { + self.set_return(error.return_value()); + return; + } + }; + match filesystem.remove(&path, flags == AT_REMOVE_DIRECTORY) { + Ok(()) => self.set_return(0), + Err(error) => self.set_return(filesystem_error_return(error)), + } + } + + fn dispatch_renameat(&mut self, filesystem: &mut F) { + if self.register(0) != AT_FDCWD || self.register(2) != AT_FDCWD { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let old_path = match self.read_guest_path(GuestAddress::new(self.register(1))) { + Ok(path) if path.first() == Some(&b'/') => path, + Ok(_) => { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + Err(error) => { + self.set_return(error.return_value()); + return; + } + }; + let new_path = match self.read_guest_path(GuestAddress::new(self.register(3))) { + Ok(path) if path.first() == Some(&b'/') => path, + Ok(_) => { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + Err(error) => { + self.set_return(error.return_value()); + return; + } + }; + match filesystem.rename(&old_path, &new_path) { + Ok(()) => self.set_return(0), + Err(error) => self.set_return(filesystem_error_return(error)), + } + } + + fn dispatch_getdents64(&mut self, filesystem: &mut F) { + let Some(handle) = self.file_handle(self.register(0)) else { + self.set_return(Errno::BadFileDescriptor.return_value()); + return; + }; + let Ok(capacity) = usize::try_from(self.register(2)) else { + self.set_return(Errno::InvalidArgument.return_value()); + return; + }; + if self.register(2) > self.limits.max_memory_bytes { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + let mut bytes = Vec::new(); + let mut consumed_entries = 0_i64; + loop { + let entry = match filesystem.read_directory(handle, 1) { + Ok(entries) => entries.into_iter().next(), + Err(error) => { + self.set_return(filesystem_error_return(error)); + return; + } + }; + let Some(entry) = entry else { + break; + }; + consumed_entries += 1; + let unaligned = DIRECTORY_ENTRY_HEADER_SIZE + .checked_add(entry.name.len()) + .and_then(|length| length.checked_add(1)); + let Some(record_length) = unaligned.and_then(|length| { + length + .checked_add(DIRECTORY_ENTRY_ALIGNMENT - 1) + .map(|length| length & !(DIRECTORY_ENTRY_ALIGNMENT - 1)) + }) else { + let _ = filesystem.seek(handle, -1, FileSeekFrom::Current); + self.set_return(Errno::InvalidArgument.return_value()); + return; + }; + if bytes.len().saturating_add(record_length) > capacity { + let _ = filesystem.seek(handle, -1, FileSeekFrom::Current); + consumed_entries -= 1; + if bytes.is_empty() { + self.set_return(Errno::InvalidArgument.return_value()); + return; + } + break; + } + let Ok(record_length_u16) = u16::try_from(record_length) else { + let _ = filesystem.seek(handle, -1, FileSeekFrom::Current); + self.set_return(Errno::InvalidArgument.return_value()); + return; + }; + let record_start = bytes.len(); + bytes.resize(record_start + record_length, 0); + let inode = directory_inode(&entry.name); + bytes[record_start..record_start + 8].copy_from_slice(&inode.to_le_bytes()); + bytes[record_start + 8..record_start + 16] + .copy_from_slice(&consumed_entries.to_le_bytes()); + bytes[record_start + 16..record_start + 18] + .copy_from_slice(&record_length_u16.to_le_bytes()); + bytes[record_start + 18] = match entry.file_type { + FileType::Directory => DIRECTORY_TYPE, + FileType::Regular => REGULAR_FILE_TYPE, + }; + bytes[record_start + DIRECTORY_ENTRY_HEADER_SIZE + ..record_start + DIRECTORY_ENTRY_HEADER_SIZE + entry.name.len()] + .copy_from_slice(&entry.name); + } + if self + .memory + .write(GuestAddress::new(self.register(1)), &bytes) + .is_err() + { + if consumed_entries != 0 { + let _ = filesystem.seek(handle, -consumed_entries, FileSeekFrom::Current); + } + self.set_return(Errno::Fault.return_value()); + return; + } + self.set_return(bytes.len() as u64); + } + fn dispatch_openat(&mut self, filesystem: &mut F) { let flags = self.register(2); let access = match flags & OPEN_ACCESS_MASK { @@ -480,6 +695,7 @@ impl Process { (OPEN_EXCLUSIVE, FileOpenFlags::EXCLUSIVE), (OPEN_TRUNCATE, FileOpenFlags::TRUNCATE), (OPEN_APPEND, FileOpenFlags::APPEND), + (OPEN_DIRECTORY, FileOpenFlags::DIRECTORY), ] { if flags & linux_flag != 0 { open_flags = open_flags.union(host_flag); @@ -911,6 +1127,12 @@ impl Process { } } +fn directory_inode(name: &[u8]) -> u64 { + name.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100_0000_01b3) + }) +} + const fn filesystem_error_return(error: FileSystemError) -> u64 { match error { FileSystemError::NotFound => Errno::NoEntry, @@ -921,6 +1143,7 @@ const fn filesystem_error_return(error: FileSystemError) -> u64 { FileSystemError::PermissionDenied => Errno::PermissionDenied, FileSystemError::InvalidInput => Errno::InvalidArgument, FileSystemError::NoSpace => Errno::NoSpace, + FileSystemError::NotEmpty => Errno::DirectoryNotEmpty, FileSystemError::Unsupported => Errno::NoSystemCall, } .return_value() diff --git a/crates/memory-fs/src/lib.rs b/crates/memory-fs/src/lib.rs index 898eb0a..1d77108 100644 --- a/crates/memory-fs/src/lib.rs +++ b/crates/memory-fs/src/lib.rs @@ -4,10 +4,13 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use binarrow_host_api::{ - FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, HostFileSystem, + DirectoryEntry, FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, + FileType, HostFileSystem, }; -const SNAPSHOT_MAGIC: &[u8; 8] = b"BNFS\x01\0\0\0"; +const SNAPSHOT_MAGIC: &[u8; 8] = b"BNFS\x02\0\0\0"; +const SNAPSHOT_DIRECTORY: u8 = 1; +const SNAPSHOT_REGULAR_FILE: u8 = 2; /// Rejection reason for an imported deterministic filesystem snapshot. #[derive(Clone, Debug, Eq, PartialEq)] @@ -40,11 +43,17 @@ impl fmt::Display for SnapshotError { impl std::error::Error for SnapshotError {} #[derive(Clone, Debug, Eq, PartialEq)] -struct OpenFile { - path: Vec, - position: u64, - access: FileAccess, - append: bool, +enum OpenHandle { + File { + path: Vec, + position: u64, + access: FileAccess, + append: bool, + }, + Directory { + path: Vec, + position: usize, + }, } /// An ephemeral filesystem containing regular files and a small directory set. @@ -52,7 +61,7 @@ struct OpenFile { pub struct MemoryFileSystem { directories: BTreeSet>, files: BTreeMap, Vec>, - open_files: BTreeMap, + open_handles: BTreeMap, next_handle: u64, stored_bytes: u64, byte_limit: u64, @@ -64,7 +73,7 @@ impl MemoryFileSystem { Self { directories: BTreeSet::from([b"/".to_vec(), b"/project".to_vec(), b"/tmp".to_vec()]), files: BTreeMap::new(), - open_files: BTreeMap::new(), + open_handles: BTreeMap::new(), next_handle: 1, stored_bytes: 0, byte_limit, @@ -102,6 +111,7 @@ impl MemoryFileSystem { let entry_count = reader.read_u32()?; let mut filesystem = Self::new(byte_limit); for _ in 0..entry_count { + let entry_type = reader.read_u8()?; let path_length = reader.read_u32()?; let file_length = reader.read_u64()?; let path_length = @@ -114,17 +124,37 @@ impl MemoryFileSystem { return Err(SnapshotError::InvalidPath); } let bytes = reader.read(file_length)?.to_vec(); - let next_size = filesystem - .stored_bytes - .checked_add(file_length as u64) - .ok_or(SnapshotError::CapacityExceeded)?; - if next_size > byte_limit { - return Err(SnapshotError::CapacityExceeded); + match entry_type { + SNAPSHOT_DIRECTORY if bytes.is_empty() => { + let parent = parent_path(&path).ok_or(SnapshotError::InvalidPath)?; + if !filesystem.directories.contains(parent) + || filesystem.files.contains_key(&path) + || !filesystem.directories.insert(path) + { + return Err(SnapshotError::DuplicatePath); + } + } + SNAPSHOT_REGULAR_FILE => { + let parent = parent_path(&path).ok_or(SnapshotError::InvalidPath)?; + if !filesystem.directories.contains(parent) { + return Err(SnapshotError::InvalidPath); + } + let next_size = filesystem + .stored_bytes + .checked_add(file_length as u64) + .ok_or(SnapshotError::CapacityExceeded)?; + if next_size > byte_limit { + return Err(SnapshotError::CapacityExceeded); + } + if filesystem.directories.contains(&path) + || filesystem.files.insert(path, bytes).is_some() + { + return Err(SnapshotError::DuplicatePath); + } + filesystem.stored_bytes = next_size; + } + _ => return Err(SnapshotError::InvalidPath), } - if filesystem.files.insert(path, bytes).is_some() { - return Err(SnapshotError::DuplicatePath); - } - filesystem.stored_bytes = next_size; } if !reader.is_finished() { return Err(SnapshotError::TrailingBytes); @@ -139,6 +169,11 @@ impl MemoryFileSystem { /// Returns [`SnapshotError::HostSizeUnsupported`] if host collection sizes /// cannot be represented by the portable snapshot format. pub fn export_snapshot(&self) -> Result, SnapshotError> { + let directories = self + .directories + .iter() + .filter(|path| path.starts_with(b"/project/")) + .collect::>(); let files = self .files .iter() @@ -147,11 +182,22 @@ impl MemoryFileSystem { let mut snapshot = Vec::new(); snapshot.extend_from_slice(SNAPSHOT_MAGIC); snapshot.extend_from_slice( - &u32::try_from(files.len()) + &u32::try_from(directories.len() + files.len()) .map_err(|_| SnapshotError::HostSizeUnsupported)? .to_le_bytes(), ); + for path in directories { + snapshot.push(SNAPSHOT_DIRECTORY); + snapshot.extend_from_slice( + &u32::try_from(path.len()) + .map_err(|_| SnapshotError::HostSizeUnsupported)? + .to_le_bytes(), + ); + snapshot.extend_from_slice(&0_u64.to_le_bytes()); + snapshot.extend_from_slice(path); + } for (path, bytes) in files { + snapshot.push(SNAPSHOT_REGULAR_FILE); snapshot.extend_from_slice( &u32::try_from(path.len()) .map_err(|_| SnapshotError::HostSizeUnsupported)? @@ -174,7 +220,24 @@ impl MemoryFileSystem { options: FileOpenOptions, ) -> Result { if self.directories.contains(&path) { - return Err(FileSystemError::IsDirectory); + if options.access.can_write() + || options.flags.contains(FileOpenFlags::CREATE) + || options.flags.contains(FileOpenFlags::TRUNCATE) + || options.flags.contains(FileOpenFlags::APPEND) + { + return Err(FileSystemError::IsDirectory); + } + let handle = self.allocate_handle()?; + self.open_handles + .insert(handle, OpenHandle::Directory { path, position: 0 }); + return Ok(handle); + } + if options.flags.contains(FileOpenFlags::DIRECTORY) { + return if self.files.contains_key(&path) { + Err(FileSystemError::NotDirectory) + } else { + Err(FileSystemError::NotFound) + }; } let exists = self.files.contains_key(&path); if exists @@ -204,14 +267,10 @@ impl MemoryFileSystem { self.stored_bytes -= file.len() as u64; file.clear(); } - let handle = self.next_handle; - self.next_handle = self - .next_handle - .checked_add(1) - .ok_or(FileSystemError::NoSpace)?; - self.open_files.insert( + let handle = self.allocate_handle()?; + self.open_handles.insert( handle, - OpenFile { + OpenHandle::File { path, position: 0, access: options.access, @@ -221,52 +280,71 @@ impl MemoryFileSystem { Ok(handle) } + fn allocate_handle(&mut self) -> Result { + let handle = self.next_handle; + self.next_handle = self + .next_handle + .checked_add(1) + .ok_or(FileSystemError::NoSpace)?; + Ok(handle) + } + fn read_open_file( &mut self, handle: u64, destination: &mut [u8], ) -> Result { - let open_file = self - .open_files + let OpenHandle::File { + path, + position, + access, + .. + } = self + .open_handles .get_mut(&handle) - .ok_or(FileSystemError::BadDescriptor)?; - if !open_file.access.can_read() { + .ok_or(FileSystemError::BadDescriptor)? + else { + return Err(FileSystemError::IsDirectory); + }; + if !access.can_read() { return Err(FileSystemError::PermissionDenied); } - let file = self - .files - .get(&open_file.path) - .ok_or(FileSystemError::NotFound)?; - let position = - usize::try_from(open_file.position).map_err(|_| FileSystemError::InvalidInput)?; - if position >= file.len() { + let file = self.files.get(path).ok_or(FileSystemError::NotFound)?; + let host_position = + usize::try_from(*position).map_err(|_| FileSystemError::InvalidInput)?; + if host_position >= file.len() { return Ok(0); } - let available = file.len().saturating_sub(position); + let available = file.len().saturating_sub(host_position); let count = destination.len().min(available); - destination[..count].copy_from_slice(&file[position..position + count]); - open_file.position += count as u64; + destination[..count].copy_from_slice(&file[host_position..host_position + count]); + *position += count as u64; Ok(count) } fn write_open_file(&mut self, handle: u64, source: &[u8]) -> Result { - let open_file = self - .open_files + let OpenHandle::File { + path, + position, + access, + append, + } = self + .open_handles .get_mut(&handle) - .ok_or(FileSystemError::BadDescriptor)?; - if !open_file.access.can_write() { + .ok_or(FileSystemError::BadDescriptor)? + else { + return Err(FileSystemError::IsDirectory); + }; + if !access.can_write() { return Err(FileSystemError::PermissionDenied); } - let file = self - .files - .get_mut(&open_file.path) - .ok_or(FileSystemError::NotFound)?; - let position = if open_file.append { + let file = self.files.get_mut(path).ok_or(FileSystemError::NotFound)?; + let host_position = if *append { file.len() } else { - usize::try_from(open_file.position).map_err(|_| FileSystemError::InvalidInput)? + usize::try_from(*position).map_err(|_| FileSystemError::InvalidInput)? }; - let end = position + let end = host_position .checked_add(source.len()) .ok_or(FileSystemError::NoSpace)?; let growth = end.saturating_sub(file.len()) as u64; @@ -277,8 +355,8 @@ impl MemoryFileSystem { file.resize(end, 0); self.stored_bytes += growth; } - file[position..end].copy_from_slice(source); - open_file.position = end as u64; + file[host_position..end].copy_from_slice(source); + *position = end as u64; Ok(source.len()) } @@ -288,28 +366,54 @@ impl MemoryFileSystem { offset: i64, from: FileSeekFrom, ) -> Result { - let open_file = self - .open_files + let open_handle = self + .open_handles .get_mut(&handle) .ok_or(FileSystemError::BadDescriptor)?; - let file_length = self - .files - .get(&open_file.path) - .ok_or(FileSystemError::NotFound)? - .len() as u64; + if let OpenHandle::Directory { position, .. } = open_handle { + let base = match from { + FileSeekFrom::Start => 0, + FileSeekFrom::Current => *position as u64, + FileSeekFrom::End => return Err(FileSystemError::InvalidInput), + }; + let new_position = if offset < 0 { + base.checked_sub(offset.unsigned_abs()) + } else { + base.checked_add(offset.cast_unsigned()) + } + .and_then(|position| usize::try_from(position).ok()) + .ok_or(FileSystemError::InvalidInput)?; + *position = new_position; + return Ok(new_position as u64); + } + let OpenHandle::File { path, position, .. } = open_handle else { + unreachable!("all open-handle variants were considered") + }; + let file_length = self.files.get(path).ok_or(FileSystemError::NotFound)?.len() as u64; let base = match from { FileSeekFrom::Start => 0, - FileSeekFrom::Current => open_file.position, + FileSeekFrom::Current => *position, FileSeekFrom::End => file_length, }; - let position = if offset < 0 { + let new_position = if offset < 0 { base.checked_sub(offset.unsigned_abs()) } else { base.checked_add(offset.cast_unsigned()) } .ok_or(FileSystemError::InvalidInput)?; - open_file.position = position; - Ok(position) + *position = new_position; + Ok(new_position) + } + + fn replace_open_paths(&mut self, old_prefix: &[u8], new_prefix: &[u8]) { + for handle in self.open_handles.values_mut() { + let path = match handle { + OpenHandle::File { path, .. } | OpenHandle::Directory { path, .. } => path, + }; + if path == old_prefix || is_descendant(path, old_prefix) { + *path = replace_prefix(path, old_prefix, new_prefix); + } + } } } @@ -336,11 +440,163 @@ impl HostFileSystem for MemoryFileSystem { } fn close(&mut self, handle: u64) -> Result<(), FileSystemError> { - self.open_files + self.open_handles .remove(&handle) .map(|_| ()) .ok_or(FileSystemError::BadDescriptor) } + + fn create_directory(&mut self, path: &[u8]) -> Result<(), FileSystemError> { + let path = normalize_path(path)?; + if self.directories.contains(&path) || self.files.contains_key(&path) { + return Err(FileSystemError::AlreadyExists); + } + let parent = parent_path(&path).ok_or(FileSystemError::InvalidInput)?; + if !self.directories.contains(parent) { + return Err(FileSystemError::NotDirectory); + } + self.directories.insert(path); + Ok(()) + } + + fn read_directory( + &mut self, + handle: u64, + max_entries: usize, + ) -> Result, FileSystemError> { + let (path, position) = match self.open_handles.get(&handle) { + Some(OpenHandle::Directory { path, position }) => (path.clone(), *position), + Some(OpenHandle::File { .. }) => return Err(FileSystemError::NotDirectory), + None => return Err(FileSystemError::BadDescriptor), + }; + let mut entries = vec![ + DirectoryEntry { + name: b".".to_vec(), + file_type: FileType::Directory, + }, + DirectoryEntry { + name: b"..".to_vec(), + file_type: FileType::Directory, + }, + ]; + entries.extend( + self.directories + .iter() + .filter(|candidate| { + candidate.as_slice() != path && parent_path(candidate) == Some(&path) + }) + .map(|candidate| DirectoryEntry { + name: file_name(candidate).to_vec(), + file_type: FileType::Directory, + }), + ); + entries.extend( + self.files + .keys() + .filter(|candidate| parent_path(candidate) == Some(&path)) + .map(|candidate| DirectoryEntry { + name: file_name(candidate).to_vec(), + file_type: FileType::Regular, + }), + ); + entries[2..].sort_by(|left, right| left.name.cmp(&right.name)); + let end = position.saturating_add(max_entries).min(entries.len()); + let selected = entries.get(position..end).unwrap_or_default().to_vec(); + if let Some(OpenHandle::Directory { position, .. }) = self.open_handles.get_mut(&handle) { + *position = end; + } + Ok(selected) + } + + fn rename(&mut self, old_path: &[u8], new_path: &[u8]) -> Result<(), FileSystemError> { + let old_path = normalize_path(old_path)?; + let new_path = normalize_path(new_path)?; + if old_path == new_path { + return Ok(()); + } + if matches!(old_path.as_slice(), b"/" | b"/project" | b"/tmp") { + return Err(FileSystemError::PermissionDenied); + } + let new_parent = parent_path(&new_path).ok_or(FileSystemError::InvalidInput)?; + if !self.directories.contains(new_parent) { + return Err(FileSystemError::NotDirectory); + } + if self.files.contains_key(&new_path) || self.directories.contains(&new_path) { + return Err(FileSystemError::AlreadyExists); + } + if let Some(bytes) = self.files.remove(&old_path) { + self.files.insert(new_path.clone(), bytes); + self.replace_open_paths(&old_path, &new_path); + return Ok(()); + } + if !self.directories.contains(&old_path) { + return Err(FileSystemError::NotFound); + } + if is_descendant(&new_path, &old_path) { + return Err(FileSystemError::InvalidInput); + } + let moved_directories = self + .directories + .iter() + .filter(|path| **path == old_path || is_descendant(path, &old_path)) + .cloned() + .collect::>(); + let moved_files = self + .files + .keys() + .filter(|path| is_descendant(path, &old_path)) + .cloned() + .collect::>(); + for path in &moved_directories { + self.directories.remove(path); + } + for path in moved_directories { + self.directories + .insert(replace_prefix(&path, &old_path, &new_path)); + } + for path in moved_files { + let bytes = self.files.remove(&path).expect("collected file exists"); + self.files + .insert(replace_prefix(&path, &old_path, &new_path), bytes); + } + self.replace_open_paths(&old_path, &new_path); + Ok(()) + } + + fn remove(&mut self, path: &[u8], directory: bool) -> Result<(), FileSystemError> { + let path = normalize_path(path)?; + if directory { + if matches!(path.as_slice(), b"/" | b"/project" | b"/tmp") { + return Err(FileSystemError::PermissionDenied); + } + if !self.directories.contains(&path) { + return if self.files.contains_key(&path) { + Err(FileSystemError::NotDirectory) + } else { + Err(FileSystemError::NotFound) + }; + } + if self + .directories + .iter() + .any(|candidate| is_descendant(candidate, &path)) + || self + .files + .keys() + .any(|candidate| is_descendant(candidate, &path)) + { + return Err(FileSystemError::NotEmpty); + } + self.directories.remove(&path); + return Ok(()); + } + if self.directories.contains(&path) { + return Err(FileSystemError::IsDirectory); + } + let bytes = self.files.remove(&path).ok_or(FileSystemError::NotFound)?; + self.stored_bytes -= bytes.len() as u64; + Ok(()) + } } fn normalize_path(path: &[u8]) -> Result, FileSystemError> { @@ -378,6 +634,27 @@ fn parent_path(path: &[u8]) -> Option<&[u8]> { }) } +fn file_name(path: &[u8]) -> &[u8] { + parent_path(path) + .and_then(|parent| path.get(parent.len() + usize::from(parent != b"/")..)) + .unwrap_or(path) +} + +fn is_descendant(path: &[u8], parent: &[u8]) -> bool { + path.strip_prefix(parent) + .is_some_and(|suffix| suffix.first() == Some(&b'/')) +} + +fn replace_prefix(path: &[u8], old_prefix: &[u8], new_prefix: &[u8]) -> Vec { + let suffix = path + .strip_prefix(old_prefix) + .expect("renamed path has the selected prefix"); + let mut replaced = Vec::with_capacity(new_prefix.len() + suffix.len()); + replaced.extend_from_slice(new_prefix); + replaced.extend_from_slice(suffix); + replaced +} + struct SnapshotReader<'a> { bytes: &'a [u8], offset: usize, @@ -410,6 +687,10 @@ impl<'a> SnapshotReader<'a> { )) } + fn read_u8(&mut self) -> Result { + Ok(self.read(1)?[0]) + } + fn read_u64(&mut self) -> Result { let bytes = self.read(8)?; Ok(u64::from_le_bytes( @@ -427,7 +708,8 @@ impl<'a> SnapshotReader<'a> { #[cfg(test)] mod tests { use binarrow_host_api::{ - FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, HostFileSystem, + FileAccess, FileOpenFlags, FileOpenOptions, FileSeekFrom, FileSystemError, FileType, + HostFileSystem, }; use super::{MemoryFileSystem, SnapshotError}; @@ -522,4 +804,58 @@ mod tests { Err(SnapshotError::CapacityExceeded) ); } + + #[test] + fn creates_lists_renames_and_removes_directory_trees() { + let mut filesystem = MemoryFileSystem::new(64); + filesystem.create_directory(b"/project/src").unwrap(); + let file = filesystem + .open(b"/project/src/main.py", CREATE_READ_WRITE) + .unwrap(); + filesystem.write(file, b"pass\n").unwrap(); + filesystem.close(file).unwrap(); + + let directory = filesystem + .open( + b"/project/src", + FileOpenOptions { + access: FileAccess::ReadOnly, + flags: FileOpenFlags::DIRECTORY, + }, + ) + .unwrap(); + let entries = filesystem.read_directory(directory, 8).unwrap(); + assert_eq!(entries[2].name, b"main.py"); + assert_eq!(entries[2].file_type, FileType::Regular); + + filesystem.rename(b"/project/src", b"/project/lib").unwrap(); + assert_eq!( + filesystem.read_file(b"/project/lib/main.py"), + Some(&b"pass\n"[..]) + ); + filesystem.remove(b"/project/lib/main.py", false).unwrap(); + filesystem.remove(b"/project/lib", true).unwrap(); + + let snapshot = filesystem.export_snapshot().unwrap(); + let restored = MemoryFileSystem::from_snapshot(64, &snapshot).unwrap(); + assert_eq!(restored.export_snapshot().unwrap(), snapshot); + } + + #[test] + fn snapshots_preserve_empty_project_directories() { + let mut filesystem = MemoryFileSystem::new(64); + filesystem.create_directory(b"/project/empty").unwrap(); + let snapshot = filesystem.export_snapshot().unwrap(); + let mut restored = MemoryFileSystem::from_snapshot(64, &snapshot).unwrap(); + let handle = restored + .open( + b"/project/empty", + FileOpenOptions { + access: FileAccess::ReadOnly, + flags: FileOpenFlags::DIRECTORY, + }, + ) + .unwrap(); + assert_eq!(restored.read_directory(handle, 8).unwrap().len(), 2); + } } diff --git a/guest-tests/vfs-lifecycle/README.md b/guest-tests/vfs-lifecycle/README.md new file mode 100644 index 0000000..14f83bf --- /dev/null +++ b/guest-tests/vfs-lifecycle/README.md @@ -0,0 +1,11 @@ +# VFS lifecycle AArch64 fixture + +This freestanding static AArch64 Linux program creates and modifies a file, +enumerates its directory with `getdents64`, renames and reads the file, then +deletes both the file and its directory. + +Build the deterministic fixture with Zig 0.16.0: + +```sh +guest-tests/vfs-lifecycle/build.sh +``` diff --git a/guest-tests/vfs-lifecycle/build.sh b/guest-tests/vfs-lifecycle/build.sh new file mode 100755 index 0000000..2d6de3d --- /dev/null +++ b/guest-tests/vfs-lifecycle/build.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +fixture_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +cache_directory=${TMPDIR:-/tmp}/binarrow-zig-cache +output=$fixture_directory/vfs-lifecycle.aarch64.elf +zig_version=$(zig version) + +if [ "$zig_version" != "0.16.0" ]; then + echo "vfs-lifecycle requires Zig 0.16.0; found $zig_version" >&2 + exit 1 +fi + +env \ + ZIG_LOCAL_CACHE_DIR="$cache_directory/local" \ + ZIG_GLOBAL_CACHE_DIR="$cache_directory/global" \ + zig cc \ + -target aarch64-linux-musl \ + -nostdlib \ + -static \ + -fno-stack-protector \ + -O1 \ + -g0 \ + -s \ + -Wl,--build-id=none \ + -Wl,-e,_start \ + "$fixture_directory/main.c" \ + -o "$output" + +chmod 0644 "$output" diff --git a/guest-tests/vfs-lifecycle/main.c b/guest-tests/vfs-lifecycle/main.c new file mode 100644 index 0000000..46a8900 --- /dev/null +++ b/guest-tests/vfs-lifecycle/main.c @@ -0,0 +1,138 @@ +enum { + SYS_MKDIRAT = 34, + SYS_UNLINKAT = 35, + SYS_RENAMEAT = 38, + SYS_OPENAT = 56, + SYS_CLOSE = 57, + SYS_GETDENTS64 = 61, + SYS_LSEEK = 62, + SYS_READ = 63, + SYS_WRITE = 64, + SYS_EXIT = 93, + AT_FDCWD = -100, + AT_REMOVEDIR = 0x200, + O_RDONLY = 0, + O_RDWR = 2, + O_CREAT = 0x40, + O_TRUNC = 0x200, + O_DIRECTORY = 0x10000, +}; + +static long syscall4(long number, long first, long second, long third, long fourth) { + register long x0 __asm__("x0") = first; + register long x1 __asm__("x1") = second; + register long x2 __asm__("x2") = third; + register long x3 __asm__("x3") = fourth; + register long x8 __asm__("x8") = number; + __asm__ volatile("svc #0" + : "+r"(x0) + : "r"(x1), "r"(x2), "r"(x3), "r"(x8) + : "memory"); + return x0; +} + +static long syscall3(long number, long first, long second, long third) { + return syscall4(number, first, second, third, 0); +} + +__attribute__((noreturn)) static void exit_guest(long status) { + (void)syscall3(SYS_EXIT, status, 0, 0); + __builtin_unreachable(); +} + +static int equals(const unsigned char *left, const char *right) { + while (*left != 0 && *right != 0 && *left == (unsigned char)*right) { + ++left; + ++right; + } + return *left == 0 && *right == 0; +} + +__attribute__((noreturn)) void _start(void) { + static const char directory[] = "/project/work"; + static const char original[] = "/project/work/main.txt"; + static const char renamed[] = "/project/work/renamed.txt"; + static const char initial[] = "initial\n"; + static const char updated[] = "updated\n"; + unsigned char entries[512]; + char contents[sizeof(updated) - 1]; + + if (syscall3(SYS_MKDIRAT, AT_FDCWD, (long)directory, 0700) != 0) { + exit_guest(1); + } + long file = syscall4( + SYS_OPENAT, + AT_FDCWD, + (long)original, + O_RDWR | O_CREAT | O_TRUNC, + 0600); + if (file < 0) { + exit_guest(2); + } + if (syscall3(SYS_WRITE, file, (long)initial, sizeof(initial) - 1) != + sizeof(initial) - 1) { + exit_guest(3); + } + if (syscall3(SYS_LSEEK, file, 0, 0) != 0 || + syscall3(SYS_WRITE, file, (long)updated, sizeof(updated) - 1) != + sizeof(updated) - 1 || + syscall3(SYS_CLOSE, file, 0, 0) != 0) { + exit_guest(4); + } + + long directory_file = + syscall4(SYS_OPENAT, AT_FDCWD, (long)directory, O_RDONLY | O_DIRECTORY, 0); + if (directory_file < 0) { + exit_guest(5); + } + long bytes = syscall3( + SYS_GETDENTS64, + directory_file, + (long)entries, + sizeof(entries)); + if (bytes <= 0) { + exit_guest(6); + } + int found = 0; + long offset = 0; + while (offset < bytes) { + unsigned short length = + (unsigned short)entries[offset + 16] | + (unsigned short)((unsigned short)entries[offset + 17] << 8); + if (length < 20 || offset + length > bytes) { + exit_guest(7); + } + if (equals(&entries[offset + 19], "main.txt")) { + found = 1; + } + offset += length; + } + if (!found || syscall3(SYS_CLOSE, directory_file, 0, 0) != 0) { + exit_guest(8); + } + + if (syscall4( + SYS_RENAMEAT, + AT_FDCWD, + (long)original, + AT_FDCWD, + (long)renamed) != 0) { + exit_guest(9); + } + file = syscall4(SYS_OPENAT, AT_FDCWD, (long)renamed, O_RDONLY, 0); + if (file < 0 || + syscall3(SYS_READ, file, (long)contents, sizeof(contents)) != + sizeof(contents) || + syscall3(SYS_CLOSE, file, 0, 0) != 0) { + exit_guest(10); + } + if (syscall3(SYS_WRITE, 1, (long)contents, sizeof(contents)) != + sizeof(contents)) { + exit_guest(11); + } + if (syscall3(SYS_UNLINKAT, AT_FDCWD, (long)renamed, 0) != 0 || + syscall3(SYS_UNLINKAT, AT_FDCWD, (long)directory, AT_REMOVEDIR) != 0) { + exit_guest(12); + } + exit_guest(0); +} diff --git a/guest-tests/vfs-lifecycle/vfs-lifecycle.aarch64.elf b/guest-tests/vfs-lifecycle/vfs-lifecycle.aarch64.elf new file mode 100644 index 0000000000000000000000000000000000000000..39ae0b523a128eba65e737ec36df1b765984bef3 GIT binary patch literal 2040 zcmb<-^>JfjWMqH=CWh?{Al@HFh@b;h!h#LTU|?WyV6b3dWpH3%XJBIh3A2F3Ao30j zVC@VrS^{Jk0|Nt$=71;zD+1|*azPYSC0L5#50nR^p~iwGSs56R5r{Sh7!6Vj5(<1;k^=H0h)n=PO@^q_FDS~-N=+`& zFV8Q^W6KKJpa~bH<|XE)rhsKK^D;{^6LYv2N()jFOHxy~7~q;17#O9Q ztpyk$KJ#E;WYAz>U;yz!HZxWQF)-H1F-r5uGf05K9gGo$jd7*e0(U*5b}VTbJas;`BjgYqJZIl{w(?{Q?>vp!jBF#2sHCI~{P?sQ~gnl9?!Jh>0Nt?jIyKWkCI+z=%i( zich`^z~UJuzkrcpO9Kvj6L5$lhc_%Pz-~lJ$1rgRklV3{C*Tloz#+Z>hxh>;;vV_A zsYykt7by1dlK}*>boW3C4Ujn4^_qh0M!}9J^%m! literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html index 9488cfc..c158b21 100644 --- a/web/index.html +++ b/web/index.html @@ -32,6 +32,7 @@ + diff --git a/web/src/probe.ts b/web/src/probe.ts index f70c98b..5370750 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -19,7 +19,8 @@ export type FixtureName = | "infinite-loop" | "file-roundtrip" | "project-persistence-write" - | "project-persistence-read"; + | "project-persistence-read" + | "vfs-lifecycle"; export interface FeatureResult { name: FeatureName; diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index d82d60f..09408c5 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -37,7 +37,7 @@ interface JspiApi { promising: (callback: () => number) => () => Promise; } -const filesystemSnapshotName = "binarrow-project-v1.snapshot"; +const filesystemSnapshotName = "binarrow-project-v2.snapshot"; async function loadFilesystemSnapshot(): Promise { const root = await navigator.storage.getDirectory(); diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 83987a6..a6cd44c 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -111,6 +111,25 @@ test("persists project files in OPFS across a page reload", async ({ page }) => ); }); +test("creates, modifies, lists, renames, and deletes guest files", async ({ + page, +}) => { + await page.getByLabel("Fixture").selectOption("vfs-lifecycle"); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.getByRole("status")).toHaveText("Guest exited"); + await expect(page.getByLabel("Guest terminal output")).toHaveText("updated"); + await expect(page.getByLabel("System call trace")).toContainText( + "getdents64(fd=3", + ); + await expect(page.getByLabel("System call trace")).toContainText( + "renameat(olddirfd=-100", + ); + await expect(page.getByLabel("System call trace")).toContainText( + "unlinkat(dirfd=-100", + ); +}); + test("terminates and restarts a Worker running an infinite guest", async ({ page }) => { await page.getByLabel("Fixture").selectOption("infinite-loop"); await page