diff --git a/lua/sshfs/lib/ssh.lua b/lua/sshfs/lib/ssh.lua index 2c51063..9bd27cc 100644 --- a/lua/sshfs/lib/ssh.lua +++ b/lua/sshfs/lib/ssh.lua @@ -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 @@ -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 @@ -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 }) @@ -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") diff --git a/tests/remote_command_spec.lua b/tests/remote_command_spec.lua new file mode 100644 index 0000000..7eb408d --- /dev/null +++ b/tests/remote_command_spec.lua @@ -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 " 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) diff --git a/tests/ssh_spec.lua b/tests/ssh_spec.lua index d9e555a..68bcc4f 100644 --- a/tests/ssh_spec.lua +++ b/tests/ssh_spec.lua @@ -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()