use crate::domain::{error::AppError, files::*, ssh::OperationResponse}; use base64::{engine::general_purpose::STANDARD, Engine}; use std::{ fs, path::{Path, PathBuf}, }; pub fn read_file(file_path: String) -> ReadFileResponse { match fs::read(expand_home(&file_path)) { Ok(data) => ReadFileResponse { success: true, data: Some(STANDARD.encode(data)), error: None, }, Err(error) => ReadFileResponse { success: false, data: None, error: Some(error.to_string()), }, } } pub fn write_file(file_path: String, data: String) -> OperationResponse { let result = (|| -> Result<(), AppError> { let bytes = STANDARD.decode(&data)?; fs::write(expand_home(&file_path), bytes)?; Ok(()) })(); match result { Ok(()) => OperationResponse::ok(), Err(error) => error.into(), } } pub fn local_read_dir(path: String) -> LocalFilesResponse { match read_local_dir(&expand_home(&path)) { Ok(files) => LocalFilesResponse { success: true, files: Some(files), error: None, }, Err(error) => LocalFilesResponse { success: false, files: None, error: Some(error), }, } } pub fn local_get_stats(path: String) -> LocalStatsResponse { let path = expand_home(&path); match local_entry(&path) { Ok(stats) => LocalStatsResponse { success: true, stats: Some(stats), error: None, }, Err(error) => LocalStatsResponse { success: false, stats: None, error: Some(error), }, } } pub fn local_mkdir(path: String) -> OperationResponse { operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string())) } pub fn local_delete(path: String) -> OperationResponse { let path = expand_home(&path); let result = if path.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }; operation(result.map_err(|error| error.to_string())) } pub fn local_rename(old_path: String, new_path: String) -> OperationResponse { operation( fs::rename(expand_home(&old_path), expand_home(&new_path)) .map_err(|error| error.to_string()), ) } fn read_local_dir(path: &Path) -> Result, String> { let mut files = Vec::new(); for entry in fs::read_dir(path).map_err(|error| error.to_string())? { let entry = entry.map_err(|error| error.to_string())?; files.push(local_entry(&entry.path())?); } files.sort_by(|a, b| { ((a.entry_type == "file"), a.name.to_lowercase()) .cmp(&((b.entry_type == "file"), b.name.to_lowercase())) }); Ok(files) } fn local_entry(path: &Path) -> Result { let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; let entry_type = if metadata.file_type().is_symlink() { "symlink" } else if metadata.is_dir() { "directory" } else { "file" }; Ok(LocalFileEntry { name: path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(), path: path.to_string_lossy().to_string(), entry_type: entry_type.to_string(), size: metadata.len(), modified: metadata .modified() .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_secs()) .unwrap_or(0), permissions: permissions(&metadata, entry_type), }) } #[cfg(unix)] fn permissions(metadata: &fs::Metadata, kind: &str) -> String { use std::os::unix::fs::PermissionsExt; let mode = metadata.permissions().mode(); let prefix = if kind == "directory" { 'd' } else if kind == "symlink" { 'l' } else { '-' }; format!("{}{}", prefix, permission_bits(mode)) } #[cfg(not(unix))] fn permissions(_metadata: &fs::Metadata, kind: &str) -> String { if kind == "directory" { "drwxr-xr-x".to_string() } else { "-rw-r--r--".to_string() } } #[cfg(unix)] fn permission_bits(mode: u32) -> String { let mut output = String::new(); for shift in [6, 3, 0] { let bits = (mode >> shift) & 7; output.push(if bits & 4 != 0 { 'r' } else { '-' }); output.push(if bits & 2 != 0 { 'w' } else { '-' }); output.push(if bits & 1 != 0 { 'x' } else { '-' }); } output } pub fn expand_home(value: &str) -> PathBuf { if value == "~" { return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value)); } let relative = value .strip_prefix("~/") .or_else(|| value.strip_prefix("~\\")); if let Some(relative) = relative { if let Some(home) = dirs::home_dir() { return home.join(std::path::Path::new(relative)); } } PathBuf::from(value) } fn operation(result: Result<(), String>) -> OperationResponse { match result { Ok(()) => OperationResponse::ok(), Err(error) => OperationResponse::error(error), } } /// Resolve the native downloads path in the command, then perform I/O off the UI thread. pub fn open_downloads_folder(path: PathBuf) -> Result<(), String> { fs::create_dir_all(&path).map_err(|error| format!("创建下载目录失败: {error}"))?; open::that(&path).map_err(|error| format!("打开下载目录失败: {error}")) } #[cfg(test)] mod tests { use super::*; struct TestDirectory(PathBuf); impl TestDirectory { fn new() -> Self { let path = std::env::temp_dir().join(format!("opsxshell-files-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&path).unwrap(); Self(path) } fn path(&self, name: &str) -> String { self.0.join(name).to_string_lossy().into_owned() } } impl Drop for TestDirectory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[test] fn binary_file_roundtrip_and_directory_operations() { let dir = TestDirectory::new(); let bytes = [0, 255, 128, 10]; let file = dir.path("z.bin"); assert!(write_file(file.clone(), STANDARD.encode(bytes)).success); assert_eq!(read_file(file.clone()).data, Some(STANDARD.encode(bytes))); assert!(local_mkdir(dir.path("folder")).success); let listing = local_read_dir(dir.path("")); assert!(listing.success); let entries = listing.files.unwrap(); assert_eq!(entries[0].entry_type, "directory"); assert_eq!(entries[1].name, "z.bin"); assert_eq!(local_get_stats(file.clone()).stats.unwrap().size, 4); let renamed = dir.path("renamed.bin"); assert!(local_rename(file.clone(), renamed.clone()).success); assert!(!Path::new(&file).exists()); assert!(local_delete(renamed.clone()).success); assert!(!read_file(renamed).success); } #[test] fn invalid_base64_never_overwrites_existing_file() { let dir = TestDirectory::new(); let path = dir.path("keep.bin"); fs::write(&path, b"keep").unwrap(); let response = write_file(path.clone(), "%%%".into()); assert!(!response.success); assert_eq!( response.error_code.as_deref(), Some(crate::domain::error::codes::INVALID_BASE64) ); assert_eq!(fs::read(path).unwrap(), b"keep"); } }