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#[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
71pub struct StandaloneSftpClient {
74 session: Option<Arc<client::Handle<Client>>>,
75 sftp: Option<SftpSession>,
76}
77
78impl StandaloneSftpClient {
79 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 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 self.sftp.take();
122 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 drop(arc_session);
134 }
135 }
136 }
137 Ok(())
138 }
139
140 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 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 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 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 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 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 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 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 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#[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}