Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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/
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: test lint format

# Run the unit test suite. Pass PATTERN=<substring> 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/
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -342,3 +343,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.
24 changes: 24 additions & 0 deletions lua/sshfs/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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" })
Expand Down
167 changes: 167 additions & 0 deletions lua/sshfs/diagnostic.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
-- 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

--- @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))
if include_stdout ~= false then append_output(lines, "stdout", result.stdout) end
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",
"",
}

-- `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

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, "<mount-point>", remote_path)
table.insert(lines, " " .. shell_join(mount_cmd))
table.insert(lines, " Note: <mount-point> 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 pid_before = Ssh.control_master_pid(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

-- 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 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)
return
end

run(Ssh.build_home_command(host), function(home_result)
finish(auth_result, home_result)
end)
end)
end)
end

return Diagnostic
1 change: 1 addition & 0 deletions lua/sshfs/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading