From 11a941a16b8daa407556eaa9a1884e2b3cd0ccb9 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 30 Aug 2026 08:46:18 -0400 Subject: [PATCH 1/7] feat: add SSHTest connection preflight diagnostics --- README.md | 1 + lua/sshfs/api.lua | 24 ++++++ lua/sshfs/diagnostic.lua | 154 +++++++++++++++++++++++++++++++++++ lua/sshfs/init.lua | 1 + lua/sshfs/lib/ssh.lua | 171 ++++++++++++++++++++++----------------- lua/sshfs/lib/sshfs.lua | 31 ++++--- 6 files changed, 295 insertions(+), 87 deletions(-) create mode 100644 lua/sshfs/diagnostic.lua diff --git a/README.md b/README.md index ee346f6..e1cabdb 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ require("sshfs").setup({ - `:checkhealth sshfs` - Verify dependencies and configuration - `:SSHConnect [host]` - Mount a remote host +- `:SSHTest [host]` - Test SSH resolution and authentication without mounting - `:SSHDisconnect` - Unmount current host - `:SSHDisconnectAll` - Unmount all hosts - `:SSHConfig` - Edit SSH config files diff --git a/lua/sshfs/api.lua b/lua/sshfs/api.lua index 1e5d562..55d4ad8 100644 --- a/lua/sshfs/api.lua +++ b/lua/sshfs/api.lua @@ -18,6 +18,20 @@ Api.connect = function(host) end end +--- Test an SSH host without mounting - use picker if no host provided. +--- @param host table|nil SSH host object (optional) +Api.test = function(host) + local Diagnostic = require("sshfs.diagnostic") + if host then + Diagnostic.test(host) + else + local Select = require("sshfs.ui.select") + Select.host(function(selected_host) + if selected_host then Diagnostic.test(selected_host) end + end) + end +end + --- Mount SSH host (alias for connect) Api.mount = function() Api.connect() @@ -325,6 +339,16 @@ Api.setup = function() end end, { nargs = "?", desc = "Remotely connect to host via picker or command as argument." }) + vim.api.nvim_create_user_command("SSHTest", function(opts) + if opts.args and opts.args ~= "" then + local SSHConfig = require("sshfs.lib.ssh_config") + local host = SSHConfig.parse_host(opts.args) + Api.test(host) + else + Api.test() + end + end, { nargs = "?", desc = "Test SSH connection and show resolved parameters and exit codes" }) + vim.api.nvim_create_user_command("SSHConfig", function() Api.config() end, { desc = "Edit SSH config files" }) diff --git a/lua/sshfs/diagnostic.lua b/lua/sshfs/diagnostic.lua new file mode 100644 index 0000000..b69e005 --- /dev/null +++ b/lua/sshfs/diagnostic.lua @@ -0,0 +1,154 @@ +-- lua/sshfs/diagnostic.lua +-- Read-only SSH connection preflight diagnostics. + +local Diagnostic = {} + +local function shell_join(cmd) + return table.concat(vim.tbl_map(vim.fn.shellescape, cmd), " ") +end + +local function run(cmd, callback) + vim.system(cmd, { text = true }, function(obj) + vim.schedule(function() + callback({ + command = cmd, + code = obj.code, + stdout = vim.trim(obj.stdout or ""), + stderr = vim.trim(obj.stderr or ""), + }) + end) + end) +end + +local function parse_ssh_config(output) + local resolved = {} + for line in output:gmatch("[^\r\n]+") do + local key, value = line:match("^(%S+)%s+(.+)$") + if key and value then + if resolved[key] == nil then + resolved[key] = value + elseif key == "identityfile" then + resolved[key] = resolved[key] .. ", " .. value + end + end + end + return resolved +end + +local function append_output(lines, label, output) + if output == "" then return end + table.insert(lines, label .. ":") + for line in output:gmatch("[^\r\n]+") do + table.insert(lines, " " .. line) + end +end + +local function append_result(lines, name, result) + table.insert(lines, string.format("[%s] %s", result.code == 0 and "PASS" or "FAIL", name)) + table.insert(lines, "Command: " .. shell_join(result.command)) + table.insert(lines, "Exit code: " .. tostring(result.code)) + append_output(lines, "stdout", result.stdout) + append_output(lines, "stderr", result.stderr) + table.insert(lines, "") +end + +local function show_report(host, resolved, config_result, auth_result, home_result) + local Sshfs = require("sshfs.lib.sshfs") + local lines = { + "sshfs.nvim connection test", + "============================", + "", + "Input", + " host: " .. tostring(host.name), + " user: " .. tostring(host.user or "(SSH config/default)"), + " path: " .. tostring(host.path or "(selected during connect)"), + " port: " .. tostring(host.port or "(SSH config/default)"), + "", + "Resolved SSH configuration", + " hostname: " .. tostring(resolved.hostname or "unknown"), + " user: " .. tostring(resolved.user or "unknown"), + " port: " .. tostring(resolved.port or "unknown"), + " proxyjump: " .. tostring(resolved.proxyjump or "none"), + " identityfile: " .. tostring(resolved.identityfile or "default"), + "", + "Tests", + "", + } + + append_result(lines, "SSH configuration", config_result) + append_result(lines, "SSH authentication", auth_result) + if home_result then append_result(lines, "Remote home", home_result) end + + table.insert(lines, "SSHFS command") + if host.path and host.path ~= "" then + local remote_path = host.path + if home_result and home_result.code == 0 and remote_path:match("^~") then + local home_path = vim.trim(home_result.stdout) + if home_path:sub(1, 1) == "/" then remote_path = remote_path:gsub("^~", home_path) end + end + local mount_cmd = Sshfs.build_mount_command(host, "", remote_path) + table.insert(lines, " " .. shell_join(mount_cmd)) + table.insert(lines, " Note: is determined by SSHConnect and is not created by SSHTest.") + else + table.insert(lines, " Not shown: SSHConnect prompts for a remote path before the mount command can be determined.") + end + table.insert(lines, "") + table.insert(lines, "This preflight does not mount SSHFS or leave diagnostic ControlMaster state behind.") + + vim.cmd("new") + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_name(buf, string.format("sshfs://test/%s/%d", host.name, buf)) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].swapfile = false + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false +end + +--- Run a read-only preflight for a host and show a scratch-buffer report. +---@param host table Host object produced by ssh_config.parse_host/get_host_config +function Diagnostic.test(host) + local Ssh = require("sshfs.lib.ssh") + + local config_cmd = { "ssh", "-G" } + if host.port then vim.list_extend(config_cmd, { "-p", tostring(host.port) }) end + if host.user then vim.list_extend(config_cmd, { "-l", host.user }) end + table.insert(config_cmd, host.name) + + run(config_cmd, function(config_result) + local resolved = parse_ssh_config(config_result.stdout) + local had_control_master = Ssh.has_control_master(host) + local socket_dir, socket_error = Ssh.prepare_socket_dir() + + if not socket_dir then + show_report(host, resolved, config_result, { + command = Ssh.build_batch_command(host), + code = 1, + stdout = "", + stderr = socket_error or "Failed to prepare SSH control socket directory", + }, nil) + return + end + + local function finish(auth_result, home_result) + if not had_control_master then Ssh.cleanup_control_master(host) end + show_report(host, resolved, config_result, auth_result, home_result) + end + + local auth_cmd = Ssh.build_batch_command(host) + run(auth_cmd, function(auth_result) + local needs_home = host.path and host.path:match("^~") + if not needs_home or auth_result.code ~= 0 then + finish(auth_result, nil) + return + end + + run(Ssh.build_home_command(host), function(home_result) + finish(auth_result, home_result) + end) + end) + end) +end + +return Diagnostic diff --git a/lua/sshfs/init.lua b/lua/sshfs/init.lua index f31434f..7522205 100644 --- a/lua/sshfs/init.lua +++ b/lua/sshfs/init.lua @@ -84,5 +84,6 @@ App.explore = Api.explore App.change_dir = Api.change_dir App.ssh_terminal = Api.ssh_terminal App.command = Api.command +App.test = Api.test return App diff --git a/lua/sshfs/lib/ssh.lua b/lua/sshfs/lib/ssh.lua index f31a8b7..35da207 100644 --- a/lua/sshfs/lib/ssh.lua +++ b/lua/sshfs/lib/ssh.lua @@ -60,6 +60,34 @@ local function get_ssh_options(auth_type) return options end +local function normalize_host(host) + if type(host) == "table" then return host end + return { name = host } +end + +local function append_host_options(cmd, host) + host = normalize_host(host) + if host.port then vim.list_extend(cmd, { "-p", tostring(host.port) }) end + if host.user then vim.list_extend(cmd, { "-l", host.user }) end + table.insert(cmd, host.name) +end + +local function build_with_options(host, auth_type) + local cmd = { "ssh" } + for _, opt in ipairs(get_ssh_options(auth_type)) do + vim.list_extend(cmd, { "-o", opt }) + end + append_host_options(cmd, host) + return cmd +end + +--- Ensure the configured SSH ControlMaster socket directory exists. +--- @return string|nil socket_dir +--- @return string|nil error_msg +function Ssh.prepare_socket_dir() + return get_or_create_socket_dir() +end + --- Build SSH command string with options for use with sshfs ssh_command option --- @param auth_type string|nil Authentication type ("batch", "socket", or nil) --- @return string SSH command string (e.g., "ssh -o ControlMaster=auto -o ControlPath=... -o BatchMode=yes") @@ -75,6 +103,60 @@ function Ssh.build_command_string(auth_type) return table.concat(cmd_parts, " ") end +--- Build the same non-interactive authentication command used by SSHConnect. +---@param host table|string Host object or SSH host name +---@return table SSH command array +function Ssh.build_batch_command(host) + local cmd = build_with_options(host, "batch") + table.insert(cmd, "exit") + return cmd +end + +--- Build the command used to resolve the remote home through an established socket. +---@param host table|string Host object or SSH host name +---@return table SSH command array +function Ssh.build_home_command(host) + local cmd = build_with_options(host, "socket") + table.insert(cmd, "readlink -f $HOME 2>/dev/null || echo $HOME") + return cmd +end + +--- Build the interactive authentication command used after batch auth fails. +---@param host table|string Host object or SSH host name +---@return table SSH command array +function Ssh.build_auth_command(host) + local cmd = { "ssh" } + for _, opt in ipairs(get_ssh_options(nil)) do + if opt:match("^ControlMaster=") then opt = "ControlMaster=yes" end + vim.list_extend(cmd, { "-o", opt }) + end + append_host_options(cmd, host) + table.insert(cmd, "exit") + return cmd +end + +--- Build a ControlMaster management command. +---@param host table|string SSH host object or name +---@param operation string Control operation such as "check" or "exit" +---@return table SSH command array +function Ssh.build_control_command(host, operation) + local cmd = { "ssh" } + for _, opt in ipairs(get_ssh_options("socket")) do + vim.list_extend(cmd, { "-o", opt }) + end + vim.list_extend(cmd, { "-O", operation }) + append_host_options(cmd, host) + return cmd +end + +--- Return whether a matching ControlMaster socket is already active. +---@param host table|string SSH host object or name +---@return boolean +function Ssh.has_control_master(host) + vim.fn.system(Ssh.build_control_command(host, "check")) + return vim.v.shell_error == 0 +end + --- Build a safe cd command that handles tilde expansion and path escaping --- @param remote_path string Remote path to cd into --- @return string Shell command to cd into the path @@ -96,20 +178,11 @@ local function build_cd_command(remote_path) end --- Build SSH command with optional remote path and ControlMaster options ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param remote_path string|nil Optional remote path to cd into ---@return table SSH command as array (safer than string to avoid shell injection) function Ssh.build_command(host, remote_path) - local cmd = { "ssh" } - - -- Add SSH options (ControlMaster, etc.) - local options = get_ssh_options(nil) -- No auth type for interactive terminal - for _, opt in ipairs(options) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - - table.insert(cmd, host) + local cmd = build_with_options(host, nil) -- If remote_path specified, cd into it and start a login shell if remote_path and remote_path ~= "" then @@ -122,7 +195,7 @@ function Ssh.build_command(host, remote_path) end --- Open SSH terminal session ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param remote_path string|nil Optional remote path to cd into function Ssh.open_terminal(host, remote_path) local ssh_cmd = Ssh.build_command(host, remote_path) @@ -134,21 +207,10 @@ end --- Get remote home directory by executing 'echo $HOME' on the remote server (async) --- This handles non-standard home directory structures (e.g., /home//) --- Uses existing ControlMaster socket if available for zero authentication overhead ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param callback function Callback(home_path: string|nil, error: string|nil) function Ssh.get_remote_home(host, callback) - local cmd = { "ssh" } - - -- Add ControlPath option to reuse existing socket - local options = get_ssh_options("socket") - for _, opt in ipairs(options) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - - table.insert(cmd, host) - -- Use readlink -f to resolve symlinks and get the canonical path with fallback if no readlink - table.insert(cmd, "readlink -f $HOME 2>/dev/null || echo $HOME") + local cmd = Ssh.build_home_command(host) -- Execute asynchronously vim.system(cmd, { text = true }, function(obj) @@ -170,31 +232,18 @@ end --- Close ControlMaster connection and clean up socket --- Sends "exit" command to ControlMaster to gracefully close connection and remove socket ----@param host string SSH host name +---@param host table|string SSH host object or name ---@return boolean True if cleanup command was sent successfully function Ssh.cleanup_control_master(host) - local Config = require("sshfs.config") - local control_opts = Config.get_control_master_options() - - -- Build ssh -O exit command for ControlPath - local cmd = { "ssh" } - for _, opt in ipairs(control_opts) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - table.insert(cmd, "-O") - table.insert(cmd, "exit") - table.insert(cmd, host) - -- Execute synchronously (must complete before nvim exit) - vim.fn.system(cmd) + vim.fn.system(Ssh.build_control_command(host, "exit")) -- Ignore exit code - socket may already be closed/expired return true end --- Try batch SSH connection to establish ControlMaster socket (async, non-interactive) --- Attempts to connect using existing keys without prompting for passwords or passphrases ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param callback function Callback(success: boolean, exit_code: number, error: string|nil) function Ssh.try_batch_connect(host, callback) -- Ensure socket directory exists before attempting connection @@ -206,20 +255,8 @@ function Ssh.try_batch_connect(host, callback) return end - local cmd = { "ssh" } - - -- Add SSH options for batch connection (ControlMaster=yes + BatchMode=yes) - local options = get_ssh_options("batch") - for _, opt in ipairs(options) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - - -- Add host and exit command (just test connection, don't start shell) - table.insert(cmd, host) - table.insert(cmd, "exit") - - -- Execute asynchronously + -- Build and execute the batch command asynchronously + local cmd = Ssh.build_batch_command(host) vim.system(cmd, { text = true }, function(obj) vim.schedule(function() local success = obj.code == 0 @@ -232,7 +269,7 @@ end --- Open interactive SSH terminal for authentication in floating window (async) --- Allows user to complete any SSH authentication method (password, 2FA, host verification, etc.) --- Creates floating terminal window and tracks exit code for success/failure ----@param host string SSH host name +---@param host table|string SSH host object or name ---@param callback function Callback(success: boolean, exit_code: number) function Ssh.open_auth_terminal(host, callback) -- Ensure socket directory exists before attempting connection @@ -246,28 +283,12 @@ function Ssh.open_auth_terminal(host, callback) end -- Build SSH command for authentication (ControlMaster=yes to create socket) - local cmd = { "ssh" } - local options = get_ssh_options(nil) -- Get ControlMaster options - local modified_opts = {} - for _, opt in ipairs(options) do - if opt:match("^ControlMaster=") then - table.insert(modified_opts, "ControlMaster=yes") - else - table.insert(modified_opts, opt) - end - end - - -- Finalize command options, end with exit to close shell after authentication flow - for _, opt in ipairs(modified_opts) do - table.insert(cmd, "-o") - table.insert(cmd, opt) - end - table.insert(cmd, host) - table.insert(cmd, "exit") + local host_obj = normalize_host(host) + local cmd = Ssh.build_auth_command(host_obj) -- Open authentication terminal window local Terminal = require("sshfs.ui.terminal") - Terminal.open_auth_floating(cmd, host, callback) + Terminal.open_auth_floating(cmd, host_obj.name, callback) end return Ssh diff --git a/lua/sshfs/lib/sshfs.lua b/lua/sshfs/lib/sshfs.lua index 9e5131d..f0b3617 100644 --- a/lua/sshfs/lib/sshfs.lua +++ b/lua/sshfs/lib/sshfs.lua @@ -20,6 +20,7 @@ local function build_sshfs_args(options_table) -- false or nil: skip this option end + table.sort(result) return result end @@ -34,8 +35,7 @@ local function get_sshfs_options(auth_type) -- Add user-configured sshfs options from config (convert table to array) if opts.connections and opts.connections.sshfs_options then - local sshfs_opts = build_sshfs_args(opts.connections.sshfs_options) - vim.list_extend(options, sshfs_opts) + vim.list_extend(options, build_sshfs_args(opts.connections.sshfs_options)) end -- Add SSH command to reuse existing ControlMaster socket @@ -47,12 +47,12 @@ local function get_sshfs_options(auth_type) return options end ---- Execute the actual mount command (private helper) +--- Build the SSHFS command used to mount an already resolved remote path. --- @param host table Host object with name, user, port, and path fields --- @param mount_point string Local mount point directory --- @param remote_path_suffix string Remote path to mount (already resolved) ---- @param callback function Callback function(result: table) - result has fields: success, message, resolved_path -local function mount_with_path(host, mount_point, remote_path_suffix, callback) +--- @return table command SSHFS command array +function Sshfs.build_mount_command(host, mount_point, remote_path_suffix) local options = get_sshfs_options("socket") -- Use host.name (the alias) to let SSH config resolution work properly @@ -62,10 +62,17 @@ local function mount_with_path(host, mount_point, remote_path_suffix, callback) -- Add options/port local cmd = { "sshfs", remote_path, mount_point, "-o", table.concat(options, ",") } - if host.port then - table.insert(cmd, "-p") - table.insert(cmd, host.port) - end + if host.port then vim.list_extend(cmd, { "-p", tostring(host.port) }) end + return cmd +end + +--- Execute the actual mount command (private helper) +--- @param host table Host object with name, user, port, and path fields +--- @param mount_point string Local mount point directory +--- @param remote_path_suffix string Remote path to mount (already resolved) +--- @param callback function Callback function(result: table) - result has fields: success, message, resolved_path +local function mount_with_path(host, mount_point, remote_path_suffix, callback) + local cmd = Sshfs.build_mount_command(host, mount_point, remote_path_suffix) -- Execute mount command asynchronously vim.system(cmd, { text = true }, function(obj) @@ -100,7 +107,7 @@ local function mount_via_socket(host, mount_point, remote_path_suffix, callback) -- This handles symlinked home directories and non-standard structures if remote_path_suffix:match("^~") then local Ssh = require("sshfs.lib.ssh") - Ssh.get_remote_home(host.name, function(actual_home, error) + Ssh.get_remote_home(host, function(actual_home, error) if actual_home then -- Replace ~ with the actual home path and mount local resolved_path = remote_path_suffix:gsub("^~", actual_home) @@ -130,14 +137,14 @@ function Sshfs.authenticate_and_mount(host, mount_point, remote_path_suffix, cal vim.notify("Connecting to " .. host.name .. "...", vim.log.levels.INFO) -- Try batch connection (non-interactive) - Ssh.try_batch_connect(host.name, function(success, exit_code, error) + Ssh.try_batch_connect(host, function(success, exit_code, error) if success then mount_via_socket(host, mount_point, remote_path_suffix, callback) return end -- Batch failed, try interactive terminal - Ssh.open_auth_terminal(host.name, function(term_success, term_exit_code) + Ssh.open_auth_terminal(host, function(term_success, term_exit_code) if term_success then mount_via_socket(host, mount_point, remote_path_suffix, callback) else From a337138775885198be926a9bd07e565ef096438a Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Sun, 30 Aug 2026 23:51:02 -0400 Subject: [PATCH 2/7] fix: omit the raw ssh -G dump from the SSHTest report `ssh -G` prints the entire resolved configuration, which added roughly 85 lines and buried the test results. The "Resolved SSH configuration" section already summarizes the relevant fields, so stdout is now suppressed for that step while stderr is still shown. --- lua/sshfs/diagnostic.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lua/sshfs/diagnostic.lua b/lua/sshfs/diagnostic.lua index b69e005..4975051 100644 --- a/lua/sshfs/diagnostic.lua +++ b/lua/sshfs/diagnostic.lua @@ -43,11 +43,12 @@ local function append_output(lines, label, output) end end -local function append_result(lines, name, result) +--- @param include_stdout boolean|nil Set to false to omit stdout (already summarized elsewhere) +local function append_result(lines, name, result, include_stdout) table.insert(lines, string.format("[%s] %s", result.code == 0 and "PASS" or "FAIL", name)) table.insert(lines, "Command: " .. shell_join(result.command)) table.insert(lines, "Exit code: " .. tostring(result.code)) - append_output(lines, "stdout", result.stdout) + if include_stdout ~= false then append_output(lines, "stdout", result.stdout) end append_output(lines, "stderr", result.stderr) table.insert(lines, "") end @@ -75,7 +76,9 @@ local function show_report(host, resolved, config_result, auth_result, home_resu "", } - append_result(lines, "SSH configuration", config_result) + -- `ssh -G` prints the whole resolved config; the summary above already covers + -- the fields that matter, so the raw dump is omitted to keep the report readable. + append_result(lines, "SSH configuration", config_result, false) append_result(lines, "SSH authentication", auth_result) if home_result then append_result(lines, "Remote home", home_result) end From 06d6f40f4b523b7fe72676f4c9f3b62b5b8de7b1 Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Mon, 31 Aug 2026 00:02:48 -0400 Subject: [PATCH 3/7] fix: only close a ControlMaster the preflight itself created Cleanup previously ran whenever no master existed before the test, which races with a concurrent :SSHConnect: a socket created in that window was closed by the preflight, tearing down the other connection. Replace has_control_master with control_master_pid and claim ownership only when no master existed beforehand and the batch command connected successfully, then verify the pid is unchanged before sending -O exit. --- lua/sshfs/diagnostic.lua | 14 ++++++++++++-- lua/sshfs/lib/ssh.lua | 13 ++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/lua/sshfs/diagnostic.lua b/lua/sshfs/diagnostic.lua index 4975051..4d3b104 100644 --- a/lua/sshfs/diagnostic.lua +++ b/lua/sshfs/diagnostic.lua @@ -121,7 +121,7 @@ function Diagnostic.test(host) run(config_cmd, function(config_result) local resolved = parse_ssh_config(config_result.stdout) - local had_control_master = Ssh.has_control_master(host) + local pid_before = Ssh.control_master_pid(host) local socket_dir, socket_error = Ssh.prepare_socket_dir() if not socket_dir then @@ -134,13 +134,23 @@ function Diagnostic.test(host) return end + -- Only a master this preflight started may be closed. Closing on "no master + -- existed beforehand" alone would race with a concurrent :SSHConnect that + -- creates the socket in the meantime, tearing down that connection instead. + local owned_pid = nil + local function finish(auth_result, home_result) - if not had_control_master then Ssh.cleanup_control_master(host) end + if owned_pid and Ssh.control_master_pid(host) == owned_pid then Ssh.cleanup_control_master(host) end show_report(host, resolved, config_result, auth_result, home_result) end local auth_cmd = Ssh.build_batch_command(host) run(auth_cmd, function(auth_result) + -- ControlMaster=yes refuses to take over an existing socket, so a master + -- is this command's own only when none existed beforehand and the command + -- itself connected successfully. + if pid_before == nil and auth_result.code == 0 then owned_pid = Ssh.control_master_pid(host) end + local needs_home = host.path and host.path:match("^~") if not needs_home or auth_result.code ~= 0 then finish(auth_result, nil) diff --git a/lua/sshfs/lib/ssh.lua b/lua/sshfs/lib/ssh.lua index 35da207..a8d0017 100644 --- a/lua/sshfs/lib/ssh.lua +++ b/lua/sshfs/lib/ssh.lua @@ -149,12 +149,15 @@ function Ssh.build_control_command(host, operation) return cmd end ---- Return whether a matching ControlMaster socket is already active. +--- Return the pid of the active ControlMaster for a host, if one is running. +--- The pid identifies a specific master process, so a caller that created a +--- master can prove the socket still belongs to it before closing it. ---@param host table|string SSH host object or name ----@return boolean -function Ssh.has_control_master(host) - vim.fn.system(Ssh.build_control_command(host, "check")) - return vim.v.shell_error == 0 +---@return number|nil pid Master pid, or nil when no master is running +function Ssh.control_master_pid(host) + local output = vim.fn.system(Ssh.build_control_command(host, "check")) + if vim.v.shell_error ~= 0 then return nil end + return tonumber((output or ""):match("pid=(%d+)")) end --- Build a safe cd command that handles tilde expansion and path escaping From d683d325791978be461a7622105b1677a4ef8e43 Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Mon, 31 Aug 2026 00:13:56 -0400 Subject: [PATCH 4/7] test: add automated test framework and baseline coverage Adds a dependency-free unit test harness that runs inside headless Neovim, plus regression coverage for the behavior that exists on main. The harness provides describe/it, deep-equality and error assertions, and a runner (`nvim -l tests/run.lua`, wrapped as `make test`) that exits non-zero on failure so CI can gate on it. A stub module replaces the handful of system-facing calls the plugin makes (vim.fn.system, vim.fn.executable, vim.system, vim.notify) and restores them per case, so no test contacts a real SSH server or mount table. Baseline suites cover mount table parsing for Linux fuse.sshfs, macFUSE and findmnt output, mount directory creation, SSH command construction including tilde expansion and quote escaping, ad-hoc host string parsing, configuration merging with its deprecation shims, and remote path mapping. CI runs the suite and stylua --check on every pull request. Refs #24 --- .github/workflows/test.yml | 33 ++++++++ Makefile | 12 +++ README.md | 10 +++ tests/README.md | 55 +++++++++++++ tests/config_spec.lua | 98 ++++++++++++++++++++++ tests/harness.lua | 165 +++++++++++++++++++++++++++++++++++++ tests/mount_point_spec.lua | 146 ++++++++++++++++++++++++++++++++ tests/path_spec.lua | 34 ++++++++ tests/run.lua | 43 ++++++++++ tests/ssh_config_spec.lua | 55 +++++++++++++ tests/ssh_spec.lua | 92 +++++++++++++++++++++ tests/stub.lua | 152 ++++++++++++++++++++++++++++++++++ 12 files changed, 895 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 Makefile create mode 100644 tests/README.md create mode 100644 tests/config_spec.lua create mode 100644 tests/harness.lua create mode 100644 tests/mount_point_spec.lua create mode 100644 tests/path_spec.lua create mode 100644 tests/run.lua create mode 100644 tests/ssh_config_spec.lua create mode 100644 tests/ssh_spec.lua create mode 100644 tests/stub.lua diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e401dbb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Neovim + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + + - name: Run tests + run: nvim -l tests/run.lua + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check formatting + uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: latest + args: --check lua/ tests/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f09981 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: test lint format + +# Run the unit test suite. Pass PATTERN= to run a subset: +# make test PATTERN=mount_point +test: + nvim -l tests/run.lua $(PATTERN) + +lint: + stylua --check lua/ tests/ + +format: + stylua lua/ tests/ diff --git a/README.md b/README.md index ee346f6..09877f9 100644 --- a/README.md +++ b/README.md @@ -342,3 +342,13 @@ Auth flow: keys first, then floating terminal for passphrases/passwords/2FA; Con - **Configure `global_paths`** with common directories (`/var/www`, `/var/log`, `~/.config`) to have them available across all hosts - **Configure `host_paths`** for frequently-used hosts to skip path selection - **Set `preferred_picker` for local/remote pickers** to force specific file picker(s) instead of auto-detection + +## 🧪 Development + +Run the unit test suite with: + +```sh +make test +``` + +Tests run in headless Neovim with no external dependencies and never contact a real SSH server or mount table. See [`tests/README.md`](tests/README.md) for the harness, the available stubs, and how to add coverage. CI runs the suite and `stylua --check` on every pull request. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0cd2e1c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,55 @@ +# Tests + +Unit tests for sshfs.nvim. They run inside headless Neovim, use no external dependencies, and never contact a real SSH server or mount table. + +## Running + +```sh +make test # everything +make test PATTERN=mount_point # only spec files whose path contains the pattern +nvim -l tests/run.lua # same as `make test` +``` + +The runner exits non-zero when any case fails, so it can gate CI. `.github/workflows/test.yml` runs the suite and `stylua --check` on every pull request. + +## Layout + +| File | Purpose | +| --- | --- | +| `harness.lua` | `describe`/`it` registry, assertions, result reporting | +| `stub.lua` | Replaces the system-facing calls and restores them afterwards | +| `run.lua` | Entry point: discovers `tests/*_spec.lua` and exits with the result | +| `*_spec.lua` | One spec file per module under test | + +## Writing a test + +Spec files are plain Lua. The runner exposes `describe`, `it`, `expect`, and `stub` as globals, so no requires are needed. + +```lua +describe("MountPoint.list_active", function() + it("ignores mounts outside the configured base directory", function() + stub.reload() + require("sshfs.config").setup({ mounts = { base_dir = "/home/tester/mnt" } }) + stub.executable({}) + stub.system(function() + return "deploy@example.com:/srv/app on /somewhere/else type fuse.sshfs (rw)", 0 + end) + + expect.eq(require("sshfs.lib.mount_point").list_active(), {}) + stub.restore_all() + end) +end) +``` + +### Assertions + +`expect.eq` (deep equality), `expect.truthy`, `expect.falsy`, `expect.is_nil`, `expect.contains` (plain substring), `expect.errors`, `expect.no_error`. Each takes an optional trailing context string that is shown on failure. + +### Stubs + +`stub.system(handler)` replaces `vim.fn.system` and the `v:shell_error` it reports; the handler returns `output, code`. `stub.vim_system(handler)` replaces `vim.system`, supporting both the async-callback and `:wait()` call styles, and returns the list of commands it received. `stub.executable(names)` controls `vim.fn.executable`. `stub.notifications()` captures `vim.notify` calls instead of printing them. `stub.set(path, value)` replaces any other field under `vim`. + +Two rules matter: + +- Call `stub.restore_all()` at the end of a case that stubbed anything. +- Call `stub.reload()` before requiring a plugin module when the test depends on fresh module state. Several modules memoize (configuration, SSHFS version detection), so a stale load will leak state between cases. diff --git a/tests/config_spec.lua b/tests/config_spec.lua new file mode 100644 index 0000000..c8aaf00 --- /dev/null +++ b/tests/config_spec.lua @@ -0,0 +1,98 @@ +-- tests/config_spec.lua +-- Configuration merging, accessors, and deprecation shims + +local function load_config(user_config) + stub.reload() + local Config = require("sshfs.config") + Config.setup(user_config) + return Config +end + +describe("Config.setup", function() + it("keeps defaults the user did not override", function() + local Config = load_config({ mounts = { base_dir = "/custom/mnt" } }) + local opts = Config.get() + + expect.eq(opts.mounts.base_dir, "/custom/mnt") + expect.eq(opts.connections.sshfs_options.reconnect, true) + expect.eq(opts.connections.sshfs_options.ConnectTimeout, 5) + end) + + it("merges nested tables instead of replacing them", function() + local Config = load_config({ connections = { sshfs_options = { ConnectTimeout = 30 } } }) + local options = Config.get().connections.sshfs_options + + expect.eq(options.ConnectTimeout, 30, "the overridden value wins") + expect.eq(options.compression, "yes", "sibling defaults survive the merge") + end) + + it("accepts no user configuration at all", function() + local Config = load_config(nil) + expect.truthy(Config.get().mounts.base_dir) + end) +end) + +describe("Config accessors", function() + it("returns the configured mount base directory", function() + local Config = load_config({ mounts = { base_dir = "/custom/mnt" } }) + expect.eq(Config.get_base_dir(), "/custom/mnt") + end) + + it("returns the configured socket directory", function() + local Config = load_config({ connections = { socket_dir = "/custom/sockets" } }) + expect.eq(Config.get_socket_dir(), "/custom/sockets") + end) + + it("builds ControlMaster options from the socket directory and persist window", function() + local Config = load_config({ connections = { socket_dir = "/custom/sockets", control_persist = "5m" } }) + + expect.eq(Config.get_control_master_options(), { + "ControlMaster=auto", + "ControlPath=/custom/sockets/%C", + "ControlPersist=5m", + }) + end) +end) + +describe("Config deprecations", function() + it("maps ui.file_picker onto ui.local_picker and warns", function() + stub.reload() + local notifications = stub.notifications() + local Config = require("sshfs.config") + Config.setup({ ui = { file_picker = { preferred_picker = "telescope" } } }) + stub.restore_all() + + expect.eq(Config.get().ui.local_picker.preferred_picker, "telescope") + expect.eq(#notifications, 1) + expect.contains(notifications[1].message, "ui.file_picker") + end) + + it("maps mounts.unmount_on_exit onto hooks.on_exit.auto_unmount", function() + stub.reload() + stub.notifications() + local Config = require("sshfs.config") + Config.setup({ mounts = { unmount_on_exit = true } }) + stub.restore_all() + + expect.eq(Config.get().hooks.on_exit.auto_unmount, true) + end) + + it("maps mounts.auto_change_dir_on_mount onto hooks.on_mount.auto_change_to_dir", function() + stub.reload() + stub.notifications() + local Config = require("sshfs.config") + Config.setup({ mounts = { auto_change_dir_on_mount = false } }) + stub.restore_all() + + expect.eq(Config.get().hooks.on_mount.auto_change_to_dir, false) + end) + + it("stays quiet when no deprecated key is used", function() + stub.reload() + local notifications = stub.notifications() + require("sshfs.config").setup({ mounts = { base_dir = "/custom/mnt" } }) + stub.restore_all() + + expect.eq(#notifications, 0) + end) +end) diff --git a/tests/harness.lua b/tests/harness.lua new file mode 100644 index 0000000..a58b15e --- /dev/null +++ b/tests/harness.lua @@ -0,0 +1,165 @@ +-- tests/harness.lua +-- Minimal dependency-free test harness for sshfs.nvim +-- +-- Tests are plain Lua files that call describe/it and the expect helpers. The +-- runner executes them inside headless Neovim so the real vim API is available +-- and only the system-facing calls need stubbing. + +local Harness = {} + +local suites = {} +local current_suite = nil + +--- Register a group of test cases +--- @param name string Suite name +--- @param fn function Function that registers cases with it() +function Harness.describe(name, fn) + local suite = { name = name, cases = {} } + table.insert(suites, suite) + + current_suite = suite + local ok, err = pcall(fn) + current_suite = nil + + if not ok then + -- A suite that fails to register is reported as a single failing case so + -- the runner still exits non-zero instead of silently skipping it. + table.insert(suite.cases, { + name = "", + fn = function() + error(err, 0) + end, + }) + end +end + +--- Register a single test case +--- @param name string Case name +--- @param fn function Test body; failures are raised as errors +function Harness.it(name, fn) + if not current_suite then error("it() called outside of describe()", 2) end + table.insert(current_suite.cases, { name = name, fn = fn }) +end + +local function render(value) + if type(value) == "string" then return string.format("%q", value) end + if type(value) == "table" then return vim.inspect(value) end + return tostring(value) +end + +local function fail(message, level) + error(message, (level or 2) + 1) +end + +Harness.expect = {} + +--- Assert deep equality, comparing tables by value +function Harness.expect.eq(actual, expected, context) + if not vim.deep_equal(actual, expected) then + fail( + string.format("%sexpected %s\n got %s", context and (context .. ": ") or "", render(expected), render(actual)) + ) + end +end + +--- Assert a value is neither nil nor false +function Harness.expect.truthy(value, context) + if not value then + fail(string.format("%sexpected a truthy value, got %s", context and (context .. ": ") or "", render(value))) + end +end + +--- Assert a value is nil or false +function Harness.expect.falsy(value, context) + if value then + fail(string.format("%sexpected a falsy value, got %s", context and (context .. ": ") or "", render(value))) + end +end + +--- Assert a value is exactly nil (distinct from false) +function Harness.expect.is_nil(value, context) + if value ~= nil then + fail(string.format("%sexpected nil, got %s", context and (context .. ": ") or "", render(value))) + end +end + +--- Assert a string contains a plain substring +function Harness.expect.contains(haystack, needle, context) + if type(haystack) ~= "string" or not haystack:find(needle, 1, true) then + fail( + string.format( + "%sexpected %s to contain %s", + context and (context .. ": ") or "", + render(haystack), + render(needle) + ) + ) + end +end + +--- Assert a function raises, optionally matching a plain substring of the error +function Harness.expect.errors(fn, needle, context) + local ok, err = pcall(fn) + if ok then fail(string.format("%sexpected the call to raise an error", context and (context .. ": ") or "")) end + if needle then Harness.expect.contains(tostring(err), needle, context) end +end + +--- Assert a function does not raise, returning its first result +function Harness.expect.no_error(fn, context) + local ok, result = pcall(fn) + if not ok then + fail(string.format("%sexpected no error, got %s", context and (context .. ": ") or "", tostring(result))) + end + return result +end + +--- Run every registered suite and report results +--- @return number exit_code 0 when all cases pass +function Harness.run() + -- io.write keeps line breaks deterministic; print() interleaves oddly under `nvim -l` + local function say(text) + io.write((text or "") .. "\n") + end + + local passed, failed = 0, 0 + local failures = {} + + for _, suite in ipairs(suites) do + say(suite.name) + for _, case in ipairs(suite.cases) do + local ok, err = pcall(case.fn) + if ok then + passed = passed + 1 + say(" ok " .. case.name) + else + failed = failed + 1 + say(" FAIL " .. case.name) + table.insert(failures, { name = suite.name .. " :: " .. case.name, err = tostring(err) }) + end + end + end + + say("") + if failed > 0 then + say("Failures:") + for _, failure in ipairs(failures) do + say("") + say(" " .. failure.name) + for line in failure.err:gmatch("[^\r\n]+") do + say(" " .. line) + end + end + say("") + end + + say(string.format("%d passed, %d failed", passed, failed)) + return failed == 0 and 0 or 1 +end + +--- Discard registered suites (used by the runner between files in-process) +function Harness.reset() + suites = {} + current_suite = nil +end + +return Harness diff --git a/tests/mount_point_spec.lua b/tests/mount_point_spec.lua new file mode 100644 index 0000000..052c5a7 --- /dev/null +++ b/tests/mount_point_spec.lua @@ -0,0 +1,146 @@ +-- tests/mount_point_spec.lua +-- Mount table parsing and mount directory handling + +local BASE_DIR = "/home/tester/mnt" + +--- Load MountPoint with a known base directory and a stubbed system mount table +--- @param opts table {mount_output, findmnt_output, findmnt_available} +local function with_mounts(opts) + stub.reload() + require("sshfs.config").setup({ mounts = { base_dir = BASE_DIR } }) + + stub.executable({ findmnt = opts.findmnt_available or false }) + stub.system(function(cmd) + if type(cmd) == "table" and cmd[1] == "findmnt" then return opts.findmnt_output or "", opts.findmnt_code or 0 end + return opts.mount_output or "", 0 + end) + + return require("sshfs.lib.mount_point") +end + +describe("MountPoint.list_active", function() + it("parses Linux fuse.sshfs mount output", function() + local MountPoint = with_mounts({ + mount_output = table.concat({ + "proc on /proc type proc (rw,nosuid)", + "deploy@example.com:/srv/app on " .. BASE_DIR .. "/example type fuse.sshfs (rw,nosuid,nodev)", + }, "\n"), + }) + + expect.eq(MountPoint.list_active(), { + { + host = "example.com", + mount_path = BASE_DIR .. "/example", + remote_path = "/srv/app", + }, + }) + stub.restore_all() + end) + + it("parses macFUSE mount output", function() + local MountPoint = with_mounts({ + mount_output = "deploy@example.com:/srv/app on " .. BASE_DIR .. "/example (macfuse, nodev, nosuid)", + }) + + local mounts = MountPoint.list_active() + expect.eq(#mounts, 1) + expect.eq(mounts[1].host, "example.com") + expect.eq(mounts[1].remote_path, "/srv/app") + stub.restore_all() + end) + + it("parses a remote spec without a user", function() + local MountPoint = with_mounts({ + mount_output = "example.com:/srv/app on " .. BASE_DIR .. "/example type fuse.sshfs (rw)", + }) + + local mounts = MountPoint.list_active() + expect.eq(mounts[1].host, "example.com") + expect.eq(mounts[1].remote_path, "/srv/app") + stub.restore_all() + end) + + it("prefers findmnt output when findmnt is available", function() + local MountPoint = with_mounts({ + findmnt_available = true, + findmnt_output = "deploy@example.com:/srv/app " .. BASE_DIR .. "/example\n", + mount_output = "should-not-be-read on /elsewhere type fuse.sshfs (rw)", + }) + + local mounts = MountPoint.list_active() + expect.eq(#mounts, 1) + expect.eq(mounts[1].mount_path, BASE_DIR .. "/example") + stub.restore_all() + end) + + it("ignores mounts outside the configured base directory", function() + local MountPoint = with_mounts({ + mount_output = "deploy@example.com:/srv/app on /somewhere/else type fuse.sshfs (rw)", + }) + + expect.eq(MountPoint.list_active(), {}) + stub.restore_all() + end) + + it("ignores non-sshfs mount lines", function() + local MountPoint = with_mounts({ + mount_output = table.concat({ + "tmpfs on " .. BASE_DIR .. "/tmp type tmpfs (rw)", + "/dev/sda1 on " .. BASE_DIR .. "/disk type ext4 (rw)", + }, "\n"), + }) + + expect.eq(MountPoint.list_active(), {}) + stub.restore_all() + end) + + it("returns no mounts when the mount command fails", function() + stub.reload() + require("sshfs.config").setup({ mounts = { base_dir = BASE_DIR } }) + stub.executable({}) + stub.system(function() + return "", 1 + end) + + expect.eq(require("sshfs.lib.mount_point").list_active(), {}) + stub.restore_all() + end) +end) + +describe("MountPoint.format_label", function() + it("includes the remote path when it is meaningful", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + expect.eq(MountPoint.format_label({ host = "example.com", remote_path = "/srv/app" }), "example.com: /srv/app") + end) + + it("omits the remote path for root and empty paths", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + expect.eq(MountPoint.format_label({ host = "example.com", remote_path = "/" }), "example.com") + expect.eq(MountPoint.format_label({ host = "example.com", remote_path = "" }), "example.com") + expect.eq(MountPoint.format_label({ host = "example.com" }), "example.com") + end) +end) + +describe("MountPoint.get_or_create", function() + it("reports success for a directory that already exists", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + local temp_dir = vim.fn.tempname() + vim.fn.mkdir(temp_dir, "p") + + expect.truthy(MountPoint.get_or_create(temp_dir)) + vim.fn.delete(temp_dir, "rf") + end) + + it("creates a missing directory", function() + stub.reload() + local MountPoint = require("sshfs.lib.mount_point") + local temp_dir = vim.fn.tempname() .. "/nested/mount" + + expect.truthy(MountPoint.get_or_create(temp_dir)) + expect.eq(vim.fn.isdirectory(temp_dir), 1) + vim.fn.delete(vim.fn.fnamemodify(temp_dir, ":h:h"), "rf") + end) +end) diff --git a/tests/path_spec.lua b/tests/path_spec.lua new file mode 100644 index 0000000..2edb1eb --- /dev/null +++ b/tests/path_spec.lua @@ -0,0 +1,34 @@ +-- tests/path_spec.lua +-- Remote-to-local path mapping used by the picker integrations + +local function load_path() + stub.reload() + return require("sshfs.lib.path") +end + +describe("Path.map_remote_to_relative", function() + it("strips an absolute remote base path", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("/srv/app/lib/init.lua", "/srv/app"), "lib/init.lua") + end) + + it("strips a leading ./ for relative searches", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("./lib/init.lua", "."), "lib/init.lua") + end) + + it("strips a leading slash when the base does not match", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("/other/lib/init.lua", "/srv/app"), "other/lib/init.lua") + end) + + it("returns the base path itself as an empty relative path", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("/srv/app", "/srv/app"), "") + end) + + it("leaves an already relative path untouched", function() + local Path = load_path() + expect.eq(Path.map_remote_to_relative("lib/init.lua", "."), "lib/init.lua") + end) +end) diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..fca6f3d --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,43 @@ +-- tests/run.lua +-- Test entry point: `nvim -l tests/run.lua [pattern]` +-- +-- Discovers tests/*_spec.lua, runs them in one headless Neovim instance, and +-- exits non-zero when any case fails so CI can gate on it. + +local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") +vim.opt.runtimepath:prepend(root) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path + +local Harness = require("tests.harness") +local Stub = require("tests.stub") + +-- describe/it/expect are exposed as globals so spec files stay free of boilerplate +_G.describe = Harness.describe +_G.it = Harness.it +_G.expect = Harness.expect +_G.stub = Stub + +local pattern = arg and arg[1] +local spec_files = vim.fn.globpath(root .. "/tests", "*_spec.lua", false, true) +table.sort(spec_files) + +local loaded = 0 +for _, file in ipairs(spec_files) do + if not pattern or file:find(pattern, 1, true) then + loaded = loaded + 1 + local chunk, err = loadfile(file) + if not chunk then error("could not load " .. file .. ": " .. tostring(err)) end + chunk() + end +end + +if loaded == 0 then + print("no spec files matched" .. (pattern and (" pattern " .. pattern) or "")) + os.exit(1) +end + +-- Every case restores its own stubs, but a case that fails mid-test may not, so +-- the runner clears anything left behind before reporting. +local exit_code = Harness.run() +Stub.restore_all() +os.exit(exit_code) diff --git a/tests/ssh_config_spec.lua b/tests/ssh_config_spec.lua new file mode 100644 index 0000000..55d9ac3 --- /dev/null +++ b/tests/ssh_config_spec.lua @@ -0,0 +1,55 @@ +-- tests/ssh_config_spec.lua +-- Ad-hoc host string parsing used by :SSHConnect and friends + +local function load_ssh_config() + stub.reload() + return require("sshfs.lib.ssh_config") +end + +describe("SSHConfig.parse_host", function() + it("parses a bare host alias", function() + local host = load_ssh_config().parse_host("production") + + expect.eq(host.name, "production") + expect.is_nil(host.user) + expect.is_nil(host.path) + expect.is_nil(host.port) + end) + + it("parses user@host", function() + local host = load_ssh_config().parse_host("deploy@example.com") + + expect.eq(host.name, "example.com") + expect.eq(host.user, "deploy") + expect.is_nil(host.path) + end) + + it("parses user@host:path", function() + local host = load_ssh_config().parse_host("deploy@example.com:/srv/app") + + expect.eq(host.name, "example.com") + expect.eq(host.user, "deploy") + expect.eq(host.path, "/srv/app") + end) + + it("parses host:path without a user", function() + local host = load_ssh_config().parse_host("example.com:/srv/app") + + expect.eq(host.name, "example.com") + expect.is_nil(host.user) + expect.eq(host.path, "/srv/app") + end) + + it("extracts an explicit port and keeps it out of the host name", function() + local host = load_ssh_config().parse_host("deploy@example.com:/srv/app -p 2222") + + expect.eq(host.port, "2222") + expect.eq(host.name, "example.com") + expect.eq(host.path, "/srv/app") + end) + + it("treats an empty path as absent", function() + local host = load_ssh_config().parse_host("example.com:") + expect.is_nil(host.path) + end) +end) diff --git a/tests/ssh_spec.lua b/tests/ssh_spec.lua new file mode 100644 index 0000000..d9e555a --- /dev/null +++ b/tests/ssh_spec.lua @@ -0,0 +1,92 @@ +-- tests/ssh_spec.lua +-- SSH command construction + +local SOCKET_DIR = "/home/tester/.ssh/sockets" +local CONTROL_PATH = "ControlPath=" .. SOCKET_DIR .. "/%C" + +local function load_ssh() + stub.reload() + require("sshfs.config").setup({ connections = { socket_dir = SOCKET_DIR, control_persist = "10m" } }) + return require("sshfs.lib.ssh") +end + +describe("Ssh.build_command_string", function() + it("passes only the control path when reusing a socket", function() + local Ssh = load_ssh() + expect.eq(Ssh.build_command_string("socket"), "ssh -o " .. CONTROL_PATH) + end) + + it("forces a master and disables prompts for batch connections", function() + local Ssh = load_ssh() + local command = Ssh.build_command_string("batch") + + expect.contains(command, "ControlMaster=yes") + expect.contains(command, CONTROL_PATH) + expect.contains(command, "BatchMode=yes") + end) + + it("uses an automatic master for interactive sessions", function() + local Ssh = load_ssh() + local command = Ssh.build_command_string(nil) + + expect.contains(command, "ControlMaster=auto") + expect.contains(command, "ControlPersist=10m") + expect.falsy(command:find("BatchMode", 1, true), "interactive sessions must stay interactive") + end) +end) + +describe("Ssh.build_command", function() + it("returns a command list rather than a shell string", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", nil) + + expect.eq(cmd[1], "ssh") + expect.eq(cmd[#cmd], "example.com") + end) + + it("requests a tty and a login shell when a remote path is given", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "/srv/app") + + expect.eq(cmd[#cmd - 1], "-t") + expect.eq(cmd[#cmd], "cd '/srv/app' && exec $SHELL -l") + end) + + it("expands a bare tilde through the remote shell", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "~") + expect.eq(cmd[#cmd], "cd ~ && exec $SHELL -l") + end) + + it("expands the home prefix while quoting the remainder", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "~/projects/app") + expect.eq(cmd[#cmd], "cd ~ && cd 'projects/app' && exec $SHELL -l") + end) + + it("escapes single quotes in remote paths", function() + local Ssh = load_ssh() + local cmd = Ssh.build_command("example.com", "/srv/it's here") + + expect.contains(cmd[#cmd], "'/srv/it'\\''s here'") + end) +end) + +describe("Ssh.cleanup_control_master", function() + it("sends an exit control command for the host", function() + local Ssh = load_ssh() + local received = nil + stub.system(function(cmd) + received = cmd + return "" + end) + + Ssh.cleanup_control_master("example.com") + stub.restore_all() + + expect.eq(received[1], "ssh") + expect.eq(received[#received], "example.com") + expect.eq(received[#received - 2], "-O") + expect.eq(received[#received - 1], "exit") + end) +end) diff --git a/tests/stub.lua b/tests/stub.lua new file mode 100644 index 0000000..607fe0b --- /dev/null +++ b/tests/stub.lua @@ -0,0 +1,152 @@ +-- tests/stub.lua +-- Stubbing helpers for the system-facing calls sshfs.nvim makes +-- +-- Unit tests must never touch a real SSH server or mount table, so the few +-- entry points that reach the operating system (vim.fn.system, vim.fn.executable, +-- vim.system, vim.uv.fs_stat, vim.notify) are replaced per test and restored +-- afterwards by Stub.restore_all(). + +local Stub = {} + +local restorers = {} + +local function split_path(path) + local parts = {} + for part in path:gmatch("[^%.]+") do + table.insert(parts, part) + end + return parts +end + +--- Replace a dotted field under `vim` (e.g. "fn.system") for the current test +--- @param path string Dotted path relative to the vim table +--- @param value any Replacement value +function Stub.set(path, value) + local parts = split_path(path) + local target = vim + for index = 1, #parts - 1 do + target = target[parts[index]] + end + + local key = parts[#parts] + local original = target[key] + target[key] = value + table.insert(restorers, function() + target[key] = original + end) +end + +--- Replace vim.v so tests can control v:shell_error, which is read-only +--- @param shell_error number Value reported to code that inspects vim.v.shell_error +function Stub.shell_error(shell_error) + local original = vim.v + vim.v = setmetatable({ shell_error = shell_error }, { __index = original }) + table.insert(restorers, function() + vim.v = original + end) +end + +--- Stub vim.fn.system with a handler and set the resulting v:shell_error +--- The handler receives the command (string or list) and returns output plus an +--- optional exit code, defaulting to 0. +--- @param handler function fun(cmd): string, number|nil +function Stub.system(handler) + local shell_error = 0 + Stub.set("fn.system", function(cmd) + local output, code = handler(cmd) + shell_error = code or 0 + return output or "" + end) + + local original = vim.v + vim.v = setmetatable({}, { + __index = function(_, key) + if key == "shell_error" then return shell_error end + return original[key] + end, + }) + table.insert(restorers, function() + vim.v = original + end) +end + +--- Stub vim.fn.executable from a set of available command names +--- @param available table Map or list of command names that should report as present +function Stub.executable(available) + local lookup = {} + if vim.islist(available) then + for _, name in ipairs(available) do + lookup[name] = true + end + else + lookup = available + end + + Stub.set("fn.executable", function(name) + return lookup[name] and 1 or 0 + end) +end + +--- Build a vim.system replacement +--- The handler receives the command list and returns a result table with code, +--- stdout, and stderr. The stub supports both call styles used in the plugin: +--- an async callback and a synchronous `:wait()` on the returned object. +--- @param handler function fun(cmd): table +--- @return table calls List of command lists the stub received +function Stub.vim_system(handler) + local calls = {} + + Stub.set("system", function(cmd, _, callback) + table.insert(calls, cmd) + local result = handler(cmd) or {} + result.code = result.code or 0 + result.stdout = result.stdout or "" + result.stderr = result.stderr or "" + + if callback then + callback(result) + return { + wait = function() + return result + end, + } + end + + return { + wait = function() + return result + end, + } + end) + + return calls +end + +--- Capture vim.notify calls instead of printing them +--- @return table notifications List of {message, level} tables +function Stub.notifications() + local captured = {} + Stub.set("notify", function(message, level) + table.insert(captured, { message = message, level = level }) + end) + return captured +end + +--- Drop cached sshfs.nvim modules so per-module state is rebuilt +--- Several modules memoize (SSHFS version detection, config options), so tests +--- that exercise that state must start from a clean load. +function Stub.reload() + for name in pairs(package.loaded) do + if name:match("^sshfs") then package.loaded[name] = nil end + end +end + +--- Undo every stub applied since the last restore +function Stub.restore_all() + for index = #restorers, 1, -1 do + restorers[index]() + end + restorers = {} +end + +return Stub From 844dd5e665310c3fdb85f596ebaa80fcce51d15f Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Mon, 31 Aug 2026 00:18:36 -0400 Subject: [PATCH 5/7] test: forward every return value through expect.no_error A call under test can return more than one value, and keeping only the first silently turned assertions on the later ones into assertions on nil. --- tests/README.md | 2 +- tests/harness.lua | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/README.md b/tests/README.md index 0cd2e1c..3c7b43a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,7 +43,7 @@ end) ### Assertions -`expect.eq` (deep equality), `expect.truthy`, `expect.falsy`, `expect.is_nil`, `expect.contains` (plain substring), `expect.errors`, `expect.no_error`. Each takes an optional trailing context string that is shown on failure. +`expect.eq` (deep equality), `expect.truthy`, `expect.falsy`, `expect.is_nil`, `expect.contains` (plain substring), `expect.errors`, `expect.no_error` (which forwards every value the call returned). Each takes an optional trailing context string that is shown on failure. ### Stubs diff --git a/tests/harness.lua b/tests/harness.lua index a58b15e..2649a3c 100644 --- a/tests/harness.lua +++ b/tests/harness.lua @@ -104,13 +104,13 @@ function Harness.expect.errors(fn, needle, context) if needle then Harness.expect.contains(tostring(err), needle, context) end end ---- Assert a function does not raise, returning its first result +--- Assert a function does not raise, returning every result it produced function Harness.expect.no_error(fn, context) - local ok, result = pcall(fn) - if not ok then - fail(string.format("%sexpected no error, got %s", context and (context .. ": ") or "", tostring(result))) + local results = { pcall(fn) } + if not results[1] then + fail(string.format("%sexpected no error, got %s", context and (context .. ": ") or "", tostring(results[2]))) end - return result + return unpack(results, 2, #results) end --- Run every registered suite and report results From 241e264f80fa852f6d2360da8b8cc16ca556c206 Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Mon, 31 Aug 2026 00:21:10 -0400 Subject: [PATCH 6/7] test: add SSHTest preflight regression coverage Adds the regression suite this PR describes, on top of the shared harness. Covers the SSH command builders that SSHTest shares with SSHConnect: the batch probe forces a master and disables prompts, the home lookup reuses the socket without renegotiating one, interactive authentication stays promptable, and control operations address the socket. Explicit user and port values propagate through all of them and are omitted when unset. Covers control_master_pid parsing for a running master, a missing socket, and unparseable output. Covers the report itself: the resolved configuration summary, joined identity files, the omission of the raw ssh -G dump, pass and fail rendering with exit codes and stderr, the prospective sshfs command (shown but never executed), tilde resolution through the remote home, the socket directory failure path, and the disposable scratch buffer. Covers ControlMaster lifecycle: a master the preflight created is closed, a pre-existing one is preserved, and one that appeared from a concurrent connect or was replaced mid-test is left alone. Refs #24 --- tests/diagnostic_spec.lua | 234 ++++++++++++++++++++++++++++++++++++ tests/ssh_builders_spec.lua | 183 ++++++++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 tests/diagnostic_spec.lua create mode 100644 tests/ssh_builders_spec.lua diff --git a/tests/diagnostic_spec.lua b/tests/diagnostic_spec.lua new file mode 100644 index 0000000..a6d0590 --- /dev/null +++ b/tests/diagnostic_spec.lua @@ -0,0 +1,234 @@ +-- tests/diagnostic_spec.lua +-- :SSHTest preflight reporting and ControlMaster handling + +local SSH_G_OUTPUT = table.concat({ + "host example.com", + "user deploy", + "hostname 10.0.0.5", + "port 2222", + "proxyjump bastion.example.com", + "identityfile ~/.ssh/id_ed25519", + "identityfile ~/.ssh/id_rsa", + "addkeystoagent false", +}, "\n") + +--- Run Diagnostic.test against stubbed ssh invocations +--- @param opts table {host, auth_code, auth_stderr, home_stdout, pids, socket_error} +--- @return table report Lines of the report buffer +--- @return table control Control operations that were run, in order +--- @return table commands Every ssh command the preflight executed +local function run_preflight(opts) + stub.reload() + -- A real writable directory: the preflight refuses to authenticate without one. + local socket_dir = vim.fn.tempname() .. "/sockets" + require("sshfs.config").setup({ connections = { socket_dir = socket_dir } }) + + local control = {} + local commands = {} + local pids = vim.deepcopy(opts.pids or {}) + + -- ssh -O check / -O exit go through vim.fn.system + stub.system(function(cmd) + local operation = nil + for index, argument in ipairs(cmd) do + if argument == "-O" then operation = cmd[index + 1] end + end + table.insert(control, operation) + + if operation == "check" then + -- `false` stands for "no master running" so the list keeps no nil holes. + local pid = table.remove(pids, 1) + if pid then return "Master running (pid=" .. pid .. ")", 0 end + return "Control socket connect: No such file or directory", 255 + end + return "", 0 + end) + + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(cmd, _, callback) + table.insert(commands, cmd) + + local result = { code = 0, stdout = "", stderr = "" } + if vim.tbl_contains(cmd, "-G") then + result.stdout = SSH_G_OUTPUT + elseif cmd[#cmd] == "exit" then + result.code = opts.auth_code or 0 + result.stderr = opts.auth_stderr or "" + else + result.stdout = opts.home_stdout or "/home/deploy" + end + + if callback then callback(result) end + return { + wait = function() + return result + end, + } + end) + + if opts.socket_error then + package.loaded["sshfs.lib.ssh"] = setmetatable({ + prepare_socket_dir = function() + return nil, opts.socket_error + end, + }, { __index = require("sshfs.lib.ssh") }) + end + + require("sshfs.diagnostic").test(opts.host or { name = "example.com" }) + + local report = vim.api.nvim_buf_get_lines(0, 0, -1, false) + vim.cmd("bwipeout!") + stub.restore_all() + vim.fn.delete(vim.fn.fnamemodify(socket_dir, ":h"), "rf") + + return report, control, commands +end + +--- Join report lines for substring assertions +local function text(report) + return table.concat(report, "\n") +end + +describe("SSHTest report", function() + it("summarizes the resolved SSH configuration", function() + local report = run_preflight({}) + local body = text(report) + + expect.contains(body, "hostname: 10.0.0.5") + expect.contains(body, "user: deploy") + expect.contains(body, "port: 2222") + expect.contains(body, "proxyjump: bastion.example.com") + end) + + it("joins every identity file into one summary line", function() + local report = run_preflight({}) + expect.contains(text(report), "identityfile: ~/.ssh/id_ed25519, ~/.ssh/id_rsa") + end) + + it("omits the raw ssh -G dump that the summary already covers", function() + local report = run_preflight({}) + local body = text(report) + + expect.falsy(body:find("addkeystoagent", 1, true), "the full resolved config would bury the results") + expect.truthy(#report < 45, "the report stays readable; got " .. #report .. " lines") + end) + + it("marks a successful authentication as passing", function() + local report = run_preflight({}) + expect.contains(text(report), "[PASS] SSH authentication") + end) + + it("reports the exit code and stderr of a failed authentication", function() + local report = run_preflight({ + auth_code = 255, + auth_stderr = "ssh: Could not resolve hostname example.com", + }) + local body = text(report) + + expect.contains(body, "[FAIL] SSH authentication") + expect.contains(body, "Exit code: 255") + expect.contains(body, "Could not resolve hostname") + end) + + it("shows the prospective sshfs command without running it", function() + local report, _, commands = run_preflight({ host = { name = "example.com", path = "/srv/app" } }) + + expect.contains(text(report), "sshfs") + expect.contains(text(report), "") + for _, cmd in ipairs(commands) do + expect.falsy(cmd[1] == "sshfs", "the preflight must never execute a mount") + end + end) + + it("explains why no sshfs command is shown without a remote path", function() + local report = run_preflight({}) + + expect.contains(text(report), "Not shown") + expect.falsy(text(report):find("", 1, true)) + end) + + it("resolves a tilde path through the remote home before showing the command", function() + local report = run_preflight({ + host = { name = "example.com", path = "~/projects" }, + home_stdout = "/home/deploy", + }) + + expect.contains(text(report), "/home/deploy/projects") + end) + + it("reports a socket directory failure instead of authenticating", function() + local report = run_preflight({ socket_error = "Failed to create socket directory: permission denied" }) + + expect.contains(text(report), "permission denied") + expect.contains(text(report), "[FAIL] SSH authentication") + end) + + it("renders into a disposable scratch buffer", function() + stub.reload() + require("sshfs.config").setup({}) + stub.system(function() + return "", 255 + end) + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(cmd, _, callback) + local result = { code = 0, stdout = vim.tbl_contains(cmd, "-G") and SSH_G_OUTPUT or "", stderr = "" } + if callback then callback(result) end + return { + wait = function() + return result + end, + } + end) + + require("sshfs.diagnostic").test({ name = "example.com" }) + local buf = vim.api.nvim_get_current_buf() + + expect.eq(vim.bo[buf].buftype, "nofile") + expect.eq(vim.bo[buf].bufhidden, "wipe") + expect.falsy(vim.bo[buf].modifiable, "the report is read-only") + expect.falsy(vim.bo[buf].swapfile) + + vim.cmd("bwipeout!") + stub.restore_all() + end) +end) + +describe("SSHTest ControlMaster handling", function() + it("closes a master it created itself", function() + -- No master before, one running afterwards: the preflight created it. + local _, control = run_preflight({ pids = { false, 4242, 4242 } }) + + expect.truthy(vim.tbl_contains(control, "exit"), "a diagnostic connection must not outlive the report") + end) + + it("preserves a master that already existed", function() + local _, control = run_preflight({ pids = { 1111, 1111 } }) + + expect.falsy(vim.tbl_contains(control, "exit"), "an existing shared session must survive the preflight") + end) + + it("does not close a master created by something else mid-test", function() + -- No master before, but authentication failed, so the master that appeared + -- belongs to a concurrent :SSHConnect rather than to this preflight. + local _, control = run_preflight({ auth_code = 255, pids = { false, 9999, 9999 } }) + + expect.falsy(vim.tbl_contains(control, "exit"), "closing another process's connection would break its mount") + end) + + it("does not close a master whose pid changed after the test ran", function() + -- Our master exited and a different one replaced it before cleanup. + local _, control = run_preflight({ pids = { false, 4242, 5555 } }) + + expect.falsy(vim.tbl_contains(control, "exit"), "the socket is no longer the one this preflight created") + end) + + it("leaves no master behind when none was ever created", function() + local _, control = run_preflight({ auth_code = 255, pids = {} }) + + expect.falsy(vim.tbl_contains(control, "exit")) + end) +end) diff --git a/tests/ssh_builders_spec.lua b/tests/ssh_builders_spec.lua new file mode 100644 index 0000000..67448f0 --- /dev/null +++ b/tests/ssh_builders_spec.lua @@ -0,0 +1,183 @@ +-- tests/ssh_builders_spec.lua +-- Shared SSH command builders used by both SSHConnect and SSHTest +-- +-- SSHTest is only trustworthy if it runs the same commands the real connection +-- path runs, so these cover the builders both sides share. + +local SOCKET_DIR = "/home/tester/.ssh/sockets" + +local function load_ssh() + stub.reload() + require("sshfs.config").setup({ connections = { socket_dir = SOCKET_DIR, control_persist = "10m" } }) + return require("sshfs.lib.ssh") +end + +--- Position of a value in a command list, or nil +local function index_of(cmd, value) + for index, argument in ipairs(cmd) do + if argument == value then return index end + end + return nil +end + +describe("Ssh.build_batch_command", function() + it("forces a master, disables prompts, and only tests the connection", function() + local cmd = load_ssh().build_batch_command("example.com") + local joined = table.concat(cmd, " ") + + expect.contains(joined, "ControlMaster=yes") + expect.contains(joined, "BatchMode=yes") + expect.contains(joined, "ControlPath=" .. SOCKET_DIR .. "/%C") + expect.eq(cmd[#cmd], "exit", "the batch probe must not start a shell") + end) + + it("accepts a plain host name", function() + local cmd = load_ssh().build_batch_command("example.com") + expect.eq(cmd[#cmd - 1], "example.com") + end) + + it("propagates an explicit user and port from a host object", function() + local cmd = load_ssh().build_batch_command({ name = "example.com", user = "deploy", port = 2222 }) + + expect.eq(cmd[index_of(cmd, "-l") + 1], "deploy") + expect.eq(cmd[index_of(cmd, "-p") + 1], "2222", "the port must be passed as a string") + expect.eq(cmd[#cmd - 1], "example.com") + end) + + it("omits user and port when the host object does not set them", function() + local cmd = load_ssh().build_batch_command({ name = "example.com" }) + + expect.is_nil(index_of(cmd, "-l")) + expect.is_nil(index_of(cmd, "-p")) + end) +end) + +describe("Ssh.build_home_command", function() + it("reuses the existing socket without renegotiating a master", function() + local cmd = load_ssh().build_home_command("example.com") + local joined = table.concat(cmd, " ") + + expect.contains(joined, "ControlPath=" .. SOCKET_DIR .. "/%C") + expect.falsy(joined:find("ControlMaster", 1, true), "resolving the home directory must reuse the socket") + end) + + it("resolves the canonical home directory with a fallback", function() + local cmd = load_ssh().build_home_command("example.com") + expect.eq(cmd[#cmd], "readlink -f $HOME 2>/dev/null || echo $HOME") + end) + + it("propagates an explicit user and port", function() + local cmd = load_ssh().build_home_command({ name = "example.com", user = "deploy", port = 2222 }) + + expect.eq(cmd[index_of(cmd, "-l") + 1], "deploy") + expect.eq(cmd[index_of(cmd, "-p") + 1], "2222") + end) +end) + +describe("Ssh.build_auth_command", function() + it("forces a master so interactive authentication creates the socket", function() + local cmd = load_ssh().build_auth_command("example.com") + local joined = table.concat(cmd, " ") + + expect.contains(joined, "ControlMaster=yes") + expect.falsy(joined:find("BatchMode", 1, true), "interactive authentication must be able to prompt") + expect.eq(cmd[#cmd], "exit") + end) +end) + +describe("Ssh.build_control_command", function() + it("addresses the socket for a control operation", function() + local cmd = load_ssh().build_control_command("example.com", "check") + + expect.contains(table.concat(cmd, " "), "ControlPath=" .. SOCKET_DIR .. "/%C") + expect.eq(cmd[index_of(cmd, "-O") + 1], "check") + expect.eq(cmd[#cmd], "example.com") + end) + + it("builds the exit operation used for cleanup", function() + local cmd = load_ssh().build_control_command({ name = "example.com", port = 2222 }, "exit") + + expect.eq(cmd[index_of(cmd, "-O") + 1], "exit") + expect.eq(cmd[index_of(cmd, "-p") + 1], "2222") + end) +end) + +describe("Ssh.control_master_pid", function() + it("returns the pid reported by a running master", function() + local Ssh = load_ssh() + stub.system(function() + return "Master running (pid=4242)\r\n", 0 + end) + + local pid = Ssh.control_master_pid("example.com") + stub.restore_all() + + expect.eq(pid, 4242) + end) + + it("returns nil when no master is running", function() + local Ssh = load_ssh() + stub.system(function() + return "Control socket connect(/home/tester/.ssh/sockets/abc): No such file or directory", 255 + end) + + local pid = Ssh.control_master_pid("example.com") + stub.restore_all() + + expect.is_nil(pid) + end) + + it("returns nil when the check succeeds without a parseable pid", function() + local Ssh = load_ssh() + stub.system(function() + return "unexpected output", 0 + end) + + local pid = Ssh.control_master_pid("example.com") + stub.restore_all() + + expect.is_nil(pid) + end) +end) + +describe("Ssh.get_remote_home", function() + it("accepts a host object and reports the resolved home", function() + local Ssh = load_ssh() + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(_, _, callback) + callback({ code = 0, stdout = "/home/deploy\n", stderr = "" }) + return { wait = function() end } + end) + + local home, err = nil, nil + Ssh.get_remote_home({ name = "example.com", user = "deploy" }, function(resolved, error_message) + home, err = resolved, error_message + end) + stub.restore_all() + + expect.eq(home, "/home/deploy") + expect.is_nil(err) + end) + + it("rejects output that is not an absolute path", function() + local Ssh = load_ssh() + stub.set("schedule", function(fn) + fn() + end) + stub.set("system", function(_, _, callback) + callback({ code = 0, stdout = "not-a-path", stderr = "" }) + return { wait = function() end } + end) + + local home, err = nil, nil + Ssh.get_remote_home("example.com", function(resolved, error_message) + home, err = resolved, error_message + end) + stub.restore_all() + + expect.is_nil(home) + expect.contains(err, "invalid") + end) +end) From ca7350b7621445ed6c525dbe645742202fb978fa Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Mon, 31 Aug 2026 00:37:56 -0400 Subject: [PATCH 7/7] test: allow the preflight to probe the sshfs version The assertion that SSHTest never runs sshfs was too broad. Once the fuse-t work in #20 lands, build_mount_command probes `sshfs --version` to decide whether to render 3.x or 2.x cache option names, so the preflight legitimately spawns sshfs without mounting anything. Narrow the assertion to what it was meant to catch: an actual mount. --- tests/diagnostic_spec.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/diagnostic_spec.lua b/tests/diagnostic_spec.lua index a6d0590..7da1177 100644 --- a/tests/diagnostic_spec.lua +++ b/tests/diagnostic_spec.lua @@ -138,7 +138,10 @@ describe("SSHTest report", function() expect.contains(text(report), "sshfs") expect.contains(text(report), "") for _, cmd in ipairs(commands) do - expect.falsy(cmd[1] == "sshfs", "the preflight must never execute a mount") + -- Probing `sshfs --version` is fine, and once #20 lands the preflight does + -- exactly that to decide which cache option names to render. Actually + -- invoking sshfs to mount something is what must never happen. + expect.falsy(cmd[1] == "sshfs" and cmd[2] ~= "--version", "the preflight must never execute a mount") end end)