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
53 changes: 53 additions & 0 deletions lua/sshfs/health.lua
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,55 @@ local function check_system_dependencies()
end
end

--- Check that ControlMaster sockets fit inside the Unix socket path limit
--- ControlMaster sockets are Unix domain sockets, and the kernel caps their
--- path at sun_path: 108 bytes on Linux, 104 on macOS and the BSDs. SSH builds
--- one by appending "/%C" (a 40 character hash) plus a temporary suffix while
--- creating it, so a socket directory that is merely long makes every
--- connection fail with a cryptic "unix_listener: path ... too long for Unix
--- domain socket" instead of anything that points at the real cause.
local function check_socket_path_length()
local Config = require("sshfs.config")
local socket_dir = Config.get_socket_dir()

local is_bsd = vim.fn.has("mac") == 1 or vim.fn.has("bsd") == 1
local sun_path_max = is_bsd and 104 or 108
local control_hash_length = 40
-- Upper bound: ssh renames a temporary socket into place, so budget errs short rather than long.
local temp_suffix_max = 16
local reserved = 1 + control_hash_length + 1 + temp_suffix_max
local budget = sun_path_max - 1 - reserved
local length = #socket_dir

if length > budget then
health.error(
string.format(
"SSH socket directory path is too long: %d characters, but only %d fit (%s)",
length,
budget,
socket_dir
),
"Every connection will fail with 'unix_listener: path ... too long for Unix domain socket'. "
.. "Set connections.socket_dir to a shorter path, for example ~/.ssh/sockets."
)
elseif length > budget - 10 then
health.warn(
string.format(
"SSH socket directory path is close to the limit: %d of %d characters (%s)",
length,
budget,
socket_dir
),
"Connections still work, but a longer hostname or a deeper path will break them. "
.. "Consider a shorter connections.socket_dir."
)
else
health.ok(
string.format("SSH socket directory path fits the Unix socket limit (%d of %d characters)", length, budget)
)
end
end

--- Check SSH configuration
local function check_ssh_config()
health.start("SSH Configuration")
Expand Down Expand Up @@ -178,6 +227,10 @@ local function check_ssh_config()
"Create SSH directory with: mkdir -p ~/.ssh && chmod 700 ~/.ssh"
)
end

-- Checked outside the ~/.ssh branch above: a custom socket_dir can live
-- anywhere, so this must run even when ~/.ssh is missing.
check_socket_path_length()
end

--- Check mount configuration
Expand Down
23 changes: 19 additions & 4 deletions lua/sshfs/lib/mount_point.lua
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,29 @@ end

--- Get or create mount directory
--- @param mount_dir string|nil Directory path (defaults to base mount dir from config)
--- @return boolean True if directory exists or was created successfully
--- @return boolean success True if directory exists or was created successfully
--- @return boolean created True only when this call created the directory
function MountPoint.get_or_create(mount_dir)
mount_dir = mount_dir or Config.get_base_dir()
local stat = vim.uv.fs_stat(mount_dir)
if stat and stat.type == "directory" then return true end
if stat and stat.type == "directory" then return true, false end

-- Ensure parent directories exist, then create the leaf without "p" so
-- ownership is only claimed when this call actually creates mount_dir.
-- vim.fn.mkdir raises E739 instead of returning 0, so every call is wrapped.
local parent_dir = vim.fn.fnamemodify(mount_dir, ":h")
if parent_dir ~= mount_dir then
local parent_ok, parent_created = pcall(vim.fn.mkdir, parent_dir, "p")
if (not parent_ok or parent_created == 0) and vim.fn.isdirectory(parent_dir) ~= 1 then return false, false end
end

local created_ok, created = pcall(vim.fn.mkdir, mount_dir)
if created_ok and created == 1 then return true, true end

local success = vim.fn.mkdir(mount_dir, "p")
return success == 1
-- Another process may have won the creation race. Treat an existing
-- directory as usable, but do not claim ownership of it.
stat = vim.uv.fs_stat(mount_dir)
return stat ~= nil and stat.type == "directory", false
end

--- Release buffers associated with sshfs mount
Expand Down
15 changes: 13 additions & 2 deletions lua/sshfs/lib/ssh.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@

local Ssh = {}

--- Return the first non-empty process output after trimming whitespace.
--- @param ... string|nil Process output values in priority order
--- @return string|nil output First non-empty output, or nil
local function first_nonempty_output(...)
Comment thread
Copilot marked this conversation as resolved.
for i = 1, select("#", ...) do
local trimmed = vim.trim(select(i, ...) or "")
if trimmed ~= "" then return trimmed end
end
return nil
end

--- Get SSH socket directory, creating it if it doesn't exist
--- @return string|nil socket_dir The socket directory path, or nil if creation failed
--- @return string|nil error_msg Error message if creation failed
Expand Down Expand Up @@ -226,7 +237,7 @@ function Ssh.get_remote_home(host, callback)
callback(nil, "Remote $HOME output invalid: '" .. home_path .. "'")
end
else
local error_msg = vim.trim(obj.stderr or obj.stdout or "Unknown error")
local error_msg = first_nonempty_output(obj.stderr, obj.stdout) or "Unknown error"
callback(nil, error_msg)
end
end)
Expand Down Expand Up @@ -263,7 +274,7 @@ function Ssh.try_batch_connect(host, callback)
vim.system(cmd, { text = true }, function(obj)
vim.schedule(function()
local success = obj.code == 0
local error_msg = success and nil or (obj.stderr or obj.stdout or "Unknown error")
local error_msg = success and nil or (first_nonempty_output(obj.stderr, obj.stdout) or "Unknown error")
callback(success, obj.code, error_msg)
end)
end)
Expand Down
61 changes: 54 additions & 7 deletions lua/sshfs/lib/sshfs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@

local Sshfs = {}

--- Determine whether an SSH batch failure is an unresolved-host error.
--- @param error_message string|nil SSH error output
--- @return boolean True when the host could not be resolved
local function is_unresolved_host_error(error_message)
local error_lower = (error_message or ""):lower()
return error_lower:find("could not resolve hostname", 1, true) ~= nil
or error_lower:find("name or service not known", 1, true) ~= nil
or error_lower:find("nodename nor servname provided", 1, true) ~= nil
end

--- Convert sshfs_options table to array format for sshfs -o
--- @param options_table table Table of options (e.g., {reconnect = true, ConnectTimeout = 5})
--- @return table Array of option strings (e.g., {"reconnect", "ConnectTimeout=5"})
Expand Down Expand Up @@ -84,10 +94,19 @@ local function mount_with_path(host, mount_point, remote_path_suffix, callback)
resolved_path = remote_path_suffix,
})
else
local error_msg = obj.stderr or obj.stdout or "Unknown error"
local stderr = vim.trim(obj.stderr or "")
local stdout = vim.trim(obj.stdout or "")
local error_output = stderr ~= "" and stderr or stdout
local message = string.format("Mount failed (exit code: %d)", obj.code)
if error_output ~= "" then message = message .. ": " .. error_output end

callback({
success = false,
message = "Mount failed: " .. error_msg,
stage = "mount",
exit_code = obj.code,
stdout = stdout ~= "" and stdout or nil,
stderr = stderr ~= "" and stderr or nil,
message = message,
})
end
end)
Expand Down Expand Up @@ -143,16 +162,44 @@ function Sshfs.authenticate_and_mount(host, mount_point, remote_path_suffix, cal
return
end

-- An unresolved host cannot be fixed by interactive authentication.
if is_unresolved_host_error(error) then
local message = string.format("SSH connection failed for %s (exit code: %d)", host.name, exit_code)
if error and error ~= "" then message = message .. ": " .. vim.trim(error) end

callback({
success = false,
stage = "connection",
exit_code = exit_code,
output = error,
message = message,
})
return
end

-- Batch failed, try interactive terminal
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
callback({
success = false,
message = string.format("SSH authentication failed for %s (exit code: %d)", host.name, term_exit_code),
})
return
end

local message = string.format(
"SSH authentication failed for %s (batch exit code: %d, interactive exit code: %d)",
host.name,
exit_code,
term_exit_code
)
if error and error ~= "" then message = message .. ": " .. vim.trim(error) end

callback({
success = false,
stage = "authentication",
exit_code = term_exit_code,
batch_exit_code = exit_code,
output = error,
message = message,
})
end)
end)
end
Expand Down
13 changes: 11 additions & 2 deletions lua/sshfs/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ function Session.connect(host)
PRE_MOUNT_DIRS[mount_dir] = vim.uv.cwd()

-- Ensure the unique mount directory exists
if not MountPoint.get_or_create(mount_dir) then
local mount_ready, mount_created = MountPoint.get_or_create(mount_dir)
if not mount_ready then
PRE_MOUNT_DIRS[mount_dir] = nil
vim.notify("Failed to create mount directory: " .. mount_dir, vim.log.levels.ERROR)
return
end
Expand All @@ -61,8 +63,15 @@ function Session.connect(host)
Sshfs.authenticate_and_mount(host, mount_dir, remote_path_suffix, function(result)
-- Handle connection failure
if not result.success then
PRE_MOUNT_DIRS[mount_dir] = nil

-- Only remove an empty mount directory created by this connection attempt.
if mount_created and not MountPoint.is_active(mount_dir) then
local Directory = require("sshfs.lib.directory")
if Directory.is_empty(mount_dir) then pcall(vim.fn.delete, mount_dir, "d") end
end

vim.notify("Connection failed: " .. (result.message or "Unknown error"), vim.log.levels.ERROR)
MountPoint.cleanup()
return
end

Expand Down
Loading
Loading