From f5cdb302b5b243eee204338982999b0e968a778a Mon Sep 17 00:00:00 2001 From: kromych Date: Sat, 4 Jul 2026 16:58:39 -0700 Subject: [PATCH 1/6] Fix stale crate name in tui doctest imports The doc examples still imported from `edit`, the upstream crate name, so `cargo test --workspace` failed to compile the doctests. Co-Authored-By: Claude Fable 5 --- crates/tui/src/tui.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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"); From a46f152ae263f698bd158c7ab6d3732ff78fe93c Mon Sep 17 00:00:00 2001 From: kromych Date: Sat, 4 Jul 2026 17:00:25 -0700 Subject: [PATCH 2/6] User screen on Ctrl+O Ctrl+O switches to the primary screen buffer (the output of previously run commands) until Ctrl+O or Esc is pressed, the FAR panels-off model. The macOS Copy alternative moves from Ctrl+O to Ctrl+C to free the key. The TUI enter/leave escape sequences and the suspend/restore sequencing move into platform (with_tui_suspended, run_foreground) so interactive foreground commands and the user-screen view share one implementation. run_foreground takes a prepared Command and pauses for acknowledgement on failure, which later lets other callers run programs that must own the terminal. Co-Authored-By: Claude Fable 5 --- ReadMe.md | 5 +- crates/ruf4/src/action.rs | 10 ++- crates/ruf4/src/draw.rs | 3 + crates/ruf4/src/main.rs | 19 +++-- crates/ruf4/src/platform.rs | 139 +++++++++++++++++++++++-------- crates/ruf4/src/state.rs | 27 ++++++ crates/ruf4/tests/test_action.rs | 29 ++++++- crates/ruf4/tests/test_state.rs | 27 ++++++ 8 files changed, 210 insertions(+), 49 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index 30d6634..7fec942 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -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,7 @@ 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. ### 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..5d3df3a 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; 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/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/state.rs b/crates/ruf4/src/state.rs index 9fea241..413b2ae 100644 --- a/crates/ruf4/src/state.rs +++ b/crates/ruf4/src/state.rs @@ -148,6 +148,8 @@ 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, } // ── Construction ──────────────────────────────────────────────────────────── @@ -193,6 +195,7 @@ impl State { last_click: None, job: None, repaint_requested: false, + user_screen_requested: false, }; if let Some(s) = settings::Settings::load() { @@ -243,6 +246,7 @@ impl State { last_click: None, job: None, repaint_requested: false, + user_screen_requested: false, } } @@ -382,6 +386,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 { @@ -603,6 +610,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 { 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_state.rs b/crates/ruf4/tests/test_state.rs index de02447..29733d1 100644 --- a/crates/ruf4/tests/test_state.rs +++ b/crates/ruf4/tests/test_state.rs @@ -416,3 +416,30 @@ 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)); +} From f5e72010cf47ebfd9fa4bf8112f1859e53c22f5b Mon Sep 17 00:00:00 2001 From: kromych Date: Sat, 4 Jul 2026 17:00:58 -0700 Subject: [PATCH 3/6] SFTP version 3 client A synchronous client for the SFTP v3 wire protocol (draft-ietf-secsh-filexfer-02 as implemented by OpenSSH) over any Read + Write transport, with the posix-rename@openssh.com and statvfs@openssh.com extensions. Tests run the client against an in-memory mock server and, where installed, the real OpenSSH sftp-server binary over pipes. Co-Authored-By: Claude Fable 5 --- crates/ruf4/src/lib.rs | 1 + crates/ruf4/src/sftp.rs | 567 ++++++++++++++++++++++ crates/ruf4/tests/test_sftp.rs | 826 +++++++++++++++++++++++++++++++++ 3 files changed, 1394 insertions(+) create mode 100644 crates/ruf4/src/sftp.rs create mode 100644 crates/ruf4/tests/test_sftp.rs diff --git a/crates/ruf4/src/lib.rs b/crates/ruf4/src/lib.rs index 2a9e587..9881e44 100644 --- a/crates/ruf4/src/lib.rs +++ b/crates/ruf4/src/lib.rs @@ -14,5 +14,6 @@ pub mod panel; pub mod platform; pub mod preview; pub mod settings; +pub mod sftp; pub mod state; pub mod theme; 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/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()); +} From 9e351b2c3dfd8cafe536e9a1c50ba20301558f1b Mon Sep 17 00:00:00 2001 From: kromych Date: Sat, 4 Jul 2026 17:02:29 -0700 Subject: [PATCH 4/6] SSH remote filesystems (ssh://) Panel paths of the form ssh://[user@]host[:port]/path address a remote filesystem through a vfs layer that mirrors the std::fs calls the application needs and routes them to per-host SFTP connections. The transport is a spawned `ssh -s sftp` subsystem, so keys, agents, and ~/.ssh/config behave exactly like plain ssh; on Unix a connection master is bootstrapped interactively with the TUI suspended and worker channels attach to its socket with BatchMode. Path algebra on remote paths is lexical POSIX (vfs::join/parent/normalize), never PathBuf::join, which would corrupt URLs on Windows. Browsing, sorting, quick view, copy, move, rename, mkdir, and delete work on remote directories; copies stream through this host with byte progress and remain cancellable. Enter on a remote file downloads it to a temporary directory and opens it on completion (a Download job); a download cancelled from the progress dialog discards the partial file instead of opening it. The command line runs commands on the remote host over `ssh -t` in the panel's directory, and hosts from ~/.ssh/config appear as roots in the change-root dialog. Panels sample free space at refresh time instead of statvfs per frame, which would be a round trip on remote panels. Live end-to-end coverage is gated on RUF4_SSH_E2E_DEST / RUF4_SSH_CONFIG pointing at a disposable sshd. Co-Authored-By: Claude Fable 5 --- ReadMe.md | 19 + crates/ruf4/src/draw.rs | 2 +- crates/ruf4/src/fileops.rs | 118 ++-- crates/ruf4/src/job.rs | 231 ++++--- crates/ruf4/src/lib.rs | 1 + crates/ruf4/src/panel.rs | 31 +- crates/ruf4/src/preview.rs | 52 +- crates/ruf4/src/state.rs | 116 +++- crates/ruf4/src/vfs.rs | 1127 +++++++++++++++++++++++++++++++ crates/ruf4/tests/test_job.rs | 18 + crates/ruf4/tests/test_state.rs | 27 + crates/ruf4/tests/test_vfs.rs | 312 +++++++++ 12 files changed, 1835 insertions(+), 219 deletions(-) create mode 100644 crates/ruf4/src/vfs.rs create mode 100644 crates/ruf4/tests/test_vfs.rs diff --git a/ReadMe.md b/ReadMe.md index 7fec942..9c30c18 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -150,6 +150,25 @@ 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. 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). + ### Dialogs Most confirmation dialogs respond to: diff --git a/crates/ruf4/src/draw.rs b/crates/ruf4/src/draw.rs index 5d3df3a..5ec5177 100644 --- a/crates/ruf4/src/draw.rs +++ b/crates/ruf4/src/draw.rs @@ -562,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 9881e44..9753a9e 100644 --- a/crates/ruf4/src/lib.rs +++ b/crates/ruf4/src/lib.rs @@ -17,3 +17,4 @@ pub mod settings; pub mod sftp; pub mod state; pub mod theme; +pub mod vfs; 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/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/state.rs b/crates/ruf4/src/state.rs index 413b2ae..54d9b18 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 ────────────────────────────────────────────────────────────── @@ -150,6 +151,8 @@ pub struct State { 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 ──────────────────────────────────────────────────────────── @@ -196,6 +199,7 @@ impl State { job: None, repaint_requested: false, user_screen_requested: false, + pending_open: None, }; if let Some(s) = settings::Settings::load() { @@ -247,6 +251,7 @@ impl State { job: None, repaint_requested: false, user_screen_requested: false, + pending_open: None, } } @@ -296,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(); } @@ -405,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) { @@ -473,7 +514,8 @@ impl State { } pub fn open_choose_root(&mut self) { - let roots = platform::discover_roots(); + let mut roots = platform::discover_roots(); + roots.extend(vfs::ssh_roots()); if roots.is_empty() { return; } @@ -550,8 +592,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 ────────────────────────────────── @@ -659,7 +714,7 @@ impl State { if let Some(errors) = finished { self.job = None; - self.complete_job(kind, errors); + self.complete_job(kind, errors, cancelling); return; } @@ -684,10 +739,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 }; + } + } } } @@ -880,7 +958,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; @@ -1249,7 +1327,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..21389cc --- /dev/null +++ b/crates/ruf4/src/vfs.rs @@ -0,0 +1,1127 @@ +// 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). +//! +//! 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) +} + +/// 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; `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 { + 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() { + Ok(dest) + } else { + Err(format!("Not a directory: {}", dest.display())) + } + } + } +} + +// ── 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_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_state.rs b/crates/ruf4/tests/test_state.rs index 29733d1..9af065d 100644 --- a/crates/ruf4/tests/test_state.rs +++ b/crates/ruf4/tests/test_state.rs @@ -443,3 +443,30 @@ fn test_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)); +} From 5215b02eb5af51d28ce2cf5c023247f12d73850e Mon Sep 17 00:00:00 2001 From: kromych Date: Sat, 4 Jul 2026 17:03:06 -0700 Subject: [PATCH 5/6] SMB shares (smb://) cd smb://[user@]host/share/path opens an SMB share through the operating system's native client and the panel then works on an ordinary local directory: Windows rewrites the URL to a UNC path, macOS mounts with mount_smbfs under ~/.ruf4/mnt (a marker file beside the mountpoint lets an unmounted share remount when revisited through history), Linux mounts in user space with gio mount (GVFS). Credential prompts run with the panels hidden, like the ssh bootstrap. Mounted shares appear in the change-root dialog next to the local drives. Co-Authored-By: Claude Fable 5 --- ReadMe.md | 19 ++ crates/ruf4/src/lib.rs | 1 + crates/ruf4/src/smb.rs | 431 ++++++++++++++++++++++++++++++++++ crates/ruf4/src/state.rs | 5 + crates/ruf4/src/vfs.rs | 36 ++- crates/ruf4/tests/test_smb.rs | 181 ++++++++++++++ 6 files changed, 666 insertions(+), 7 deletions(-) create mode 100644 crates/ruf4/src/smb.rs create mode 100644 crates/ruf4/tests/test_smb.rs diff --git a/ReadMe.md b/ReadMe.md index 9c30c18..f000452 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -169,6 +169,25 @@ prompts work; subsequent channels multiplex over its socket. Set 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 Most confirmation dialogs respond to: diff --git a/crates/ruf4/src/lib.rs b/crates/ruf4/src/lib.rs index 9753a9e..cddcfed 100644 --- a/crates/ruf4/src/lib.rs +++ b/crates/ruf4/src/lib.rs @@ -15,6 +15,7 @@ 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/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 54d9b18..ca3b91b 100644 --- a/crates/ruf4/src/state.rs +++ b/crates/ruf4/src/state.rs @@ -515,6 +515,11 @@ impl State { pub fn open_choose_root(&mut self) { 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; diff --git a/crates/ruf4/src/vfs.rs b/crates/ruf4/src/vfs.rs index 21389cc..68c884c 100644 --- a/crates/ruf4/src/vfs.rs +++ b/crates/ruf4/src/vfs.rs @@ -16,6 +16,9 @@ //! 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; @@ -167,7 +170,7 @@ pub fn is_remote(path: &Path) -> bool { /// 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(SCHEME) || s.starts_with(crate::smb::SCHEME) } /// Collapse `//`, `.`, and `..` in a POSIX path, lexically. @@ -432,10 +435,20 @@ pub fn ensure_host(path: &Path) -> Result<(), String> { // ── Navigation ────────────────────────────────────────────────────────────── /// Resolve `path` to a directory the panel can show. Local paths are -/// canonicalized and verified; `ssh://` paths bootstrap the connection -/// (interactively when authentication is required), substitute the remote -/// home for an empty path, and are verified to be directories. +/// 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)?; @@ -465,14 +478,23 @@ pub fn prepare_dir(path: &Path) -> Result { None => { let dest = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); if dest.is_dir() { - Ok(dest) - } else { - Err(format!("Not a directory: {}", dest.display())) + 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 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" + ); +} From 1363751453251f31e5b8058fb7b797b72fa7d5cf Mon Sep 17 00:00:00 2001 From: kromych Date: Sat, 4 Jul 2026 21:05:01 -0700 Subject: [PATCH 6/6] update version --- ReadMe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ReadMe.md b/ReadMe.md index f000452..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