use async_trait::async_trait; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tokio::io; const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CreateDirectoryOptions { pub recursive: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RemoveOptions { pub recursive: bool, pub force: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CopyOptions { pub recursive: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct FileMetadata { pub is_directory: bool, pub is_file: bool, pub created_at_ms: i64, pub modified_at_ms: i64, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ReadDirectoryEntry { pub file_name: String, pub is_directory: bool, pub is_file: bool, } pub type FileSystemResult = io::Result; #[async_trait] pub trait ExecutorFileSystem: Send + Sync { async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult>; async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec) -> FileSystemResult<()>; async fn create_directory( &self, path: &AbsolutePathBuf, options: CreateDirectoryOptions, ) -> FileSystemResult<()>; async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult; async fn read_directory( &self, path: &AbsolutePathBuf, ) -> FileSystemResult>; async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()>; async fn copy( &self, source_path: &AbsolutePathBuf, destination_path: &AbsolutePathBuf, options: CopyOptions, ) -> FileSystemResult<()>; } #[derive(Clone, Default)] pub(crate) struct LocalFileSystem; #[async_trait] impl ExecutorFileSystem for LocalFileSystem { async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult> { let metadata = tokio::fs::metadata(path.as_path()).await?; if metadata.len() > MAX_READ_FILE_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("file is too large to read: limit is {MAX_READ_FILE_BYTES} bytes"), )); } tokio::fs::read(path.as_path()).await } async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec) -> FileSystemResult<()> { tokio::fs::write(path.as_path(), contents).await } async fn create_directory( &self, path: &AbsolutePathBuf, options: CreateDirectoryOptions, ) -> FileSystemResult<()> { if options.recursive { tokio::fs::create_dir_all(path.as_path()).await?; } else { tokio::fs::create_dir(path.as_path()).await?; } Ok(()) } async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult { let metadata = tokio::fs::metadata(path.as_path()).await?; Ok(FileMetadata { is_directory: metadata.is_dir(), is_file: metadata.is_file(), created_at_ms: metadata.created().ok().map_or(0, system_time_to_unix_ms), modified_at_ms: metadata.modified().ok().map_or(0, system_time_to_unix_ms), }) } async fn read_directory( &self, path: &AbsolutePathBuf, ) -> FileSystemResult> { let mut entries = Vec::new(); let mut read_dir = tokio::fs::read_dir(path.as_path()).await?; while let Some(entry) = read_dir.next_entry().await? { let metadata = tokio::fs::metadata(entry.path()).await?; entries.push(ReadDirectoryEntry { file_name: entry.file_name().to_string_lossy().into_owned(), is_directory: metadata.is_dir(), is_file: metadata.is_file(), }); } Ok(entries) } async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> { match tokio::fs::symlink_metadata(path.as_path()).await { Ok(metadata) => { let file_type = metadata.file_type(); if file_type.is_dir() { if options.recursive { tokio::fs::remove_dir_all(path.as_path()).await?; } else { tokio::fs::remove_dir(path.as_path()).await?; } } else { tokio::fs::remove_file(path.as_path()).await?; } Ok(()) } Err(err) if err.kind() == io::ErrorKind::NotFound && options.force => Ok(()), Err(err) => Err(err), } } async fn copy( &self, source_path: &AbsolutePathBuf, destination_path: &AbsolutePathBuf, options: CopyOptions, ) -> FileSystemResult<()> { let source_path = source_path.to_path_buf(); let destination_path = destination_path.to_path_buf(); tokio::task::spawn_blocking(move || -> FileSystemResult<()> { let metadata = std::fs::symlink_metadata(source_path.as_path())?; let file_type = metadata.file_type(); if file_type.is_dir() { if !options.recursive { return Err(io::Error::new( io::ErrorKind::InvalidInput, "fs/copy requires recursive: true when sourcePath is a directory", )); } if destination_is_same_or_descendant_of_source( source_path.as_path(), destination_path.as_path(), )? { return Err(io::Error::new( io::ErrorKind::InvalidInput, "fs/copy cannot copy a directory to itself or one of its descendants", )); } copy_dir_recursive(source_path.as_path(), destination_path.as_path())?; return Ok(()); } if file_type.is_symlink() { copy_symlink(source_path.as_path(), destination_path.as_path())?; return Ok(()); } if file_type.is_file() { std::fs::copy(source_path.as_path(), destination_path.as_path())?; return Ok(()); } Err(io::Error::new( io::ErrorKind::InvalidInput, "fs/copy only supports regular files, directories, and symlinks", )) }) .await .map_err(|err| io::Error::other(format!("filesystem task failed: {err}")))? } } fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> { std::fs::create_dir_all(target)?; for entry in std::fs::read_dir(source)? { let entry = entry?; let source_path = entry.path(); let target_path = target.join(entry.file_name()); let file_type = entry.file_type()?; if file_type.is_dir() { copy_dir_recursive(&source_path, &target_path)?; } else if file_type.is_file() { std::fs::copy(&source_path, &target_path)?; } else if file_type.is_symlink() { copy_symlink(&source_path, &target_path)?; } } Ok(()) } fn destination_is_same_or_descendant_of_source( source: &Path, destination: &Path, ) -> io::Result { let source = std::fs::canonicalize(source)?; let destination = resolve_copy_destination_path(destination)?; Ok(destination.starts_with(&source)) } fn resolve_copy_destination_path(path: &Path) -> io::Result { let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), Component::RootDir => normalized.push(component.as_os_str()), Component::CurDir => {} Component::ParentDir => { normalized.pop(); } Component::Normal(part) => normalized.push(part), } } let mut unresolved_suffix = Vec::new(); let mut existing_path = normalized.as_path(); while !existing_path.exists() { let Some(file_name) = existing_path.file_name() else { break; }; unresolved_suffix.push(file_name.to_os_string()); let Some(parent) = existing_path.parent() else { break; }; existing_path = parent; } let mut resolved = std::fs::canonicalize(existing_path)?; for file_name in unresolved_suffix.iter().rev() { resolved.push(file_name); } Ok(resolved) } fn copy_symlink(source: &Path, target: &Path) -> io::Result<()> { let link_target = std::fs::read_link(source)?; #[cfg(unix)] { std::os::unix::fs::symlink(&link_target, target) } #[cfg(windows)] { if symlink_points_to_directory(source)? { std::os::windows::fs::symlink_dir(&link_target, target) } else { std::os::windows::fs::symlink_file(&link_target, target) } } #[cfg(not(any(unix, windows)))] { let _ = link_target; let _ = target; Err(io::Error::new( io::ErrorKind::Unsupported, "copying symlinks is unsupported on this platform", )) } } #[cfg(windows)] fn symlink_points_to_directory(source: &Path) -> io::Result { use std::os::windows::fs::FileTypeExt; Ok(std::fs::symlink_metadata(source)? .file_type() .is_symlink_dir()) } fn system_time_to_unix_ms(time: SystemTime) -> i64 { time.duration_since(UNIX_EPOCH) .ok() .and_then(|duration| i64::try_from(duration.as_millis()).ok()) .unwrap_or(0) } #[cfg(all(test, windows))] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn symlink_points_to_directory_handles_dangling_directory_symlinks() -> io::Result<()> { use std::os::windows::fs::symlink_dir; let temp_dir = tempfile::TempDir::new()?; let source_dir = temp_dir.path().join("source"); let link_path = temp_dir.path().join("source-link"); std::fs::create_dir(&source_dir)?; if symlink_dir(&source_dir, &link_path).is_err() { return Ok(()); } std::fs::remove_dir(&source_dir)?; assert_eq!(symlink_points_to_directory(&link_path)?, true); Ok(()) } }