//! Portable async filesystem API. //! //! Cancellation semantics: //! - Dropping an I/O future cancels interest in the result. //! - The runtime issues best-effort kernel cancellation where supported. //! - The underlying OS operation may still complete after the future is dropped. //! //! The public surface intentionally mirrors `std::fs` where that shape makes sense, while using //! async methods for operations that may block the caller. use alloc::sync::Arc; use std::ffi::OsStr; use std::io; use std::os::fd::{AsRawFd, OwnedFd}; use std::path::{Path, PathBuf}; use crate::op::fs::{ FileType as RawFileType, FsOp, MetadataTarget, OpenOptions as OpOpenOptions, RawDirEntry as OpDirEntry, RawMetadata, }; use crate::sys::current::fs as sys_fs; struct FileInner { fd: OwnedFd, } /// Async file handle. /// /// `File` is cheap to clone internally and supports both cursor-based sequential I/O and /// offset-based positioned I/O. pub struct File { inner: Arc, } /// Builder used to configure how a [`File`] is opened. pub struct OpenOptions { inner: OpOpenOptions, } #[derive(Clone, Debug, Eq, PartialEq)] /// File metadata returned by [`metadata`] or [`File::metadata`]. pub struct Metadata { inner: RawMetadata, } /// Async directory-entry stream returned by [`read_dir`]. pub struct ReadDir { inner: sys_fs::ReadDirStream, } #[derive(Clone, Debug, Eq, PartialEq)] /// Directory entry yielded by [`ReadDir::next_entry`]. pub struct DirEntry { inner: OpDirEntry, } impl File { /// Opens an existing file for reading. pub async fn open(path: impl AsRef) -> io::Result { OpenOptions::new().read(true).open(path).await } /// Opens a file for writing, creating or truncating it first. pub async fn create(path: impl AsRef) -> io::Result { OpenOptions::new() .write(true) .create(true) .truncate(true) .open(path) .await } /// Reads bytes from the file's current cursor position. pub async fn read(&mut self, buf: &mut [u8]) -> io::Result { self.read_impl(None, buf).await } /// Reads exactly `buf.len()` bytes from the current cursor position. pub async fn read_exact(&mut self, mut buf: &mut [u8]) -> io::Result<()> { while !buf.is_empty() { let read = self.read(buf).await?; if read == 0 { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "failed to fill whole buffer", )); } buf = &mut buf[read..]; } Ok(()) } /// Writes bytes at the file's current cursor position. pub async fn write(&mut self, buf: &[u8]) -> io::Result { self.write_impl(None, buf).await } /// Writes the entire buffer at the file's current cursor position. pub async fn write_all(&mut self, mut buf: &[u8]) -> io::Result<()> { while !buf.is_empty() { let written = self.write(buf).await?; if written == 0 { return Err(io::Error::new( io::ErrorKind::WriteZero, "failed to write whole buffer", )); } buf = &buf[written..]; } Ok(()) } /// Flushes any userspace buffering associated with this handle. /// /// The current implementation does not add additional buffering beyond the kernel file /// description, so this is effectively a no-op. pub async fn flush(&mut self) -> io::Result<()> { Ok(()) } /// Synchronizes file contents and metadata to stable storage. pub async fn sync_all(&self) -> io::Result<()> { sys_fs::sync_all(FsOp::SyncAll { fd: self.raw_fd() }).await } /// Synchronizes file contents to stable storage. pub async fn sync_data(&self) -> io::Result<()> { sys_fs::sync_data(FsOp::SyncData { fd: self.raw_fd() }).await } /// Reads bytes starting at `offset` without using the shared file cursor. pub async fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result { self.read_impl(Some(offset), buf).await } /// Reads exactly `buf.len()` bytes starting at `offset`. pub async fn read_exact_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { while !buf.is_empty() { let read = self.read_at(offset, buf).await?; if read == 0 { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "failed to fill whole buffer", )); } offset = offset.saturating_add(read as u64); buf = &mut buf[read..]; } Ok(()) } /// Writes bytes starting at `offset` without using the shared file cursor. pub async fn write_at(&self, offset: u64, buf: &[u8]) -> io::Result { self.write_impl(Some(offset), buf).await } /// Writes the entire buffer starting at `offset`. pub async fn write_all_at(&self, mut offset: u64, mut buf: &[u8]) -> io::Result<()> { while !buf.is_empty() { let written = self.write_at(offset, buf).await?; if written == 0 { return Err(io::Error::new( io::ErrorKind::WriteZero, "failed to write whole buffer", )); } offset = offset.saturating_add(written as u64); buf = &buf[written..]; } Ok(()) } /// Returns metadata for this file handle. pub async fn metadata(&self) -> io::Result { sys_fs::metadata(FsOp::Metadata { target: MetadataTarget::File(self.raw_fd()), follow_symlinks: true, }) .await .map(Metadata::from_raw) } /// Truncates or extends the underlying file to `len` bytes. pub async fn set_len(&self, len: u64) -> io::Result<()> { sys_fs::set_len(FsOp::SetLen { fd: self.raw_fd(), len, }) .await } /// Duplicates the underlying file description. /// /// As with `std::fs::File::try_clone`, the cloned handle shares kernel-managed cursor state. pub async fn try_clone(&self) -> io::Result { sys_fs::try_clone(FsOp::Duplicate { fd: self.raw_fd() }) .await .map(File::from_owned_fd) } fn from_owned_fd(fd: OwnedFd) -> Self { Self { inner: Arc::new(FileInner { fd }), } } fn raw_fd(&self) -> i32 { self.inner.fd.as_raw_fd() } async fn read_impl(&self, offset: Option, buf: &mut [u8]) -> io::Result { let data = sys_fs::read(FsOp::Read { fd: self.raw_fd(), offset, len: buf.len(), }) .await?; let read = data.len(); buf[..read].copy_from_slice(&data); Ok(read) } async fn write_impl(&self, offset: Option, buf: &[u8]) -> io::Result { sys_fs::write(FsOp::Write { fd: self.raw_fd(), offset, data: buf.to_vec(), }) .await } } impl OpenOptions { /// Creates a blank set of open options. pub fn new() -> Self { Self { inner: OpOpenOptions::default(), } } /// Controls read access. pub fn read(&mut self, value: bool) -> &mut Self { self.inner.read = value; self } /// Controls write access. pub fn write(&mut self, value: bool) -> &mut Self { self.inner.write = value; self } /// Controls append mode. pub fn append(&mut self, value: bool) -> &mut Self { self.inner.append = value; self } /// Controls whether the file is truncated after opening. pub fn truncate(&mut self, value: bool) -> &mut Self { self.inner.truncate = value; self } /// Controls whether the file is created if it does not already exist. pub fn create(&mut self, value: bool) -> &mut Self { self.inner.create = value; self } /// Controls whether opening must create a brand-new file. pub fn create_new(&mut self, value: bool) -> &mut Self { self.inner.create_new = value; self } /// Opens a file with the configured options. /// /// # Examples /// /// ``` /// # let _ = || async { /// let file = ruin_runtime::fs::OpenOptions::new() /// .read(true) /// .write(true) /// .open("example.txt") /// .await; /// # let _ = file; /// # }; /// ``` pub async fn open(&self, path: impl AsRef) -> io::Result { sys_fs::open(FsOp::Open { path: path.as_ref().to_path_buf(), options: self.inner.clone(), }) .await .map(File::from_owned_fd) } } impl Default for OpenOptions { fn default() -> Self { Self::new() } } impl Metadata { fn from_raw(inner: RawMetadata) -> Self { Self { inner } } /// Returns the file length in bytes. pub fn len(&self) -> u64 { self.inner.len } /// Returns `true` if the file length is zero. pub fn is_empty(&self) -> bool { self.len() == 0 } /// Returns `true` if this metadata describes a regular file. pub fn is_file(&self) -> bool { self.inner.file_type == RawFileType::File } /// Returns `true` if this metadata describes a directory. pub fn is_dir(&self) -> bool { self.inner.file_type == RawFileType::Directory } /// Returns `true` if this metadata describes a symbolic link. pub fn is_symlink(&self) -> bool { self.inner.file_type == RawFileType::Symlink } /// Returns the raw POSIX mode bits reported by the platform backend. pub fn mode(&self) -> u16 { self.inner.mode } } impl ReadDir { /// Returns the next directory entry, or `None` once the stream is exhausted. pub async fn next_entry(&mut self) -> io::Result> { self.inner .next_entry() .await .map(|entry| entry.map(|inner| DirEntry { inner })) } } impl DirEntry { /// Returns the full path to this directory entry. pub fn path(&self) -> PathBuf { self.inner.path.clone() } /// Returns the file name portion of this directory entry. pub fn file_name(&self) -> &OsStr { self.inner.file_name.as_os_str() } /// Resolves metadata for this entry. pub async fn metadata(&self) -> io::Result { metadata(self.path()).await } } /// Reads the entire contents of a file into memory. pub async fn read(path: impl AsRef) -> io::Result> { let mut file = File::open(path.as_ref()).await?; let mut output = Vec::new(); let mut chunk = vec![0; 8192]; loop { let read = file.read(&mut chunk).await?; if read == 0 { return Ok(output); } output.extend_from_slice(&chunk[..read]); } } /// Reads the entire contents of a UTF-8 file into a [`String`]. pub async fn read_to_string(path: impl AsRef) -> io::Result { let bytes = read(path).await?; String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } /// Replaces the contents of a file with `data`, creating it if needed. pub async fn write(path: impl AsRef, data: impl AsRef<[u8]>) -> io::Result<()> { let mut file = OpenOptions::new() .write(true) .create(true) .truncate(true) .open(path) .await?; file.write_all(data.as_ref()).await } /// Returns metadata for a filesystem path. pub async fn metadata(path: impl AsRef) -> io::Result { sys_fs::metadata(FsOp::Metadata { target: MetadataTarget::Path(path.as_ref().to_path_buf()), follow_symlinks: true, }) .await .map(Metadata::from_raw) } /// Creates a single directory. pub async fn create_dir(path: impl AsRef) -> io::Result<()> { sys_fs::create_dir(FsOp::CreateDir { path: path.as_ref().to_path_buf(), mode: 0o777, }) .await } /// Creates a directory and any missing parent directories. pub async fn create_dir_all(path: impl AsRef) -> io::Result<()> { let path = path.as_ref(); let mut current = PathBuf::new(); for component in path.components() { current.push(component.as_os_str()); if current.as_os_str().is_empty() { continue; } match create_dir(¤t).await { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error), } } Ok(()) } /// Removes a file. pub async fn remove_file(path: impl AsRef) -> io::Result<()> { sys_fs::remove_file(FsOp::RemoveFile { path: path.as_ref().to_path_buf(), }) .await } /// Removes an empty directory. pub async fn remove_dir(path: impl AsRef) -> io::Result<()> { sys_fs::remove_dir(FsOp::RemoveDir { path: path.as_ref().to_path_buf(), }) .await } /// Renames or moves a filesystem entry. pub async fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { sys_fs::rename(FsOp::Rename { from: from.as_ref().to_path_buf(), to: to.as_ref().to_path_buf(), }) .await } /// Opens an async directory-entry stream. /// /// # Examples /// /// ``` /// # let _ = || async { /// let mut entries = ruin_runtime::fs::read_dir(".").await.unwrap(); /// let _ = entries.next_entry().await; /// # }; /// ``` pub async fn read_dir(path: impl AsRef) -> io::Result { sys_fs::read_dir(FsOp::ReadDir { path: path.as_ref().to_path_buf(), }) .map(|inner| ReadDir { inner }) } #[cfg(test)] mod tests { use super::{ OpenOptions, create_dir_all, metadata, read, read_dir, read_to_string, remove_dir, remove_file, rename, write, }; use crate::queue_future; use crate::{queue_task, run}; use std::collections::BTreeSet; use std::ffi::OsString; use std::path::PathBuf; use std::process; use std::sync::Arc; use std::sync::Mutex; use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; fn test_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn unique_path(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("ruin-runtime-{label}-{}-{nanos}", process::id())) } #[test] fn async_fs_round_trip() { let _guard = test_lock().lock().unwrap(); let root = unique_path("fs-round-trip"); let nested = root.join("nested"); let file_path = nested.join("hello.txt"); let renamed_path = nested.join("renamed.txt"); let output = Arc::new(Mutex::new(None::)); { let output = Arc::clone(&output); queue_task(move || { queue_future(async move { create_dir_all(&nested) .await .expect("dir creation should succeed"); write(&file_path, b"hello world") .await .expect("initial write should succeed"); let file = OpenOptions::new() .read(true) .write(true) .open(&file_path) .await .expect("open should succeed"); file.write_at(6, b"runtime") .await .expect("positioned write should succeed"); file.sync_all().await.expect("sync should succeed"); let mut prefix = [0u8; 5]; file.read_exact_at(0, &mut prefix) .await .expect("positioned read should succeed"); assert_eq!(&prefix, b"hello"); let meta = file.metadata().await.expect("metadata should succeed"); assert!(meta.is_file()); assert!(meta.len() >= 13); let cloned = file.try_clone().await.expect("clone should succeed"); cloned.set_len(13).await.expect("truncate should succeed"); rename(&file_path, &renamed_path) .await .expect("rename should succeed"); let text = read_to_string(&renamed_path) .await .expect("read_to_string should succeed"); assert_eq!(text, "hello runtime"); let bytes = read(&renamed_path).await.expect("read should succeed"); assert_eq!(bytes, b"hello runtime"); let path_meta = metadata(&renamed_path) .await .expect("path metadata should work"); assert!(path_meta.is_file()); *output.lock().unwrap() = Some(text); remove_file(&renamed_path) .await .expect("remove_file should succeed"); remove_dir(&nested) .await .expect("remove nested dir should succeed"); remove_dir(&root) .await .expect("remove root dir should succeed"); }); }); } run(); assert_eq!(output.lock().unwrap().as_deref(), Some("hello runtime")); } #[test] fn async_read_dir_streams_entries() { let _guard = test_lock().lock().unwrap(); let root = unique_path("fs-read-dir"); let one = root.join("one.txt"); let two = root.join("two.txt"); let seen: Arc>> = Arc::new(Mutex::new(BTreeSet::new())); { let seen = Arc::clone(&seen); queue_task(move || { queue_future(async move { create_dir_all(&root) .await .expect("dir creation should succeed"); write(&one, b"1").await.expect("write one should succeed"); write(&two, b"2").await.expect("write two should succeed"); let mut dir = read_dir(&root).await.expect("read_dir should succeed"); while let Some(entry) = dir.next_entry().await.expect("stream should succeed") { seen.lock() .unwrap() .insert(entry.file_name().to_os_string()); } remove_file(&one).await.expect("remove one should succeed"); remove_file(&two).await.expect("remove two should succeed"); remove_dir(&root).await.expect("remove root should succeed"); }); }); } run(); let seen = seen.lock().unwrap(); assert!(seen.contains(&OsString::from("one.txt"))); assert!(seen.contains(&OsString::from("two.txt"))); } }