Skip to main content

purple_ssh/
file_browser.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7/// A file or directory entry in the browser.
8#[derive(Debug, Clone, PartialEq)]
9pub struct FileEntry {
10    pub name: String,
11    pub is_dir: bool,
12    pub size: Option<u64>,
13}
14
15/// Which pane is active.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum BrowserPane {
18    Local,
19    Remote,
20}
21
22/// Pending copy operation awaiting confirmation.
23pub struct CopyRequest {
24    pub sources: Vec<String>,
25    pub source_pane: BrowserPane,
26    pub has_dirs: bool,
27}
28
29/// State for the dual-pane file browser overlay.
30pub struct FileBrowserState {
31    pub alias: String,
32    pub askpass: Option<String>,
33    pub active_pane: BrowserPane,
34    // Local
35    pub local_path: PathBuf,
36    pub local_entries: Vec<FileEntry>,
37    pub local_list_state: ListState,
38    pub local_selected: HashSet<String>,
39    pub local_error: Option<String>,
40    // Remote
41    pub remote_path: String,
42    pub remote_entries: Vec<FileEntry>,
43    pub remote_list_state: ListState,
44    pub remote_selected: HashSet<String>,
45    pub remote_error: Option<String>,
46    pub remote_loading: bool,
47    // Options
48    pub show_hidden: bool,
49    // Copy confirmation
50    pub confirm_copy: Option<CopyRequest>,
51    // Transfer in progress
52    pub transferring: Option<String>,
53    // Transfer error (shown as dismissible dialog)
54    pub transfer_error: Option<String>,
55    // Whether the initial remote connection has been recorded in history
56    pub connection_recorded: bool,
57}
58
59/// List local directory entries.
60/// Sorts: directories first, then alphabetical. Filters dotfiles based on show_hidden.
61pub fn list_local(path: &Path, show_hidden: bool) -> anyhow::Result<Vec<FileEntry>> {
62    let mut entries = Vec::new();
63    for entry in std::fs::read_dir(path)? {
64        let entry = entry?;
65        let name = entry.file_name().to_string_lossy().to_string();
66        if !show_hidden && name.starts_with('.') {
67            continue;
68        }
69        let metadata = entry.metadata()?;
70        let is_dir = metadata.is_dir();
71        let size = if is_dir { None } else { Some(metadata.len()) };
72        entries.push(FileEntry { name, is_dir, size });
73    }
74    entries.sort_by(|a, b| {
75        b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
76    });
77    Ok(entries)
78}
79
80/// Parse `ls -lhA` output into FileEntry list.
81/// Recognizes directories via 'd' permission prefix. Skips the "total" line.
82pub fn parse_ls_output(output: &str, show_hidden: bool) -> Vec<FileEntry> {
83    let mut entries = Vec::new();
84    for line in output.lines() {
85        let line = line.trim();
86        if line.is_empty() || line.starts_with("total ") {
87            continue;
88        }
89        // ls -l format: permissions links owner group size month day time name
90        // Split on whitespace runs, taking 9 fields (last gets the rest including spaces)
91        let mut parts: Vec<&str> = Vec::with_capacity(9);
92        let mut rest = line;
93        for _ in 0..8 {
94            rest = rest.trim_start();
95            if rest.is_empty() {
96                break;
97            }
98            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
99            parts.push(&rest[..end]);
100            rest = &rest[end..];
101        }
102        rest = rest.trim_start();
103        if !rest.is_empty() {
104            parts.push(rest);
105        }
106        if parts.len() < 9 {
107            continue;
108        }
109        let permissions = parts[0];
110        let is_dir = permissions.starts_with('d');
111        let name_field = parts[8];
112        // Skip empty names
113        if name_field.is_empty() {
114            continue;
115        }
116        // For symlinks, ls shows "name -> target", strip the target
117        let name = if permissions.starts_with('l') {
118            name_field.split(" -> ").next().unwrap_or(name_field)
119        } else {
120            name_field
121        };
122        if !show_hidden && name.starts_with('.') {
123            continue;
124        }
125        // Parse human-readable size (e.g. "1.1K", "4.0M", "512")
126        let size = if is_dir {
127            None
128        } else {
129            Some(parse_human_size(parts[4]))
130        };
131        entries.push(FileEntry {
132            name: name.to_string(),
133            is_dir,
134            size,
135        });
136    }
137    entries.sort_by(|a, b| {
138        b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
139    });
140    entries
141}
142
143/// Parse a human-readable size string like "1.1K", "4.0M", "512" into bytes.
144fn parse_human_size(s: &str) -> u64 {
145    let s = s.trim();
146    if s.is_empty() {
147        return 0;
148    }
149    let last = s.as_bytes()[s.len() - 1];
150    let multiplier = match last {
151        b'K' => 1024,
152        b'M' => 1024 * 1024,
153        b'G' => 1024 * 1024 * 1024,
154        b'T' => 1024u64 * 1024 * 1024 * 1024,
155        _ => 1,
156    };
157    let num_str = if multiplier > 1 {
158        &s[..s.len() - 1]
159    } else {
160        s
161    };
162    let num: f64 = num_str.parse().unwrap_or(0.0);
163    (num * multiplier as f64) as u64
164}
165
166/// Shell-escape a path with single quotes: /path -> '/path'
167/// Internal single quotes escaped as '\''
168fn shell_escape(path: &str) -> String {
169    format!("'{}'", path.replace('\'', "'\\''"))
170}
171
172/// Get the remote home directory via `pwd`.
173pub fn get_remote_home(
174    alias: &str,
175    config_path: &Path,
176    askpass: Option<&str>,
177    bw_session: Option<&str>,
178    has_active_tunnel: bool,
179) -> anyhow::Result<String> {
180    let result = crate::snippet::run_snippet(
181        alias,
182        config_path,
183        "pwd",
184        askpass,
185        bw_session,
186        true,
187        has_active_tunnel,
188    )?;
189    if result.status.success() {
190        Ok(result.stdout.trim().to_string())
191    } else {
192        anyhow::bail!("Failed to get remote home: {}", result.stderr.trim())
193    }
194}
195
196/// Fetch remote directory listing synchronously (used by spawn_remote_listing).
197pub fn fetch_remote_listing(
198    alias: &str,
199    config_path: &Path,
200    remote_path: &str,
201    show_hidden: bool,
202    askpass: Option<&str>,
203    bw_session: Option<&str>,
204    has_tunnel: bool,
205) -> Result<Vec<FileEntry>, String> {
206    let command = format!("LC_ALL=C ls -lhA {}", shell_escape(remote_path));
207    let result = crate::snippet::run_snippet(
208        alias,
209        config_path,
210        &command,
211        askpass,
212        bw_session,
213        true,
214        has_tunnel,
215    );
216    match result {
217        Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden)),
218        Ok(r) => {
219            let msg = r.stderr.trim().to_string();
220            if msg.is_empty() {
221                Err(format!("ls exited with code {}.", r.status.code().unwrap_or(1)))
222            } else {
223                Err(msg)
224            }
225        }
226        Err(e) => Err(e.to_string()),
227    }
228}
229
230/// Spawn background thread for remote directory listing.
231/// Sends result back via the provided sender function.
232#[allow(clippy::too_many_arguments)]
233pub fn spawn_remote_listing<F>(
234    alias: String,
235    config_path: PathBuf,
236    remote_path: String,
237    show_hidden: bool,
238    askpass: Option<String>,
239    bw_session: Option<String>,
240    has_tunnel: bool,
241    send: F,
242) where
243    F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
244{
245    std::thread::spawn(move || {
246        let listing = fetch_remote_listing(
247            &alias,
248            &config_path,
249            &remote_path,
250            show_hidden,
251            askpass.as_deref(),
252            bw_session.as_deref(),
253            has_tunnel,
254        );
255        send(alias, remote_path, listing);
256    });
257}
258
259/// Result of an scp transfer.
260pub struct ScpResult {
261    pub status: ExitStatus,
262    pub stderr_output: String,
263}
264
265/// Run scp in the background with captured stderr for error reporting.
266/// Stderr is piped and captured so errors can be extracted. Progress percentage
267/// is not available because scp only outputs progress to a TTY, not to a pipe.
268/// Stdin is null (askpass handles authentication). Stdout is null (scp has no
269/// meaningful stdout output).
270pub fn run_scp(
271    alias: &str,
272    config_path: &Path,
273    askpass: Option<&str>,
274    bw_session: Option<&str>,
275    has_active_tunnel: bool,
276    scp_args: &[String],
277) -> anyhow::Result<ScpResult> {
278    let mut cmd = Command::new("scp");
279    cmd.arg("-F").arg(config_path);
280
281    if has_active_tunnel {
282        cmd.arg("-o").arg("ClearAllForwardings=yes");
283    }
284
285    for arg in scp_args {
286        cmd.arg(arg);
287    }
288
289    cmd.stdin(Stdio::null())
290        .stdout(Stdio::null())
291        .stderr(Stdio::piped());
292
293    if askpass.is_some() {
294        let exe = std::env::current_exe()
295            .ok()
296            .map(|p| p.to_string_lossy().to_string())
297            .or_else(|| std::env::args().next())
298            .unwrap_or_else(|| "purple".to_string());
299        cmd.env("SSH_ASKPASS", &exe)
300            .env("SSH_ASKPASS_REQUIRE", "prefer")
301            .env("PURPLE_ASKPASS_MODE", "1")
302            .env("PURPLE_HOST_ALIAS", alias)
303            .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
304    }
305
306    if let Some(token) = bw_session {
307        cmd.env("BW_SESSION", token);
308    }
309
310    let output = cmd
311        .output()
312        .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
313
314    let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
315
316    Ok(ScpResult { status: output.status, stderr_output })
317}
318
319/// Filter SSH warning noise from stderr, keeping only actionable error lines.
320/// Strips lines like "** WARNING: connection is not using a post-quantum key exchange".
321pub fn extract_scp_error(stderr: &str) -> String {
322    stderr
323        .lines()
324        .filter(|line| {
325            let trimmed = line.trim();
326            !trimmed.is_empty()
327                && !trimmed.starts_with("** ")
328                && !trimmed.starts_with("Warning:")
329                && !trimmed.contains("see https://")
330                && !trimmed.contains("See https://")
331                && !trimmed.starts_with("The server may need")
332                && !trimmed.starts_with("This session may be")
333        })
334        .collect::<Vec<_>>()
335        .join("\n")
336}
337
338/// Build scp arguments for a file transfer.
339/// Returns the args to pass after `scp -F <config>`.
340///
341/// Remote paths are NOT shell-escaped because scp is invoked via Command::arg()
342/// which bypasses the shell entirely. The colon in `alias:path` is the only
343/// special character scp interprets. Paths with spaces, globbing chars etc. are
344/// passed through literally by the OS exec layer.
345pub fn build_scp_args(
346    alias: &str,
347    source_pane: BrowserPane,
348    local_path: &Path,
349    remote_path: &str,
350    filenames: &[String],
351    has_dirs: bool,
352) -> Vec<String> {
353    let mut args = Vec::new();
354    if has_dirs {
355        args.push("-r".to_string());
356    }
357    args.push("--".to_string());
358
359    match source_pane {
360        // Upload: local files -> remote
361        BrowserPane::Local => {
362            for name in filenames {
363                args.push(local_path.join(name).to_string_lossy().to_string());
364            }
365            let dest = format!("{}:{}", alias, remote_path);
366            args.push(dest);
367        }
368        // Download: remote files -> local
369        BrowserPane::Remote => {
370            let base = remote_path.trim_end_matches('/');
371            for name in filenames {
372                let rpath = format!("{}/{}", base, name);
373                args.push(format!("{}:{}", alias, rpath));
374            }
375            args.push(local_path.to_string_lossy().to_string());
376        }
377    }
378    args
379}
380
381/// Format a file size in human-readable form.
382pub fn format_size(bytes: u64) -> String {
383    if bytes >= 1024 * 1024 * 1024 {
384        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
385    } else if bytes >= 1024 * 1024 {
386        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
387    } else if bytes >= 1024 {
388        format!("{:.1} KB", bytes as f64 / 1024.0)
389    } else {
390        format!("{} B", bytes)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    // =========================================================================
399    // shell_escape
400    // =========================================================================
401
402    #[test]
403    fn test_shell_escape_simple() {
404        assert_eq!(shell_escape("/home/user"), "'/home/user'");
405    }
406
407    #[test]
408    fn test_shell_escape_with_single_quote() {
409        assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
410    }
411
412    #[test]
413    fn test_shell_escape_with_spaces() {
414        assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
415    }
416
417    // =========================================================================
418    // parse_ls_output
419    // =========================================================================
420
421    #[test]
422    fn test_parse_ls_basic() {
423        let output = "\
424total 24
425drwxr-xr-x  2 user user 4096 Jan  1 12:00 subdir
426-rw-r--r--  1 user user  512 Jan  1 12:00 file.txt
427-rw-r--r--  1 user user 1.1K Jan  1 12:00 big.log
428";
429        let entries = parse_ls_output(output, true);
430        assert_eq!(entries.len(), 3);
431        assert_eq!(entries[0].name, "subdir");
432        assert!(entries[0].is_dir);
433        assert_eq!(entries[0].size, None);
434        // Files sorted alphabetically after dirs
435        assert_eq!(entries[1].name, "big.log");
436        assert!(!entries[1].is_dir);
437        assert_eq!(entries[1].size, Some(1126)); // 1.1 * 1024
438        assert_eq!(entries[2].name, "file.txt");
439        assert!(!entries[2].is_dir);
440        assert_eq!(entries[2].size, Some(512));
441    }
442
443    #[test]
444    fn test_parse_ls_hidden_filter() {
445        let output = "\
446total 8
447-rw-r--r--  1 user user  100 Jan  1 12:00 .hidden
448-rw-r--r--  1 user user  200 Jan  1 12:00 visible
449";
450        let entries = parse_ls_output(output, false);
451        assert_eq!(entries.len(), 1);
452        assert_eq!(entries[0].name, "visible");
453
454        let entries = parse_ls_output(output, true);
455        assert_eq!(entries.len(), 2);
456    }
457
458    #[test]
459    fn test_parse_ls_symlink() {
460        let output = "\
461total 4
462lrwxrwxrwx  1 user user   11 Jan  1 12:00 link -> /etc/hosts
463";
464        let entries = parse_ls_output(output, true);
465        assert_eq!(entries.len(), 1);
466        assert_eq!(entries[0].name, "link");
467    }
468
469    #[test]
470    fn test_parse_ls_filename_with_spaces() {
471        let output = "\
472total 4
473-rw-r--r--  1 user user  100 Jan  1 12:00 my file name.txt
474";
475        let entries = parse_ls_output(output, true);
476        assert_eq!(entries.len(), 1);
477        assert_eq!(entries[0].name, "my file name.txt");
478    }
479
480    #[test]
481    fn test_parse_ls_empty() {
482        let output = "total 0\n";
483        let entries = parse_ls_output(output, true);
484        assert!(entries.is_empty());
485    }
486
487    // =========================================================================
488    // parse_human_size
489    // =========================================================================
490
491    #[test]
492    fn test_parse_human_size() {
493        assert_eq!(parse_human_size("512"), 512);
494        assert_eq!(parse_human_size("1.0K"), 1024);
495        assert_eq!(parse_human_size("1.5M"), 1572864);
496        assert_eq!(parse_human_size("2.0G"), 2147483648);
497    }
498
499    // =========================================================================
500    // format_size
501    // =========================================================================
502
503    #[test]
504    fn test_format_size() {
505        assert_eq!(format_size(0), "0 B");
506        assert_eq!(format_size(512), "512 B");
507        assert_eq!(format_size(1024), "1.0 KB");
508        assert_eq!(format_size(1536), "1.5 KB");
509        assert_eq!(format_size(1048576), "1.0 MB");
510        assert_eq!(format_size(1073741824), "1.0 GB");
511    }
512
513    // =========================================================================
514    // build_scp_args
515    // =========================================================================
516
517    #[test]
518    fn test_build_scp_args_upload() {
519        let args = build_scp_args(
520            "myhost",
521            BrowserPane::Local,
522            Path::new("/home/user/docs"),
523            "/remote/path/",
524            &["file.txt".to_string()],
525            false,
526        );
527        assert_eq!(args, vec![
528            "--",
529            "/home/user/docs/file.txt",
530            "myhost:/remote/path/",
531        ]);
532    }
533
534    #[test]
535    fn test_build_scp_args_download() {
536        let args = build_scp_args(
537            "myhost",
538            BrowserPane::Remote,
539            Path::new("/home/user/docs"),
540            "/remote/path",
541            &["file.txt".to_string()],
542            false,
543        );
544        assert_eq!(args, vec![
545            "--",
546            "myhost:/remote/path/file.txt",
547            "/home/user/docs",
548        ]);
549    }
550
551    #[test]
552    fn test_build_scp_args_spaces_in_path() {
553        let args = build_scp_args(
554            "myhost",
555            BrowserPane::Remote,
556            Path::new("/local"),
557            "/remote/my path",
558            &["my file.txt".to_string()],
559            false,
560        );
561        // No shell escaping: Command::arg() passes paths literally
562        assert_eq!(args, vec![
563            "--",
564            "myhost:/remote/my path/my file.txt",
565            "/local",
566        ]);
567    }
568
569    #[test]
570    fn test_build_scp_args_with_dirs() {
571        let args = build_scp_args(
572            "myhost",
573            BrowserPane::Local,
574            Path::new("/local"),
575            "/remote/",
576            &["mydir".to_string()],
577            true,
578        );
579        assert_eq!(args[0], "-r");
580    }
581
582    // =========================================================================
583    // list_local
584    // =========================================================================
585
586    #[test]
587    fn test_list_local_sorts_dirs_first() {
588        let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
589        let _ = std::fs::remove_dir_all(&base);
590        std::fs::create_dir_all(&base).unwrap();
591        std::fs::create_dir(base.join("zdir")).unwrap();
592        std::fs::write(base.join("afile.txt"), "hello").unwrap();
593        std::fs::write(base.join("bfile.txt"), "world").unwrap();
594
595        let entries = list_local(&base, true).unwrap();
596        assert_eq!(entries.len(), 3);
597        assert!(entries[0].is_dir);
598        assert_eq!(entries[0].name, "zdir");
599        assert_eq!(entries[1].name, "afile.txt");
600        assert_eq!(entries[2].name, "bfile.txt");
601
602        let _ = std::fs::remove_dir_all(&base);
603    }
604
605    #[test]
606    fn test_list_local_hidden() {
607        let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
608        let _ = std::fs::remove_dir_all(&base);
609        std::fs::create_dir_all(&base).unwrap();
610        std::fs::write(base.join(".hidden"), "").unwrap();
611        std::fs::write(base.join("visible"), "").unwrap();
612
613        let entries = list_local(&base, false).unwrap();
614        assert_eq!(entries.len(), 1);
615        assert_eq!(entries[0].name, "visible");
616
617        let entries = list_local(&base, true).unwrap();
618        assert_eq!(entries.len(), 2);
619
620        let _ = std::fs::remove_dir_all(&base);
621    }
622
623    // =========================================================================
624    // extract_scp_error
625    // =========================================================================
626
627    #[test]
628    fn test_extract_scp_error_filters_warnings() {
629        let stderr = "\
630** WARNING: connection is not using a post-quantum key exchange algorithm.
631** This session may be vulnerable to \"store now, decrypt later\" attacks.
632** The server may need to be upgraded. See https://openssh.com/pq.html
633scp: '/root/file.rpm': No such file or directory";
634        assert_eq!(
635            extract_scp_error(stderr),
636            "scp: '/root/file.rpm': No such file or directory"
637        );
638    }
639
640    #[test]
641    fn test_extract_scp_error_keeps_plain_error() {
642        let stderr = "scp: /etc/shadow: Permission denied\n";
643        assert_eq!(extract_scp_error(stderr), "scp: /etc/shadow: Permission denied");
644    }
645
646    #[test]
647    fn test_extract_scp_error_empty() {
648        assert_eq!(extract_scp_error(""), "");
649        assert_eq!(extract_scp_error("  \n  \n"), "");
650    }
651}