Skip to main content

omnyssh_core/ssh/
sftp.rs

1//! SFTP file manager operations.
2//!
3//! Provides [`SftpManager`] — a persistent background task that owns an SSH+SFTP
4//! session and processes [`SftpCommand`] messages sent from the UI thread.
5//!
6//! All operations are non-blocking from the UI perspective.
7//! Progress is reported via [`CoreEvent::FileTransferProgress`].
8
9use 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// ---------------------------------------------------------------------------
18// FileEntry — represents one file or directory in a panel listing
19// ---------------------------------------------------------------------------
20
21/// Metadata for a single file or directory in a file panel.
22#[derive(Debug, Clone)]
23pub struct FileEntry {
24    /// Base file name (not the full path).
25    pub name: String,
26    /// Absolute path string (used as the stable identifier for marked sets).
27    pub path: String,
28    /// File size in bytes (`0` for directories).
29    pub size: u64,
30    /// `true` when this entry is a directory.
31    pub is_dir: bool,
32}
33
34// ---------------------------------------------------------------------------
35// SftpCommand — sent from UI thread → SftpManager background task
36// ---------------------------------------------------------------------------
37
38/// Commands processed by the [`SftpManager`] background task.
39pub enum SftpCommand {
40    /// List the entries in a remote directory.
41    ListDir(String),
42    /// Download a remote file to a local path.
43    Download {
44        remote: String,
45        local: String,
46        transfer_id: TransferId,
47    },
48    /// Upload a local file to a remote path.
49    Upload {
50        local: String,
51        remote: String,
52        transfer_id: TransferId,
53    },
54    /// Delete a remote file (falls back to removing an empty directory).
55    Delete(String),
56    /// Create a remote directory.
57    MkDir(String),
58    /// Rename / move a remote path.
59    Rename { from: String, to: String },
60    /// Read the first 4 096 bytes of a remote file for preview.
61    ReadPreview(String),
62    /// Shut down the task gracefully.
63    Disconnect,
64}
65
66// ---------------------------------------------------------------------------
67// SftpManager — handle held by App to communicate with the background task
68// ---------------------------------------------------------------------------
69
70/// Manages a persistent SSH+SFTP background task.
71///
72/// Use [`SftpManager::connect`] to create, [`SftpManager::send`] to enqueue
73/// commands, and [`SftpManager::disconnect`] for a clean shutdown.
74#[derive(Debug)]
75pub struct SftpManager {
76    cmd_tx: mpsc::Sender<SftpCommand>,
77}
78
79impl SftpManager {
80    /// Connects to `host` via SSH + SFTP subsystem and spawns the background task.
81    ///
82    /// On success sends [`CoreEvent::SftpConnected`] through `event_tx`.
83    /// On failure the task sends [`CoreEvent::SftpDisconnected`].
84    ///
85    /// # Errors
86    /// Returns an error if the SSH connection fails before the task is spawned.
87    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        // `session` and `sftp` are owned by this async block.  If the task
103        // panics, Rust's unwind machinery calls their Drop impls before the
104        // panic propagates to tokio — the TCP connection is therefore always
105        // released even in the panic path.  No explicit catch_unwind needed.
106        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    /// Enqueues a command (fire-and-forget). Silently drops if the task exited.
120    pub fn send(&self, cmd: SftpCommand) {
121        let _ = self.cmd_tx.try_send(cmd);
122    }
123
124    /// Sends [`SftpCommand::Disconnect`] and drops the sender.
125    pub fn disconnect(self) {
126        let _ = self.cmd_tx.try_send(SftpCommand::Disconnect);
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Background task loop
132// ---------------------------------------------------------------------------
133
134async fn sftp_task_loop(
135    _ssh: SshSession, // kept alive to hold the SSH connection open
136    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                // Try remove_file first; on failure try remove_dir (empty dirs only).
181                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
211// ---------------------------------------------------------------------------
212// SFTP helpers
213// ---------------------------------------------------------------------------
214
215async 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    // ".." parent entry (omit at root "/")
227    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    // Sort: ".." first, then dirs, then files — all alphabetically.
262    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    // Guard against path traversal in the local destination.
287    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    // Fetch size for progress (best-effort).
298    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    // Guard against path traversal in the local source.
344    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
396// ---------------------------------------------------------------------------
397// Local filesystem helpers (called via inline tokio::spawn in App)
398// ---------------------------------------------------------------------------
399
400/// Lists the entries of a local directory, sorted dirs-first then alphabetically.
401///
402/// Prepends a `".."` entry for the parent directory (omitted at filesystem root).
403///
404/// # Errors
405/// Returns an error if the directory cannot be read (e.g. permission denied).
406pub 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    // ".." parent entry.
414    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    // Sort: ".." first, then dirs, then files — case-insensitive alphabetically.
451    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
468/// Reads up to 4 096 bytes from a local file and returns them as a UTF-8 string.
469///
470/// Non-UTF-8 bytes are replaced with the Unicode replacement character.
471///
472/// # Errors
473/// Returns an error if the file cannot be opened or read.
474pub 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}