diff --git a/ReadMe.md b/ReadMe.md index 30d6634..3dcceea 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -22,7 +22,7 @@ This is built in Rust on the TUI framework derived from very small and just enough. Runs on Linux, macOS, and Windows. You can download the latest pre-release -[0.0.2](https://github.com/kromych/ruf4/releases/tag/v0.0.2). +[0.0.8](https://github.com/kromych/ruf4/releases/tag/v0.0.8). If you are a developer, [here](./ReleaseFlow.md) are the gory details and notes on builds/releases. ## Screenshots @@ -115,6 +115,7 @@ If you are a developer, [here](./ReleaseFlow.md) are the gory details and notes | F2 | Save settings | | F9 | Focus menubar | | F10 | Quit (with confirmation) | +| Ctrl+O | Show the user screen (the output of previously run commands); Ctrl+O or Esc returns | | Any letter | Activate command line | ### macOS alternatives @@ -127,7 +128,7 @@ Mission Control, media, volume). These Ctrl shortcuts work without Fn: | Ctrl+S | Save settings (F2) | | Ctrl+Q | Toggle quick view (F3) | | Ctrl+P | Rename (F4) | -| Ctrl+O | Copy (F5) | +| Ctrl+C | Copy (F5) | | Ctrl+K | Move (F6) | | Ctrl+N | Make directory (F7) | | Ctrl+X | Delete (F8) | @@ -147,7 +148,45 @@ Commands run in the active panel's directory. Commands run in the foreground with the terminal handed back to them, so interactive programs (a shell, `python`, `vim`, `less`) work normally. The panel display is restored when the command exits; press Enter at the prompt to -return. +return. Press Ctrl+O at any time to peek at that output again. + +## SSH remote filesystems + +The change-root dialog (Ctrl+G) lists the hosts from `~/.ssh/config` as +`ssh://host` roots next to the local drives. Panels can also be pointed at any +`ssh://[user@]host[:port]/path` with the `cd` command. Remote directories +browse, sort, quick-view, copy, move, rename, and delete like local ones; +copies stream between hosts through ruf4 with byte progress. Enter on a remote +file downloads it to a temporary directory and opens it; the command line runs +commands on the remote host in the panel's directory over `ssh -t`. + +Transport is the OpenSSH client: `ruf4` spawns `ssh -s sftp` and speaks +SFTP over it, so keys, agents, jump hosts, and everything else in +`~/.ssh/config` behave exactly like plain `ssh`. On the first use of a host a +connection master is opened with the panels hidden so host-key and password +prompts work; subsequent channels multiplex over its socket. Set +`RUF4_SSH_CONFIG` to point `ssh` at an alternative client configuration file. +On Windows, where OpenSSH lacks multiplexing, authentication must be +non-interactive (keys or an agent). + +## SMB shares + +`cd smb://[user@]host/share/path` opens an SMB share using the operating +system's native client, so after navigation the panel works on an ordinary +local directory: + +- **Windows**: the URL is rewritten to a UNC path (`\\host\share\path`); UNC + paths can also be entered directly. +- **macOS**: the share is mounted with `mount_smbfs` under `~/.ruf4/mnt`, + prompting for the password with the panels hidden. Unmounted shares + remount automatically when revisited through the directory history. +- **Linux**: the share is mounted in user space with `gio mount` (GVFS, + present on desktop distributions) and appears under the session's `gvfs` + directory. + +Mounted shares are listed in the change-root dialog (Ctrl+G) next to the +local drives. Shares stay mounted when ruf4 exits; unmount with the usual +system tools (`umount`, `gio mount -u`, Finder). ### Dialogs diff --git a/crates/ruf4/src/action.rs b/crates/ruf4/src/action.rs index 8054be5..e3da541 100644 --- a/crates/ruf4/src/action.rs +++ b/crates/ruf4/src/action.rs @@ -54,6 +54,7 @@ pub enum Action { ChangeRoot, DirHistory, CmdHistory, + ShowUserScreen, FocusMenu, Quit, } @@ -94,6 +95,7 @@ pub const ALL_ACTIONS: &[Action] = &[ Action::ChangeRoot, Action::DirHistory, Action::CmdHistory, + Action::ShowUserScreen, Action::FocusMenu, Action::Quit, ]; @@ -192,6 +194,10 @@ pub fn default_bindings() -> Vec { key: kbmod::CTRL | vk::E, action: Action::CmdHistory, }, + Binding { + key: kbmod::CTRL | vk::O, + action: Action::ShowUserScreen, + }, Binding { key: kbmod::CTRL | vk::R, action: Action::Refresh, @@ -286,7 +292,7 @@ pub fn default_bindings() -> Vec { action: Action::Rename, }, Binding { - key: kbmod::CTRL | vk::O, + key: kbmod::CTRL | vk::C, action: Action::Copy, }, Binding { @@ -447,6 +453,7 @@ fn action_meta(action: Action) -> (&'static str, &'static str) { Action::ChangeRoot => ("change_root", "Change root"), Action::DirHistory => ("dir_history", "Directory history"), Action::CmdHistory => ("cmd_history", "Command history"), + Action::ShowUserScreen => ("show_user_screen", "Show user screen"), Action::FocusMenu => ("focus_menu", "Focus menubar"), Action::Quit => ("quit", "Quit"), } @@ -537,6 +544,7 @@ pub fn build_help_text(bindings: &[Binding]) -> Vec<(String, &'static str, Actio Some(Action::ChangeRoot), Some(Action::DirHistory), Some(Action::CmdHistory), + Some(Action::ShowUserScreen), Some(Action::Refresh), Some(Action::ToggleHidden), Some(Action::SortBy(SortBy::Name)), diff --git a/crates/ruf4/src/draw.rs b/crates/ruf4/src/draw.rs index ff4c441..5ec5177 100644 --- a/crates/ruf4/src/draw.rs +++ b/crates/ruf4/src/draw.rs @@ -139,6 +139,9 @@ fn draw_menubar(ctx: &mut Context, state: &mut State) -> bool { ) { state.execute_action(Action::ToggleQuickView); } + if ctx.menubar_menu_button("User screen", 'U', key(Action::ShowUserScreen)) { + state.execute_action(Action::ShowUserScreen); + } // Sort modes let sort = state.active_panel().sort_by; @@ -559,7 +562,7 @@ fn draw_single_panel( let (sel_count, sel_size) = panel.selection_info(); let free = panel - .free_space() + .free_space .map(panel::format_size) .unwrap_or_else(|| "N/A".to_string()); diff --git a/crates/ruf4/src/fileops.rs b/crates/ruf4/src/fileops.rs index 564571d..80bc34d 100644 --- a/crates/ruf4/src/fileops.rs +++ b/crates/ruf4/src/fileops.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use crate::platform; use crate::state::{Dialog, State}; +use crate::vfs; /// The final path component as a lossy string, or empty if there is none. pub fn base_name(path: &Path) -> Cow<'_, str> { @@ -28,25 +29,13 @@ pub fn same_file_error(name: &str, is_copy: bool) -> String { } pub fn ops_mkdir(path: &Path) -> Result<(), String> { - fs::create_dir_all(path).map_err(|e| format!("Cannot create \"{}\": {e}", path.display())) + vfs::create_dir_all(path).map_err(|e| format!("Cannot create \"{}\": {e}", path.display())) } pub fn ops_delete(paths: &[PathBuf]) -> Vec { let mut errors = Vec::new(); for path in paths { - let is_symlink = fs::symlink_metadata(path) - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - // Symlinks are always removed as links, never following the target. - // On Windows, directory symlinks need remove_dir; file symlinks need remove_file. - let result = if is_symlink { - platform::remove_symlink(path) - } else if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - }; - if let Err(e) = result { + if let Err(e) = vfs::remove_tree(path) { errors.push(format!( "{}: {e}", path.file_name().unwrap_or_default().to_string_lossy() @@ -58,16 +47,15 @@ pub fn ops_delete(paths: &[PathBuf]) -> Vec { pub fn ops_build_pairs(sources: &[PathBuf], dest: &str) -> Vec<(PathBuf, PathBuf)> { let dest_path = PathBuf::from(dest); + let dest_is_dir = vfs::is_dir(&dest_path); let mut pairs = Vec::new(); for src in sources { - let file_name = src.file_name().unwrap_or_default(); - let target = if dest_path.is_dir() { - dest_path.join(file_name) - } else if sources.len() == 1 { - dest_path.clone() + let file_name = src.file_name().unwrap_or_default().to_string_lossy(); + let target = if dest_is_dir || sources.len() > 1 { + vfs::join(&dest_path, &file_name) } else { - dest_path.join(file_name) + dest_path.clone() }; pairs.push((src.clone(), target)); } @@ -146,15 +134,12 @@ pub fn ops_execute_all(pairs: &[(PathBuf, PathBuf)], is_copy: bool) -> Vec bool { - match (fs::canonicalize(a), fs::canonicalize(b)) { - (Ok(ca), Ok(cb)) => ca == cb, - _ => false, - } + vfs::same_file(a, b) } /// Resolve `path` to an absolute form even if it doesn't exist yet: /// canonicalize the longest existing prefix, then append the rest. -fn normalize_against_existing(path: &Path) -> std::io::Result { +pub(crate) fn normalize_against_existing(path: &Path) -> std::io::Result { if let Ok(p) = fs::canonicalize(path) { return Ok(p); } @@ -220,7 +205,7 @@ pub fn do_mkdir(state: &mut State, name: &str) { state.dialog = Dialog::None; return; } - let path = state.active_panel().path.join(name); + let path = vfs::join(&state.active_panel().path, name); match ops_mkdir(&path) { Ok(()) => { state.dialog = Dialog::None; @@ -242,23 +227,27 @@ pub fn do_delete(state: &mut State) { } pub fn do_copy(state: &mut State, dest: &str) { - let sources = state.active_panel().selected_or_current(); - let pairs = ops_build_pairs(&sources, dest); - if pairs.is_empty() { - state.dialog = Dialog::None; - return; - } - state.start_job(crate::job::spawn_copy_move(pairs, true)); + do_copy_move(state, dest, true); } pub fn do_move(state: &mut State, dest: &str) { + do_copy_move(state, dest, false); +} + +fn do_copy_move(state: &mut State, dest: &str, is_copy: bool) { + // Connect a remote destination now: the worker cannot prompt for + // authentication. + if let Err(message) = vfs::ensure_host(Path::new(dest)) { + state.dialog = Dialog::Error { message }; + return; + } let sources = state.active_panel().selected_or_current(); let pairs = ops_build_pairs(&sources, dest); if pairs.is_empty() { state.dialog = Dialog::None; return; } - state.start_job(crate::job::spawn_copy_move(pairs, false)); + state.start_job(crate::job::spawn_copy_move(pairs, is_copy)); } pub fn do_rename(state: &mut State, new_name: &str) { @@ -267,9 +256,9 @@ pub fn do_rename(state: &mut State, new_name: &str) { return; } let panel = state.active_panel(); - let old_path = panel.path.join(&panel.entries[panel.cursor].name); - let new_path = panel.path.join(new_name); - match fs::rename(&old_path, &new_path) { + let old_path = vfs::join(&panel.path, &panel.entries[panel.cursor].name); + let new_path = vfs::join(&panel.path, new_name); + match vfs::rename(&old_path, &new_path) { Ok(()) => { state.dialog = Dialog::None; state.active_panel_mut().refresh(); @@ -289,31 +278,23 @@ pub fn execute_command(state: &mut State) { // Intercept "cd" to change the active panel's directory. if let Some(target) = parse_cd_command(&cmd) { - let raw = if target.is_empty() || target == "~" { - platform::home_dir() - } else { - let p = PathBuf::from(&target); - if p.is_absolute() { p } else { cwd.join(p) } - }; - let dest = fs::canonicalize(&raw).unwrap_or(raw); - if dest.is_dir() { - state.record_dir_change(&dest); - state.active_panel_mut().navigate_to(dest); - } else { - state.dialog = Dialog::Error { - message: format!("cd: not a directory: {}", dest.display()), - }; - } state.command_line.clear(); + let raw = resolve_cd_target(&cwd, &target); + state.navigate_panel(raw); return; } state.command_line.clear(); // External commands run in the foreground with the terminal handed back to - // them, so interactive programs work. The TUI is suspended and restored by - // `run_interactive`; the screen must be fully repainted afterwards. - if let Err(msg) = platform::run_interactive(&cmd, &cwd) { + // them, so interactive programs work. The TUI is suspended and restored for + // the duration; the screen must be fully repainted afterwards. With a + // remote working directory the command runs on that host over ssh. + let result = match vfs::parse_remote(&cwd) { + Some(remote) => vfs::run_remote_interactive(&remote, &cmd), + None => platform::run_interactive(&cmd, &cwd), + }; + if let Err(msg) = result { state.dialog = Dialog::Error { message: msg }; } state.request_repaint(); @@ -321,6 +302,33 @@ pub fn execute_command(state: &mut State) { state.right.refresh(); } +/// Resolve a `cd` argument against the panel's working directory. An empty +/// target and `~` go to the home directory (the remote login home when the +/// panel is on a remote host); `ssh://` targets switch hosts. +pub fn resolve_cd_target(cwd: &Path, target: &str) -> PathBuf { + if vfs::is_url(target) { + return PathBuf::from(target); + } + match vfs::parse_remote(cwd) { + Some(remote) => { + if target.is_empty() || target == "~" { + // No path component resolves to the remote home directory. + PathBuf::from(remote.host.display()) + } else { + vfs::join(cwd, target) + } + } + None => { + if target.is_empty() || target == "~" { + platform::home_dir() + } else { + let p = PathBuf::from(target); + if p.is_absolute() { p } else { cwd.join(p) } + } + } + } +} + fn parse_cd_command(cmd: &str) -> Option { let trimmed = cmd.trim(); if trimmed == "cd" { diff --git a/crates/ruf4/src/job.rs b/crates/ruf4/src/job.rs index d1f963e..4aa2358 100644 --- a/crates/ruf4/src/job.rs +++ b/crates/ruf4/src/job.rs @@ -16,7 +16,8 @@ use std::sync::mpsc::{Receiver, RecvError, Sender, channel}; use std::thread::JoinHandle; use crate::fileops; -use crate::platform; +use crate::sftp; +use crate::vfs; /// The operation a [`Job`] performs. Drives completion handling (which panels to /// refresh) and the progress-dialog title. @@ -25,6 +26,8 @@ pub enum JobKind { Copy, Move, Delete, + /// Copy a remote file to a local temporary target, opened on completion. + Download, } impl JobKind { @@ -33,6 +36,7 @@ impl JobKind { JobKind::Copy => "Copying", JobKind::Move => "Moving", JobKind::Delete => "Deleting", + JobKind::Download => "Downloading", } } } @@ -135,7 +139,14 @@ pub fn spawn_copy_move(pairs: Vec<(PathBuf, PathBuf)>, is_copy: bool) -> Job { JobKind::Move }; spawn(kind, move |tx, rx, cancel| { - run_copy_move(pairs, is_copy, &tx, &rx, &cancel) + run_copy_move(pairs, is_copy, false, &tx, &rx, &cancel) + }) +} + +/// Spawn a download of one file to a temporary target, overwriting silently. +pub fn spawn_download(src: PathBuf, target: PathBuf) -> Job { + spawn(JobKind::Download, move |tx, rx, cancel| { + run_copy_move(vec![(src, target)], true, true, &tx, &rx, &cancel) }) } @@ -176,12 +187,13 @@ fn cancelled(cancel: &AtomicBool) -> bool { fn run_copy_move( pairs: Vec<(PathBuf, PathBuf)>, is_copy: bool, + mut overwrite_all: bool, tx: &Sender, rx: &Receiver, cancel: &AtomicBool, ) { // Pre-scan each pair so the gauge reflects total files and bytes. - let counts: Vec<(u64, u64)> = pairs.iter().map(|(src, _)| scan_tree(src)).collect(); + let counts: Vec<(u64, u64)> = pairs.iter().map(|(src, _)| vfs::scan_tree(src)).collect(); let mut prog = Progress { files_total: counts.iter().map(|c| c.0).sum(), bytes_total: counts.iter().map(|c| c.1).sum(), @@ -189,7 +201,6 @@ fn run_copy_move( }; let mut errors = Vec::new(); - let mut overwrite_all = false; for (i, (src, target)) in pairs.iter().enumerate() { if cancelled(cancel) { @@ -203,7 +214,7 @@ fn run_copy_move( continue; } - if target.exists() && !overwrite_all { + if !overwrite_all && vfs::exists(target) { let name = fileops::base_name(target).into_owned(); let _ = tx.send(JobEvent::NeedOverwrite(name)); match rx.recv() { @@ -218,17 +229,28 @@ fn run_copy_move( } } - copy_move_one(src, target, is_copy, &mut prog, tx, cancel, &mut errors); + copy_move_one( + src, + target, + is_copy, + counts[i], + &mut prog, + tx, + cancel, + &mut errors, + ); } let _ = tx.send(JobEvent::Finished { errors }); } /// Execute one top-level (src, target) pair, reporting per-file progress. +#[allow(clippy::too_many_arguments)] fn copy_move_one( src: &Path, target: &Path, is_copy: bool, + count: (u64, u64), prog: &mut Progress, tx: &Sender, cancel: &AtomicBool, @@ -242,38 +264,44 @@ fn copy_move_one( return; } - // Move: try a rename first; it is atomic and instant for same-filesystem moves. - match std::fs::rename(src, target) { - Ok(()) => { - prog.current = name; - prog.files_done += count_files(src).max(1); - let _ = tx.send(JobEvent::Progress(prog.clone())); - } - Err(e) if is_cross_device(&e) => { - // Fall back to copy + remove with progress. - if let Err(e) = copy_tree(src, target, prog, tx, cancel) { - errors.push(format!("{name}: {e}")); - return; - } - if cancelled(cancel) { + // Move: try a rename first; it is atomic and instant within one + // filesystem domain. + if vfs::same_domain(src, target) { + match vfs::rename(src, target) { + Ok(()) => { + prog.current = name; + advance(prog, count); + let _ = tx.send(JobEvent::Progress(prog.clone())); return; } - let removed = if src.is_dir() { - std::fs::remove_dir_all(src) - } else { - std::fs::remove_file(src) - }; - if let Err(e) = removed { + // A local rename across devices falls back to copy + remove. A + // failed remote rename carries no distinguishable error code, so + // it falls back the same way; a real error resurfaces from the copy. + Err(e) if vfs::is_remote(src) || is_cross_device(&e) => {} + Err(e) => { errors.push(format!("{name}: {e}")); + return; } } - Err(e) => errors.push(format!("{name}: {e}")), + } + + // Copy + remove: across domains, or a same-domain rename fell through. + if let Err(e) = copy_tree(src, target, prog, tx, cancel) { + errors.push(format!("{name}: {e}")); + return; + } + if cancelled(cancel) { + return; + } + if let Err(e) = vfs::remove_tree(src) { + errors.push(format!("{name}: {e}")); } } -/// Recursively copy `src` to `dst`, reporting progress and honouring cancellation. -/// Mirrors `fileops::copy_dir_recursive` but per file, so a large copy stays -/// cancellable and the gauge advances smoothly. +/// Recursively copy `src` to `dst`, reporting progress and honouring +/// cancellation. Mirrors `fileops::copy_dir_recursive` but per file, so a +/// large copy stays cancellable and the gauge advances smoothly. Either side +/// may be remote; remote-to-remote copies stream through this host. fn copy_tree( src: &Path, dst: &Path, @@ -281,50 +309,99 @@ fn copy_tree( tx: &Sender, cancel: &AtomicBool, ) -> std::io::Result<()> { - if cancelled(cancel) { - return Ok(()); - } + let meta = vfs::symlink_meta(src)?; + copy_tree_inner(src, dst, meta, prog, tx, cancel) +} - let meta = std::fs::symlink_metadata(src)?; - if meta.file_type().is_symlink() { - platform::copy_symlink(src, dst)?; - bump(prog, src, 0, tx); +fn copy_tree_inner( + src: &Path, + dst: &Path, + meta: vfs::Meta, + prog: &mut Progress, + tx: &Sender, + cancel: &AtomicBool, +) -> std::io::Result<()> { + if cancelled(cancel) { return Ok(()); } - if meta.is_dir() { - // Guard against copying a directory into itself. - if let Ok(cs) = std::fs::canonicalize(src) - && let Ok(cd) = std::fs::canonicalize(dst) - && cd.starts_with(&cs) - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "cannot copy directory into itself", - )); + match meta.kind { + vfs::Kind::Symlink => { + vfs::copy_symlink(src, dst)?; + bump(prog, src, 0, tx); + } + vfs::Kind::Dir => { + if vfs::dir_contains(src, dst) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cannot copy directory into itself", + )); + } + vfs::create_dir_all(dst)?; + for (child, child_meta) in vfs::read_dir_meta(src)? { + if cancelled(cancel) { + return Ok(()); + } + copy_tree_inner( + &vfs::join(src, &child), + &vfs::join(dst, &child), + child_meta, + prog, + tx, + cancel, + )?; + } } - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - if cancelled(cancel) { - return Ok(()); + vfs::Kind::File => { + if !vfs::is_remote(src) && !vfs::is_remote(dst) { + std::fs::copy(src, dst)?; + bump(prog, src, meta.size, tx); + } else { + prog.current = fileops::base_name(src).into_owned(); + let _ = tx.send(JobEvent::Progress(prog.clone())); + copy_file_stream(src, dst, prog, tx, cancel)?; + bump(prog, src, 0, tx); } - let entry = entry?; - copy_tree( - &entry.path(), - &dst.join(entry.file_name()), - prog, - tx, - cancel, - )?; } - return Ok(()); } - - std::fs::copy(src, dst)?; - bump(prog, src, meta.len(), tx); Ok(()) } +/// Report streamed progress every this many chunks (chunks are +/// [`sftp::MAX_DATA`] bytes, so this is roughly once per mebibyte). +const STREAM_PROGRESS_INTERVAL: u64 = 32; + +/// Copy one file through read/write streams, advancing the byte gauge as +/// chunks complete. Cancellation leaves a partial target, like the local path. +fn copy_file_stream( + src: &Path, + dst: &Path, + prog: &mut Progress, + tx: &Sender, + cancel: &AtomicBool, +) -> std::io::Result<()> { + let mut reader = vfs::open_read(src)?; + let mut writer = vfs::open_write(dst)?; + let mut buf = vec![0u8; sftp::MAX_DATA]; + let mut chunks = 0u64; + loop { + if cancelled(cancel) { + return Ok(()); + } + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + writer.write_all(&buf[..n])?; + prog.bytes_done += n as u64; + chunks += 1; + if chunks.is_multiple_of(STREAM_PROGRESS_INTERVAL) { + let _ = tx.send(JobEvent::Progress(prog.clone())); + } + } + writer.flush() +} + fn run_delete(paths: Vec, tx: &Sender, cancel: &AtomicBool) { let mut prog = Progress { files_total: paths.iter().map(|p| count_files(p)).sum(), @@ -360,34 +437,8 @@ fn advance(prog: &mut Progress, count: (u64, u64)) { prog.bytes_done += count.1; } -/// Count files and total bytes under `path` (the path itself counts as one entry -/// when it is a file or symlink). -fn scan_tree(path: &Path) -> (u64, u64) { - let meta = match std::fs::symlink_metadata(path) { - Ok(m) => m, - Err(_) => return (1, 0), - }; - if meta.file_type().is_symlink() { - return (1, 0); - } - if meta.is_dir() { - let mut files = 0; - let mut bytes = 0; - if let Ok(rd) = std::fs::read_dir(path) { - for entry in rd.flatten() { - let (f, b) = scan_tree(&entry.path()); - files += f; - bytes += b; - } - } - (files, bytes) - } else { - (1, meta.len()) - } -} - fn count_files(path: &Path) -> u64 { - scan_tree(path).0 + vfs::scan_tree(path).0 } fn is_cross_device(e: &std::io::Error) -> bool { diff --git a/crates/ruf4/src/lib.rs b/crates/ruf4/src/lib.rs index 2a9e587..cddcfed 100644 --- a/crates/ruf4/src/lib.rs +++ b/crates/ruf4/src/lib.rs @@ -14,5 +14,8 @@ pub mod panel; pub mod platform; pub mod preview; pub mod settings; +pub mod sftp; +pub mod smb; pub mod state; pub mod theme; +pub mod vfs; diff --git a/crates/ruf4/src/main.rs b/crates/ruf4/src/main.rs index 5641967..09b060d 100644 --- a/crates/ruf4/src/main.rs +++ b/crates/ruf4/src/main.rs @@ -8,6 +8,7 @@ use std::process::ExitCode; use ruf4::draw; +use ruf4::platform; use ruf4::state; use ruf4_tui::helpers::*; use ruf4_tui::input; @@ -26,18 +27,14 @@ struct TerminalGuard; impl TerminalGuard { fn new() -> Self { - // Alternate screen buffer, then enable SGR mouse mode + all-motion tracking. - sys::write_stdout("\x1b[?1049h\x1b[?1003;1006h"); + sys::write_stdout(platform::TUI_ENTER_SEQ); Self } } impl Drop for TerminalGuard { fn drop(&mut self) { - // Reset cursor style (DECSCUSR 0), show cursor (DECTCEM), reset attributes, - // disable mouse tracking, then exit alternate screen LAST so the main screen - // is restored cleanly without leftover SGR state. - sys::write_stdout("\x1b[0 q\x1b[?25h\x1b[m\x1b[?1003;1006l\x1b[?1049l"); + sys::write_stdout(platform::TUI_LEAVE_SEQ); } } @@ -56,7 +53,7 @@ fn run() -> std::io::Result<()> { let default_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - sys::write_stdout("\x1b[0 q\x1b[?25h\x1b[m\x1b[?1003;1006l\x1b[?1049l"); + sys::write_stdout(platform::TUI_LEAVE_SEQ); default_hook(info); })); @@ -136,6 +133,14 @@ fn run() -> std::io::Result<()> { let scratch = scratch_arena(None); let output = tui.render(&scratch); sys::write_stdout(&output); + drop(scratch); + + // Ctrl+O: hand the screen back to the user until dismissed. Runs at the + // frame boundary so no draw pass is in flight while the TUI is left. + if state.take_user_screen_request() { + platform::view_user_screen(&state.user_screen_exit_keys()); + tui.request_full_redraw(); + } } Ok(()) diff --git a/crates/ruf4/src/panel.rs b/crates/ruf4/src/panel.rs index 725571b..4ed224b 100644 --- a/crates/ruf4/src/panel.rs +++ b/crates/ruf4/src/panel.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; use crate::platform; +use crate::vfs; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SortBy { @@ -158,6 +159,8 @@ pub struct Panel { pub sort_dir: SortDir, pub show_hidden: bool, pub last_refresh: String, + /// Available bytes on the panel's filesystem, sampled at refresh time. + pub free_space: Option, } impl Panel { @@ -171,6 +174,7 @@ impl Panel { sort_dir: SortDir::Ascending, show_hidden: false, last_refresh: String::new(), + free_space: None, }; panel.refresh(); panel @@ -186,12 +190,14 @@ impl Panel { sort_dir: SortDir::Ascending, show_hidden: false, last_refresh: String::new(), + free_space: None, } } pub fn refresh(&mut self) { self.last_refresh = platform::format_current_time(); - self.entries = scan_dir(&self.path, self.show_hidden); + self.entries = vfs::scan_dir(&self.path, self.show_hidden); + self.free_space = vfs::free_space(&self.path); self.sort(); self.cursor = self.cursor.min(self.entries.len().saturating_sub(1)); } @@ -268,12 +274,19 @@ impl Panel { && entry.is_dir { let new_path = if entry.name == ".." { - self.path.parent().unwrap_or(&self.path).to_path_buf() + vfs::parent(&self.path).unwrap_or_else(|| self.path.clone()) } else { - self.path.join(&entry.name) + vfs::join(&self.path, &entry.name) }; - if let Ok(canonical) = fs::canonicalize(&new_path) { + // Remote paths are normalized lexically by the vfs; resolving + // symlinks there would cost a round trip per navigation. + let canonical = if vfs::is_remote(&new_path) { + Ok(new_path) + } else { + fs::canonicalize(&new_path) + }; + if let Ok(canonical) = canonical { let old_name = self .path .file_name() @@ -321,7 +334,7 @@ impl Panel { } pub fn current_path(&self) -> Option { - self.current_entry().map(|e| self.path.join(&e.name)) + self.current_entry().map(|e| vfs::join(&self.path, &e.name)) } pub fn selected_or_current(&self) -> Vec { @@ -329,7 +342,7 @@ impl Panel { .entries .iter() .filter(|e| e.selected && e.name != "..") - .map(|e| self.path.join(&e.name)) + .map(|e| vfs::join(&self.path, &e.name)) .collect(); if !selected.is_empty() { @@ -339,7 +352,7 @@ impl Panel { if let Some(entry) = self.current_entry() && entry.name != ".." { - return vec![self.path.join(&entry.name)]; + return vec![vfs::join(&self.path, &entry.name)]; } Vec::new() } @@ -394,10 +407,6 @@ impl Panel { } } - pub fn free_space(&self) -> Option { - platform::disk_free(&self.path) - } - pub fn navigate_to_prefix(&mut self, prefix: &str) { let lower = prefix.to_lowercase(); if let Some(pos) = self diff --git a/crates/ruf4/src/platform.rs b/crates/ruf4/src/platform.rs index fe6060f..180f0f9 100644 --- a/crates/ruf4/src/platform.rs +++ b/crates/ruf4/src/platform.rs @@ -11,6 +11,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::SystemTime; +use ruf4_tui::input::InputKey; + // ── Local time ───────────────────────────────────────────────────────────── /// Local time components. @@ -447,56 +449,119 @@ pub fn settings_path() -> Option { None } -// ── Shell commands ───────────────────────────────────────────────────────── +// ── Terminal screen switching ────────────────────────────────────────────── -/// Run an external command in the foreground with the terminal handed back to -/// it, so interactive programs (a shell, `python`, `vim`, `less`) work normally. -/// The TUI is suspended for the duration and restored afterwards. -/// -/// stdin/stdout/stderr are inherited (not captured); a full-screen program owns -/// the screen and its output scrolls the real terminal. After it exits we wait -/// for the user to acknowledge so the output stays readable, then repaint. -pub fn run_interactive(cmd: &str, cwd: &Path) -> Result<(), String> { - use ruf4_tui::sys; - use std::io::{BufRead, Write}; +/// Leave the TUI screen: reset cursor style (DECSCUSR 0), show the cursor +/// (DECTCEM), reset attributes, disable mouse tracking, then exit the +/// alternate screen LAST so the primary screen is restored cleanly. +pub const TUI_LEAVE_SEQ: &str = "\x1b[0 q\x1b[?25h\x1b[m\x1b[?1003;1006l\x1b[?1049l"; + +/// Enter the TUI screen: alternate screen buffer, then SGR mouse mode with +/// all-motion tracking. +pub const TUI_ENTER_SEQ: &str = "\x1b[?1049h\x1b[?1003;1006h"; - // Leave the TUI: reset cursor style, show cursor, reset attributes, disable - // mouse tracking, then leave the alternate screen (matches the startup guard). - sys::write_stdout("\x1b[0 q\x1b[?25h\x1b[m\x1b[?1003;1006l\x1b[?1049l"); +/// Suspend the TUI (primary screen, cooked terminal modes), run `f`, then +/// restore the TUI and inject a window-size event so the next frame repaints +/// in full. +pub fn with_tui_suspended(f: impl FnOnce() -> R) -> R { + use ruf4_tui::sys; + sys::write_stdout(TUI_LEAVE_SEQ); sys::suspend(); + let result = f(); + sys::resume(); + sys::write_stdout(TUI_ENTER_SEQ); + sys::inject_window_size_into_stdin(); + result +} - #[cfg(windows)] - let status = Command::new("cmd.exe") - .arg("/C") - .arg(cmd) - .current_dir(cwd) - .status(); - #[cfg(not(windows))] - let status = Command::new("sh") - .arg("-c") - .arg(cmd) - .current_dir(cwd) - .status(); +/// Show the user screen (the primary buffer with the output of previously run +/// commands) until one of `exit_keys` is pressed. The terminal stays in raw +/// mode and all other input is discarded. A window-size event is injected on +/// return so the next frame repaints in full. +pub fn view_user_screen(exit_keys: &[InputKey]) { + use ruf4_tui::{input, sys, vt}; + + sys::write_stdout(TUI_LEAVE_SEQ); + + let mut vt_parser = vt::Parser::new(); + let mut input_parser = input::Parser::new(); + 'wait: loop { + let scratch = stdext::arena::scratch_arena(None); + let Some(text) = sys::read_stdin(&scratch, vt_parser.read_timeout()) else { + break; // EOF or read error; the main loop will observe it as well. + }; + let stream = vt_parser.parse(&text); + for ev in input_parser.parse(stream) { + if let input::Input::Keyboard(key) = ev + && exit_keys.contains(&key) + { + break 'wait; + } + } + } - let result = match status { - Ok(_) => Ok(()), - Err(e) => Err(format!("Failed to execute \"{cmd}\": {e}")), - }; + sys::write_stdout(TUI_ENTER_SEQ); + sys::inject_window_size_into_stdin(); +} + +// ── Shell commands ───────────────────────────────────────────────────────── - // Keep the output on screen until acknowledged. +/// Block on stdin until Enter, so foreground command output stays readable. +fn wait_for_enter() { + use std::io::{BufRead, Write}; let mut out = std::io::stdout(); let _ = write!(out, "\n[Press Enter to return to ruf4] "); let _ = out.flush(); let mut line = String::new(); let _ = std::io::stdin().lock().read_line(&mut line); +} - // Restore the TUI: raw mode, alternate screen, mouse tracking, then force a - // full repaint by injecting a window-size event. - sys::resume(); - sys::write_stdout("\x1b[?1049h\x1b[?1003;1006h"); - sys::inject_window_size_into_stdin(); +/// Run a prepared command in the foreground with the TUI suspended, so +/// interactive programs (a shell, `python`, `vim`, `less`) work normally. +/// +/// stdin/stdout/stderr are inherited (not captured); a full-screen program owns +/// the screen and its output scrolls the real terminal. When `pause_on_success` +/// is set, wait for the user to acknowledge before repainting; failures always +/// pause so the error output stays readable. +pub fn run_foreground( + command: &mut Command, + display: &str, + pause_on_success: bool, +) -> Result { + with_tui_suspended(|| { + let status = command.status(); + match status { + Ok(st) => { + if pause_on_success || !st.success() { + wait_for_enter(); + } + Ok(st) + } + Err(e) => { + wait_for_enter(); + Err(format!("Failed to execute \"{display}\": {e}")) + } + } + }) +} - result +/// Run a shell command in the foreground with the terminal handed back to it. +/// The exit status is not treated as an error; the user saw the output. +pub fn run_interactive(cmd: &str, cwd: &Path) -> Result<(), String> { + #[cfg(windows)] + let mut command = { + let mut c = Command::new("cmd.exe"); + c.arg("/C").arg(cmd); + c + }; + #[cfg(not(windows))] + let mut command = { + let mut c = Command::new("sh"); + c.arg("-c").arg(cmd); + c + }; + command.current_dir(cwd); + run_foreground(&mut command, cmd, true).map(|_| ()) } pub fn run_command(cmd: &str, cwd: &Path) -> Result<(String, i32), String> { diff --git a/crates/ruf4/src/preview.rs b/crates/ruf4/src/preview.rs index f1798f2..67fa69f 100644 --- a/crates/ruf4/src/preview.rs +++ b/crates/ruf4/src/preview.rs @@ -3,11 +3,10 @@ //! File preview for the quick-view panel. -use std::fs; -use std::io::Read; use std::path::Path; use crate::lsh::{self, HighlightKind}; +use crate::vfs; const MAX_PREVIEW_BYTES: usize = 64 * 1024; const BINARY_CHECK_BYTES: usize = 512; @@ -45,12 +44,13 @@ pub fn generate(path: &Path) -> Preview { .to_string_lossy() .into_owned(); - if path.is_dir() { + if vfs::is_dir(path) { return dir_preview(path, &name); } - let meta = match fs::metadata(path) { - Ok(m) => m, + // TODO: remote previews block the UI thread for the duration of the read. + let (buf, file_size) = match vfs::read_prefix(path, MAX_PREVIEW_BYTES) { + Ok(v) => v, Err(e) => { return Preview { lines: vec![format!("Error: {e}")], @@ -62,37 +62,7 @@ pub fn generate(path: &Path) -> Preview { } }; - let file_size = meta.len(); - - let mut file = match fs::File::open(path) { - Ok(f) => f, - Err(e) => { - return Preview { - lines: vec![format!("Error: {e}")], - highlights: Vec::new(), - is_binary: false, - file_size, - title: name, - }; - } - }; - - let mut buf = vec![0u8; MAX_PREVIEW_BYTES.min(file_size as usize)]; - let n = match file.read(&mut buf) { - Ok(n) => n, - Err(e) => { - return Preview { - lines: vec![format!("Error reading: {e}")], - highlights: Vec::new(), - is_binary: false, - file_size, - title: name, - }; - } - }; - buf.truncate(n); - - let check_len = n.min(BINARY_CHECK_BYTES); + let check_len = buf.len().min(BINARY_CHECK_BYTES); let is_binary = buf[..check_len].contains(&0); if is_binary { @@ -181,15 +151,11 @@ fn dir_preview(path: &Path, name: &str) -> Preview { lines.push(format!("Directory: {name}")); lines.push(String::new()); - match fs::read_dir(path) { + match vfs::list_names(path) { Ok(entries) => { let mut items: Vec = entries - .flatten() - .map(|e| { - let fname = e.file_name().to_string_lossy().into_owned(); - let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); - if is_dir { format!("{fname}/") } else { fname } - }) + .into_iter() + .map(|(fname, is_dir)| if is_dir { format!("{fname}/") } else { fname }) .collect(); items.sort(); let total = items.len(); diff --git a/crates/ruf4/src/sftp.rs b/crates/ruf4/src/sftp.rs new file mode 100644 index 0000000..c8fc3e4 --- /dev/null +++ b/crates/ruf4/src/sftp.rs @@ -0,0 +1,567 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! SFTP version 3 client (draft-ietf-secsh-filexfer-02, as implemented by +//! OpenSSH). +//! +//! The client speaks the binary SFTP protocol over any `Read + Write` +//! transport; in production that is the stdio of a spawned +//! `ssh -s sftp` subsystem, in tests an in-memory mock. +//! Requests are synchronous with a single outstanding id, which matches how +//! the rest of the application consumes it. + +use std::io::{self, Read, Write}; + +// Packet types. +const SSH_FXP_INIT: u8 = 1; +const SSH_FXP_VERSION: u8 = 2; +const SSH_FXP_OPEN: u8 = 3; +const SSH_FXP_CLOSE: u8 = 4; +const SSH_FXP_READ: u8 = 5; +const SSH_FXP_WRITE: u8 = 6; +const SSH_FXP_LSTAT: u8 = 7; +const SSH_FXP_OPENDIR: u8 = 11; +const SSH_FXP_READDIR: u8 = 12; +const SSH_FXP_REMOVE: u8 = 13; +const SSH_FXP_MKDIR: u8 = 14; +const SSH_FXP_RMDIR: u8 = 15; +const SSH_FXP_REALPATH: u8 = 16; +const SSH_FXP_STAT: u8 = 17; +const SSH_FXP_RENAME: u8 = 18; +const SSH_FXP_READLINK: u8 = 19; +const SSH_FXP_SYMLINK: u8 = 20; +const SSH_FXP_STATUS: u8 = 101; +const SSH_FXP_HANDLE: u8 = 102; +const SSH_FXP_DATA: u8 = 103; +const SSH_FXP_NAME: u8 = 104; +const SSH_FXP_ATTRS: u8 = 105; +const SSH_FXP_EXTENDED: u8 = 200; +const SSH_FXP_EXTENDED_REPLY: u8 = 201; + +// Status codes. +const SSH_FX_OK: u32 = 0; +const SSH_FX_EOF: u32 = 1; +const SSH_FX_NO_SUCH_FILE: u32 = 2; +const SSH_FX_PERMISSION_DENIED: u32 = 3; +const SSH_FX_OP_UNSUPPORTED: u32 = 8; + +// Attribute presence flags. +const ATTR_SIZE: u32 = 0x0000_0001; +const ATTR_UIDGID: u32 = 0x0000_0002; +const ATTR_PERMISSIONS: u32 = 0x0000_0004; +const ATTR_ACMODTIME: u32 = 0x0000_0008; +const ATTR_EXTENDED: u32 = 0x8000_0000; + +// `SSH_FXP_OPEN` pflags. +pub const OPEN_READ: u32 = 0x0000_0001; +pub const OPEN_WRITE: u32 = 0x0000_0002; +pub const OPEN_CREAT: u32 = 0x0000_0008; +pub const OPEN_TRUNC: u32 = 0x0000_0010; + +// POSIX file type bits carried in the permissions attribute. +const S_IFMT: u32 = 0o170000; +const S_IFDIR: u32 = 0o040000; +const S_IFLNK: u32 = 0o120000; + +/// Upper bound on a single packet, matching OpenSSH's limit. +const MAX_PACKET: usize = 256 * 1024; + +/// Chunk size for READ/WRITE requests, matching OpenSSH's default block size. +pub const MAX_DATA: usize = 32 * 1024; + +const POSIX_RENAME_EXT: &str = "posix-rename@openssh.com"; +const STATVFS_EXT: &str = "statvfs@openssh.com"; + +// ── Wire helpers ──────────────────────────────────────────────────────────── + +fn put_u32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_be_bytes()); +} + +fn put_u64(buf: &mut Vec, v: u64) { + buf.extend_from_slice(&v.to_be_bytes()); +} + +fn put_bytes(buf: &mut Vec, b: &[u8]) { + put_u32(buf, b.len() as u32); + buf.extend_from_slice(b); +} + +fn put_str(buf: &mut Vec, s: &str) { + put_bytes(buf, s.as_bytes()); +} + +fn truncated() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "sftp: truncated packet") +} + +fn unexpected(ptype: u8) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sftp: unexpected packet type {ptype}"), + ) +} + +fn status_error(code: u32, msg: &str) -> io::Error { + let kind = match code { + SSH_FX_NO_SUCH_FILE => io::ErrorKind::NotFound, + SSH_FX_PERMISSION_DENIED => io::ErrorKind::PermissionDenied, + SSH_FX_OP_UNSUPPORTED => io::ErrorKind::Unsupported, + _ => io::ErrorKind::Other, + }; + io::Error::new(kind, format!("sftp: {msg} (status {code})")) +} + +/// Cursor over a received packet payload. +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + fn take(&mut self, n: usize) -> io::Result<&'a [u8]> { + if self.remaining() < n { + return Err(truncated()); + } + let s = &self.buf[self.pos..self.pos + n]; + self.pos += n; + Ok(s) + } + + fn u32(&mut self) -> io::Result { + Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + + fn u64(&mut self) -> io::Result { + Ok(u64::from_be_bytes(self.take(8)?.try_into().unwrap())) + } + + fn bytes(&mut self) -> io::Result<&'a [u8]> { + let len = self.u32()? as usize; + self.take(len) + } + + fn string(&mut self) -> io::Result { + Ok(String::from_utf8_lossy(self.bytes()?).into_owned()) + } +} + +/// File attributes as transmitted in SFTP v3; absent fields are `None`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Attrs { + pub size: Option, + pub uid_gid: Option<(u32, u32)>, + pub permissions: Option, + /// (atime, mtime) in seconds since the epoch. + pub times: Option<(u32, u32)>, +} + +impl Attrs { + pub fn is_dir(&self) -> bool { + self.permissions.is_some_and(|p| p & S_IFMT == S_IFDIR) + } + + pub fn is_symlink(&self) -> bool { + self.permissions.is_some_and(|p| p & S_IFMT == S_IFLNK) + } +} + +fn read_attrs(r: &mut Reader<'_>) -> io::Result { + let flags = r.u32()?; + let mut attrs = Attrs::default(); + if flags & ATTR_SIZE != 0 { + attrs.size = Some(r.u64()?); + } + if flags & ATTR_UIDGID != 0 { + attrs.uid_gid = Some((r.u32()?, r.u32()?)); + } + if flags & ATTR_PERMISSIONS != 0 { + attrs.permissions = Some(r.u32()?); + } + if flags & ATTR_ACMODTIME != 0 { + attrs.times = Some((r.u32()?, r.u32()?)); + } + if flags & ATTR_EXTENDED != 0 { + let count = r.u32()?; + for _ in 0..count { + r.bytes()?; + r.bytes()?; + } + } + Ok(attrs) +} + +/// One directory entry from `SSH_FXP_READDIR` (lstat semantics). +#[derive(Clone, Debug)] +pub struct DirEntry { + pub name: String, + pub attrs: Attrs, +} + +// ── Client ────────────────────────────────────────────────────────────────── + +pub struct Client { + transport: T, + next_id: u32, + extensions: Vec<(String, String)>, +} + +impl Client { + /// Perform the version handshake over `transport`. + pub fn new(transport: T) -> io::Result { + let mut client = Self { + transport, + next_id: 0, + extensions: Vec::new(), + }; + client.handshake()?; + Ok(client) + } + + fn handshake(&mut self) -> io::Result<()> { + let mut body = Vec::new(); + put_u32(&mut body, 3); + self.send_packet(SSH_FXP_INIT, &body)?; + + let (ptype, payload) = self.recv_packet()?; + if ptype != SSH_FXP_VERSION { + return Err(unexpected(ptype)); + } + let mut r = Reader::new(&payload); + let version = r.u32()?; + if version < 3 { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("sftp: server protocol version {version} < 3"), + )); + } + while r.remaining() > 0 { + let name = r.string()?; + let data = r.string()?; + self.extensions.push((name, data)); + } + Ok(()) + } + + pub fn has_extension(&self, name: &str) -> bool { + self.extensions.iter().any(|(n, _)| n == name) + } + + fn send_packet(&mut self, ptype: u8, payload: &[u8]) -> io::Result<()> { + let mut pkt = Vec::with_capacity(payload.len() + 5); + put_u32(&mut pkt, payload.len() as u32 + 1); + pkt.push(ptype); + pkt.extend_from_slice(payload); + self.transport.write_all(&pkt)?; + self.transport.flush() + } + + fn recv_packet(&mut self) -> io::Result<(u8, Vec)> { + let mut len_buf = [0u8; 4]; + self.transport.read_exact(&mut len_buf)?; + let len = u32::from_be_bytes(len_buf) as usize; + if len == 0 || len > MAX_PACKET { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("sftp: invalid packet length {len}"), + )); + } + let mut buf = vec![0u8; len]; + self.transport.read_exact(&mut buf)?; + let payload = buf.split_off(1); + Ok((buf[0], payload)) + } + + /// Send one request (`body` without the id) and return the matching reply + /// with the id already consumed from the payload. + fn request(&mut self, ptype: u8, body: &[u8]) -> io::Result<(u8, Vec)> { + let id = self.next_id; + self.next_id = self.next_id.wrapping_add(1); + + let mut payload = Vec::with_capacity(body.len() + 4); + put_u32(&mut payload, id); + payload.extend_from_slice(body); + self.send_packet(ptype, &payload)?; + + let (rtype, rbuf) = self.recv_packet()?; + let mut r = Reader::new(&rbuf); + let rid = r.u32()?; + if rid != id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("sftp: reply id {rid} does not match request id {id}"), + )); + } + Ok((rtype, rbuf[4..].to_vec())) + } + + fn parse_status(payload: &[u8]) -> io::Result<(u32, String)> { + let mut r = Reader::new(payload); + let code = r.u32()?; + // Some servers omit message/language on success. + let msg = r.string().unwrap_or_default(); + Ok((code, msg)) + } + + /// A request whose only interesting reply is a `STATUS`. + fn request_status(&mut self, ptype: u8, body: &[u8]) -> io::Result<()> { + let (rtype, payload) = self.request(ptype, body)?; + if rtype != SSH_FXP_STATUS { + return Err(unexpected(rtype)); + } + let (code, msg) = Self::parse_status(&payload)?; + if code == SSH_FX_OK { + Ok(()) + } else { + Err(status_error(code, &msg)) + } + } + + /// A request answered with `NAME`; returns the first (typically only) name. + fn request_name(&mut self, ptype: u8, body: &[u8]) -> io::Result { + let (rtype, payload) = self.request(ptype, body)?; + match rtype { + SSH_FXP_NAME => { + let mut r = Reader::new(&payload); + let count = r.u32()?; + if count == 0 { + return Err(truncated()); + } + r.string() + } + SSH_FXP_STATUS => { + let (code, msg) = Self::parse_status(&payload)?; + Err(status_error(code, &msg)) + } + t => Err(unexpected(t)), + } + } + + fn request_attrs(&mut self, ptype: u8, path: &str) -> io::Result { + let mut body = Vec::new(); + put_str(&mut body, path); + let (rtype, payload) = self.request(ptype, &body)?; + match rtype { + SSH_FXP_ATTRS => read_attrs(&mut Reader::new(&payload)), + SSH_FXP_STATUS => { + let (code, msg) = Self::parse_status(&payload)?; + Err(status_error(code, &msg)) + } + t => Err(unexpected(t)), + } + } + + // ── Operations ────────────────────────────────────────────────────── + + pub fn realpath(&mut self, path: &str) -> io::Result { + let mut body = Vec::new(); + put_str(&mut body, path); + self.request_name(SSH_FXP_REALPATH, &body) + } + + /// Attributes following symlinks. + pub fn stat(&mut self, path: &str) -> io::Result { + self.request_attrs(SSH_FXP_STAT, path) + } + + /// Attributes without following symlinks. + pub fn lstat(&mut self, path: &str) -> io::Result { + self.request_attrs(SSH_FXP_LSTAT, path) + } + + pub fn read_link(&mut self, path: &str) -> io::Result { + let mut body = Vec::new(); + put_str(&mut body, path); + self.request_name(SSH_FXP_READLINK, &body) + } + + /// Create a symlink at `link` pointing to `target`. The argument order on + /// the wire is OpenSSH's (target first), which deviates from the draft but + /// is the de-facto standard. + pub fn symlink(&mut self, target: &str, link: &str) -> io::Result<()> { + let mut body = Vec::new(); + put_str(&mut body, target); + put_str(&mut body, link); + self.request_status(SSH_FXP_SYMLINK, &body) + } + + /// List a directory. `.` and `..` are passed through as the server sent + /// them; callers filter. + pub fn read_dir(&mut self, path: &str) -> io::Result> { + let mut body = Vec::new(); + put_str(&mut body, path); + let (rtype, payload) = self.request(SSH_FXP_OPENDIR, &body)?; + let handle = match rtype { + SSH_FXP_HANDLE => Reader::new(&payload).bytes()?.to_vec(), + SSH_FXP_STATUS => { + let (code, msg) = Self::parse_status(&payload)?; + return Err(status_error(code, &msg)); + } + t => return Err(unexpected(t)), + }; + + let mut entries = Vec::new(); + let result = loop { + match self.read_dir_chunk(&handle) { + Ok(Some(chunk)) => entries.extend(chunk), + Ok(None) => break Ok(()), + Err(e) => break Err(e), + } + }; + let _ = self.close(&handle); + result.map(|()| entries) + } + + fn read_dir_chunk(&mut self, handle: &[u8]) -> io::Result>> { + let mut body = Vec::new(); + put_bytes(&mut body, handle); + let (rtype, payload) = self.request(SSH_FXP_READDIR, &body)?; + match rtype { + SSH_FXP_NAME => { + let mut r = Reader::new(&payload); + let count = r.u32()?; + let mut chunk = Vec::with_capacity(count as usize); + for _ in 0..count { + let name = r.string()?; + let _longname = r.bytes()?; + let attrs = read_attrs(&mut r)?; + chunk.push(DirEntry { name, attrs }); + } + Ok(Some(chunk)) + } + SSH_FXP_STATUS => { + let (code, msg) = Self::parse_status(&payload)?; + if code == SSH_FX_EOF { + Ok(None) + } else { + Err(status_error(code, &msg)) + } + } + t => Err(unexpected(t)), + } + } + + /// Open a file and return its handle. + pub fn open(&mut self, path: &str, pflags: u32) -> io::Result> { + let mut body = Vec::new(); + put_str(&mut body, path); + put_u32(&mut body, pflags); + put_u32(&mut body, 0); // empty attrs + let (rtype, payload) = self.request(SSH_FXP_OPEN, &body)?; + match rtype { + SSH_FXP_HANDLE => Ok(Reader::new(&payload).bytes()?.to_vec()), + SSH_FXP_STATUS => { + let (code, msg) = Self::parse_status(&payload)?; + Err(status_error(code, &msg)) + } + t => Err(unexpected(t)), + } + } + + pub fn close(&mut self, handle: &[u8]) -> io::Result<()> { + let mut body = Vec::new(); + put_bytes(&mut body, handle); + self.request_status(SSH_FXP_CLOSE, &body) + } + + /// Read up to `len` bytes at `offset`; `None` signals end of file. + pub fn read(&mut self, handle: &[u8], offset: u64, len: u32) -> io::Result>> { + let mut body = Vec::new(); + put_bytes(&mut body, handle); + put_u64(&mut body, offset); + put_u32(&mut body, len); + let (rtype, payload) = self.request(SSH_FXP_READ, &body)?; + match rtype { + SSH_FXP_DATA => Ok(Some(Reader::new(&payload).bytes()?.to_vec())), + SSH_FXP_STATUS => { + let (code, msg) = Self::parse_status(&payload)?; + if code == SSH_FX_EOF { + Ok(None) + } else { + Err(status_error(code, &msg)) + } + } + t => Err(unexpected(t)), + } + } + + pub fn write(&mut self, handle: &[u8], offset: u64, data: &[u8]) -> io::Result<()> { + let mut body = Vec::new(); + put_bytes(&mut body, handle); + put_u64(&mut body, offset); + put_bytes(&mut body, data); + self.request_status(SSH_FXP_WRITE, &body) + } + + pub fn mkdir(&mut self, path: &str) -> io::Result<()> { + let mut body = Vec::new(); + put_str(&mut body, path); + put_u32(&mut body, 0); // empty attrs + self.request_status(SSH_FXP_MKDIR, &body) + } + + pub fn rmdir(&mut self, path: &str) -> io::Result<()> { + let mut body = Vec::new(); + put_str(&mut body, path); + self.request_status(SSH_FXP_RMDIR, &body) + } + + pub fn remove(&mut self, path: &str) -> io::Result<()> { + let mut body = Vec::new(); + put_str(&mut body, path); + self.request_status(SSH_FXP_REMOVE, &body) + } + + /// Rename, preferring the POSIX-semantics extension (replaces an existing + /// target) over the plain v3 rename (which fails on one). + pub fn rename(&mut self, old: &str, new: &str) -> io::Result<()> { + if self.has_extension(POSIX_RENAME_EXT) { + let mut body = Vec::new(); + put_str(&mut body, POSIX_RENAME_EXT); + put_str(&mut body, old); + put_str(&mut body, new); + self.request_status(SSH_FXP_EXTENDED, &body) + } else { + let mut body = Vec::new(); + put_str(&mut body, old); + put_str(&mut body, new); + self.request_status(SSH_FXP_RENAME, &body) + } + } + + /// Available bytes on the filesystem holding `path`, via the + /// `statvfs@openssh.com` extension. `None` when the server lacks it. + pub fn statvfs_avail(&mut self, path: &str) -> io::Result> { + if !self.has_extension(STATVFS_EXT) { + return Ok(None); + } + let mut body = Vec::new(); + put_str(&mut body, STATVFS_EXT); + put_str(&mut body, path); + let (rtype, payload) = self.request(SSH_FXP_EXTENDED, &body)?; + match rtype { + SSH_FXP_EXTENDED_REPLY => { + let mut r = Reader::new(&payload); + let _bsize = r.u64()?; + let frsize = r.u64()?; + let _blocks = r.u64()?; + let _bfree = r.u64()?; + let bavail = r.u64()?; + Ok(Some(bavail.saturating_mul(frsize))) + } + SSH_FXP_STATUS => { + let (code, msg) = Self::parse_status(&payload)?; + Err(status_error(code, &msg)) + } + t => Err(unexpected(t)), + } + } +} diff --git a/crates/ruf4/src/smb.rs b/crates/ruf4/src/smb.rs new file mode 100644 index 0000000..4a58989 --- /dev/null +++ b/crates/ruf4/src/smb.rs @@ -0,0 +1,431 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! SMB share access through the operating system's native client. +//! +//! `smb://[user@]host[:port]/share/path` resolves to a local directory and +//! every subsequent filesystem operation is ordinary local I/O: +//! +//! * Windows: rewritten to a UNC path (`\\host\share\path`); the redirector +//! handles the protocol, no mount step exists. +//! * macOS: mounted with `mount_smbfs` under `~/.ruf4/mnt//`, +//! prompting for credentials with the TUI suspended. A marker file beside +//! the mountpoint records the URL so the share can be remounted when an +//! unmounted path resurfaces (directory history, saved panels). +//! * Linux: mounted in user space with `gio mount` (GVFS); the share appears +//! under the `gvfs` FUSE directory of the runtime dir. +//! +//! TODO: share enumeration for `smb://host` without a share name. +//! TODO: credential prompt for unauthenticated UNC access on Windows. + +use std::path::{Path, PathBuf}; + +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::platform; +use crate::vfs; + +pub const SCHEME: &str = "smb://"; + +/// Name of the marker file recording a mountpoint's URL. It lives inside the +/// unmounted mountpoint directory; a live mount shadows it, which is fine +/// because remounting is only needed when the share is not mounted. +#[cfg(target_os = "macos")] +const URL_MARKER: &str = ".ruf4-url"; + +/// A parsed `smb://` location. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SmbPath { + pub user: Option, + pub host: String, + pub port: Option, + /// Share name; empty when the URL names only a host. + pub share: String, + /// Path below the share root, `/`-separated; empty or starting with `/`. + pub path: String, +} + +impl SmbPath { + /// Canonical URL form, e.g. `smb://user@host/share/dir`. + pub fn display(&self) -> String { + let mut s = String::from(SCHEME); + if let Some(user) = &self.user { + s.push_str(user); + s.push('@'); + } + if self.host.contains(':') { + s.push('['); + s.push_str(&self.host); + s.push(']'); + } else { + s.push_str(&self.host); + } + if let Some(port) = self.port { + s.push(':'); + s.push_str(&port.to_string()); + } + if !self.share.is_empty() { + s.push('/'); + s.push_str(&self.share); + s.push_str(&self.path); + } + s + } +} + +/// Parse `smb://[user@]host[:port]/share/path`. `None` for other paths. +pub fn parse(path: &Path) -> Option { + let s = path.to_str()?; + let rest = s.strip_prefix(SCHEME)?; + let (authority, full_path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + let (user, host, port) = vfs::parse_authority(authority)?; + let trimmed = full_path.trim_start_matches('/'); + let (share, sub) = match trimmed.find('/') { + Some(i) => (&trimmed[..i], &trimmed[i..]), + None => (trimmed, ""), + }; + let sub = vfs::normalize_posix(sub); + Some(SmbPath { + user, + host, + port, + share: share.to_string(), + path: if sub == "/" { String::new() } else { sub }, + }) +} + +/// UNC form of an SMB location: `\\host\share\path`. +pub fn unc_path(smb: &SmbPath) -> PathBuf { + let mut unc = format!(r"\\{}\{}", smb.host, smb.share); + for comp in smb.path.split('/').filter(|c| !c.is_empty()) { + unc.push('\\'); + unc.push_str(comp); + } + PathBuf::from(unc) +} + +/// GVFS FUSE directory entry name for a share, e.g. +/// `smb-share:server=nas,share=media`. +pub fn gvfs_entry_name(smb: &SmbPath) -> String { + let mut name = format!( + "smb-share:server={},share={}", + smb.host.to_lowercase(), + smb.share.to_lowercase() + ); + if let Some(port) = smb.port { + name.push_str(&format!(",port={port}")); + } + if let Some(user) = &smb.user { + name.push_str(&format!(",user={user}")); + } + name +} + +/// Parse a GVFS `smb-share:` entry name back into server and share. +pub fn parse_gvfs_entry_name(name: &str) -> Option { + let rest = name.strip_prefix("smb-share:")?; + let mut server = None; + let mut share = None; + let mut user = None; + let mut port = None; + for kv in rest.split(',') { + let (key, value) = kv.split_once('=')?; + match key { + "server" => server = Some(value.to_string()), + "share" => share = Some(value.to_string()), + "user" => user = Some(value.to_string()), + "port" => port = value.parse().ok(), + _ => {} + } + } + Some(SmbPath { + user, + host: server?, + port, + share: share?, + path: String::new(), + }) +} + +/// Replace characters unsuitable for a directory name. +#[cfg(target_os = "macos")] +fn sanitize(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect() +} + +// ── macOS: mount_smbfs under a private mount base ─────────────────────────── + +#[cfg(target_os = "macos")] +fn mount_base() -> PathBuf { + platform::home_dir().join(".ruf4").join("mnt") +} + +#[cfg(target_os = "macos")] +fn mountpoint_for(smb: &SmbPath) -> PathBuf { + let host_dir = match smb.port { + Some(port) => format!("{}_{port}", sanitize(&smb.host)), + None => sanitize(&smb.host), + }; + mount_base().join(host_dir).join(sanitize(&smb.share)) +} + +/// Whether `dir` currently is an smbfs mountpoint. +#[cfg(target_os = "macos")] +fn is_smbfs_mount(dir: &Path) -> bool { + use std::ffi::{CStr, CString}; + let Ok(canonical) = std::fs::canonicalize(dir) else { + return false; + }; + let Ok(c_path) = CString::new(canonical.as_os_str().as_encoded_bytes()) else { + return false; + }; + unsafe { + let mut stat: libc::statfs = std::mem::zeroed(); + if libc::statfs(c_path.as_ptr(), &mut stat) != 0 { + return false; + } + let fstype = CStr::from_ptr(stat.f_fstypename.as_ptr()); + let mnt_on = CStr::from_ptr(stat.f_mntonname.as_ptr()); + fstype.to_string_lossy() == "smbfs" && Path::new(&*mnt_on.to_string_lossy()) == canonical + } +} + +/// Mount source for `mount_smbfs`: `//[user@]host[:port]/share`. +#[cfg(target_os = "macos")] +fn mount_source(smb: &SmbPath) -> String { + let mut s = String::from("//"); + if let Some(user) = &smb.user { + s.push_str(user); + s.push('@'); + } + s.push_str(&smb.host); + if let Some(port) = smb.port { + s.push(':'); + s.push_str(&port.to_string()); + } + s.push('/'); + s.push_str(&smb.share); + s +} + +#[cfg(target_os = "macos")] +fn ensure_mounted(smb: &SmbPath) -> Result { + let mountpoint = mountpoint_for(smb); + if is_smbfs_mount(&mountpoint) { + return Ok(mountpoint); + } + + std::fs::create_dir_all(&mountpoint) + .map_err(|e| format!("Cannot create {}: {e}", mountpoint.display()))?; + // Record the URL beside the mount so an unmounted path can be remounted. + let _ = std::fs::write(mountpoint.join(URL_MARKER), smb.display()); + + // Interactive: mount_smbfs prompts for the password on the terminal. + let source = mount_source(smb); + let mut cmd = std::process::Command::new("/sbin/mount_smbfs"); + cmd.arg(&source).arg(&mountpoint); + let display = format!("mount_smbfs {source}"); + match platform::run_foreground(&mut cmd, &display, false) { + Ok(st) if st.success() => Ok(mountpoint), + Ok(_) => Err(format!("Cannot mount {}", smb.display())), + Err(e) => Err(e), + } +} + +#[cfg(target_os = "macos")] +fn mounted_roots_impl() -> Vec { + let mut roots = Vec::new(); + let Ok(hosts) = std::fs::read_dir(mount_base()) else { + return roots; + }; + for host in hosts.flatten() { + let Ok(shares) = std::fs::read_dir(host.path()) else { + continue; + }; + for share in shares.flatten() { + if is_smbfs_mount(&share.path()) { + roots.push(share.path()); + } + } + } + roots.sort(); + roots +} + +/// Reconstruct the URL for a path under the mount base from its marker file. +/// `None` when the path is not managed here or the marker is unreadable. +#[cfg(target_os = "macos")] +fn url_for_local(path: &Path) -> Option { + let rel = path.strip_prefix(mount_base()).ok()?; + let mut comps = rel.components(); + let host_dir = comps.next()?; + let share_dir = comps.next()?; + let mountpoint = mount_base().join(host_dir).join(share_dir); + let url = std::fs::read_to_string(mountpoint.join(URL_MARKER)).ok()?; + parse(Path::new(url.trim())) +} + +// ── Linux: user-space mounts via GVFS ─────────────────────────────────────── + +#[cfg(target_os = "linux")] +fn gvfs_dir() -> PathBuf { + if let Ok(d) = std::env::var("XDG_RUNTIME_DIR") + && !d.is_empty() + { + return PathBuf::from(d).join("gvfs"); + } + PathBuf::from(format!("/run/user/{}/gvfs", unsafe { libc::getuid() })) +} + +/// Find the GVFS entry for a share, tolerating extra name fields (user, port). +#[cfg(target_os = "linux")] +fn find_gvfs_mount(smb: &SmbPath) -> Option { + let entries = std::fs::read_dir(gvfs_dir()).ok()?; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(parsed) = parse_gvfs_entry_name(&name) + && parsed.host.eq_ignore_ascii_case(&smb.host) + && parsed.share.eq_ignore_ascii_case(&smb.share) + && (smb.port.is_none() || parsed.port == smb.port) + { + return Some(entry.path()); + } + } + None +} + +#[cfg(target_os = "linux")] +fn ensure_mounted(smb: &SmbPath) -> Result { + if let Some(dir) = find_gvfs_mount(smb) { + return Ok(dir); + } + + // Interactive: `gio mount` prompts for credentials on the terminal. + let uri = smb.display(); + let mut cmd = std::process::Command::new("gio"); + cmd.arg("mount").arg(&uri); + let display = format!("gio mount {uri}"); + match platform::run_foreground(&mut cmd, &display, false) { + Ok(st) if st.success() => {} + Ok(_) => return Err(format!("Cannot mount {uri}")), + Err(_) => { + return Err( + "Mounting SMB shares needs `gio` (GVFS); install gvfs-fuse or mount manually" + .to_string(), + ); + } + } + + // The FUSE directory appears right after `gio mount` returns; poll briefly. + for _ in 0..20 { + if let Some(dir) = find_gvfs_mount(smb) { + return Ok(dir); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err(format!( + "{uri} mounted, but no GVFS directory appeared under {}", + gvfs_dir().display() + )) +} + +#[cfg(target_os = "linux")] +fn mounted_roots_impl() -> Vec { + let mut roots = Vec::new(); + if let Ok(entries) = std::fs::read_dir(gvfs_dir()) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if parse_gvfs_entry_name(&name).is_some() { + roots.push(entry.path()); + } + } + } + roots.sort(); + roots +} + +#[cfg(target_os = "linux")] +fn url_for_local(path: &Path) -> Option { + let rel = path.strip_prefix(gvfs_dir()).ok()?; + let entry = rel.components().next()?; + parse_gvfs_entry_name(&entry.as_os_str().to_string_lossy()) +} + +// ── Dispatch ──────────────────────────────────────────────────────────────── + +/// Resolve an SMB location to a local directory, mounting the share first +/// when necessary (interactively, with the TUI suspended). +pub fn resolve_dir(smb: &SmbPath) -> Result { + if smb.share.is_empty() { + // TODO: enumerate shares for a bare host. + return Err(format!( + "{}: share name required (smb://host/share)", + smb.display() + )); + } + + #[cfg(windows)] + { + if smb.user.is_some() || smb.port.is_some() { + // TODO: establish credentials/port with `net use` first. + return Err("smb:// with user or port is not supported on Windows; \ + authenticate to the share first" + .to_string()); + } + Ok(unc_path(smb)) + } + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let mountpoint = ensure_mounted(smb)?; + let mut dir = mountpoint; + for comp in smb.path.split('/').filter(|c| !c.is_empty()) { + dir.push(comp); + } + Ok(dir) + } + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] + { + Err(format!( + "{}: no SMB support on this platform", + smb.display() + )) + } +} + +/// Remount the share backing a currently-unmounted managed path. `None` when +/// the path is not SMB-managed; `Some(Err)` when remounting was attempted and +/// failed. +pub fn try_remount(path: &Path) -> Option> { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let smb = url_for_local(path)?; + Some(ensure_mounted(&smb).map(|_| ())) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = path; + None + } +} + +/// Local mountpoints of currently mounted SMB shares, for the roots dialog. +pub fn mounted_roots() -> Vec { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + mounted_roots_impl() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Vec::new() + } +} diff --git a/crates/ruf4/src/state.rs b/crates/ruf4/src/state.rs index 9fea241..ca3b91b 100644 --- a/crates/ruf4/src/state.rs +++ b/crates/ruf4/src/state.rs @@ -11,12 +11,13 @@ use ruf4_tui::input::{Input, InputKey, InputMouseState, kbmod, vk}; use crate::action::{self, Action, Binding}; use crate::fileops; -use crate::job::{Decision, Job, JobKind}; +use crate::job::{self, Decision, Job, JobKind}; use crate::panel::{Panel, SortBy}; use crate::platform; use crate::preview::{self, Preview}; use crate::settings; use crate::theme::Theme; +use crate::vfs; // ── Constants ────────────────────────────────────────────────────────────── @@ -148,6 +149,10 @@ pub struct State { pub job: Option, /// Set after an external program returns, to force a full screen repaint. repaint_requested: bool, + /// Set by [`Action::ShowUserScreen`]; consumed by the main loop. + user_screen_requested: bool, + /// Local file to open when the running download job finishes. + pending_open: Option, } // ── Construction ──────────────────────────────────────────────────────────── @@ -193,6 +198,8 @@ impl State { last_click: None, job: None, repaint_requested: false, + user_screen_requested: false, + pending_open: None, }; if let Some(s) = settings::Settings::load() { @@ -243,6 +250,8 @@ impl State { last_click: None, job: None, repaint_requested: false, + user_screen_requested: false, + pending_open: None, } } @@ -292,7 +301,7 @@ impl State { Action::OpenOrEnter => self.open_or_enter(), Action::ParentDir => { // Go up one directory (Backspace). - if self.active_panel().path.parent().is_some() { + if vfs::parent(&self.active_panel().path).is_some() { self.active_panel_mut().cursor = 0; // ".." is always first self.open_or_enter(); } @@ -382,6 +391,9 @@ impl State { Action::ChangeRoot => self.open_choose_root(), Action::DirHistory => self.open_dir_history(), Action::CmdHistory => self.open_cmd_history(), + // The switch blocks on input, so it runs from the main loop at a + // frame boundary rather than from inside input/draw handling. + Action::ShowUserScreen => self.user_screen_requested = true, Action::FocusMenu => self.want_menu_focus = true, Action::Quit => { self.dialog = Dialog::ConfirmQuit { @@ -398,18 +410,54 @@ impl State { } pub fn open_or_enter(&mut self) { - if let Some(entry) = self.active_panel().current_entry() { - if entry.is_dir { - self.active_panel_mut().enter(); - let path = self.active_panel().path.clone(); - self.record_dir_change(&path); - } else { - let path = self.active_panel().path.join(&entry.name); - if let Err(msg) = platform::open_file(&path) { - self.dialog = Dialog::Error { message: msg }; + let Some(entry) = self.active_panel().current_entry() else { + return; + }; + let is_dir = entry.is_dir; + let name = entry.name.clone(); + if is_dir { + self.active_panel_mut().enter(); + let path = self.active_panel().path.clone(); + self.record_dir_change(&path); + return; + } + let path = vfs::join(&self.active_panel().path, &name); + if vfs::is_remote(&path) { + // Remote files are downloaded to a temporary target first and + // opened when the job finishes. + self.download_and_open(path, &name); + } else if let Err(msg) = platform::open_file(&path) { + self.dialog = Dialog::Error { message: msg }; + } + } + + /// Download a remote file into the temporary directory and open it with + /// the system-associated application on completion. + pub fn download_and_open(&mut self, src: PathBuf, name: &str) { + let host_tag: String = src + .to_string_lossy() + .strip_prefix(vfs::SCHEME) + .unwrap_or_default() + .chars() + .take_while(|c| *c != '/') + .map(|c| { + if c.is_alphanumeric() || c == '.' || c == '-' { + c + } else { + '_' } - } + }) + .collect(); + let dir = std::env::temp_dir().join("ruf4-remote").join(host_tag); + if let Err(e) = std::fs::create_dir_all(&dir) { + self.dialog = Dialog::Error { + message: format!("Cannot create {}: {e}", dir.display()), + }; + return; } + let target = dir.join(name); + self.pending_open = Some(target.clone()); + self.start_job(job::spawn_download(src, target)); } pub fn record_dir_change(&mut self, path: &Path) { @@ -466,7 +514,13 @@ impl State { } pub fn open_choose_root(&mut self) { - let roots = platform::discover_roots(); + let mut roots = platform::discover_roots(); + for root in vfs::smb_roots() { + if !roots.contains(&root) { + roots.push(root); + } + } + roots.extend(vfs::ssh_roots()); if roots.is_empty() { return; } @@ -543,8 +597,21 @@ impl State { /// keyboard Enter and mouse double-click to avoid duplication. fn choose_root(&mut self, path: PathBuf) { self.dialog = Dialog::None; - self.record_dir_change(&path); - self.active_panel_mut().navigate_to(path); + self.navigate_panel(path); + } + + /// Navigate the active panel to `raw`, resolving it through the vfs first + /// (for remote paths this connects, prompting for authentication when + /// needed). Failures surface as an error dialog. + pub fn navigate_panel(&mut self, raw: PathBuf) { + match vfs::prepare_dir(&raw) { + Ok(dir) => { + self.record_dir_change(&dir); + self.active_panel_mut().navigate_to(dir); + self.request_repaint(); + } + Err(message) => self.dialog = Dialog::Error { message }, + } } // ── Layout feedback from draw pass ────────────────────────────────── @@ -603,6 +670,26 @@ impl State { std::mem::take(&mut self.repaint_requested) } + /// Consume a pending user-screen request. + pub fn take_user_screen_request(&mut self) -> bool { + std::mem::take(&mut self.user_screen_requested) + } + + /// Keys that end the user-screen view: every key bound to + /// [`Action::ShowUserScreen`], plus Escape. + pub fn user_screen_exit_keys(&self) -> Vec { + let mut keys: Vec = self + .bindings + .iter() + .filter(|b| b.action == Action::ShowUserScreen) + .map(|b| b.key) + .collect(); + if !keys.contains(&vk::ESCAPE) { + keys.push(vk::ESCAPE); + } + keys + } + /// Start a background operation and show its progress dialog. pub fn start_job(&mut self, job: Job) { self.dialog = Dialog::Progress { @@ -632,7 +719,7 @@ impl State { if let Some(errors) = finished { self.job = None; - self.complete_job(kind, errors); + self.complete_job(kind, errors, cancelling); return; } @@ -657,10 +744,33 @@ impl State { } } - fn complete_job(&mut self, kind: JobKind, errors: Vec) { + fn complete_job(&mut self, kind: JobKind, errors: Vec, cancelled: bool) { match kind { JobKind::Delete => fileops::finish_operation(self, errors, true), JobKind::Copy | JobKind::Move => fileops::finish_operation(self, errors, false), + JobKind::Download => { + let target = self.pending_open.take(); + if !errors.is_empty() { + self.dialog = Dialog::Error { + message: errors.join("\n"), + }; + return; + } + self.dialog = Dialog::None; + if cancelled { + // A cancelled transfer leaves a partial target; discard it + // instead of opening it. + if let Some(target) = target { + let _ = std::fs::remove_file(&target); + } + return; + } + if let Some(target) = target + && let Err(msg) = platform::open_file(&target) + { + self.dialog = Dialog::Error { message: msg }; + } + } } } @@ -853,7 +963,7 @@ impl State { }; let hidden_label = if panel.show_hidden { "[H]" } else { "[ ]" }; let free = panel - .free_space() + .free_space .map(crate::panel::format_size) .unwrap_or_else(|| "N/A".to_string()); let refreshed = &panel.last_refresh; @@ -1222,7 +1332,7 @@ impl State { } ListSelectKind::DirHistory { entries } => { if let Some(path) = entries.into_iter().nth(cursor) { - self.active_panel_mut().navigate_to(path); + self.navigate_panel(path); } } ListSelectKind::CmdHistory { entries } => { diff --git a/crates/ruf4/src/vfs.rs b/crates/ruf4/src/vfs.rs new file mode 100644 index 0000000..68c884c --- /dev/null +++ b/crates/ruf4/src/vfs.rs @@ -0,0 +1,1149 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! Virtual filesystem dispatch. +//! +//! Panel paths are `PathBuf`s throughout the application. Local paths behave +//! as before; paths of the form `ssh://[user@]host[:port]/path` address a +//! remote filesystem reached through the SFTP client in [`crate::sftp`]. The +//! functions here mirror the `std::fs` calls the application needs and route +//! each one to the local filesystem or to a cached per-host SFTP connection. +//! +//! Transport: a spawned `ssh -s sftp` subsystem, so key +//! handling, agents, and `~/.ssh/config` all behave exactly like the user's +//! `ssh`. On Unix a connection master (`ControlMaster`) is bootstrapped +//! interactively with the TUI suspended, and every channel attaches to its +//! socket without re-authenticating. On Windows multiplexing is unavailable; +//! channels authenticate non-interactively (keys or agent). +//! +//! `smb://` locations resolve to local directories through [`crate::smb`] +//! (the operating system's native SMB client); after navigation they are +//! ordinary local paths. +//! TODO: interactive ssh authentication on Windows. + +use std::collections::HashMap; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, SystemTime}; + +use crate::panel::{self, FileEntry}; +use crate::platform; +use crate::sftp; + +pub const SCHEME: &str = "ssh://"; + +// ── Remote path model ─────────────────────────────────────────────────────── + +/// A remote SSH destination. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct HostSpec { + pub user: Option, + pub host: String, + pub port: Option, +} + +impl HostSpec { + /// URL prefix form, e.g. `ssh://user@host:2222`. IPv6 hosts are bracketed. + pub fn display(&self) -> String { + let mut s = String::from(SCHEME); + if let Some(user) = &self.user { + s.push_str(user); + s.push('@'); + } + if self.host.contains(':') { + s.push('['); + s.push_str(&self.host); + s.push(']'); + } else { + s.push_str(&self.host); + } + if let Some(port) = self.port { + s.push(':'); + s.push_str(&port.to_string()); + } + s + } + + /// Destination argument for `ssh`: `user@host` or `host`. + fn ssh_dest(&self) -> String { + match &self.user { + Some(user) => format!("{user}@{}", self.host), + None => self.host.clone(), + } + } + + /// Options common to every `ssh` invocation for this destination. + /// `RUF4_SSH_CONFIG` overrides the client configuration file (`ssh -F`). + fn common_args(&self) -> Vec { + let mut args = Vec::new(); + if let Ok(config) = std::env::var("RUF4_SSH_CONFIG") + && !config.is_empty() + { + args.push("-F".into()); + args.push(config); + } + args.extend([ + "-o".into(), + "PermitLocalCommand=no".into(), + "-o".into(), + "ClearAllForwardings=yes".into(), + "-o".into(), + "ForwardX11=no".into(), + ]); + if let Some(port) = self.port { + args.push("-p".into()); + args.push(port.to_string()); + } + args + } +} + +/// A parsed `ssh://` path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemotePath { + pub host: HostSpec, + /// Absolute POSIX path on the remote side. Empty when the URL carries no + /// path; the remote home directory is substituted on first use. + pub path: String, +} + +impl RemotePath { + pub fn to_path_buf(&self) -> PathBuf { + PathBuf::from(format!("{}{}", self.host.display(), self.path)) + } +} + +/// Parse a URL authority `[user@]host[:port]`; IPv6 hosts are bracketed. +/// `None` for an empty host or a malformed bracket form. +pub(crate) fn parse_authority(authority: &str) -> Option<(Option, String, Option)> { + let (user, host_port) = match authority.rsplit_once('@') { + Some((u, h)) if !u.is_empty() => (Some(u.to_string()), h), + Some((_, h)) => (None, h), + None => (None, authority), + }; + let (host, port) = if let Some(bracketed) = host_port.strip_prefix('[') { + // IPv6 literal: [addr] or [addr]:port. + let (host, tail) = bracketed.split_once(']')?; + let port = match tail.strip_prefix(':') { + Some(p) => Some(p.parse().ok()?), + None if tail.is_empty() => None, + None => return None, + }; + (host.to_string(), port) + } else { + match host_port.rsplit_once(':') { + Some((h, p)) => match p.parse::() { + Ok(port) => (h.to_string(), Some(port)), + Err(_) => (host_port.to_string(), None), + }, + None => (host_port.to_string(), None), + } + }; + if host.is_empty() { + return None; + } + Some((user, host, port)) +} + +/// Parse `ssh://[user@]host[:port]/path`. `None` for local paths and for +/// malformed remote ones. +pub fn parse_remote(path: &Path) -> Option { + let s = path.to_str()?; + let rest = s.strip_prefix(SCHEME)?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + let (user, host, port) = parse_authority(authority)?; + Some(RemotePath { + host: HostSpec { user, host, port }, + path: path.to_string(), + }) +} + +pub fn is_remote(path: &Path) -> bool { + parse_remote(path).is_some() +} + +/// Whether `s` is a location URL in one of the vfs schemes. +pub fn is_url(s: &str) -> bool { + s.starts_with(SCHEME) || s.starts_with(crate::smb::SCHEME) +} + +/// Collapse `//`, `.`, and `..` in a POSIX path, lexically. +pub fn normalize_posix(path: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for comp in path.split('/') { + match comp { + "" | "." => {} + ".." => { + parts.pop(); + } + c => parts.push(c), + } + } + let mut out = String::from("/"); + out.push_str(&parts.join("/")); + out +} + +fn posix_join(dir: &str, name: &str) -> String { + if dir.ends_with('/') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} + +/// Append `name` to a directory path, dispatching on scheme. +pub fn join(dir: &Path, name: &str) -> PathBuf { + match parse_remote(dir) { + Some(r) => { + let joined = if name.starts_with('/') { + name.to_string() + } else { + posix_join(&r.path, name) + }; + RemotePath { + host: r.host, + path: normalize_posix(&joined), + } + .to_path_buf() + } + None => dir.join(name), + } +} + +/// Parent directory, dispatching on scheme. Remote roots (and `/`) have none. +pub fn parent(path: &Path) -> Option { + match parse_remote(path) { + Some(r) => { + let p = normalize_posix(&r.path); + if p == "/" { + return None; + } + let parent = match p.rfind('/') { + Some(0) => "/".to_string(), + Some(i) => p[..i].to_string(), + None => return None, + }; + Some( + RemotePath { + host: r.host, + path: parent, + } + .to_path_buf(), + ) + } + None => path.parent().map(Path::to_path_buf), + } +} + +// ── Connections ───────────────────────────────────────────────────────────── + +/// SFTP channel over the stdio of a spawned `ssh` subsystem process. +struct ChildTransport { + child: Child, + stdin: ChildStdin, + stdout: ChildStdout, +} + +impl Read for ChildTransport { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.stdout.read(buf) + } +} + +impl Write for ChildTransport { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.stdin.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.stdin.flush() + } +} + +impl Drop for ChildTransport { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +type Conn = sftp::Client; + +static CONNECTIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn connections() -> MutexGuard<'static, HashMap> { + CONNECTIONS.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Multiplexing socket template; `%C` hashes host, port, and user. The +/// socket lives in the runtime directory when available, else `~/.ssh` +/// (created on demand), else `/tmp`. `TMPDIR` is not used: on macOS it is +/// deep enough to overflow the 104-byte socket-path limit once `ssh` appends +/// its temporary rendezvous suffix. +#[cfg(unix)] +fn control_path() -> String { + let dir = 'dir: { + if let Ok(d) = std::env::var("XDG_RUNTIME_DIR") + && !d.is_empty() + { + break 'dir PathBuf::from(d); + } + let ssh_dir = platform::home_dir().join(".ssh"); + if ssh_dir.is_dir() { + break 'dir ssh_dir; + } + if fs::create_dir_all(&ssh_dir).is_ok() { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&ssh_dir, fs::Permissions::from_mode(0o700)); + break 'dir ssh_dir; + } + PathBuf::from("/tmp") + }; + format!("{}/ruf4-%C", dir.display()) +} + +/// Ensure a connection master for `host` is running, prompting for +/// authentication through a suspended TUI when needed. +#[cfg(unix)] +fn ensure_master(host: &HostSpec) -> Result<(), String> { + let control = format!("ControlPath={}", control_path()); + + let check = Command::new("ssh") + .args(host.common_args()) + .args(["-o", &control, "-O", "check"]) + .arg(host.ssh_dest()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + if matches!(check, Ok(st) if st.success()) { + return Ok(()); + } + + // Start a master in the foreground so host-key and password prompts reach + // the user; `-f -N` backgrounds it once authenticated. + let mut cmd = Command::new("ssh"); + cmd.args(host.common_args()); + cmd.args(["-o", &control]); + cmd.args(["-o", "ControlMaster=auto", "-o", "ControlPersist=600"]); + cmd.args(["-f", "-N"]); + cmd.arg(host.ssh_dest()); + let display = format!("ssh {}", host.ssh_dest()); + match platform::run_foreground(&mut cmd, &display, false) { + Ok(st) if st.success() => Ok(()), + Ok(_) => Err(format!("Cannot connect to {}", host.display())), + Err(e) => Err(e), + } +} + +/// Multiplexing is unavailable; channels authenticate on their own with keys +/// or an agent. TODO: interactive authentication on Windows. +#[cfg(not(unix))] +fn ensure_master(_host: &HostSpec) -> Result<(), String> { + Ok(()) +} + +/// Spawn one SFTP channel. Never prompts: with a live master the channel +/// attaches to its socket; otherwise `BatchMode` restricts authentication to +/// keys and agents so a broken setup fails fast instead of hanging. +fn spawn_channel(host: &HostSpec) -> io::Result { + let mut cmd = Command::new("ssh"); + cmd.args(host.common_args()); + cmd.args(["-o", "BatchMode=yes"]); + #[cfg(unix)] + { + cmd.arg("-o").arg(format!("ControlPath={}", control_path())); + cmd.args(["-o", "ControlMaster=no"]); + } + cmd.arg("-s").arg(host.ssh_dest()).arg("sftp"); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::null()); + let mut child = cmd.spawn()?; + let stdin = child.stdin.take().expect("stdin is piped"); + let stdout = child.stdout.take().expect("stdout is piped"); + Ok(ChildTransport { + child, + stdin, + stdout, + }) +} + +fn is_transport_error(e: &io::Error) -> bool { + matches!( + e.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::UnexpectedEof + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::WriteZero + ) +} + +/// Run `f` against the cached connection for `host`, opening a channel first +/// when none exists. A transport-level failure drops the connection and +/// retries once on a fresh channel, so an expired channel heals transparently. +fn with_conn(host: &HostSpec, mut f: impl FnMut(&mut Conn) -> io::Result) -> io::Result { + let mut map = connections(); + let mut retried = false; + loop { + if !map.contains_key(host) { + let transport = spawn_channel(host)?; + let client = sftp::Client::new(transport).map_err(|e| { + io::Error::new(e.kind(), format!("sftp channel to {}: {e}", host.display())) + })?; + map.insert(host.clone(), client); + } + let conn = map.get_mut(host).expect("inserted above"); + match f(conn) { + Err(e) if !retried && is_transport_error(&e) => { + map.remove(host); + retried = true; + } + result => return result, + } + } +} + +/// Close a remote handle on the cached connection, if it still exists. +fn close_handle(host: &HostSpec, handle: &[u8]) { + let mut map = connections(); + if let Some(conn) = map.get_mut(host) { + let _ = conn.close(handle); + } +} + +/// Bootstrap connectivity for `path`'s host when it is remote, prompting for +/// authentication through a suspended TUI as needed. No-op for local paths. +/// Workers must not authenticate interactively, so jobs touching a remote +/// destination call this on the UI thread before spawning. +pub fn ensure_host(path: &Path) -> Result<(), String> { + match parse_remote(path) { + Some(r) => ensure_master(&r.host), + None => Ok(()), + } +} + +// ── Navigation ────────────────────────────────────────────────────────────── + +/// Resolve `path` to a directory the panel can show. Local paths are +/// canonicalized and verified; `smb://` locations resolve to a local +/// mountpoint (mounting the share first when needed); `ssh://` paths +/// bootstrap the connection (interactively when authentication is required), +/// substitute the remote home for an empty path, and are verified to be +/// directories. +pub fn prepare_dir(path: &Path) -> Result { + if let Some(smb) = crate::smb::parse(path) { + let dir = crate::smb::resolve_dir(&smb)?; + return if dir.is_dir() { + Ok(dir) + } else { + Err(format!("Not a directory: {}", dir.display())) + }; + } + match parse_remote(path) { + Some(r) => { + ensure_master(&r.host)?; + let host = r.host.clone(); + let resolved = with_conn(&host, |c| { + let p = if r.path.is_empty() { + c.realpath(".")? + } else { + normalize_posix(&r.path) + }; + let attrs = c.stat(&p)?; + if !attrs.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "not a directory", + )); + } + Ok(p) + }) + .map_err(|e| format!("{}: {e}", r.host.display()))?; + Ok(RemotePath { + host, + path: normalize_posix(&resolved), + } + .to_path_buf()) + } + None => { + let dest = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if dest.is_dir() { + return Ok(dest); + } + // A managed SMB mountpoint may simply be unmounted; remount it. + match crate::smb::try_remount(&dest) { + Some(Ok(())) if dest.is_dir() => Ok(dest), + Some(Err(message)) => Err(message), + _ => Err(format!("Not a directory: {}", dest.display())), + } + } + } +} + +/// SMB mountpoints for the roots dialog. +pub fn smb_roots() -> Vec { + crate::smb::mounted_roots() +} + +// ── Directory listing ─────────────────────────────────────────────────────── + +/// List a directory as panel entries, dispatching on scheme. A failed remote +/// listing degrades to `..` so the panel stays navigable. +pub fn scan_dir(path: &Path, show_hidden: bool) -> Vec { + match parse_remote(path) { + Some(r) => scan_dir_remote(&r, show_hidden).unwrap_or_else(|_| { + let mut entries = Vec::new(); + if normalize_posix(&r.path) != "/" { + entries.push(panel::make_entry("..", true, 0)); + } + entries + }), + None => panel::scan_dir(path, show_hidden), + } +} + +fn scan_dir_remote(r: &RemotePath, show_hidden: bool) -> io::Result> { + with_conn(&r.host, |c| { + let mut entries = Vec::new(); + if normalize_posix(&r.path) != "/" { + entries.push(panel::make_entry("..", true, 0)); + } + for de in c.read_dir(&r.path)? { + if de.name == "." || de.name == ".." { + continue; + } + if !show_hidden && de.name.starts_with('.') { + continue; + } + let is_symlink = de.attrs.is_symlink(); + let mut attrs = de.attrs; + if is_symlink { + // Follow the link for display metadata, as the local scan does. + if let Ok(followed) = c.stat(&posix_join(&r.path, &de.name)) { + attrs = followed; + } + } + entries.push(remote_entry(de.name, is_symlink, &attrs)); + } + Ok(entries) + }) +} + +/// Panel entry from remote attributes. When the entry is a symlink, `attrs` +/// describes the link target where resolution succeeded. +pub fn remote_entry(name: String, is_symlink: bool, attrs: &sftp::Attrs) -> FileEntry { + let is_dir = attrs.is_dir(); + let perms = attrs.permissions.unwrap_or(0); + FileEntry { + is_hidden: name.starts_with('.'), + name, + is_dir, + is_symlink, + is_hardlink: false, + is_executable: !is_dir && perms & 0o111 != 0, + is_readonly: attrs.permissions.is_some_and(|p| p & 0o200 == 0), + size: attrs.size.unwrap_or(0), + modified: attrs + .times + .map(|(_, mtime)| SystemTime::UNIX_EPOCH + Duration::from_secs(mtime as u64)), + selected: false, + } +} + +/// Child names with a directory marker, for the quick-view listing. +pub fn list_names(path: &Path) -> io::Result> { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| { + Ok(c.read_dir(&r.path)? + .into_iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| (e.name, e.attrs.is_dir())) + .collect()) + }), + None => { + let mut out = Vec::new(); + for entry in fs::read_dir(path)? { + let entry = entry?; + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + out.push((entry.file_name().to_string_lossy().into_owned(), is_dir)); + } + Ok(out) + } + } +} + +// ── Metadata ──────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + File, + Dir, + Symlink, +} + +#[derive(Clone, Copy, Debug)] +pub struct Meta { + pub kind: Kind, + pub size: u64, +} + +fn meta_from_attrs(attrs: &sftp::Attrs) -> Meta { + let kind = if attrs.is_symlink() { + Kind::Symlink + } else if attrs.is_dir() { + Kind::Dir + } else { + Kind::File + }; + Meta { + kind, + size: attrs.size.unwrap_or(0), + } +} + +/// lstat-style metadata (symlinks are not followed). +pub fn symlink_meta(path: &Path) -> io::Result { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| Ok(meta_from_attrs(&c.lstat(&r.path)?))), + None => { + let meta = fs::symlink_metadata(path)?; + let ft = meta.file_type(); + let kind = if ft.is_symlink() { + Kind::Symlink + } else if ft.is_dir() { + Kind::Dir + } else { + Kind::File + }; + Ok(Meta { + kind, + size: meta.len(), + }) + } + } +} + +/// Whether `path` exists, following symlinks. +pub fn exists(path: &Path) -> bool { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| c.stat(&r.path)).is_ok(), + None => path.exists(), + } +} + +/// Whether `path` is a directory, following symlinks. +pub fn is_dir(path: &Path) -> bool { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| c.stat(&r.path)) + .map(|a| a.is_dir()) + .unwrap_or(false), + None => path.is_dir(), + } +} + +/// Whether the two paths refer to the same file. +pub fn same_file(a: &Path, b: &Path) -> bool { + match (parse_remote(a), parse_remote(b)) { + (None, None) => match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(ca), Ok(cb)) => ca == cb, + _ => false, + }, + (Some(x), Some(y)) => { + x.host == y.host && normalize_posix(&x.path) == normalize_posix(&y.path) + } + _ => false, + } +} + +/// Whether a rename between the two paths can possibly succeed (local↔local +/// or the same remote host). Local renames may still fail across devices; +/// callers keep that fallback. +pub fn same_domain(a: &Path, b: &Path) -> bool { + match (parse_remote(a), parse_remote(b)) { + (None, None) => true, + (Some(x), Some(y)) => x.host == y.host, + _ => false, + } +} + +/// Whether the directory `outer` contains (or equals) `inner`, even when +/// `inner` does not exist yet. Guards copies of a directory into itself. +pub fn dir_contains(outer: &Path, inner: &Path) -> bool { + match (parse_remote(outer), parse_remote(inner)) { + (None, None) => match ( + fs::canonicalize(outer), + crate::fileops::normalize_against_existing(inner), + ) { + (Ok(co), Ok(ci)) => ci.starts_with(&co), + _ => false, + }, + (Some(a), Some(b)) if a.host == b.host => { + let outer = normalize_posix(&a.path); + let inner = normalize_posix(&b.path); + inner == outer || outer == "/" || inner.starts_with(&format!("{outer}/")) + } + _ => false, + } +} + +/// Available bytes on the filesystem holding `path`, if known. +pub fn free_space(path: &Path) -> Option { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| c.statvfs_avail(&r.path)) + .ok() + .flatten(), + None => platform::disk_free(path), + } +} + +/// Count files and total bytes under `path`; the path itself counts as one +/// entry when it is a file or symlink. Used for progress totals. +pub fn scan_tree(path: &Path) -> (u64, u64) { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| Ok(scan_tree_conn(c, &r.path))).unwrap_or((1, 0)), + None => scan_tree_local(path), + } +} + +fn scan_tree_conn(c: &mut Conn, path: &str) -> (u64, u64) { + let Ok(attrs) = c.lstat(path) else { + return (1, 0); + }; + if attrs.is_symlink() { + return (1, 0); + } + if attrs.is_dir() { + let mut files = 0; + let mut bytes = 0; + if let Ok(entries) = c.read_dir(path) { + for e in entries { + if e.name == "." || e.name == ".." { + continue; + } + if e.attrs.is_dir() && !e.attrs.is_symlink() { + let (f, b) = scan_tree_conn(c, &posix_join(path, &e.name)); + files += f; + bytes += b; + } else if e.attrs.is_symlink() { + files += 1; + } else { + files += 1; + bytes += e.attrs.size.unwrap_or(0); + } + } + } + (files, bytes) + } else { + (1, attrs.size.unwrap_or(0)) + } +} + +fn scan_tree_local(path: &Path) -> (u64, u64) { + let meta = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(_) => return (1, 0), + }; + if meta.file_type().is_symlink() { + return (1, 0); + } + if meta.is_dir() { + let mut files = 0; + let mut bytes = 0; + if let Ok(rd) = fs::read_dir(path) { + for entry in rd.flatten() { + let (f, b) = scan_tree_local(&entry.path()); + files += f; + bytes += b; + } + } + (files, bytes) + } else { + (1, meta.len()) + } +} + +/// Directory children as (name, lstat metadata), one listing per directory. +pub fn read_dir_meta(path: &Path) -> io::Result> { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| { + Ok(c.read_dir(&r.path)? + .into_iter() + .filter(|e| e.name != "." && e.name != "..") + .map(|e| (e.name.clone(), meta_from_attrs(&e.attrs))) + .collect()) + }), + None => { + let mut out = Vec::new(); + for entry in fs::read_dir(path)? { + let entry = entry?; + let ft = entry.file_type()?; + let kind = if ft.is_symlink() { + Kind::Symlink + } else if ft.is_dir() { + Kind::Dir + } else { + Kind::File + }; + let size = if kind == Kind::File { + entry.metadata().map(|m| m.len()).unwrap_or(0) + } else { + 0 + }; + out.push(( + entry.file_name().to_string_lossy().into_owned(), + Meta { kind, size }, + )); + } + Ok(out) + } + } +} + +// ── Mutations ─────────────────────────────────────────────────────────────── + +/// Create a directory and any missing ancestors. +pub fn create_dir_all(path: &Path) -> io::Result<()> { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| { + let normalized = normalize_posix(&r.path); + let mut cur = String::new(); + for comp in normalized.split('/').filter(|c| !c.is_empty()) { + cur.push('/'); + cur.push_str(comp); + match c.stat(&cur) { + Ok(attrs) if attrs.is_dir() => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{cur}: exists and is not a directory"), + )); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => c.mkdir(&cur)?, + Err(e) => return Err(e), + } + } + Ok(()) + }), + None => fs::create_dir_all(path), + } +} + +/// Rename within one filesystem domain (see [`same_domain`]). +pub fn rename(src: &Path, dst: &Path) -> io::Result<()> { + match (parse_remote(src), parse_remote(dst)) { + (None, None) => fs::rename(src, dst), + (Some(a), Some(b)) if a.host == b.host => { + with_conn(&a.host, |c| c.rename(&a.path, &b.path)) + } + _ => Err(io::Error::new( + io::ErrorKind::Unsupported, + "rename across filesystem domains", + )), + } +} + +/// Remove a file, symlink, or directory tree. +pub fn remove_tree(path: &Path) -> io::Result<()> { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| remove_tree_conn(c, &r.path)), + None => { + let is_symlink = fs::symlink_metadata(path) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + // Symlinks are removed as links, never following the target. + if is_symlink { + platform::remove_symlink(path) + } else if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } + } + } +} + +fn remove_tree_conn(c: &mut Conn, path: &str) -> io::Result<()> { + let attrs = c.lstat(path)?; + if attrs.is_dir() && !attrs.is_symlink() { + for e in c.read_dir(path)? { + if e.name == "." || e.name == ".." { + continue; + } + remove_tree_conn(c, &posix_join(path, &e.name))?; + } + c.rmdir(path) + } else { + c.remove(path) + } +} + +/// Symlink target as a string, without following. +pub fn read_link(path: &Path) -> io::Result { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| c.read_link(&r.path)), + None => Ok(fs::read_link(path)?.to_string_lossy().into_owned()), + } +} + +/// Create a symlink at `link` pointing to `target`. +pub fn symlink(link: &Path, target: &str) -> io::Result<()> { + match parse_remote(link) { + Some(r) => with_conn(&r.host, |c| c.symlink(target, &r.path)), + None => { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link) + } + #[cfg(windows)] + { + // The target's type is unknown here. TODO: directory symlinks + // on Windows for cross-domain copies. + std::os::windows::fs::symlink_file(target, link) + } + #[cfg(not(any(unix, windows)))] + { + std::os::unix::fs::symlink(target, link) + } + } + } +} + +/// Recreate the symlink `src` at `dst` with the same target. +pub fn copy_symlink(src: &Path, dst: &Path) -> io::Result<()> { + match (parse_remote(src), parse_remote(dst)) { + (None, None) => platform::copy_symlink(src, dst), + _ => { + let target = read_link(src)?; + symlink(dst, &target) + } + } +} + +// ── File I/O ──────────────────────────────────────────────────────────────── + +struct RemoteReader { + host: HostSpec, + handle: Vec, + offset: u64, + eof: bool, +} + +impl Read for RemoteReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.eof || buf.is_empty() { + return Ok(0); + } + let want = buf.len().min(sftp::MAX_DATA) as u32; + let data = with_conn(&self.host, |c| c.read(&self.handle, self.offset, want))?; + match data { + None => { + self.eof = true; + Ok(0) + } + Some(d) => { + if d.len() > buf.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sftp: server returned more data than requested", + )); + } + if d.is_empty() { + self.eof = true; + return Ok(0); + } + buf[..d.len()].copy_from_slice(&d); + self.offset += d.len() as u64; + Ok(d.len()) + } + } + } +} + +impl Drop for RemoteReader { + fn drop(&mut self) { + close_handle(&self.host, &self.handle); + } +} + +struct RemoteWriter { + host: HostSpec, + handle: Vec, + offset: u64, +} + +impl Write for RemoteWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + let n = buf.len().min(sftp::MAX_DATA); + with_conn(&self.host, |c| { + c.write(&self.handle, self.offset, &buf[..n]) + })?; + self.offset += n as u64; + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for RemoteWriter { + fn drop(&mut self) { + close_handle(&self.host, &self.handle); + } +} + +/// Open `path` for sequential reading. +pub fn open_read(path: &Path) -> io::Result> { + match parse_remote(path) { + Some(r) => { + let handle = with_conn(&r.host, |c| c.open(&r.path, sftp::OPEN_READ))?; + Ok(Box::new(RemoteReader { + host: r.host, + handle, + offset: 0, + eof: false, + })) + } + None => Ok(Box::new(fs::File::open(path)?)), + } +} + +/// Create or truncate `path` for sequential writing. +pub fn open_write(path: &Path) -> io::Result> { + match parse_remote(path) { + Some(r) => { + let flags = sftp::OPEN_WRITE | sftp::OPEN_CREAT | sftp::OPEN_TRUNC; + let handle = with_conn(&r.host, |c| c.open(&r.path, flags))?; + Ok(Box::new(RemoteWriter { + host: r.host, + handle, + offset: 0, + })) + } + None => Ok(Box::new(fs::File::create(path)?)), + } +} + +/// Read up to `max` bytes from the start of the file, plus its total size. +pub fn read_prefix(path: &Path, max: usize) -> io::Result<(Vec, u64)> { + match parse_remote(path) { + Some(r) => with_conn(&r.host, |c| { + let attrs = c.stat(&r.path)?; + let size = attrs.size.unwrap_or(0); + let handle = c.open(&r.path, sftp::OPEN_READ)?; + let mut buf = Vec::with_capacity(max.min(size as usize)); + let result = loop { + if buf.len() >= max { + break Ok(()); + } + let want = (max - buf.len()).min(sftp::MAX_DATA) as u32; + match c.read(&handle, buf.len() as u64, want) { + Ok(Some(d)) if d.is_empty() => break Ok(()), + Ok(Some(d)) => buf.extend_from_slice(&d), + Ok(None) => break Ok(()), + Err(e) => break Err(e), + } + }; + let _ = c.close(&handle); + result.map(|()| (buf, size)) + }), + None => { + let size = fs::metadata(path)?.len(); + let file = fs::File::open(path)?; + let mut buf = Vec::new(); + file.take(max as u64).read_to_end(&mut buf)?; + Ok((buf, size)) + } + } +} + +// ── Remote shell commands ─────────────────────────────────────────────────── + +/// Quote `s` for a POSIX shell. +pub fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Run `cmd` on the remote host in the foreground with the TUI suspended, +/// with the panel path as the remote working directory. +pub fn run_remote_interactive(remote: &RemotePath, cmd: &str) -> Result<(), String> { + let mut command = Command::new("ssh"); + command.args(remote.host.common_args()); + #[cfg(unix)] + { + command + .arg("-o") + .arg(format!("ControlPath={}", control_path())); + command.args(["-o", "ControlMaster=auto", "-o", "ControlPersist=600"]); + } + command.arg("-t"); + command.arg(remote.host.ssh_dest()); + let remote_cmd = if remote.path.is_empty() { + cmd.to_string() + } else { + format!("cd {} && {}", shell_quote(&remote.path), cmd) + }; + command.arg(remote_cmd); + platform::run_foreground(&mut command, cmd, true).map(|_| ()) +} + +// ── SSH roots discovery ───────────────────────────────────────────────────── + +/// Concrete `Host` aliases from OpenSSH client configuration text. Wildcard +/// and negated patterns are skipped. TODO: honor Include directives. +pub fn ssh_config_hosts(text: &str) -> Vec { + let mut hosts: Vec = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // Keyword and arguments separate on whitespace or a single '='. + let (keyword, rest) = match line.split_once(['=', ' ', '\t']) { + Some((k, r)) => (k.trim(), r.trim()), + None => continue, + }; + if !keyword.eq_ignore_ascii_case("host") { + continue; + } + for pattern in rest.split_whitespace() { + let pattern = pattern.trim_matches('"'); + if pattern.is_empty() || pattern.contains(['*', '?']) || pattern.starts_with('!') { + continue; + } + if !hosts.iter().any(|h| h == pattern) { + hosts.push(pattern.to_string()); + } + } + } + hosts +} + +/// SSH destinations from the client configuration (`~/.ssh/config`, or +/// `RUF4_SSH_CONFIG` when set) as `ssh://host` roots. +pub fn ssh_roots() -> Vec { + let config = match std::env::var("RUF4_SSH_CONFIG") { + Ok(c) if !c.is_empty() => PathBuf::from(c), + _ => platform::home_dir().join(".ssh").join("config"), + }; + let Ok(text) = fs::read_to_string(&config) else { + return Vec::new(); + }; + ssh_config_hosts(&text) + .into_iter() + .map(|h| PathBuf::from(format!("{SCHEME}{h}"))) + .collect() +} diff --git a/crates/ruf4/tests/test_action.rs b/crates/ruf4/tests/test_action.rs index 35f240d..ccb15a0 100644 --- a/crates/ruf4/tests/test_action.rs +++ b/crates/ruf4/tests/test_action.rs @@ -9,8 +9,8 @@ use std::collections::HashSet; use ruf4::action::{ - ALL_ACTIONS, action_label, action_str, default_bindings, key_display_name, parse_action, - parse_key_name, + ALL_ACTIONS, Action, action_label, action_str, build_help_text, default_bindings, + key_display_name, lookup, parse_action, parse_key_name, }; use ruf4_tui::input::{kbmod, vk}; @@ -123,3 +123,28 @@ fn unknown_key_name_is_rejected() { assert!(parse_key_name("Nope").is_none()); assert!(parse_key_name("Ctrl+Nope").is_none()); } + +#[test] +fn ctrl_o_is_bound_to_show_user_screen_on_all_platforms() { + let bindings = default_bindings(); + assert_eq!( + lookup(&bindings, kbmod::CTRL | vk::O), + Some(Action::ShowUserScreen) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_copy_alternative_is_ctrl_c() { + let bindings = default_bindings(); + assert_eq!(lookup(&bindings, kbmod::CTRL | vk::C), Some(Action::Copy)); +} + +#[test] +fn help_lists_show_user_screen_with_its_key() { + let bindings = default_bindings(); + let help = build_help_text(&bindings); + assert!(help.iter().any(|(keys, label, _)| { + *label == action_label(Action::ShowUserScreen) && keys.contains("Ctrl+O") + })); +} diff --git a/crates/ruf4/tests/test_job.rs b/crates/ruf4/tests/test_job.rs index 845283d..1fe2869 100644 --- a/crates/ruf4/tests/test_job.rs +++ b/crates/ruf4/tests/test_job.rs @@ -191,3 +191,21 @@ fn state_delete_flow_runs_and_refreshes() { ); cleanup(&root); } + +#[test] +fn download_copies_and_overwrites_silently() { + let root = temp_dir(); + let src = root.join("src.txt"); + let dst = root.join("dst.txt"); + fs::write(&src, b"payload").unwrap(); + // The target exists; a download must not raise an overwrite prompt. + fs::write(&dst, b"old").unwrap(); + + let mut job = job::spawn_download(src.clone(), dst.clone()); + assert_eq!(job.kind.title(), "Downloading"); + let errors = drive(&mut job, no_overwrite); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(fs::read(&dst).unwrap(), b"payload"); + assert_eq!(fs::read(&src).unwrap(), b"payload"); + cleanup(&root); +} diff --git a/crates/ruf4/tests/test_sftp.rs b/crates/ruf4/tests/test_sftp.rs new file mode 100644 index 0000000..bcbf985 --- /dev/null +++ b/crates/ruf4/tests/test_sftp.rs @@ -0,0 +1,826 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! SFTP client tests against an in-memory mock server that implements the +//! v3 wire format over `Read + Write`. Locks the packet layout and the +//! client's request/response handling without a network. + +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::io::{self, Read, Write}; + +use ruf4::sftp::{Attrs, Client, MAX_DATA, OPEN_CREAT, OPEN_READ, OPEN_TRUNC, OPEN_WRITE}; + +// Packet types and status codes, spelled out independently of the client. +const FXP_INIT: u8 = 1; +const FXP_VERSION: u8 = 2; +const FXP_OPEN: u8 = 3; +const FXP_CLOSE: u8 = 4; +const FXP_READ: u8 = 5; +const FXP_WRITE: u8 = 6; +const FXP_LSTAT: u8 = 7; +const FXP_OPENDIR: u8 = 11; +const FXP_READDIR: u8 = 12; +const FXP_REMOVE: u8 = 13; +const FXP_MKDIR: u8 = 14; +const FXP_RMDIR: u8 = 15; +const FXP_REALPATH: u8 = 16; +const FXP_STAT: u8 = 17; +const FXP_RENAME: u8 = 18; +const FXP_READLINK: u8 = 19; +const FXP_SYMLINK: u8 = 20; +const FXP_STATUS: u8 = 101; +const FXP_HANDLE: u8 = 102; +const FXP_DATA: u8 = 103; +const FXP_NAME: u8 = 104; +const FXP_ATTRS: u8 = 105; +const FXP_EXTENDED: u8 = 200; +const FXP_EXTENDED_REPLY: u8 = 201; + +const FX_OK: u32 = 0; +const FX_EOF: u32 = 1; +const FX_NO_SUCH_FILE: u32 = 2; +const FX_FAILURE: u32 = 4; + +const ATTR_SIZE: u32 = 1; +const ATTR_PERMISSIONS: u32 = 4; +const ATTR_ACMODTIME: u32 = 8; + +/// Entries per READDIR response, small to exercise the client's chunk loop. +const READDIR_CHUNK: usize = 2; + +// ── Server-side wire helpers ──────────────────────────────────────────────── + +fn put_u32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_be_bytes()); +} + +fn put_u64(buf: &mut Vec, v: u64) { + buf.extend_from_slice(&v.to_be_bytes()); +} + +fn put_str(buf: &mut Vec, s: &[u8]) { + put_u32(buf, s.len() as u32); + buf.extend_from_slice(s); +} + +struct Cursor<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn u32(&mut self) -> u32 { + let v = u32::from_be_bytes(self.buf[self.pos..self.pos + 4].try_into().unwrap()); + self.pos += 4; + v + } + + fn u64(&mut self) -> u64 { + let v = u64::from_be_bytes(self.buf[self.pos..self.pos + 8].try_into().unwrap()); + self.pos += 8; + v + } + + fn bytes(&mut self) -> &'a [u8] { + let len = self.u32() as usize; + let s = &self.buf[self.pos..self.pos + len]; + self.pos += len; + s + } + + fn string(&mut self) -> String { + String::from_utf8(self.bytes().to_vec()).unwrap() + } +} + +fn attr_bytes(size: Option, perms: u32, mtime: u32) -> Vec { + let mut b = Vec::new(); + let mut flags = ATTR_PERMISSIONS | ATTR_ACMODTIME; + if size.is_some() { + flags |= ATTR_SIZE; + } + put_u32(&mut b, flags); + if let Some(size) = size { + put_u64(&mut b, size); + } + put_u32(&mut b, perms); + put_u32(&mut b, mtime); // atime + put_u32(&mut b, mtime); + b +} + +// ── Mock server ───────────────────────────────────────────────────────────── + +enum Handle { + Dir(VecDeque<(String, Vec)>), + File(String), +} + +struct MockServer { + dirs: BTreeSet, + files: BTreeMap>, + symlinks: BTreeMap, + advertise_posix_rename: bool, + handles: HashMap, + next_handle: u32, + inbox: Vec, + outbox: VecDeque, +} + +impl MockServer { + fn new() -> Self { + let mut s = Self { + dirs: BTreeSet::new(), + files: BTreeMap::new(), + symlinks: BTreeMap::new(), + advertise_posix_rename: true, + handles: HashMap::new(), + next_handle: 0, + inbox: Vec::new(), + outbox: VecDeque::new(), + }; + s.dirs.insert("/".to_string()); + s.dirs.insert("/home".to_string()); + s.dirs.insert("/home/u".to_string()); + s.files + .insert("/home/u/hello.txt".to_string(), b"hello world".to_vec()); + s.files.insert("/home/u/run.sh".to_string(), b"#!".to_vec()); + s.symlinks + .insert("/home/u/link".to_string(), "/home/u/hello.txt".to_string()); + s + } + + fn respond(&mut self, ptype: u8, body: &[u8]) { + let mut pkt = Vec::with_capacity(body.len() + 5); + put_u32(&mut pkt, body.len() as u32 + 1); + pkt.push(ptype); + pkt.extend_from_slice(body); + self.outbox.extend(pkt); + } + + fn status(&mut self, id: u32, code: u32, msg: &str) { + let mut b = Vec::new(); + put_u32(&mut b, id); + put_u32(&mut b, code); + put_str(&mut b, msg.as_bytes()); + put_str(&mut b, b""); + self.respond(FXP_STATUS, &b); + } + + fn handle_reply(&mut self, id: u32, handle: Handle) { + let hid = self.next_handle; + self.next_handle += 1; + self.handles.insert(hid, handle); + let mut b = Vec::new(); + put_u32(&mut b, id); + put_str(&mut b, &hid.to_be_bytes()); + self.respond(FXP_HANDLE, &b); + } + + fn attrs_of(&self, path: &str) -> Option> { + if self.dirs.contains(path) { + Some(attr_bytes(None, 0o040755, 1_700_000_000)) + } else if let Some(data) = self.files.get(path) { + let perms = if path.ends_with(".sh") { + 0o100755 + } else { + 0o100644 + }; + Some(attr_bytes(Some(data.len() as u64), perms, 1_700_000_000)) + } else if self.symlinks.contains_key(path) { + Some(attr_bytes(None, 0o120777, 1_700_000_000)) + } else { + None + } + } + + fn resolve(&self, path: &str) -> String { + match self.symlinks.get(path) { + Some(target) => target.clone(), + None => path.to_string(), + } + } + + fn listing(&self, dir: &str) -> VecDeque<(String, Vec)> { + let prefix = if dir == "/" { + "/".to_string() + } else { + format!("{dir}/") + }; + let child_of = |p: &str| { + p.strip_prefix(&prefix) + .filter(|rest| !rest.is_empty() && !rest.contains('/')) + .map(str::to_string) + }; + let mut out = VecDeque::new(); + out.push_back((".".to_string(), attr_bytes(None, 0o040755, 0))); + out.push_back(("..".to_string(), attr_bytes(None, 0o040755, 0))); + for d in &self.dirs { + if let Some(name) = child_of(d) { + out.push_back((name, attr_bytes(None, 0o040755, 1_700_000_000))); + } + } + for (f, data) in &self.files { + if let Some(name) = child_of(f) { + let perms = if f.ends_with(".sh") { + 0o100755 + } else { + 0o100644 + }; + out.push_back(( + name, + attr_bytes(Some(data.len() as u64), perms, 1_700_000_000), + )); + } + } + for l in self.symlinks.keys() { + if let Some(name) = child_of(l) { + out.push_back((name, attr_bytes(None, 0o120777, 1_700_000_000))); + } + } + out + } + + fn exists(&self, path: &str) -> bool { + self.dirs.contains(path) + || self.files.contains_key(path) + || self.symlinks.contains_key(path) + } + + fn process(&mut self) { + loop { + if self.inbox.len() < 4 { + return; + } + let len = u32::from_be_bytes(self.inbox[..4].try_into().unwrap()) as usize; + if self.inbox.len() < 4 + len { + return; + } + let packet: Vec = self.inbox.drain(..4 + len).collect(); + let ptype = packet[4]; + let body = &packet[5..]; + self.dispatch(ptype, body); + } + } + + fn dispatch(&mut self, ptype: u8, body: &[u8]) { + let mut c = Cursor { buf: body, pos: 0 }; + + if ptype == FXP_INIT { + let _version = c.u32(); + let mut b = Vec::new(); + put_u32(&mut b, 3); + if self.advertise_posix_rename { + put_str(&mut b, b"posix-rename@openssh.com"); + put_str(&mut b, b"1"); + } + put_str(&mut b, b"statvfs@openssh.com"); + put_str(&mut b, b"2"); + self.respond(FXP_VERSION, &b); + return; + } + + let id = c.u32(); + match ptype { + FXP_REALPATH => { + let path = c.string(); + let resolved = if path == "." { + "/home/u".to_string() + } else { + path + }; + let mut b = Vec::new(); + put_u32(&mut b, id); + put_u32(&mut b, 1); + put_str(&mut b, resolved.as_bytes()); + put_str(&mut b, b""); + b.extend_from_slice(&attr_bytes(None, 0o040755, 0)); + self.respond(FXP_NAME, &b); + } + FXP_STAT | FXP_LSTAT => { + let path = c.string(); + let lookup = if ptype == FXP_STAT { + self.resolve(&path) + } else { + path + }; + match self.attrs_of(&lookup) { + Some(attrs) => { + let mut b = Vec::new(); + put_u32(&mut b, id); + b.extend_from_slice(&attrs); + self.respond(FXP_ATTRS, &b); + } + None => self.status(id, FX_NO_SUCH_FILE, "no such file"), + } + } + FXP_OPENDIR => { + let path = c.string(); + if self.dirs.contains(&path) { + let listing = self.listing(&path); + self.handle_reply(id, Handle::Dir(listing)); + } else { + self.status(id, FX_NO_SUCH_FILE, "no such directory"); + } + } + FXP_READDIR => { + let hid = u32::from_be_bytes(c.bytes().try_into().unwrap()); + let chunk: Vec<(String, Vec)> = match self.handles.get_mut(&hid) { + Some(Handle::Dir(entries)) => { + let n = entries.len().min(READDIR_CHUNK); + entries.drain(..n).collect() + } + _ => { + self.status(id, FX_FAILURE, "bad handle"); + return; + } + }; + if chunk.is_empty() { + self.status(id, FX_EOF, "eof"); + return; + } + let mut b = Vec::new(); + put_u32(&mut b, id); + put_u32(&mut b, chunk.len() as u32); + for (name, attrs) in chunk { + put_str(&mut b, name.as_bytes()); + put_str(&mut b, b""); + b.extend_from_slice(&attrs); + } + self.respond(FXP_NAME, &b); + } + FXP_OPEN => { + let path = c.string(); + let pflags = c.u32(); + if pflags & OPEN_CREAT != 0 { + let entry = self.files.entry(path.clone()).or_default(); + if pflags & OPEN_TRUNC != 0 { + entry.clear(); + } + } else if !self.files.contains_key(&path) { + self.status(id, FX_NO_SUCH_FILE, "no such file"); + return; + } + self.handle_reply(id, Handle::File(path)); + } + FXP_READ => { + let hid = u32::from_be_bytes(c.bytes().try_into().unwrap()); + let offset = c.u64() as usize; + let len = c.u32() as usize; + let data = match self.handles.get(&hid) { + Some(Handle::File(path)) => self.files.get(path).cloned().unwrap_or_default(), + _ => { + self.status(id, FX_FAILURE, "bad handle"); + return; + } + }; + if offset >= data.len() { + self.status(id, FX_EOF, "eof"); + return; + } + let end = (offset + len).min(data.len()); + let mut b = Vec::new(); + put_u32(&mut b, id); + put_str(&mut b, &data[offset..end]); + self.respond(FXP_DATA, &b); + } + FXP_WRITE => { + let hid = u32::from_be_bytes(c.bytes().try_into().unwrap()); + let offset = c.u64() as usize; + let data = c.bytes().to_vec(); + match self.handles.get(&hid) { + Some(Handle::File(path)) => { + let path = path.clone(); + let file = self.files.get_mut(&path).unwrap(); + if file.len() < offset + data.len() { + file.resize(offset + data.len(), 0); + } + file[offset..offset + data.len()].copy_from_slice(&data); + self.status(id, FX_OK, ""); + } + _ => self.status(id, FX_FAILURE, "bad handle"), + } + } + FXP_CLOSE => { + let hid = u32::from_be_bytes(c.bytes().try_into().unwrap()); + self.handles.remove(&hid); + self.status(id, FX_OK, ""); + } + FXP_MKDIR => { + let path = c.string(); + if self.exists(&path) { + self.status(id, FX_FAILURE, "exists"); + } else { + self.dirs.insert(path); + self.status(id, FX_OK, ""); + } + } + FXP_RMDIR => { + let path = c.string(); + let prefix = format!("{path}/"); + let has_children = self.dirs.iter().any(|d| d.starts_with(&prefix)) + || self.files.keys().any(|f| f.starts_with(&prefix)) + || self.symlinks.keys().any(|l| l.starts_with(&prefix)); + if has_children { + self.status(id, FX_FAILURE, "not empty"); + } else if self.dirs.remove(&path) { + self.status(id, FX_OK, ""); + } else { + self.status(id, FX_NO_SUCH_FILE, "no such directory"); + } + } + FXP_REMOVE => { + let path = c.string(); + if self.files.remove(&path).is_some() || self.symlinks.remove(&path).is_some() { + self.status(id, FX_OK, ""); + } else { + self.status(id, FX_NO_SUCH_FILE, "no such file"); + } + } + FXP_RENAME => { + let old = c.string(); + let new = c.string(); + // v3 semantics: fail when the target exists. + if self.exists(&new) { + self.status(id, FX_FAILURE, "target exists"); + } else { + self.rename_entry(id, &old, &new); + } + } + FXP_READLINK => { + let path = c.string(); + match self.symlinks.get(&path) { + Some(target) => { + let mut b = Vec::new(); + put_u32(&mut b, id); + put_u32(&mut b, 1); + put_str(&mut b, target.as_bytes()); + put_str(&mut b, b""); + b.extend_from_slice(&attr_bytes(None, 0o120777, 0)); + self.respond(FXP_NAME, &b); + } + None => self.status(id, FX_NO_SUCH_FILE, "not a symlink"), + } + } + FXP_SYMLINK => { + // OpenSSH argument order: target first, then link path. + let target = c.string(); + let link = c.string(); + self.symlinks.insert(link, target); + self.status(id, FX_OK, ""); + } + FXP_EXTENDED => { + let name = c.string(); + match name.as_str() { + "posix-rename@openssh.com" => { + let old = c.string(); + let new = c.string(); + self.files.remove(&new); + self.rename_entry(id, &old, &new); + } + "statvfs@openssh.com" => { + let _path = c.string(); + let mut b = Vec::new(); + put_u32(&mut b, id); + for v in [4096u64, 4096, 1000, 600, 500, 100, 90, 80, 7, 0, 255] { + put_u64(&mut b, v); + } + self.respond(FXP_EXTENDED_REPLY, &b); + } + _ => self.status(id, FX_FAILURE, "unsupported extension"), + } + } + _ => self.status(id, FX_FAILURE, "unsupported request"), + } + } + + fn rename_entry(&mut self, id: u32, old: &str, new: &str) { + if let Some(data) = self.files.remove(old) { + self.files.insert(new.to_string(), data); + self.status(id, FX_OK, ""); + } else if self.dirs.remove(old) { + self.dirs.insert(new.to_string()); + self.status(id, FX_OK, ""); + } else if let Some(t) = self.symlinks.remove(old) { + self.symlinks.insert(new.to_string(), t); + self.status(id, FX_OK, ""); + } else { + self.status(id, FX_NO_SUCH_FILE, "no such file"); + } + } +} + +impl Write for MockServer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inbox.extend_from_slice(buf); + self.process(); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Read for MockServer { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let n = buf.len().min(self.outbox.len()); + for b in buf.iter_mut().take(n) { + *b = self.outbox.pop_front().unwrap(); + } + Ok(n) + } +} + +fn client() -> Client { + Client::new(MockServer::new()).unwrap() +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[test] +fn handshake_reads_extensions() { + let c = client(); + assert!(c.has_extension("posix-rename@openssh.com")); + assert!(c.has_extension("statvfs@openssh.com")); + assert!(!c.has_extension("nope@example.com")); +} + +#[test] +fn realpath_resolves_dot() { + let mut c = client(); + assert_eq!(c.realpath(".").unwrap(), "/home/u"); +} + +#[test] +fn stat_follows_symlinks_lstat_does_not() { + let mut c = client(); + let followed = c.stat("/home/u/link").unwrap(); + assert!(!followed.is_symlink()); + assert_eq!(followed.size, Some(11)); + let link = c.lstat("/home/u/link").unwrap(); + assert!(link.is_symlink()); + + let dir = c.stat("/home/u").unwrap(); + assert!(dir.is_dir()); + assert_eq!(c.stat("/gone").unwrap_err().kind(), io::ErrorKind::NotFound); +} + +#[test] +fn read_dir_collects_across_chunks() { + let mut c = client(); + let entries = c.read_dir("/home/u").unwrap(); + // "." and ".." pass through; the caller filters. + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&".")); + assert!(names.contains(&"..")); + assert!(names.contains(&"hello.txt")); + assert!(names.contains(&"run.sh")); + assert!(names.contains(&"link")); + // More entries than one READDIR chunk, so the loop ran several times. + assert!(entries.len() > READDIR_CHUNK); + + let hello = entries.iter().find(|e| e.name == "hello.txt").unwrap(); + assert_eq!(hello.attrs.size, Some(11)); + assert!(!hello.attrs.is_dir()); + let sh = entries.iter().find(|e| e.name == "run.sh").unwrap(); + assert_eq!(sh.attrs.permissions, Some(0o100755)); +} + +#[test] +fn file_read_write_roundtrip() { + let mut c = client(); + + // Write a payload larger than one chunk at explicit offsets. + let payload: Vec = (0..(MAX_DATA + 100)).map(|i| (i % 251) as u8).collect(); + let handle = c + .open("/home/u/new.bin", OPEN_WRITE | OPEN_CREAT | OPEN_TRUNC) + .unwrap(); + let mut offset = 0usize; + for chunk in payload.chunks(MAX_DATA) { + c.write(&handle, offset as u64, chunk).unwrap(); + offset += chunk.len(); + } + c.close(&handle).unwrap(); + + // Read it back through the EOF-terminated read loop. + let handle = c.open("/home/u/new.bin", OPEN_READ).unwrap(); + let mut back = Vec::new(); + while let Some(data) = c.read(&handle, back.len() as u64, MAX_DATA as u32).unwrap() { + back.extend_from_slice(&data); + } + c.close(&handle).unwrap(); + assert_eq!(back, payload); + + assert_eq!( + c.open("/missing", OPEN_READ).unwrap_err().kind(), + io::ErrorKind::NotFound + ); +} + +#[test] +fn mkdir_rmdir_remove() { + let mut c = client(); + c.mkdir("/home/u/newdir").unwrap(); + assert!(c.stat("/home/u/newdir").unwrap().is_dir()); + assert!(c.mkdir("/home/u/newdir").is_err()); + c.rmdir("/home/u/newdir").unwrap(); + assert!(c.stat("/home/u/newdir").is_err()); + + c.remove("/home/u/hello.txt").unwrap(); + assert!(c.stat("/home/u/hello.txt").is_err()); + assert!(c.remove("/home/u/hello.txt").is_err()); +} + +#[test] +fn rename_uses_posix_extension_and_overwrites() { + let mut c = client(); + // Target exists; the extension replaces it (plain v3 RENAME would fail). + c.rename("/home/u/hello.txt", "/home/u/run.sh").unwrap(); + assert_eq!(c.stat("/home/u/run.sh").unwrap().size, Some(11)); + assert!(c.stat("/home/u/hello.txt").is_err()); +} + +#[test] +fn rename_without_extension_fails_on_existing_target() { + let mut server = MockServer::new(); + server.advertise_posix_rename = false; + let mut c = Client::new(server).unwrap(); + assert!(!c.has_extension("posix-rename@openssh.com")); + assert!(c.rename("/home/u/hello.txt", "/home/u/run.sh").is_err()); + c.rename("/home/u/hello.txt", "/home/u/moved.txt").unwrap(); + assert_eq!(c.stat("/home/u/moved.txt").unwrap().size, Some(11)); +} + +#[test] +fn symlink_and_readlink_argument_order() { + let mut c = client(); + c.symlink("/home/u/run.sh", "/home/u/newlink").unwrap(); + assert_eq!(c.read_link("/home/u/newlink").unwrap(), "/home/u/run.sh"); + assert!(c.lstat("/home/u/newlink").unwrap().is_symlink()); +} + +#[test] +fn statvfs_reports_available_bytes() { + let mut c = client(); + // bavail(500) * frsize(4096) + assert_eq!(c.statvfs_avail("/").unwrap(), Some(500 * 4096)); +} + +#[test] +fn attrs_type_predicates() { + let dir = Attrs { + permissions: Some(0o040755), + ..Attrs::default() + }; + let link = Attrs { + permissions: Some(0o120777), + ..Attrs::default() + }; + let file = Attrs { + permissions: Some(0o100644), + ..Attrs::default() + }; + let unknown = Attrs::default(); + assert!(dir.is_dir() && !dir.is_symlink()); + assert!(link.is_symlink() && !link.is_dir()); + assert!(!file.is_dir() && !file.is_symlink()); + assert!(!unknown.is_dir() && !unknown.is_symlink()); +} + +// ── Against the real OpenSSH sftp-server ──────────────────────────────────── + +struct ChildIo { + child: std::process::Child, + stdin: std::process::ChildStdin, + stdout: std::process::ChildStdout, +} + +impl Read for ChildIo { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.stdout.read(buf) + } +} + +impl Write for ChildIo { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.stdin.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.stdin.flush() + } +} + +impl Drop for ChildIo { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn spawn_sftp_server() -> Option { + let path = [ + "/usr/libexec/sftp-server", + "/usr/lib/openssh/sftp-server", + "/usr/lib/ssh/sftp-server", + ] + .iter() + .find(|p| std::path::Path::new(p).exists())?; + let mut child = std::process::Command::new(path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + Some(ChildIo { + child, + stdin, + stdout, + }) +} + +/// Full protocol pass against the OpenSSH sftp-server binary when one is +/// installed (macOS and Linux); a no-op elsewhere. +#[test] +fn against_real_openssh_sftp_server() { + let Some(io) = spawn_sftp_server() else { + return; + }; + let mut c = Client::new(io).unwrap(); + + let root = std::env::temp_dir().join(format!("ruf4_sftp_e2e_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + // sftp-server resolves through symlinks (/var -> /private/var on macOS). + let root = std::fs::canonicalize(&root).unwrap(); + let root_s = root.to_str().unwrap(); + + // realpath and stat on a real directory. + assert_eq!(c.realpath(root_s).unwrap(), root_s); + assert!(c.stat(root_s).unwrap().is_dir()); + + // mkdir, write, read back. + let sub = format!("{root_s}/sub"); + c.mkdir(&sub).unwrap(); + let file = format!("{root_s}/data.bin"); + let payload: Vec = (0..(MAX_DATA * 2 + 17)).map(|i| (i % 256) as u8).collect(); + let handle = c.open(&file, OPEN_WRITE | OPEN_CREAT | OPEN_TRUNC).unwrap(); + let mut off = 0usize; + for chunk in payload.chunks(MAX_DATA) { + c.write(&handle, off as u64, chunk).unwrap(); + off += chunk.len(); + } + c.close(&handle).unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), payload); + + let attrs = c.stat(&file).unwrap(); + assert_eq!(attrs.size, Some(payload.len() as u64)); + assert!(!attrs.is_dir()); + + let handle = c.open(&file, OPEN_READ).unwrap(); + let mut back = Vec::new(); + while let Some(d) = c.read(&handle, back.len() as u64, MAX_DATA as u32).unwrap() { + back.extend_from_slice(&d); + } + c.close(&handle).unwrap(); + assert_eq!(back, payload); + + // Directory listing shows both entries with attributes. + let entries = c.read_dir(root_s).unwrap(); + let find = |n: &str| entries.iter().find(|e| e.name == n); + assert!(find("sub").unwrap().attrs.is_dir()); + assert_eq!( + find("data.bin").unwrap().attrs.size, + Some(payload.len() as u64) + ); + + // Symlinks (created target-first, OpenSSH order) and readlink. + #[cfg(unix)] + { + let link = format!("{root_s}/link"); + c.symlink(&file, &link).unwrap(); + assert_eq!(c.read_link(&link).unwrap(), file); + assert!(c.lstat(&link).unwrap().is_symlink()); + assert!(!c.stat(&link).unwrap().is_symlink()); + c.remove(&link).unwrap(); + } + + // Rename over an existing target goes through posix-rename. + let file2 = format!("{root_s}/renamed.bin"); + std::fs::write(&file2, b"old").unwrap(); + c.rename(&file, &file2).unwrap(); + assert_eq!(std::fs::read(&file2).unwrap(), payload); + assert!(c.stat(&file).is_err()); + + // Free space via statvfs@openssh.com. + assert!(c.statvfs_avail(root_s).unwrap().unwrap() > 0); + + // Cleanup through the protocol. + c.remove(&file2).unwrap(); + c.rmdir(&sub).unwrap(); + c.rmdir(root_s).unwrap(); + assert!(!root.exists()); +} diff --git a/crates/ruf4/tests/test_smb.rs b/crates/ruf4/tests/test_smb.rs new file mode 100644 index 0000000..74a9ac8 --- /dev/null +++ b/crates/ruf4/tests/test_smb.rs @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! Tests for the SMB location model: `smb://` parsing, UNC and GVFS name +//! mapping, and cd/url dispatch. Nothing here mounts a share. + +use std::path::{Path, PathBuf}; + +use ruf4::smb::{self, SmbPath, gvfs_entry_name, parse_gvfs_entry_name, unc_path}; +use ruf4::{fileops, vfs}; + +fn p(s: &str) -> PathBuf { + PathBuf::from(s) +} + +// ── Parsing ───────────────────────────────────────────────────────────────── + +#[test] +fn parse_full_smb_url() { + let s = smb::parse(&p("smb://alice@nas:1445/media/movies/2024")).unwrap(); + assert_eq!(s.user.as_deref(), Some("alice")); + assert_eq!(s.host, "nas"); + assert_eq!(s.port, Some(1445)); + assert_eq!(s.share, "media"); + assert_eq!(s.path, "/movies/2024"); + assert_eq!(s.display(), "smb://alice@nas:1445/media/movies/2024"); +} + +#[test] +fn parse_share_only_and_host_only() { + let s = smb::parse(&p("smb://nas/media")).unwrap(); + assert_eq!(s.share, "media"); + assert_eq!(s.path, ""); + assert_eq!(s.display(), "smb://nas/media"); + + let bare = smb::parse(&p("smb://nas")).unwrap(); + assert_eq!(bare.share, ""); + assert_eq!(bare.display(), "smb://nas"); + // A bare host cannot resolve; the error asks for a share. + let err = smb::resolve_dir(&bare).unwrap_err(); + assert!(err.contains("share name required"), "{err}"); +} + +#[test] +fn parse_normalizes_subpath() { + let s = smb::parse(&p("smb://nas/media//a/./b/../c")).unwrap(); + assert_eq!(s.path, "/a/c"); + let root = smb::parse(&p("smb://nas/media/")).unwrap(); + assert_eq!(root.path, ""); +} + +#[test] +fn parse_rejects_other_schemes_and_malformed() { + assert!(smb::parse(Path::new("/local/path")).is_none()); + assert!(smb::parse(Path::new("ssh://host/x")).is_none()); + assert!(smb::parse(Path::new("smb://")).is_none()); + assert!(smb::parse(Path::new("smb:///share")).is_none()); +} + +#[test] +fn parse_ipv6_host() { + let s = smb::parse(&p("smb://[fe80::1]:445/backup")).unwrap(); + assert_eq!(s.host, "fe80::1"); + assert_eq!(s.port, Some(445)); + assert_eq!(s.display(), "smb://[fe80::1]:445/backup"); +} + +// ── UNC mapping ───────────────────────────────────────────────────────────── + +#[test] +fn unc_path_from_url() { + let s = smb::parse(&p("smb://nas/media/movies/2024")).unwrap(); + assert_eq!(unc_path(&s), p(r"\\nas\media\movies\2024")); + let root = smb::parse(&p("smb://nas/media")).unwrap(); + assert_eq!(unc_path(&root), p(r"\\nas\media")); +} + +// ── GVFS entry names ──────────────────────────────────────────────────────── + +#[test] +fn gvfs_name_round_trip() { + let s = smb::parse(&p("smb://alice@NAS:1445/Media")).unwrap(); + let name = gvfs_entry_name(&s); + assert_eq!( + name, + "smb-share:server=nas,share=media,port=1445,user=alice" + ); + let parsed = parse_gvfs_entry_name(&name).unwrap(); + assert_eq!(parsed.host, "nas"); + assert_eq!(parsed.share, "media"); + assert_eq!(parsed.port, Some(1445)); + assert_eq!(parsed.user.as_deref(), Some("alice")); +} + +#[test] +fn gvfs_name_parse_tolerates_extra_fields_and_rejects_others() { + let parsed = parse_gvfs_entry_name("smb-share:server=nas,share=media,flags=1").unwrap(); + assert_eq!(parsed.host, "nas"); + assert_eq!(parsed.share, "media"); + assert!(parse_gvfs_entry_name("sftp:host=x").is_none()); + assert!(parse_gvfs_entry_name("smb-share:server=nas").is_none()); // no share +} + +// ── Dispatch ──────────────────────────────────────────────────────────────── + +#[test] +fn is_url_covers_both_schemes() { + assert!(vfs::is_url("ssh://host/x")); + assert!(vfs::is_url("smb://host/share")); + assert!(!vfs::is_url("/usr/local")); + assert!(!vfs::is_url("http://host/x")); +} + +#[test] +fn cd_accepts_smb_urls_from_anywhere() { + assert_eq!( + fileops::resolve_cd_target(Path::new("/tmp"), "smb://nas/media"), + p("smb://nas/media") + ); + assert_eq!( + fileops::resolve_cd_target(&p("ssh://box/home"), "smb://nas/media"), + p("smb://nas/media") + ); +} + +#[test] +fn mounted_roots_is_well_formed() { + // Machine-dependent contents; every entry must at least be an absolute + // local directory path, not a URL. + for root in smb::mounted_roots() { + assert!(root.is_absolute()); + assert!(smb::parse(&root).is_none()); + } +} + +#[test] +fn display_round_trips_through_parse() { + for url in [ + "smb://nas/media", + "smb://alice@nas/media/sub", + "smb://nas:139/media", + "smb://[::1]/share", + ] { + let s = smb::parse(Path::new(url)).unwrap(); + assert_eq!(smb::parse(Path::new(&s.display())), Some(s)); + } +} + +/// SmbPath is constructible for callers building locations programmatically. +#[test] +fn constructed_paths_display() { + let s = SmbPath { + user: None, + host: "nas".to_string(), + port: None, + share: "media".to_string(), + path: "/x".to_string(), + }; + assert_eq!(s.display(), "smb://nas/media/x"); +} + +// ── Live end-to-end (env-gated) ───────────────────────────────────────────── + +/// Full mount-and-browse pass against a live share. Runs only when +/// `RUF4_SMB_E2E_URL` names one (e.g. `smb://user@host/share`); credentials +/// must be available non-interactively or entered at the prompt. +#[test] +fn live_smb_end_to_end() { + let Ok(url) = std::env::var("RUF4_SMB_E2E_URL") else { + return; + }; + let dir = vfs::prepare_dir(Path::new(&url)).expect("mount and resolve"); + assert!(dir.is_dir()); + // After resolution everything is ordinary local I/O. + std::fs::read_dir(&dir).expect("list mounted share"); + #[cfg(any(target_os = "macos", target_os = "linux"))] + assert!( + smb::mounted_roots().iter().any(|r| dir.starts_with(r)), + "mounted share must appear in the roots list" + ); +} diff --git a/crates/ruf4/tests/test_state.rs b/crates/ruf4/tests/test_state.rs index de02447..9af065d 100644 --- a/crates/ruf4/tests/test_state.rs +++ b/crates/ruf4/tests/test_state.rs @@ -416,3 +416,57 @@ fn test_preview_scroll_resets_on_file_change() { "a new previewed file starts at the top" ); } + +// --- User screen (Ctrl+O) --- + +#[test] +fn test_ctrl_o_requests_user_screen() { + let mut s = test_state(); + s.handle_global_input(&Input::Keyboard(kbmod::CTRL | vk::O)); + assert!(s.take_user_screen_request()); + // The request is consumed. + assert!(!s.take_user_screen_request()); +} + +#[test] +fn test_user_screen_not_requested_while_dialog_open() { + let mut s = test_state(); + s.dialog = Dialog::Help { scroll: 0 }; + s.handle_global_input(&Input::Keyboard(kbmod::CTRL | vk::O)); + assert!(!s.take_user_screen_request()); +} + +#[test] +fn test_user_screen_exit_keys() { + let s = test_state(); + let keys = s.user_screen_exit_keys(); + assert!(keys.contains(&(kbmod::CTRL | vk::O))); + assert!(keys.contains(&vk::ESCAPE)); +} + +// --- Download completion --- + +#[test] +fn test_cancelled_download_is_discarded_not_opened() { + let root = std::env::temp_dir().join(format!("ruf4_state_dl_{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let src = root.join("payload.bin"); + std::fs::write(&src, vec![7u8; 256 * 1024]).unwrap(); + + let mut s = test_state(); + s.download_and_open(src.clone(), "payload.bin"); + assert!(s.job.is_some()); + let target = std::env::temp_dir().join("ruf4-remote").join("payload.bin"); + + // Cancel from the UI side; whether the worker already finished the copy + // must not matter for the outcome. + s.job.as_mut().unwrap().cancel(); + while s.job.is_some() { + s.poll_job(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + assert!(matches!(s.dialog, Dialog::None)); + assert!(!target.exists(), "partial download must be removed"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/ruf4/tests/test_vfs.rs b/crates/ruf4/tests/test_vfs.rs new file mode 100644 index 0000000..c3bfff4 --- /dev/null +++ b/crates/ruf4/tests/test_vfs.rs @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ruf4 contributors. +// Licensed under the MIT License. + +//! Tests for the vfs path model: `ssh://` parsing, path algebra, ssh_config +//! host extraction, and the pure dispatch predicates. Nothing here opens a +//! connection. + +use std::path::{Path, PathBuf}; + +use ruf4::fileops; +use ruf4::sftp::Attrs; +use ruf4::vfs::{ + self, dir_contains, is_remote, join, normalize_posix, parent, parse_remote, remote_entry, + same_domain, shell_quote, ssh_config_hosts, +}; + +fn p(s: &str) -> PathBuf { + PathBuf::from(s) +} + +// ── Parsing ───────────────────────────────────────────────────────────────── + +#[test] +fn parse_full_remote_path() { + let r = parse_remote(&p("ssh://alice@box:2222/var/log")).unwrap(); + assert_eq!(r.host.user.as_deref(), Some("alice")); + assert_eq!(r.host.host, "box"); + assert_eq!(r.host.port, Some(2222)); + assert_eq!(r.path, "/var/log"); + assert_eq!(r.to_path_buf(), p("ssh://alice@box:2222/var/log")); +} + +#[test] +fn parse_host_only() { + let r = parse_remote(&p("ssh://box")).unwrap(); + assert_eq!(r.host.user, None); + assert_eq!(r.host.port, None); + assert_eq!(r.path, ""); +} + +#[test] +fn parse_ipv6_host() { + let r = parse_remote(&p("ssh://[::1]:2200/tmp")).unwrap(); + assert_eq!(r.host.host, "::1"); + assert_eq!(r.host.port, Some(2200)); + assert_eq!(r.path, "/tmp"); + // Display round-trips with brackets. + assert_eq!(r.to_path_buf(), p("ssh://[::1]:2200/tmp")); +} + +#[test] +fn parse_rejects_non_remote_and_malformed() { + assert!(parse_remote(Path::new("/usr/local")).is_none()); + assert!(parse_remote(Path::new("ssh://")).is_none()); + assert!(parse_remote(Path::new("ssh:///path")).is_none()); + assert!(!is_remote(Path::new("/ssh://host"))); + assert!(is_remote(Path::new("ssh://host/"))); +} + +#[test] +fn parse_nonnumeric_port_is_part_of_no_port() { + // ssh_config aliases cannot contain ':'; treat a non-numeric suffix as + // part of the host rather than failing. + let r = parse_remote(&p("ssh://weird:alias/x")).unwrap(); + assert_eq!(r.host.host, "weird:alias"); + assert_eq!(r.host.port, None); +} + +// ── Path algebra ──────────────────────────────────────────────────────────── + +#[test] +fn normalize_posix_collapses() { + assert_eq!(normalize_posix(""), "/"); + assert_eq!(normalize_posix("/"), "/"); + assert_eq!(normalize_posix("/a//b/./c"), "/a/b/c"); + assert_eq!(normalize_posix("/a/b/../c"), "/a/c"); + assert_eq!(normalize_posix("/a/../../b"), "/b"); +} + +#[test] +fn join_remote_relative_absolute_and_dotdot() { + let base = p("ssh://box/home/user"); + assert_eq!(join(&base, "docs"), p("ssh://box/home/user/docs")); + assert_eq!(join(&base, "/etc"), p("ssh://box/etc")); + assert_eq!(join(&base, ".."), p("ssh://box/home")); + assert_eq!(join(&p("ssh://box/"), "x"), p("ssh://box/x")); +} + +#[test] +fn join_local_still_joins() { + let joined = join(Path::new("/tmp"), "file.txt"); + assert_eq!(joined, Path::new("/tmp").join("file.txt")); +} + +#[test] +fn parent_remote_chain_ends_at_root() { + let start = p("ssh://alice@box/a/b"); + let up1 = parent(&start).unwrap(); + assert_eq!(up1, p("ssh://alice@box/a")); + let up2 = parent(&up1).unwrap(); + assert_eq!(up2, p("ssh://alice@box/")); + assert_eq!(parent(&up2), None); +} + +// ── Dispatch predicates ───────────────────────────────────────────────────── + +#[test] +fn same_domain_and_same_file_remote() { + let a = p("ssh://box/a/b/../c"); + let b = p("ssh://box/a/c"); + let other = p("ssh://other/a/c"); + assert!(same_domain(&a, &b)); + assert!(!same_domain(&a, &other)); + assert!(!same_domain(&a, Path::new("/a/c"))); + assert!(fileops::same_file(&a, &b)); + assert!(!fileops::same_file(&a, &other)); +} + +#[test] +fn dir_contains_remote() { + assert!(dir_contains(&p("ssh://box/a"), &p("ssh://box/a/b"))); + assert!(dir_contains(&p("ssh://box/a"), &p("ssh://box/a"))); + assert!(dir_contains(&p("ssh://box/"), &p("ssh://box/x"))); + assert!(!dir_contains(&p("ssh://box/a"), &p("ssh://box/ab"))); + assert!(!dir_contains(&p("ssh://box/a"), &p("ssh://other/a/b"))); + assert!(!dir_contains(&p("ssh://box/a"), Path::new("/a/b"))); +} + +#[test] +fn dir_contains_local() { + let root = std::env::temp_dir().join(format!("ruf4_vfs_{}", std::process::id())); + let sub = root.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + assert!(dir_contains(&root, &sub)); + assert!(dir_contains(&root, &sub.join("not-yet-created"))); + assert!(!dir_contains(&sub, &root)); + let _ = std::fs::remove_dir_all(&root); +} + +// ── Remote entry mapping ──────────────────────────────────────────────────── + +#[test] +fn remote_entry_maps_attrs() { + let attrs = Attrs { + size: Some(1234), + uid_gid: Some((501, 20)), + permissions: Some(0o100755), + times: Some((0, 1_700_000_000)), + }; + let e = remote_entry("run.sh".to_string(), false, &attrs); + assert!(!e.is_dir); + assert!(e.is_executable); + assert!(!e.is_readonly); + assert!(!e.is_hidden); + assert_eq!(e.size, 1234); + assert!(e.modified.is_some()); + + let dir_attrs = Attrs { + permissions: Some(0o040555), // no write bits + ..Attrs::default() + }; + let d = remote_entry(".config".to_string(), false, &dir_attrs); + assert!(d.is_dir); + assert!(d.is_hidden); + assert!(d.is_readonly); + assert!(!d.is_executable); +} + +// ── cd resolution ─────────────────────────────────────────────────────────── + +#[test] +fn resolve_cd_target_remote() { + let cwd = p("ssh://box/home/user"); + assert_eq!( + fileops::resolve_cd_target(&cwd, "docs"), + p("ssh://box/home/user/docs") + ); + assert_eq!(fileops::resolve_cd_target(&cwd, ".."), p("ssh://box/home")); + assert_eq!(fileops::resolve_cd_target(&cwd, "/etc"), p("ssh://box/etc")); + // Home: the empty path resolves to the remote login home on connect. + assert_eq!(fileops::resolve_cd_target(&cwd, "~"), p("ssh://box")); + // Switching hosts by URL works from anywhere. + assert_eq!( + fileops::resolve_cd_target(&cwd, "ssh://other/x"), + p("ssh://other/x") + ); + assert_eq!( + fileops::resolve_cd_target(Path::new("/tmp"), "ssh://box"), + p("ssh://box") + ); +} + +#[test] +fn resolve_cd_target_local() { + assert_eq!( + fileops::resolve_cd_target(Path::new("/tmp"), "sub"), + Path::new("/tmp").join("sub") + ); + assert_eq!( + fileops::resolve_cd_target(Path::new("/tmp"), "/var"), + p("/var") + ); +} + +// ── Shell quoting ─────────────────────────────────────────────────────────── + +#[test] +fn shell_quote_escapes_single_quotes() { + assert_eq!(shell_quote("plain"), "'plain'"); + assert_eq!(shell_quote("it's"), "'it'\\''s'"); + assert_eq!(shell_quote("a b;c"), "'a b;c'"); +} + +// ── ssh_config parsing ────────────────────────────────────────────────────── + +#[test] +fn ssh_config_hosts_extracts_concrete_aliases() { + let text = "\ +# comment +Host dev + HostName dev.example.com + +host prod staging +Host *.internal !bastion +Host \"quoted\" +Host=eqform +Match host something +"; + let hosts = ssh_config_hosts(text); + assert_eq!(hosts, vec!["dev", "prod", "staging", "quoted", "eqform"]); +} + +#[test] +fn ssh_config_hosts_dedupes_and_skips_patterns() { + let text = "Host a\nHost a b\nHost b?x c*\n"; + let hosts = ssh_config_hosts(text); + assert_eq!(hosts, vec!["a", "b"]); +} + +#[test] +fn ssh_roots_form() { + // Independent of the machine's real config: every returned root must be a + // parseable remote path with no path component. + for root in vfs::ssh_roots() { + let r = parse_remote(&root).expect("ssh root must parse"); + assert_eq!(r.path, ""); + } +} + +// ── Live end-to-end (env-gated) ───────────────────────────────────────────── + +/// Full-stack pass against a live SSH destination. Runs only when +/// `RUF4_SSH_E2E_DEST` names one (e.g. `ssh://127.0.0.1:2299`); pair with +/// `RUF4_SSH_CONFIG` pointing at a client config that authenticates +/// non-interactively. +#[test] +fn live_ssh_end_to_end() { + let Ok(dest) = std::env::var("RUF4_SSH_E2E_DEST") else { + return; + }; + let root = vfs::prepare_dir(Path::new(&dest)).expect("connect and resolve home"); + let r = parse_remote(&root).unwrap(); + assert!(r.path.starts_with('/'), "home must be absolute: {r:?}"); + + // Panel listing of the home directory works. + let _ = vfs::scan_dir(&root, true); + + // Work in an isolated subtree. + let work = join(&root, &format!("ruf4-e2e-{}", std::process::id())); + vfs::create_dir_all(&work).expect("mkdir"); + assert!(vfs::is_dir(&work)); + + // Write, stat, and read back a file crossing the chunk size. + let file = join(&work, "data.bin"); + let payload: Vec = (0..100_000).map(|i| (i % 251) as u8).collect(); + { + let mut w = vfs::open_write(&file).expect("open for write"); + w.write_all(&payload).unwrap(); + w.flush().unwrap(); + } + assert!(vfs::exists(&file)); + let meta = vfs::symlink_meta(&file).unwrap(); + assert_eq!(meta.size, payload.len() as u64); + let (prefix, size) = vfs::read_prefix(&file, 4096).unwrap(); + assert_eq!(size, payload.len() as u64); + assert_eq!(prefix, payload[..4096]); + let mut back = Vec::new(); + vfs::open_read(&file) + .expect("open for read") + .read_to_end(&mut back) + .unwrap(); + assert_eq!(back, payload); + + // Directory metadata listing and rename. + let listed = vfs::read_dir_meta(&work).unwrap(); + assert!( + listed + .iter() + .any(|(n, m)| n == "data.bin" && m.kind == vfs::Kind::File) + ); + let renamed = join(&work, "renamed.bin"); + vfs::rename(&file, &renamed).unwrap(); + assert!(!vfs::exists(&file)); + assert!(vfs::exists(&renamed)); + + // Free space is reported by OpenSSH servers. + assert!(vfs::free_space(&work).is_some()); + + // Recursive removal cleans up. + vfs::remove_tree(&work).unwrap(); + assert!(!vfs::exists(&work)); +} diff --git a/crates/tui/src/tui.rs b/crates/tui/src/tui.rs index 47a2ae7..eb90068 100644 --- a/crates/tui/src/tui.rs +++ b/crates/tui/src/tui.rs @@ -89,9 +89,9 @@ //! # Example //! //! ``` -//! use edit::helpers::Size; -//! use edit::input::Input; -//! use edit::tui::*; +//! use ruf4_tui::helpers::Size; +//! use ruf4_tui::input::Input; +//! use ruf4_tui::tui::*; //! use stdext::{arena, arena_format}; //! //! struct State { @@ -2024,8 +2024,8 @@ impl<'a> Context<'a, '_> { /// /// # Example /// ``` - /// use edit::framebuffer::IndexedColor; - /// use edit::tui::Context; + /// use ruf4_tui::framebuffer::IndexedColor; + /// use ruf4_tui::tui::Context; /// /// fn draw(ctx: &mut Context) { /// ctx.styled_label_begin("label");