1use anyhow::Context;
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::sync::mpsc;
12
13use crate::event::{CoreEvent, TransferId};
14use crate::ssh::client::Host;
15use crate::ssh::session::SshSession;
16
17#[derive(Debug, Clone)]
23pub struct FileEntry {
24 pub name: String,
26 pub path: String,
28 pub size: u64,
30 pub is_dir: bool,
32}
33
34pub enum SftpCommand {
40 ListDir(String),
42 Download {
44 remote: String,
45 local: String,
46 transfer_id: TransferId,
47 },
48 Upload {
50 local: String,
51 remote: String,
52 transfer_id: TransferId,
53 },
54 Delete(String),
56 MkDir(String),
58 Rename { from: String, to: String },
60 ReadPreview(String),
62 Disconnect,
64}
65
66#[derive(Debug)]
75pub struct SftpManager {
76 cmd_tx: mpsc::Sender<SftpCommand>,
77}
78
79impl SftpManager {
80 pub async fn connect(host: &Host, event_tx: mpsc::Sender<CoreEvent>) -> anyhow::Result<Self> {
88 let session = SshSession::connect(host)
89 .await
90 .context("SFTP SSH connect")?;
91 let stream = session
92 .open_sftp_channel()
93 .await
94 .context("open SFTP channel")?;
95 let sftp = russh_sftp::client::SftpSession::new(stream)
96 .await
97 .context("create SFTP session")?;
98
99 let (cmd_tx, cmd_rx) = mpsc::channel::<SftpCommand>(64);
100 let host_name = host.name.clone();
101
102 tokio::spawn(async move {
107 let _ = event_tx
108 .send(CoreEvent::SftpConnected {
109 host_name: host_name.clone(),
110 })
111 .await;
112 sftp_task_loop(session, sftp, cmd_rx, event_tx.clone()).await;
113 tracing::info!("SFTP task for '{}' exited", host_name);
114 });
115
116 Ok(Self { cmd_tx })
117 }
118
119 pub fn send(&self, cmd: SftpCommand) {
121 let _ = self.cmd_tx.try_send(cmd);
122 }
123
124 pub fn disconnect(self) {
126 let _ = self.cmd_tx.try_send(SftpCommand::Disconnect);
127 }
128}
129
130async fn sftp_task_loop(
135 _ssh: SshSession, sftp: russh_sftp::client::SftpSession,
137 mut cmd_rx: mpsc::Receiver<SftpCommand>,
138 event_tx: mpsc::Sender<CoreEvent>,
139) {
140 while let Some(cmd) = cmd_rx.recv().await {
141 match cmd {
142 SftpCommand::ListDir(path) => match do_list_dir(&sftp, &path).await {
143 Ok(entries) => {
144 let _ = event_tx
145 .send(CoreEvent::FileDirListed { path, entries })
146 .await;
147 }
148 Err(e) => {
149 let _ = event_tx
150 .send(CoreEvent::SftpDisconnected {
151 reason: format!("ListDir failed: {e}"),
152 })
153 .await;
154 }
155 },
156
157 SftpCommand::Download {
158 remote,
159 local,
160 transfer_id,
161 } => {
162 let result = do_download(&sftp, &remote, &local, transfer_id, &event_tx)
163 .await
164 .map_err(|e| e.to_string());
165 let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await;
166 }
167
168 SftpCommand::Upload {
169 local,
170 remote,
171 transfer_id,
172 } => {
173 let result = do_upload(&local, &sftp, &remote, transfer_id, &event_tx)
174 .await
175 .map_err(|e| e.to_string());
176 let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await;
177 }
178
179 SftpCommand::Delete(path) => {
180 let result = match sftp.remove_file(&path).await {
182 Ok(()) => Ok(()),
183 Err(_) => sftp.remove_dir(&path).await.map_err(|e| e.to_string()),
184 };
185 let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await;
186 }
187
188 SftpCommand::MkDir(path) => {
189 let result = sftp.create_dir(&path).await.map_err(|e| e.to_string());
190 let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await;
191 }
192
193 SftpCommand::Rename { from, to } => {
194 let result = sftp.rename(&from, &to).await.map_err(|e| e.to_string());
195 let _ = event_tx.send(CoreEvent::SftpOpDone { result }).await;
196 }
197
198 SftpCommand::ReadPreview(path) => {
199 if let Ok(content) = do_read_preview(&sftp, &path).await {
200 let _ = event_tx
201 .send(CoreEvent::FilePreviewReady { path, content })
202 .await;
203 }
204 }
205
206 SftpCommand::Disconnect => break,
207 }
208 }
209}
210
211async fn do_list_dir(
216 sftp: &russh_sftp::client::SftpSession,
217 path: &str,
218) -> anyhow::Result<Vec<FileEntry>> {
219 let read_dir = sftp
220 .read_dir(path)
221 .await
222 .with_context(|| format!("read remote dir '{path}'"))?;
223
224 let mut entries: Vec<FileEntry> = Vec::new();
225
226 if let Some(parent) = std::path::Path::new(path).parent() {
228 let parent_str = parent.to_string_lossy();
229 let parent_str = if parent_str.is_empty() {
230 "/"
231 } else {
232 &parent_str
233 };
234 entries.push(FileEntry {
235 name: "..".to_string(),
236 path: parent_str.to_string(),
237 size: 0,
238 is_dir: true,
239 });
240 }
241
242 for entry in read_dir {
243 let name = entry.file_name();
244 let ft = entry.file_type();
245 let meta = entry.metadata();
246
247 let full_path = if path.ends_with('/') {
248 format!("{path}{name}")
249 } else {
250 format!("{path}/{name}")
251 };
252
253 entries.push(FileEntry {
254 name,
255 path: full_path,
256 size: meta.size.unwrap_or(0),
257 is_dir: ft.is_dir(),
258 });
259 }
260
261 entries.sort_by(|a, b| {
263 if a.name == ".." {
264 return std::cmp::Ordering::Less;
265 }
266 if b.name == ".." {
267 return std::cmp::Ordering::Greater;
268 }
269 match (a.is_dir, b.is_dir) {
270 (true, false) => std::cmp::Ordering::Less,
271 (false, true) => std::cmp::Ordering::Greater,
272 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
273 }
274 });
275
276 Ok(entries)
277}
278
279async fn do_download(
280 sftp: &russh_sftp::client::SftpSession,
281 remote: &str,
282 local: &str,
283 transfer_id: TransferId,
284 event_tx: &mpsc::Sender<CoreEvent>,
285) -> anyhow::Result<()> {
286 if std::path::Path::new(local)
288 .components()
289 .any(|c| c == std::path::Component::ParentDir)
290 {
291 anyhow::bail!("Download destination path contains '..': {local}");
292 }
293 if local.contains('\0') || remote.contains('\0') {
294 anyhow::bail!("Path contains null bytes");
295 }
296
297 let total = sftp
299 .metadata(remote)
300 .await
301 .map(|m| m.size.unwrap_or(0))
302 .unwrap_or(0);
303
304 let mut remote_file = sftp
305 .open(remote)
306 .await
307 .context("open remote file for download")?;
308 let mut local_file = tokio::fs::File::create(local)
309 .await
310 .context("create local file")?;
311
312 let mut buf = vec![0u8; 65_536];
313 let mut done: u64 = 0;
314
315 loop {
316 let n = remote_file
317 .read(&mut buf)
318 .await
319 .context("read remote file")?;
320 if n == 0 {
321 break;
322 }
323 local_file
324 .write_all(&buf[..n])
325 .await
326 .context("write local file")?;
327 done += n as u64;
328 let _ = event_tx
329 .send(CoreEvent::FileTransferProgress(transfer_id, done, total))
330 .await;
331 }
332
333 Ok(())
334}
335
336async fn do_upload(
337 local: &str,
338 sftp: &russh_sftp::client::SftpSession,
339 remote: &str,
340 transfer_id: TransferId,
341 event_tx: &mpsc::Sender<CoreEvent>,
342) -> anyhow::Result<()> {
343 if std::path::Path::new(local)
345 .components()
346 .any(|c| c == std::path::Component::ParentDir)
347 {
348 anyhow::bail!("Upload source path contains '..': {local}");
349 }
350 if local.contains('\0') || remote.contains('\0') {
351 anyhow::bail!("Path contains null bytes");
352 }
353
354 let mut local_file = tokio::fs::File::open(local)
355 .await
356 .context("open local file for upload")?;
357 let total = local_file.metadata().await.map(|m| m.len()).unwrap_or(0);
358
359 let mut remote_file = sftp
360 .create(remote)
361 .await
362 .context("create remote file for upload")?;
363
364 let mut buf = vec![0u8; 65_536];
365 let mut done: u64 = 0;
366
367 loop {
368 let n = local_file.read(&mut buf).await.context("read local file")?;
369 if n == 0 {
370 break;
371 }
372 remote_file
373 .write_all(&buf[..n])
374 .await
375 .context("write remote file")?;
376 done += n as u64;
377 let _ = event_tx
378 .send(CoreEvent::FileTransferProgress(transfer_id, done, total))
379 .await;
380 }
381
382 Ok(())
383}
384
385async fn do_read_preview(
386 sftp: &russh_sftp::client::SftpSession,
387 path: &str,
388) -> anyhow::Result<String> {
389 let mut file = sftp.open(path).await.context("open for preview")?;
390 let mut buf = vec![0u8; 4_096];
391 let n = file.read(&mut buf).await.context("read preview bytes")?;
392 buf.truncate(n);
393 Ok(String::from_utf8_lossy(&buf).into_owned())
394}
395
396pub async fn list_local_dir(path: &str) -> anyhow::Result<Vec<FileEntry>> {
407 let mut read_dir = tokio::fs::read_dir(path)
408 .await
409 .with_context(|| format!("read local dir '{path}'"))?;
410
411 let mut entries: Vec<FileEntry> = Vec::new();
412
413 if let Some(parent) = std::path::Path::new(path).parent() {
415 let parent_str = parent.to_string_lossy();
416 let parent_str = if parent_str.is_empty() {
417 "/"
418 } else {
419 &parent_str
420 };
421 entries.push(FileEntry {
422 name: "..".to_string(),
423 path: parent_str.to_string(),
424 size: 0,
425 is_dir: true,
426 });
427 }
428
429 while let Some(entry) = read_dir
430 .next_entry()
431 .await
432 .context("read local dir entry")?
433 {
434 let file_type = entry.file_type().await.ok();
435 let is_dir = file_type.as_ref().map(|ft| ft.is_dir()).unwrap_or(false);
436 let meta = entry.metadata().await.ok();
437 let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
438
439 let name = entry.file_name().to_string_lossy().into_owned();
440 let path_str = entry.path().to_string_lossy().into_owned();
441
442 entries.push(FileEntry {
443 name,
444 path: path_str,
445 size,
446 is_dir,
447 });
448 }
449
450 entries.sort_by(|a, b| {
452 if a.name == ".." {
453 return std::cmp::Ordering::Less;
454 }
455 if b.name == ".." {
456 return std::cmp::Ordering::Greater;
457 }
458 match (a.is_dir, b.is_dir) {
459 (true, false) => std::cmp::Ordering::Less,
460 (false, true) => std::cmp::Ordering::Greater,
461 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
462 }
463 });
464
465 Ok(entries)
466}
467
468pub async fn preview_local_file(path: &str) -> anyhow::Result<String> {
475 let mut file = tokio::fs::File::open(path)
476 .await
477 .context("open local file for preview")?;
478 let mut buf = vec![0u8; 4_096];
479 let n = file
480 .read(&mut buf)
481 .await
482 .context("read local preview bytes")?;
483 buf.truncate(n);
484 Ok(String::from_utf8_lossy(&buf).into_owned())
485}