Skip to main content

ssh_commander_core/
sftp_client.rs

1use anyhow::Result;
2use russh::*;
3use russh_sftp::client::SftpSession;
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use std::time::Duration;
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8
9use crate::ssh::{Client, HostKeyStore};
10
11/// Configuration for a standalone SFTP connection (SSH transport, no PTY).
12#[derive(Clone, Deserialize)]
13pub struct SftpConfig {
14    pub host: String,
15    pub port: u16,
16    pub username: String,
17    pub auth_method: SftpAuthMethod,
18}
19
20impl std::fmt::Debug for SftpConfig {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.debug_struct("SftpConfig")
23            .field("host", &self.host)
24            .field("port", &self.port)
25            .field("username", &self.username)
26            .field("auth_method", &self.auth_method)
27            .finish()
28    }
29}
30
31#[derive(Clone, Deserialize)]
32#[serde(tag = "type")]
33pub enum SftpAuthMethod {
34    Password {
35        password: String,
36    },
37    PublicKey {
38        key_path: String,
39        passphrase: Option<String>,
40    },
41}
42
43impl std::fmt::Debug for SftpAuthMethod {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            SftpAuthMethod::Password { .. } => f
47                .debug_struct("SftpAuthMethod::Password")
48                .field("password", &"<redacted>")
49                .finish(),
50            SftpAuthMethod::PublicKey {
51                key_path,
52                passphrase,
53            } => f
54                .debug_struct("SftpAuthMethod::PublicKey")
55                .field("key_path", key_path)
56                .field(
57                    "passphrase",
58                    &passphrase
59                        .as_ref()
60                        .map(|_| "<redacted>")
61                        .unwrap_or("<none>"),
62                )
63                .finish(),
64        }
65    }
66}
67
68/// A single file/directory entry returned from directory listings.
69/// Used by both local and remote (SFTP/FTP) file operations.
70#[derive(Debug, Clone, Serialize)]
71pub struct FileEntry {
72    pub name: String,
73    pub size: u64,
74    /// Pre-formatted timestamp string for human display.
75    pub modified: Option<String>,
76    /// Raw modification time as Unix epoch seconds. Surfaced
77    /// alongside `modified` so consumers (the macOS file table)
78    /// can sort numerically and reformat per-locale instead of
79    /// relying on lexical comparison of the formatted string.
80    pub modified_unix: Option<i64>,
81    pub permissions: Option<String>,
82    pub owner: Option<String>,
83    pub group: Option<String>,
84    pub file_type: FileEntryType,
85}
86
87/// Backward-compatible alias for code that still references RemoteFileEntry.
88pub type RemoteFileEntry = FileEntry;
89
90#[derive(Debug, Clone, Serialize, PartialEq)]
91pub enum FileEntryType {
92    File,
93    Directory,
94    Symlink,
95}
96
97/// Standalone SFTP client — opens an SSH connection and SFTP subsystem
98/// channel without allocating a PTY.
99pub struct StandaloneSftpClient {
100    session: Option<Arc<client::Handle<Client>>>,
101    sftp: Option<SftpSession>,
102}
103
104impl StandaloneSftpClient {
105    /// Establish an SSH connection, authenticate, and open the SFTP subsystem.
106    pub async fn connect(config: &SftpConfig, host_keys: Arc<HostKeyStore>) -> Result<Self> {
107        let auth = match &config.auth_method {
108            SftpAuthMethod::Password { password } => {
109                crate::ssh::ResolvedAuth::Password { password }
110            }
111            SftpAuthMethod::PublicKey {
112                key_path,
113                passphrase,
114            } => crate::ssh::ResolvedAuth::Key {
115                key: Box::new(crate::ssh::load_private_key(
116                    key_path,
117                    passphrase.as_deref(),
118                )?),
119                key_path_hint: Some(key_path),
120            },
121        };
122
123        let ssh_session = crate::ssh::connect_authenticated(
124            &config.host,
125            config.port,
126            &config.username,
127            auth,
128            Duration::from_secs(10),
129            host_keys,
130        )
131        .await?;
132        let session = Arc::new(ssh_session);
133
134        // Open an SFTP subsystem channel (no PTY)
135        let channel = session.channel_open_session().await?;
136        channel.request_subsystem(true, "sftp").await?;
137        let sftp = SftpSession::new(channel.into_stream()).await?;
138
139        Ok(Self {
140            session: Some(session),
141            sftp: Some(sftp),
142        })
143    }
144
145    pub async fn disconnect(&mut self) -> Result<()> {
146        // Drop SFTP session first
147        self.sftp.take();
148        // Disconnect SSH session
149        if let Some(session) = self.session.take() {
150            match Arc::try_unwrap(session) {
151                Ok(session) => {
152                    if let Err(e) = session.disconnect(Disconnect::ByApplication, "", "").await {
153                        tracing::warn!("SFTP SSH disconnect failed cleanly: {}", e);
154                    }
155                }
156                Err(arc_session) => {
157                    // Other references (e.g. pending SFTP ops) still exist;
158                    // drop ours. The session ends when the last reference dies.
159                    drop(arc_session);
160                }
161            }
162        }
163        Ok(())
164    }
165
166    // ===== File Operations =====
167
168    /// List directory contents at `path`.
169    pub async fn list_dir(&self, path: &str) -> Result<Vec<RemoteFileEntry>> {
170        let sftp = self
171            .sftp
172            .as_ref()
173            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
174
175        let entries = sftp
176            .read_dir(path)
177            .await
178            .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?;
179
180        let mut result = Vec::new();
181        for entry in entries {
182            let name = entry.file_name();
183            // Skip . and .. entries
184            if name == "." || name == ".." {
185                continue;
186            }
187
188            let attrs = entry.metadata();
189            let size = attrs.size.unwrap_or(0);
190            let mtime_secs = attrs.mtime.map(|t| t as i64);
191            let modified = mtime_secs.map(format_unix_timestamp);
192
193            let permissions = attrs.permissions.map(format_permissions);
194            let owner = attrs.uid.map(|u| u.to_string());
195            let group = attrs.gid.map(|g| g.to_string());
196
197            let file_type = if attrs.is_dir() {
198                FileEntryType::Directory
199            } else if attrs.is_symlink() {
200                FileEntryType::Symlink
201            } else {
202                FileEntryType::File
203            };
204
205            result.push(RemoteFileEntry {
206                name,
207                size,
208                modified,
209                modified_unix: mtime_secs,
210                permissions,
211                owner,
212                group,
213                file_type,
214            });
215        }
216
217        // Sort: directories first, then by name
218        result.sort_by(|a, b| {
219            let a_is_dir = matches!(a.file_type, FileEntryType::Directory);
220            let b_is_dir = matches!(b.file_type, FileEntryType::Directory);
221            b_is_dir
222                .cmp(&a_is_dir)
223                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
224        });
225
226        Ok(result)
227    }
228
229    /// Download a remote file to a local path. Streams chunks — never buffers
230    /// the whole file. Returns bytes downloaded.
231    pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<u64> {
232        let sftp = self
233            .sftp
234            .as_ref()
235            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
236
237        let mut remote_file = sftp
238            .open(remote_path)
239            .await
240            .map_err(|e| anyhow::anyhow!("Failed to open remote file '{}': {}", remote_path, e))?;
241        let mut local_file = tokio::fs::File::create(local_path)
242            .await
243            .map_err(|e| anyhow::anyhow!("Failed to create local file '{}': {}", local_path, e))?;
244
245        let mut buf = vec![0u8; crate::ssh::SFTP_CHUNK_SIZE];
246        let mut total_bytes = 0u64;
247        loop {
248            let n = remote_file.read(&mut buf).await?;
249            if n == 0 {
250                break;
251            }
252            local_file.write_all(&buf[..n]).await?;
253            total_bytes += n as u64;
254        }
255        local_file.flush().await?;
256        Ok(total_bytes)
257    }
258
259    /// Upload a local file to a remote path. Streams chunks — never buffers
260    /// the whole file. Returns bytes uploaded.
261    pub async fn upload_file(&self, local_path: &str, remote_path: &str) -> Result<u64> {
262        let sftp = self
263            .sftp
264            .as_ref()
265            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
266
267        let mut local_file = tokio::fs::File::open(local_path)
268            .await
269            .map_err(|e| anyhow::anyhow!("Failed to open local file '{}': {}", local_path, e))?;
270        let mut remote_file = sftp.create(remote_path).await.map_err(|e| {
271            anyhow::anyhow!("Failed to create remote file '{}': {}", remote_path, e)
272        })?;
273
274        let mut buf = vec![0u8; crate::ssh::SFTP_CHUNK_SIZE];
275        let mut total_bytes = 0u64;
276        loop {
277            let n = local_file.read(&mut buf).await?;
278            if n == 0 {
279                break;
280            }
281            remote_file.write_all(&buf[..n]).await?;
282            total_bytes += n as u64;
283        }
284        remote_file.flush().await?;
285
286        Ok(total_bytes)
287    }
288
289    /// Create a directory on the remote server.
290    pub async fn create_dir(&self, path: &str) -> Result<()> {
291        let sftp = self
292            .sftp
293            .as_ref()
294            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
295
296        sftp.create_dir(path)
297            .await
298            .map_err(|e| anyhow::anyhow!("Failed to create directory '{}': {}", path, e))?;
299        Ok(())
300    }
301
302    /// Rename a file or directory.
303    pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
304        let sftp = self
305            .sftp
306            .as_ref()
307            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
308
309        sftp.rename(old_path, new_path).await.map_err(|e| {
310            anyhow::anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)
311        })?;
312        Ok(())
313    }
314
315    /// Delete a file on the remote server.
316    pub async fn delete_file(&self, path: &str) -> Result<()> {
317        let sftp = self
318            .sftp
319            .as_ref()
320            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
321
322        sftp.remove_file(path)
323            .await
324            .map_err(|e| anyhow::anyhow!("Failed to delete file '{}': {}", path, e))?;
325        Ok(())
326    }
327
328    /// Delete a directory on the remote server.
329    pub async fn delete_dir(&self, path: &str) -> Result<()> {
330        let sftp = self
331            .sftp
332            .as_ref()
333            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
334
335        sftp.remove_dir(path)
336            .await
337            .map_err(|e| anyhow::anyhow!("Failed to delete directory '{}': {}", path, e))?;
338        Ok(())
339    }
340}
341
342/// Convert a Unix timestamp (seconds since epoch) to a readable UTC datetime
343/// string ("YYYY-MM-DD HH:MM:SS"). Uses chrono for correct leap-year and
344/// post-2106 handling.
345pub fn format_unix_timestamp(secs: i64) -> String {
346    chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
347        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
348        .unwrap_or_else(|| "invalid-timestamp".to_string())
349}
350
351/// Format Unix file permissions (mode bits) as a string like `rwxr-xr-x`.
352pub(crate) fn format_permissions(mode: u32) -> String {
353    let mut s = String::with_capacity(9);
354    let flags = [
355        (0o400, 'r'),
356        (0o200, 'w'),
357        (0o100, 'x'),
358        (0o040, 'r'),
359        (0o020, 'w'),
360        (0o010, 'x'),
361        (0o004, 'r'),
362        (0o002, 'w'),
363        (0o001, 'x'),
364    ];
365    for (bit, ch) in flags.iter() {
366        if mode & bit != 0 {
367            s.push(*ch);
368        } else {
369            s.push('-');
370        }
371    }
372    s
373}
374
375// =============================================================================
376// Unit tests — Task 4.3
377// =============================================================================
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    // ---- Helper function tests ----
383
384    #[test]
385    fn test_format_permissions_full() {
386        assert_eq!(format_permissions(0o777), "rwxrwxrwx");
387    }
388
389    #[test]
390    fn test_format_permissions_none() {
391        assert_eq!(format_permissions(0o000), "---------");
392    }
393
394    #[test]
395    fn test_format_permissions_typical_file() {
396        assert_eq!(format_permissions(0o644), "rw-r--r--");
397    }
398
399    #[test]
400    fn test_format_permissions_typical_dir() {
401        assert_eq!(format_permissions(0o755), "rwxr-xr-x");
402    }
403
404    #[test]
405    fn test_format_permissions_write_only() {
406        assert_eq!(format_permissions(0o200), "-w-------");
407    }
408
409    #[test]
410    fn format_unix_timestamp_epoch() {
411        assert_eq!(format_unix_timestamp(0), "1970-01-01 00:00:00");
412    }
413
414    #[test]
415    fn format_unix_timestamp_known_date() {
416        // 2024-01-01 00:00:00 UTC
417        assert_eq!(format_unix_timestamp(1704067200), "2024-01-01 00:00:00");
418    }
419
420    #[test]
421    fn format_unix_timestamp_with_time() {
422        // 2000-06-15 11:30:45 UTC
423        assert_eq!(format_unix_timestamp(961068645), "2000-06-15 11:30:45");
424    }
425
426    #[test]
427    fn format_unix_timestamp_post_2106() {
428        // 2200-01-01 00:00:00 UTC — past the u32 epoch cutoff that the old
429        // hand-rolled code would have silently truncated.
430        assert_eq!(format_unix_timestamp(7258118400), "2200-01-01 00:00:00");
431    }
432
433    // ---- StandaloneSftpClient unit tests ----
434
435    #[test]
436    fn test_file_entry_type_serialization() {
437        // Verify that FileEntryType variants serialize correctly
438        let entry = RemoteFileEntry {
439            name: "test.txt".to_string(),
440            size: 1024,
441            modified: Some("2024-01-01 00:00:00".to_string()),
442            modified_unix: Some(1_704_067_200),
443            permissions: Some("rw-r--r--".to_string()),
444            owner: Some("501".to_string()),
445            group: Some("20".to_string()),
446            file_type: FileEntryType::File,
447        };
448        let json = serde_json::to_string(&entry).unwrap();
449        assert!(json.contains("\"name\":\"test.txt\""));
450        assert!(json.contains("\"size\":1024"));
451        assert!(json.contains("File"));
452    }
453
454    #[test]
455    fn test_directory_entry_serialization() {
456        let entry = RemoteFileEntry {
457            name: "mydir".to_string(),
458            size: 4096,
459            modified: None,
460            modified_unix: None,
461            permissions: Some("rwxr-xr-x".to_string()),
462            owner: None,
463            group: None,
464            file_type: FileEntryType::Directory,
465        };
466        let json = serde_json::to_string(&entry).unwrap();
467        assert!(json.contains("Directory"));
468        assert!(json.contains("\"modified\":null"));
469    }
470
471    #[test]
472    fn test_symlink_entry_serialization() {
473        let entry = RemoteFileEntry {
474            name: "link".to_string(),
475            size: 0,
476            modified: None,
477            modified_unix: None,
478            permissions: None,
479            owner: None,
480            group: None,
481            file_type: FileEntryType::Symlink,
482        };
483        let json = serde_json::to_string(&entry).unwrap();
484        assert!(json.contains("Symlink"));
485    }
486
487    #[test]
488    fn test_sftp_config_deserialization() {
489        let json = r#"{"host":"10.0.0.1","port":22,"username":"admin","auth_method":{"type":"Password","password":"secret"}}"#;
490        let config: SftpConfig = serde_json::from_str(json).unwrap();
491        assert_eq!(config.host, "10.0.0.1");
492        assert_eq!(config.port, 22);
493        assert_eq!(config.username, "admin");
494        match config.auth_method {
495            SftpAuthMethod::Password { password } => assert_eq!(password, "secret"),
496            _ => panic!("Expected Password auth method"),
497        }
498    }
499
500    #[test]
501    fn test_sftp_config_publickey() {
502        let json = r#"{"host":"server","port":2222,"username":"deploy","auth_method":{"type":"PublicKey","key_path":"/home/user/.ssh/id_rsa","passphrase":null}}"#;
503        let config: SftpConfig = serde_json::from_str(json).unwrap();
504        assert_eq!(config.port, 2222);
505        match config.auth_method {
506            SftpAuthMethod::PublicKey {
507                key_path,
508                passphrase,
509            } => {
510                assert_eq!(key_path, "/home/user/.ssh/id_rsa");
511                assert!(passphrase.is_none());
512            }
513            _ => panic!("Expected PublicKey auth method"),
514        }
515    }
516}