//! SFTP service — high-level file operations over an existing SSH connection. //! //! SFTP sessions are created from SSH handles via a separate subsystem channel //! (not the PTY channel). One `SftpSession` is stored per SSH session ID and //! provides all file operations needed by the frontend. use std::sync::Arc; use std::time::{Duration, UNIX_EPOCH}; use dashmap::DashMap; use log::{debug, info}; use russh_sftp::client::SftpSession; use russh_sftp::protocol::OpenFlags; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::Mutex as TokioMutex; // ── file entry ─────────────────────────────────────────────────────────────── /// Metadata about a single remote file or directory, returned to the frontend. #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct FileEntry { pub name: String, pub path: String, pub size: u64, pub is_dir: bool, /// Unix permission string, e.g. "0755" pub permissions: String, /// Human-readable modification time, e.g. "Mar 17 14:30" pub mod_time: String, } // ── helpers ────────────────────────────────────────────────────────────────── /// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM". fn format_mtime(unix_secs: u32) -> String { // Build a SystemTime from the raw epoch value. let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64); // Convert to seconds-since-epoch for manual formatting. We avoid pulling // in chrono just for this; a simple manual decomposition is sufficient for // the "Mar 17 14:30" display format expected by the frontend. let secs = unix_secs as i64; // Days since epoch → calendar date (Gregorian, ignoring leap seconds). let days = secs / 86_400; let time_of_day = secs % 86_400; let hours = time_of_day / 3_600; let minutes = (time_of_day % 3_600) / 60; // Gregorian calendar calculation (Zeller / Julian Day approach). let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let _y = if m <= 2 { y + 1 } else { y }; let month = match m { 1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr", 5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug", 9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec", _ => "???", }; // Suppress unused variable warning — st is only used as a sanity anchor. let _ = st; format!("{} {:2} {:02}:{:02}", month, d, hours, minutes) } /// Convert raw permission bits to a "0755"-style octal string. fn format_permissions(raw: Option) -> String { match raw { Some(p) => format!("{:04o}", p & 0o7777), None => "----".to_string(), } } // ── SFTP service ───────────────────────────────────────────────────────────── /// Manages SFTP sessions keyed by SSH session ID. #[derive(Clone)] pub struct SftpService { /// One `SftpSession` per SSH session, behind a mutex so async commands can /// take a shared reference to the `SftpService` and still mutably borrow /// individual sessions. clients: DashMap>>, } impl SftpService { pub fn new() -> Self { Self { clients: DashMap::new(), } } /// Store an SFTP client for the given session. pub fn register_client(&self, session_id: &str, client: SftpSession) { info!("SFTP client registered for session {}", session_id); self.clients .insert(session_id.to_string(), Arc::new(TokioMutex::new(client))); } /// Remove and drop the SFTP client for a session (called on SSH disconnect). pub fn remove_client(&self, session_id: &str) { if self.clients.remove(session_id).is_some() { info!("SFTP client removed for session {}", session_id); } } /// List the contents of a remote directory. /// /// Returns entries sorted: directories first (alphabetically), then files /// (alphabetically). pub async fn list(&self, session_id: &str, path: &str) -> Result, String> { let arc = self.get_client(session_id)?; let client = arc.lock().await; let read_dir = client .read_dir(path) .await .map_err(|e| format!("SFTP list '{}' failed: {}", path, e))?; let mut entries: Vec = read_dir .map(|entry| { let meta = entry.metadata(); let name = entry.file_name(); let full_path = if path.ends_with('/') { format!("{}{}", path, name) } else { format!("{}/{}", path, name) }; let is_dir = meta.is_dir(); FileEntry { name, path: full_path, size: meta.size.unwrap_or(0), is_dir, permissions: format_permissions(meta.permissions), mod_time: meta.mtime.map(format_mtime).unwrap_or_default(), } }) .collect(); // Sort: directories first, then files; each group alphabetical. entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), }); debug!( "SFTP list '{}' for session {}: {} entries", path, session_id, entries.len() ); Ok(entries) } /// Read a remote file as a UTF-8 string. /// /// Enforces a 5 MB guard — returns an error for larger files. pub async fn read_file(&self, session_id: &str, path: &str) -> Result { const MAX_BYTES: u64 = 5 * 1024 * 1024; let arc = self.get_client(session_id)?; let client = arc.lock().await; // Stat first to check size before pulling bytes. let meta = client .metadata(path) .await .map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?; if let Some(size) = meta.size { if size > MAX_BYTES { return Err(format!( "File '{}' is {} bytes — exceeds 5 MB read limit", path, size )); } } let mut file = client .open(path) .await .map_err(|e| format!("SFTP open '{}' failed: {}", path, e))?; let mut buf = Vec::new(); file.read_to_end(&mut buf) .await .map_err(|e| format!("SFTP read '{}' failed: {}", path, e))?; String::from_utf8(buf) .map_err(|_| format!("File '{}' is not valid UTF-8", path)) } /// Write a UTF-8 string to a remote file (create or truncate). pub async fn write_file( &self, session_id: &str, path: &str, content: &str, ) -> Result<(), String> { let arc = self.get_client(session_id)?; let client = arc.lock().await; let mut file = client .open_with_flags( path, OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE, ) .await .map_err(|e| format!("SFTP open '{}' for write failed: {}", path, e))?; file.write_all(content.as_bytes()) .await .map_err(|e| format!("SFTP write '{}' failed: {}", path, e))?; file.flush() .await .map_err(|e| format!("SFTP flush '{}' failed: {}", path, e))?; debug!("SFTP wrote {} bytes to '{}'", content.len(), path); Ok(()) } /// Create a remote directory. pub async fn mkdir(&self, session_id: &str, path: &str) -> Result<(), String> { let arc = self.get_client(session_id)?; let client = arc.lock().await; client .create_dir(path) .await .map_err(|e| format!("SFTP mkdir '{}' failed: {}", path, e)) } /// Delete a remote file or directory. /// /// Tries `remove_file` first; falls back to `remove_dir` if that fails. pub async fn delete(&self, session_id: &str, path: &str) -> Result<(), String> { let arc = self.get_client(session_id)?; let client = arc.lock().await; match client.remove_file(path).await { Ok(()) => Ok(()), Err(_) => client .remove_dir(path) .await .map_err(|e| format!("SFTP delete '{}' failed: {}", path, e)), } } /// Rename (or move) a remote path. pub async fn rename( &self, session_id: &str, old_path: &str, new_path: &str, ) -> Result<(), String> { let arc = self.get_client(session_id)?; let client = arc.lock().await; client .rename(old_path, new_path) .await .map_err(|e| format!("SFTP rename '{}' → '{}' failed: {}", old_path, new_path, e)) } /// Stat a single remote path and return its metadata as a `FileEntry`. pub async fn stat(&self, session_id: &str, path: &str) -> Result { let arc = self.get_client(session_id)?; let client = arc.lock().await; let meta = client .metadata(path) .await .map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?; // Extract the file name from the path. let name = path .rsplit('/') .next() .unwrap_or(path) .to_string(); Ok(FileEntry { name, path: path.to_string(), size: meta.size.unwrap_or(0), is_dir: meta.is_dir(), permissions: format_permissions(meta.permissions), mod_time: meta.mtime.map(format_mtime).unwrap_or_default(), }) } // ── private helpers ─────────────────────────────────────────────────────── fn get_client( &self, session_id: &str, ) -> Result>, String> { self.clients .get(session_id) .map(|r| r.clone()) .ok_or_else(|| format!("No SFTP client for session {}", session_id)) } }