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
17 changes: 13 additions & 4 deletions lua/sshfs/lib/ssh.lua
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ local function get_ssh_options(auth_type)
local Config = require("sshfs.config")
local options = {}

-- A config RemoteCommand cannot coexist with the command these paths append,
-- and sftp needs a plain session, so it is cleared for everything but a shell.
if auth_type == "batch" or auth_type == "socket" then table.insert(options, "RemoteCommand=none") end

-- Add ControlMaster options
local control_opts = Config.get_control_master_options()
if auth_type == "batch" then
Expand Down Expand Up @@ -94,8 +98,11 @@ local function append_host_options(cmd, host)
table.insert(cmd, host.name)
end

local function build_with_options(host, auth_type)
local function build_with_options(host, auth_type, extra_options)
local cmd = { "ssh" }
for _, opt in ipairs(extra_options or {}) do
vim.list_extend(cmd, { "-o", opt })
end
for _, opt in ipairs(get_ssh_options(auth_type)) do
vim.list_extend(cmd, { "-o", opt })
end
Expand Down Expand Up @@ -147,7 +154,7 @@ end
---@param host table|string Host object or SSH host name
---@return table SSH command array
function Ssh.build_auth_command(host)
local cmd = { "ssh" }
local cmd = { "ssh", "-o", "RemoteCommand=none" }
for _, opt in ipairs(get_ssh_options(nil)) do
if opt:match("^ControlMaster=") then opt = "ControlMaster=yes" end
vim.list_extend(cmd, { "-o", opt })
Expand Down Expand Up @@ -207,10 +214,12 @@ end
---@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 = build_with_options(host, nil)
local has_remote_path = remote_path ~= nil and remote_path ~= ""
-- Only a bare session keeps a config RemoteCommand; a cd would collide with it.
local cmd = build_with_options(host, nil, has_remote_path and { "RemoteCommand=none" } or nil)

-- If remote_path specified, cd into it and start a login shell
if remote_path and remote_path ~= "" then
if has_remote_path then
table.insert(cmd, "-t")
local cd_command = build_cd_command(remote_path)
table.insert(cmd, cd_command .. " && exec $SHELL -l")
Expand Down
59 changes: 59 additions & 0 deletions tests/remote_command_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- tests/remote_command_spec.lua
-- A `RemoteCommand` in ssh_config makes a mount impossible: OpenSSH refuses to
-- run both it and a command line, and sftp needs a plain session. Every
-- non-interactive path clears it; a bare terminal keeps it.

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

--- Index of an "-o <value>" pair in a command list, or nil
local function option_index(cmd, value)
for index, argument in ipairs(cmd) do
if argument == "-o" and cmd[index + 1] == value then return index end
end
return nil
end

local function clears_remote_command(cmd)
return option_index(cmd, "RemoteCommand=none") ~= nil
end

describe("RemoteCommand handling", function()
it("clears it on every command that appends a remote command", function()
local Ssh = load_ssh()

expect.truthy(clears_remote_command(Ssh.build_batch_command("example.com")), "batch runs exit")
expect.truthy(clears_remote_command(Ssh.build_home_command("example.com")), "home runs readlink")
expect.truthy(clears_remote_command(Ssh.build_auth_command("example.com")), "auth runs exit")
end)

it("clears it for the ssh command sshfs runs", function()
local Ssh = load_ssh()
expect.contains(Ssh.build_command_string("socket"), "-o RemoteCommand=none")
end)

it("clears it on the control command", function()
local Ssh = load_ssh()
expect.truthy(clears_remote_command(Ssh.build_control_command("example.com", "exit")))
end)

it("clears it for a terminal that cds into a remote path", function()
local Ssh = load_ssh()
local cmd = Ssh.build_command("example.com", "/srv/app")

expect.truthy(clears_remote_command(cmd), "the cd would collide with a RemoteCommand")
expect.truthy(option_index(cmd, "RemoteCommand=none") < #cmd, "options must precede the host")
end)

it("keeps it for a bare terminal session", function()
local Ssh = load_ssh()
local cmd = Ssh.build_command("example.com")

expect.is_nil(option_index(cmd, "RemoteCommand=none"), "a plain shell is what RemoteCommand is for")
end)
end)
4 changes: 2 additions & 2 deletions tests/ssh_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ local function load_ssh()
end

describe("Ssh.build_command_string", function()
it("passes only the control path when reusing a socket", function()
it("passes the control path and clears any RemoteCommand when reusing a socket", function()
local Ssh = load_ssh()
expect.eq(Ssh.build_command_string("socket"), "ssh -o " .. CONTROL_PATH)
expect.eq(Ssh.build_command_string("socket"), "ssh -o RemoteCommand=none -o " .. CONTROL_PATH)
end)

it("forces a master and disables prompts for batch connections", function()
Expand Down
Loading