Skip to content
48 changes: 13 additions & 35 deletions lua/async-remote-write/browse.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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 = {}
Expand Down
37 changes: 37 additions & 0 deletions lua/async-remote-write/ssh_utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 2 additions & 4 deletions lua/async-remote-write/tree_browser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
21 changes: 9 additions & 12 deletions tests/test_file_browser_debug.lua
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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")

Expand Down Expand Up @@ -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 .. ")"
Expand All @@ -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
Expand Down
62 changes: 5 additions & 57 deletions tests/test_file_browser_ssh.lua
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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)
Expand Down
62 changes: 23 additions & 39 deletions tests/test_ssh_command_escaping.lua
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
YaQia marked this conversation as resolved.

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)
Loading
Loading