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#[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#[derive(Debug, Clone, Serialize)]
71pub struct FileEntry {
72 pub name: String,
73 pub size: u64,
74 pub modified: Option<String>,
76 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
87pub type RemoteFileEntry = FileEntry;
89
90#[derive(Debug, Clone, Serialize, PartialEq)]
91pub enum FileEntryType {
92 File,
93 Directory,
94 Symlink,
95}
96
97pub struct StandaloneSftpClient {
100 session: Option<Arc<client::Handle<Client>>>,
101 sftp: Option<SftpSession>,
102}
103
104impl StandaloneSftpClient {
105 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 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 self.sftp.take();
148 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 drop(arc_session);
160 }
161 }
162 }
163 Ok(())
164 }
165
166 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 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 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 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 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 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 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 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 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
342pub 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
351pub(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#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[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 assert_eq!(format_unix_timestamp(1704067200), "2024-01-01 00:00:00");
418 }
419
420 #[test]
421 fn format_unix_timestamp_with_time() {
422 assert_eq!(format_unix_timestamp(961068645), "2000-06-15 11:30:45");
424 }
425
426 #[test]
427 fn format_unix_timestamp_post_2106() {
428 assert_eq!(format_unix_timestamp(7258118400), "2200-01-01 00:00:00");
431 }
432
433 #[test]
436 fn test_file_entry_type_serialization() {
437 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}