Skip to main content

ssh_commander_core/
sftp_client.rs

1use anyhow::Result;
2use russh::*;
3use russh_sftp::client::SftpSession;
4use serde::Deserialize;
5use std::sync::Arc;
6use std::time::Duration;
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8
9use crate::file_entry::{
10    FileEntryType, RemoteFileEntry, format_permissions, format_unix_timestamp,
11};
12use crate::ssh::{Client, HostKeyStore};
13
14/// Configuration for a standalone SFTP connection (SSH transport, no PTY).
15#[derive(Clone, Deserialize)]
16pub struct SftpConfig {
17    pub host: String,
18    pub port: u16,
19    pub username: String,
20    pub auth_method: SftpAuthMethod,
21}
22
23impl std::fmt::Debug for SftpConfig {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.debug_struct("SftpConfig")
26            .field("host", &self.host)
27            .field("port", &self.port)
28            .field("username", &self.username)
29            .field("auth_method", &self.auth_method)
30            .finish()
31    }
32}
33
34#[derive(Clone, Deserialize)]
35#[serde(tag = "type")]
36pub enum SftpAuthMethod {
37    Password {
38        password: String,
39    },
40    PublicKey {
41        key_path: String,
42        passphrase: Option<String>,
43    },
44}
45
46impl std::fmt::Debug for SftpAuthMethod {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            SftpAuthMethod::Password { .. } => f
50                .debug_struct("SftpAuthMethod::Password")
51                .field("password", &"<redacted>")
52                .finish(),
53            SftpAuthMethod::PublicKey {
54                key_path,
55                passphrase,
56            } => f
57                .debug_struct("SftpAuthMethod::PublicKey")
58                .field("key_path", key_path)
59                .field(
60                    "passphrase",
61                    &passphrase
62                        .as_ref()
63                        .map(|_| "<redacted>")
64                        .unwrap_or("<none>"),
65                )
66                .finish(),
67        }
68    }
69}
70
71/// Standalone SFTP client — opens an SSH connection and SFTP subsystem
72/// channel without allocating a PTY.
73pub struct StandaloneSftpClient {
74    session: Option<Arc<client::Handle<Client>>>,
75    sftp: Option<SftpSession>,
76}
77
78impl StandaloneSftpClient {
79    /// Establish an SSH connection, authenticate, and open the SFTP subsystem.
80    pub async fn connect(config: &SftpConfig, host_keys: Arc<HostKeyStore>) -> Result<Self> {
81        let auth = match &config.auth_method {
82            SftpAuthMethod::Password { password } => {
83                crate::ssh::ResolvedAuth::Password { password }
84            }
85            SftpAuthMethod::PublicKey {
86                key_path,
87                passphrase,
88            } => crate::ssh::ResolvedAuth::Key {
89                key: Box::new(crate::ssh::load_private_key(
90                    key_path,
91                    passphrase.as_deref(),
92                )?),
93                key_path_hint: Some(key_path),
94            },
95        };
96
97        let ssh_session = crate::ssh::connect_authenticated(
98            &config.host,
99            config.port,
100            &config.username,
101            auth,
102            Duration::from_secs(10),
103            host_keys,
104        )
105        .await?;
106        let session = Arc::new(ssh_session);
107
108        // Open an SFTP subsystem channel (no PTY)
109        let channel = session.channel_open_session().await?;
110        channel.request_subsystem(true, "sftp").await?;
111        let sftp = SftpSession::new(channel.into_stream()).await?;
112
113        Ok(Self {
114            session: Some(session),
115            sftp: Some(sftp),
116        })
117    }
118
119    pub async fn disconnect(&mut self) -> Result<()> {
120        // Drop SFTP session first
121        self.sftp.take();
122        // Disconnect SSH session
123        if let Some(session) = self.session.take() {
124            match Arc::try_unwrap(session) {
125                Ok(session) => {
126                    if let Err(e) = session.disconnect(Disconnect::ByApplication, "", "").await {
127                        tracing::warn!("SFTP SSH disconnect failed cleanly: {}", e);
128                    }
129                }
130                Err(arc_session) => {
131                    // Other references (e.g. pending SFTP ops) still exist;
132                    // drop ours. The session ends when the last reference dies.
133                    drop(arc_session);
134                }
135            }
136        }
137        Ok(())
138    }
139
140    // ===== File Operations =====
141
142    /// List directory contents at `path`.
143    pub async fn list_dir(&self, path: &str) -> Result<Vec<RemoteFileEntry>> {
144        let sftp = self
145            .sftp
146            .as_ref()
147            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
148
149        let entries = sftp
150            .read_dir(path)
151            .await
152            .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?;
153
154        let mut result = Vec::new();
155        for entry in entries {
156            let name = entry.file_name();
157            // Skip . and .. entries
158            if name == "." || name == ".." {
159                continue;
160            }
161
162            let attrs = entry.metadata();
163            let size = attrs.size.unwrap_or(0);
164            let mtime_secs = attrs.mtime.map(|t| t as i64);
165            let modified = mtime_secs.map(format_unix_timestamp);
166
167            let permissions = attrs.permissions.map(format_permissions);
168            let owner = attrs.uid.map(|u| u.to_string());
169            let group = attrs.gid.map(|g| g.to_string());
170
171            let file_type = if attrs.is_dir() {
172                FileEntryType::Directory
173            } else if attrs.is_symlink() {
174                FileEntryType::Symlink
175            } else {
176                FileEntryType::File
177            };
178
179            result.push(RemoteFileEntry {
180                name,
181                size,
182                modified,
183                modified_unix: mtime_secs,
184                permissions,
185                owner,
186                group,
187                file_type,
188            });
189        }
190
191        // Sort: directories first, then by name
192        result.sort_by(|a, b| {
193            let a_is_dir = matches!(a.file_type, FileEntryType::Directory);
194            let b_is_dir = matches!(b.file_type, FileEntryType::Directory);
195            b_is_dir
196                .cmp(&a_is_dir)
197                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
198        });
199
200        Ok(result)
201    }
202
203    /// Download a remote file to a local path. Streams chunks — never buffers
204    /// the whole file. Returns bytes downloaded.
205    pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<u64> {
206        let sftp = self
207            .sftp
208            .as_ref()
209            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
210
211        let mut remote_file = sftp
212            .open(remote_path)
213            .await
214            .map_err(|e| anyhow::anyhow!("Failed to open remote file '{}': {}", remote_path, e))?;
215        let mut local_file = tokio::fs::File::create(local_path)
216            .await
217            .map_err(|e| anyhow::anyhow!("Failed to create local file '{}': {}", local_path, e))?;
218
219        let mut buf = vec![0u8; crate::ssh::SFTP_CHUNK_SIZE];
220        let mut total_bytes = 0u64;
221        loop {
222            let n = remote_file.read(&mut buf).await?;
223            if n == 0 {
224                break;
225            }
226            local_file.write_all(&buf[..n]).await?;
227            total_bytes += n as u64;
228        }
229        local_file.flush().await?;
230        Ok(total_bytes)
231    }
232
233    /// Upload a local file to a remote path. Streams chunks — never buffers
234    /// the whole file. Returns bytes uploaded.
235    pub async fn upload_file(&self, local_path: &str, remote_path: &str) -> Result<u64> {
236        let sftp = self
237            .sftp
238            .as_ref()
239            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
240
241        let mut local_file = tokio::fs::File::open(local_path)
242            .await
243            .map_err(|e| anyhow::anyhow!("Failed to open local file '{}': {}", local_path, e))?;
244        let mut remote_file = sftp.create(remote_path).await.map_err(|e| {
245            anyhow::anyhow!("Failed to create remote file '{}': {}", remote_path, e)
246        })?;
247
248        let mut buf = vec![0u8; crate::ssh::SFTP_CHUNK_SIZE];
249        let mut total_bytes = 0u64;
250        loop {
251            let n = local_file.read(&mut buf).await?;
252            if n == 0 {
253                break;
254            }
255            remote_file.write_all(&buf[..n]).await?;
256            total_bytes += n as u64;
257        }
258        remote_file.flush().await?;
259
260        Ok(total_bytes)
261    }
262
263    /// Create a directory on the remote server.
264    pub async fn create_dir(&self, path: &str) -> Result<()> {
265        let sftp = self
266            .sftp
267            .as_ref()
268            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
269
270        sftp.create_dir(path)
271            .await
272            .map_err(|e| anyhow::anyhow!("Failed to create directory '{}': {}", path, e))?;
273        Ok(())
274    }
275
276    /// Rename a file or directory.
277    pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
278        let sftp = self
279            .sftp
280            .as_ref()
281            .ok_or_else(|| anyhow::anyhow!("SFTP session not connected"))?;
282
283        sftp.rename(old_path, new_path).await.map_err(|e| {
284            anyhow::anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)
285        })?;
286        Ok(())
287    }
288
289    /// Delete a file on the remote server.
290    pub async fn delete_file(&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.remove_file(path)
297            .await
298            .map_err(|e| anyhow::anyhow!("Failed to delete file '{}': {}", path, e))?;
299        Ok(())
300    }
301
302    /// Delete a directory on the remote server.
303    pub async fn delete_dir(&self, 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.remove_dir(path)
310            .await
311            .map_err(|e| anyhow::anyhow!("Failed to delete directory '{}': {}", path, e))?;
312        Ok(())
313    }
314}
315
316// =============================================================================
317// Unit tests — Task 4.3
318// =============================================================================
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_sftp_config_deserialization() {
325        let json = r#"{"host":"10.0.0.1","port":22,"username":"admin","auth_method":{"type":"Password","password":"secret"}}"#;
326        let config: SftpConfig = serde_json::from_str(json).unwrap();
327        assert_eq!(config.host, "10.0.0.1");
328        assert_eq!(config.port, 22);
329        assert_eq!(config.username, "admin");
330        match config.auth_method {
331            SftpAuthMethod::Password { password } => assert_eq!(password, "secret"),
332            _ => panic!("Expected Password auth method"),
333        }
334    }
335
336    #[test]
337    fn test_sftp_config_publickey() {
338        let json = r#"{"host":"server","port":2222,"username":"deploy","auth_method":{"type":"PublicKey","key_path":"/home/user/.ssh/id_rsa","passphrase":null}}"#;
339        let config: SftpConfig = serde_json::from_str(json).unwrap();
340        assert_eq!(config.port, 2222);
341        match config.auth_method {
342            SftpAuthMethod::PublicKey {
343                key_path,
344                passphrase,
345            } => {
346                assert_eq!(key_path, "/home/user/.ssh/id_rsa");
347                assert!(passphrase.is_none());
348            }
349            _ => panic!("Expected PublicKey auth method"),
350        }
351    }
352}