diff --git a/lua/async-remote-write/browse.lua b/lua/async-remote-write/browse.lua index fd63319..d3a151c 100644 --- a/lua/async-remote-write/browse.lua +++ b/lua/async-remote-write/browse.lua @@ -352,15 +352,10 @@ function M.warm_single_directory(dir_url, job, callback) path = path .. "/" end - -- Use same command as level-based browser - local bash_cmd = [[ - cd %s 2>/dev/null && \ - find . -maxdepth 1 -not -name "." | while read f; do \ - if [ -d "$f" ]; then echo "d $f"; else echo "f $f"; fi \ - done | sort - ]] + -- Use ssh_utils helper to build directory listing command + local sh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false }) - local cmd = { "ssh", host, string.format(bash_cmd, vim.fn.shellescape(path)) } + local cmd = { "ssh", host, sh_cmd } local output = {} local job_id = vim.fn.jobstart(cmd, { @@ -556,17 +551,10 @@ function M.browse_remote_directory(url, reset_selections) return end - -- Use a bash script that's compatible with most systems - local bash_cmd = [[ - cd %s && \ - find . -maxdepth 1 | sort | while read f; do - if [ "$f" != "." ]; then - if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi - fi - done - ]] + -- Use ssh_utils helper to build directory listing command + local sh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false }) - local cmd = { "ssh", host, string.format(bash_cmd, vim.fn.shellescape(path)) } + local cmd = { "ssh", host, sh_cmd } -- Create job to execute command local output = {} @@ -1339,15 +1327,10 @@ function M.browse_remote_level_based(url, reset_selections) return end - -- Use maxdepth 1 to get ONLY immediate children - local bash_cmd = [[ - cd %s 2>/dev/null && \ - find . -maxdepth 1 -not -name "." | while read f; do \ - if [ -d "$f" ]; then echo "d $f"; else echo "f $f"; fi \ - done | sort - ]] + -- Use ssh_utils helper to build directory listing command + local sh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false }) - local cmd = { "ssh", host, string.format(bash_cmd, vim.fn.shellescape(path)) } + local cmd = { "ssh", host, sh_cmd } local output = {} local stderr_output = {} @@ -1575,11 +1558,8 @@ function M.load_directory_for_tree(url, depth, callback) path = path .. "/" end - -- Build the SSH command - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done', - vim.fn.shellescape(path) - ) + -- Use ssh_utils helper to build directory listing command + local ssh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false }) local output = {} local stderr_output = {} @@ -4001,10 +3981,8 @@ function M.load_directory_v2(url, callback) path = path .. "/" end - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done', - vim.fn.shellescape(path) - ) + -- Use ssh_utils helper to build directory listing command + local ssh_cmd = ssh_utils.build_list_dir_cmd(path, { sorted = false }) local output = {} local stderr_output = {} diff --git a/lua/async-remote-write/ssh_utils.lua b/lua/async-remote-write/ssh_utils.lua index 202d666..00a9780 100644 --- a/lua/async-remote-write/ssh_utils.lua +++ b/lua/async-remote-write/ssh_utils.lua @@ -228,4 +228,41 @@ end -- Expose is_localhost for other modules that might need it M.is_localhost = is_localhost +-- Shell script for listing directory contents (sorted) +-- Uses sh -c to ensure POSIX shell compatibility (works with fish, zsh, etc.) +-- Uses IFS= read -r to handle filenames with spaces/backslashes +local LIST_DIR_SCRIPT = [[ +cd "$1" && find . -maxdepth 1 | sort | while IFS= read -r f; do + if [ "$f" != "." ]; then + if [ -d "$f" ]; then + echo "d ${f#./}" + else + echo "f ${f#./}" + fi + fi +done +]] + +-- Shell script for listing directory contents (unsorted, with error suppression) +local LIST_DIR_SCRIPT_UNSORTED = [[ +cd "$1" 2>/dev/null && find . -maxdepth 1 -not -name "." | while IFS= read -r f; do + if [ -d "$f" ]; then + echo "d ${f#./}" + else + echo "f ${f#./}" + fi +done +]] + +--- Build a shell command for listing directory contents via SSH +--- @param path string The remote directory path +--- @param opts? {sorted?: boolean} Options: sorted (default true) +--- @return string The shell command to execute +function M.build_list_dir_cmd(path, opts) + opts = opts or {} + local sorted = opts.sorted ~= false -- default to true + local script = sorted and LIST_DIR_SCRIPT or LIST_DIR_SCRIPT_UNSORTED + return string.format("sh -c %s _ %s", vim.fn.shellescape(script), vim.fn.shellescape(path)) +end + return M diff --git a/lua/async-remote-write/tree_browser.lua b/lua/async-remote-write/tree_browser.lua index a794e3f..1f131af 100644 --- a/lua/async-remote-write/tree_browser.lua +++ b/lua/async-remote-write/tree_browser.lua @@ -406,10 +406,8 @@ local function load_directory(url, callback) path = path .. "/" end - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done', - vim.fn.shellescape(path) - ) + -- Use ssh_utils helper to build directory listing command + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) local output = {} local stderr_output = {} diff --git a/tests/test_file_browser_debug.lua b/tests/test_file_browser_debug.lua index 4c51945..33e166a 100644 --- a/tests/test_file_browser_debug.lua +++ b/tests/test_file_browser_debug.lua @@ -1,5 +1,6 @@ -- Debug test for file browser SSH issues local test = require("tests.init") +local ssh_utils = require("async-remote-write.ssh_utils") test.describe("File Browser Debug Tests", function() test.it("should simulate the exact tree browser load_directory scenario", function() @@ -19,13 +20,10 @@ test.describe("File Browser Debug Tests", function() path = path .. "/" end - -- Build the exact SSH command from tree_browser.lua - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done', - vim.fn.shellescape(path) - ) + -- Build the SSH command using ssh_utils + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) - test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd") + test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c") test.assert.contains(ssh_cmd, "/home/testuser/repos/tokio/", "SSH command should contain the path") test.assert.contains(ssh_cmd, "find . -maxdepth 1", "SSH command should contain find") @@ -136,8 +134,7 @@ test.describe("File Browser Debug Tests", function() local host = "testuser@localhost" local exit_code = 255 local stderr_output = { "Connection closed by ::1 port 22" } - local ssh_cmd = - 'cd \'/home/testuser/repos/tokio/\' && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done' + local ssh_cmd = ssh_utils.build_list_dir_cmd("/home/testuser/repos/tokio/") -- Build error message like tree_browser.lua does local error_msg = "Failed to list directory: " .. url .. " (exit code: " .. exit_code .. ")" @@ -154,20 +151,20 @@ test.describe("File Browser Debug Tests", function() test.it("should test the actual SSH command construction with IPv4", function() local host = "testuser@localhost" - local command = "cd '/home/testuser/repos/tokio/' && find . -maxdepth 1" + local command = ssh_utils.build_list_dir_cmd("/home/testuser/repos/tokio/") -- Mock ssh_utils.build_ssh_cmd behavior - local function build_ssh_cmd(host, cmd) + local function build_ssh_cmd(h, cmd) local ssh_args = { "ssh" } -- Check if host contains localhost (even with user@) - local is_localhost = host:match("localhost") or host:match("127%.0%.0%.1") or host:match("::1") + local is_localhost = h:match("localhost") or h:match("127%.0%.0%.1") or h:match("::1") if is_localhost then table.insert(ssh_args, "-4") end - table.insert(ssh_args, host) + table.insert(ssh_args, h) table.insert(ssh_args, cmd) return ssh_args diff --git a/tests/test_file_browser_ssh.lua b/tests/test_file_browser_ssh.lua index 4668646..3c38cd2 100644 --- a/tests/test_file_browser_ssh.lua +++ b/tests/test_file_browser_ssh.lua @@ -1,55 +1,6 @@ -- Test file browser SSH functionality local test = require("tests.init") - --- Mock ssh_utils functions for testing -local ssh_utils = {} - -ssh_utils.is_localhost = function(host) - return host == "localhost" or host == "127.0.0.1" or host == "::1" -end - -ssh_utils.build_ssh_cmd = function(host, command) - local ssh_args = { "ssh" } - - -- Add IPv4 preference for localhost connections to avoid IPv6 issues - if ssh_utils.is_localhost(host) then - table.insert(ssh_args, "-4") - end - - table.insert(ssh_args, host) - table.insert(ssh_args, command) - - return ssh_args -end - -ssh_utils.build_scp_cmd = function(source, destination, options) - local scp_args = { "scp" } - - -- Add standard options - if options then - for _, opt in ipairs(options) do - table.insert(scp_args, opt) - end - end - - -- Extract host from source or destination to check for localhost - local host = nil - if source:match("^[^:]+:") then - host = source:match("^([^:]+):") - elseif destination:match("^[^:]+:") then - host = destination:match("^([^:]+):") - end - - -- Add IPv4 preference for localhost connections - if host and ssh_utils.is_localhost(host) then - table.insert(scp_args, "-4") - end - - table.insert(scp_args, source) - table.insert(scp_args, destination) - - return scp_args -end +local ssh_utils = require("async-remote-write.ssh_utils") test.describe("File Browser SSH Commands", function() test.it("should build SSH commands correctly for localhost", function() @@ -152,18 +103,15 @@ test.describe("File Browser SSH Commands", function() test.it("should construct directory listing command correctly", function() local path = "/home/user/test/" - local escaped_path = vim.fn.shellescape(path) - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done', - escaped_path - ) + -- Build the SSH command using ssh_utils + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) - test.assert.contains(ssh_cmd, "cd", "Command should contain cd") + test.assert.contains(ssh_cmd, "sh -c", "Command should use sh -c") test.assert.contains(ssh_cmd, "find", "Command should contain find") test.assert.contains(ssh_cmd, "-maxdepth 1", "Command should contain maxdepth limit") test.assert.contains(ssh_cmd, "sort", "Command should contain sort") - test.assert.contains(ssh_cmd, "while read", "Command should contain while loop") + test.assert.contains(ssh_cmd, "while IFS= read -r", "Command should contain while loop") test.assert.contains(ssh_cmd, 'echo "d', "Command should output directory marker") test.assert.contains(ssh_cmd, 'echo "f', "Command should output file marker") end) diff --git a/tests/test_ssh_command_escaping.lua b/tests/test_ssh_command_escaping.lua index 6853541..4f8d7a4 100644 --- a/tests/test_ssh_command_escaping.lua +++ b/tests/test_ssh_command_escaping.lua @@ -1,71 +1,55 @@ -- Test SSH command escaping functionality local test = require("tests.init") +local ssh_utils = require("async-remote-write.ssh_utils") test.describe("SSH Command Escaping", function() test.it("should properly escape paths with spaces", function() local path = "/home/user/My Documents/test dir/" - local escaped_path = vim.fn.shellescape(path, 1) - - -- Test that the escaped path contains proper quoting - test.assert.contains(escaped_path, "My Documents", "Escaped path should contain the directory name") - - -- Test SSH command construction - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done', - escaped_path - ) + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) -- Verify command structure - test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd command") + test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c") test.assert.contains(ssh_cmd, "find", "SSH command should contain find command") - test.assert.contains(ssh_cmd, '\\"\\$f\\"', "SSH command should have properly escaped variables") + test.assert.contains(ssh_cmd, "My Documents", "SSH command should contain the path") end) test.it("should properly escape paths with quotes", function() local path = "/home/user/test's dir/" - local escaped_path = vim.fn.shellescape(path, 1) - - -- Test SSH command construction with quotes - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done', - escaped_path - ) + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) - -- Verify command doesn't break with quotes - test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd command") - test.assert.truthy(#ssh_cmd > 50, "SSH command should be properly constructed") + -- Verify command structure + test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c") + -- Verify the path is properly shell-escaped and passed as argument + local escaped_path = vim.fn.shellescape(path) + test.assert.contains(ssh_cmd, escaped_path, "SSH command should contain properly escaped path") end) test.it("should properly escape paths with special characters", function() local path = "/home/user/test (dir) & more/" - local escaped_path = vim.fn.shellescape(path, 1) - - -- Test SSH command construction with special characters - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done', - escaped_path - ) + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) -- Verify command structure is maintained - test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd command") + test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c") test.assert.contains(ssh_cmd, "find", "SSH command should contain find command") - test.assert.contains(ssh_cmd, '\\"\\$f\\"', "SSH command should have properly escaped variables") end) test.it("should handle simple paths without breaking", function() local path = "/home/user/simple/" - local escaped_path = vim.fn.shellescape(path, 1) - - -- Test SSH command construction - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ \\"\\$f\\" != \\".\\" ]; then if [ -d \\"\\$f\\" ]; then echo \\"d \\${f#./}\\"; else echo \\"f \\${f#./}\\"; fi; fi; done', - escaped_path - ) + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) -- Verify command contains expected elements test.assert.contains(ssh_cmd, "/home/user/simple", "SSH command should contain the path") test.assert.contains(ssh_cmd, "find . -maxdepth 1", "SSH command should contain find with maxdepth") test.assert.contains(ssh_cmd, "sort", "SSH command should contain sort") - test.assert.contains(ssh_cmd, "while read f", "SSH command should contain while loop") + test.assert.contains(ssh_cmd, "while IFS= read -r f", "SSH command should contain while loop") + end) + + test.it("should pass path as argument to avoid quoting issues", function() + local path = '/home/user/test\'s "quoted" dir/' + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) + + -- The key feature: path is passed as $1 argument, not embedded in script + test.assert.contains(ssh_cmd, "_ ", "SSH command should have placeholder for $0") + test.assert.contains(ssh_cmd, 'cd "$1"', "Script should reference $1 for path") end) end) diff --git a/tests/test_ssh_robust_connection.lua b/tests/test_ssh_robust_connection.lua index 1845743..0e0ab6f 100644 --- a/tests/test_ssh_robust_connection.lua +++ b/tests/test_ssh_robust_connection.lua @@ -1,5 +1,6 @@ -- Test robust SSH connection options local test = require("tests.init") +local ssh_utils = require("async-remote-write.ssh_utils") test.describe("SSH Robust Connection Options", function() test.it("should build SSH commands with robust connection options", function() @@ -121,18 +122,15 @@ test.describe("SSH Robust Connection Options", function() local host = "ianhersom@raspi0" local path = "/home/ianhersom/repo/neovim/test/old/" - -- Build the SSH command that would be executed - local ssh_cmd = string.format( - 'cd %s && find . -maxdepth 1 | sort | while read f; do if [ "$f" != "." ]; then if [ -d "$f" ]; then echo "d ${f#./}"; else echo "f ${f#./}"; fi; fi; done', - vim.fn.shellescape(path) - ) + -- Build the SSH command using ssh_utils + local ssh_cmd = ssh_utils.build_list_dir_cmd(path) - test.assert.contains(ssh_cmd, "cd", "SSH command should contain cd") + test.assert.contains(ssh_cmd, "sh -c", "SSH command should use sh -c") test.assert.contains(ssh_cmd, "/home/ianhersom/repo/neovim/test/old/", "SSH command should contain the path") test.assert.contains(ssh_cmd, "find . -maxdepth 1", "SSH command should contain find") -- Mock robust SSH command construction - local function build_ssh_cmd(host, command) + local function build_ssh_cmd(h, command) local ssh_args = { "ssh" } table.insert(ssh_args, "-o") @@ -148,7 +146,7 @@ test.describe("SSH Robust Connection Options", function() table.insert(ssh_args, "-o") table.insert(ssh_args, "ControlPath=none") - table.insert(ssh_args, host) + table.insert(ssh_args, h) table.insert(ssh_args, command) return ssh_args