use std::os::unix::fs::MetadataExt; use anyhow::{Context, anyhow, bail}; use rustix::fs::statx; use tokio::{ fs, io::{self, AsyncWriteExt as _}, }; use tokio_util::io::ReaderStream; use crate::{ config::Config, domain::warren::{ models::file::{ AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, FetchFileError, FetchFileRequest, File, FileMimeType, FileName, FilePath, FileStream, FileType, ListFilesError, ListFilesRequest, RenameEntryError, RenameEntryRequest, }, ports::FileSystemRepository, }, }; const MAX_FILE_FETCH_BYTES: &str = "MAX_FILE_FETCH_BYTES"; #[derive(Debug, Clone)] pub struct FileSystemConfig { base_directory: String, max_file_fetch_bytes: u64, } impl FileSystemConfig { pub fn new(base_directory: String, max_file_fetch_bytes: u64) -> Self { Self { base_directory, max_file_fetch_bytes, } } pub fn from_env(serve_dir: String) -> anyhow::Result { // 268435456 bytes = 0.25GB let max_file_fetch_bytes: u64 = match Config::load_env(MAX_FILE_FETCH_BYTES) { Ok(value) => value.parse()?, Err(_) => 268435456, }; Ok(Self::new(serve_dir, max_file_fetch_bytes)) } } #[derive(Debug, Clone)] pub struct FileSystem { base_directory: FilePath, max_file_fetch_bytes: u64, } impl FileSystem { pub fn new(config: FileSystemConfig) -> anyhow::Result { let file_system = Self { base_directory: FilePath::new(&config.base_directory)?, max_file_fetch_bytes: config.max_file_fetch_bytes, }; Ok(file_system) } /// Combines `self.base_directory` with the specified path /// /// * `path`: The absolute path (absolute in relation to the base directory) fn get_target_path(&self, path: &AbsoluteFilePath) -> FilePath { self.base_directory.join(&path.as_relative()) } async fn get_all_files(&self, absolute_path: &AbsoluteFilePath) -> anyhow::Result> { let directory_path = self.get_target_path(absolute_path); let mut dir = fs::read_dir(&directory_path).await?; let mut files = Vec::new(); while let Ok(Some(entry)) = dir.next_entry().await { let name = entry .file_name() .into_string() .ok() .context("Failed to get file name")?; let file_type = { let file_type = entry.file_type().await?; if file_type.is_dir() { FileType::Directory } else if file_type.is_file() { FileType::File } else { continue; } }; // TODO: Use `DirEntry::metadata` once `target=x86_64-unknown-linux-musl` updates from musl 1.2.3 to 1.2.5 // https://github.com/rust-lang/rust/pull/142682 let created_at = unsafe { statx( std::os::fd::BorrowedFd::borrow_raw(-100), entry.path(), rustix::fs::AtFlags::empty(), rustix::fs::StatxFlags::BTIME, ) } .ok() .map(|statx| statx.stx_btime.tv_sec as u64); let mime_type = match file_type { FileType::File => FileMimeType::from_name(&name), _ => None, }; files.push(File::new( FileName::new(&name)?, file_type, mime_type, created_at, )); } Ok(files) } /// Actually created a directory in the underlying file system /// /// * `path`: The directory's absolute path (absolute not in relation to the root file system but `self.base_directory`) async fn create_dir(&self, path: &AbsoluteFilePath) -> io::Result { let file_path = self.get_target_path(path); if fs::try_exists(&file_path).await? { return Err(io::ErrorKind::AlreadyExists.into()); } fs::create_dir(&file_path).await?; Ok(file_path) } /// Actually removes a directory from the underlying file system /// /// * `path`: The directory's absolute path (absolute not in relation to the root file system but `self.base_directory`) /// * `force`: Whether to delete directories that are not empty async fn remove_dir(&self, path: &AbsoluteFilePath, force: bool) -> io::Result { let file_path = self.get_target_path(path); if force { fs::remove_dir_all(&file_path).await?; } else { fs::remove_dir(&file_path).await?; } Ok(file_path) } async fn write_file(&self, path: &AbsoluteFilePath, data: &[u8]) -> anyhow::Result { let path = self.get_target_path(path); let mut file = fs::OpenOptions::new() .write(true) .create(true) .open(&path) .await?; file.write_all(data).await?; Ok(path) } async fn fetch_file(&self, path: &AbsoluteFilePath) -> anyhow::Result { let path = self.get_target_path(path); let file = fs::OpenOptions::new() .create(false) .write(false) .read(true) .open(&path) .await?; let file_size = file.metadata().await?.size(); if file_size > self.max_file_fetch_bytes { bail!("File size exceeds configured limit"); } let stream = FileStream::new(ReaderStream::new(file)); Ok(stream) } /// Actually removes a file from the underlying file system /// /// * `path`: The file's absolute path (absolute not in relation to the root file system but `self.base_directory`) async fn remove_file(&self, path: &AbsoluteFilePath) -> anyhow::Result { let path = self.get_target_path(path); fs::remove_file(&path).await?; Ok(path) } async fn rename( &self, path: &AbsoluteFilePath, new_name: &FileName, ) -> anyhow::Result { let current_path = self.get_target_path(path); let new_path = { let mut c = current_path.to_string(); let last_slash_index = c.rfind('/').unwrap(); c.drain((last_slash_index + 1)..); c.push_str(new_name.as_str()); FilePath::new(&c)? }; if fs::try_exists(&new_path).await? { bail!("File exists"); } fs::rename(current_path, &new_path).await?; Ok(new_path) } } impl FileSystemRepository for FileSystem { async fn list_files(&self, request: ListFilesRequest) -> Result, ListFilesError> { let files = self.get_all_files(request.path()).await.map_err(|err| { anyhow!(err).context(format!( "Failed to get the files at path: {}", request.path() )) })?; Ok(files) } async fn create_directory( &self, request: CreateDirectoryRequest, ) -> Result { match self.create_dir(request.path()).await { Ok(path) => Ok(path), Err(e) => match e.kind() { std::io::ErrorKind::AlreadyExists => Err(CreateDirectoryError::Exists), _ => Err(anyhow!("Failed to create directory at path: {}", request.path()).into()), }, } } async fn delete_directory( &self, request: DeleteDirectoryRequest, ) -> Result { match self.remove_dir(request.path(), request.force()).await { Ok(deleted_path) => return Ok(deleted_path), Err(e) => match e.kind() { std::io::ErrorKind::NotFound => Err(DeleteDirectoryError::NotFound), std::io::ErrorKind::DirectoryNotEmpty => Err(DeleteDirectoryError::NotEmpty), _ => Err(anyhow!("Failed to delete directory at {}: {e:?}", request.path()).into()), }, } } async fn create_file(&self, request: CreateFileRequest) -> Result { let file_path = self .write_file(request.path(), request.data()) .await .map_err(|e| { anyhow!( "Failed to write {} byte(s) to path {}: {e:?}", request.data().len(), request.path() ) })?; Ok(file_path) } async fn fetch_file(&self, request: FetchFileRequest) -> Result { let contents = self .fetch_file(request.path()) .await .map_err(|e| anyhow!("Failed to fetch file {}: {e:?}", request.path()))?; Ok(contents) } async fn delete_file(&self, request: DeleteFileRequest) -> Result { let deleted_path = self .remove_file(request.path()) .await .context(format!("Failed to delete file at {}", request.path()))?; Ok(deleted_path) } async fn rename_entry( &self, request: RenameEntryRequest, ) -> Result { let new_path = self .rename(request.path(), request.new_name()) .await .map_err(|e| { anyhow!( "Failed to rename {} to {}: {e:?}", request.path(), request.new_name() ) })?; Ok(new_path) } }