Skip to content

Commit e8dc253

Browse files
authored
fix(rsync,ssh): do not overescape spaces in remote filenames (#910)
* fix(rsync,ssh): do not overescape spaces in remote filenames Fixes #848. If a remote machine contains a file named `a b`, completing rsync command results in `rsync remote:a\\\ b`, which results in rsync failing to find the file. This commit removes the extra slashes, and now completion results in `rsync remote:a\ b`. scp somehow accepts both variants, so this change won't break it. * fix(rsync): overescape remote paths if rsync version is < 3.2.4 * test(rsync,ssh): test remote filenames with spaces using mock commands
1 parent 529aff8 commit e8dc253

File tree

4 files changed

+119
-7
lines changed

4 files changed

+119
-7
lines changed

completions/rsync

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# bash completion for rsync -*- shell-script -*-
22

3+
_comp_cmd_rsync__vercomp()
4+
{
5+
if [[ $1 == "$2" ]]; then
6+
return 0
7+
fi
8+
local i ver1 ver2
9+
_comp_split -F . ver1 "$1"
10+
_comp_split -F . ver2 "$2"
11+
local n=$((${#ver1[@]} >= ${#ver2[@]} ? ${#ver1[@]} : ${#ver2[@]}))
12+
for ((i = 0; i < n; i++)); do
13+
if ((10#${ver1[i]:-0} > 10#${ver2[i]:-0})); then
14+
return 1
15+
fi
16+
if ((10#${ver1[i]:-0} < 10#${ver2[i]:-0})); then
17+
return 2
18+
fi
19+
done
20+
return 0
21+
}
22+
323
_comp_cmd_rsync()
424
{
525
local cur prev words cword was_split comp_args
@@ -81,7 +101,15 @@ _comp_cmd_rsync()
81101
break
82102
fi
83103
done
84-
[[ $shell == ssh ]] && _comp_compgen -x scp remote_files
104+
if [[ $shell == ssh ]]; then
105+
local rsync_version=$("$1" --version 2>/dev/null | sed -n '1s/.*rsync *version \([0-9.]*\).*/\1/p')
106+
_comp_cmd_rsync__vercomp "$rsync_version" "3.2.4"
107+
if (($? == 2)); then
108+
_comp_compgen -x scp remote_files
109+
else
110+
_comp_compgen -x scp remote_files -l
111+
fi
112+
fi
85113
;;
86114
*)
87115
_comp_compgen_known_hosts -c -a -- "$cur"

completions/ssh

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -462,12 +462,30 @@ _comp_cmd_sftp()
462462
# shellcheck disable=SC2089
463463
_comp_cmd_scp__path_esc='[][(){}<>"'"'"',:;^&!$=?`\\|[:space:]]'
464464

465-
# Complete remote files with ssh. If the first arg is -d, complete on dirs
466-
# only. Returns paths escaped with three backslashes.
465+
# Complete remote files with ssh. Returns paths escaped with three backslashes
466+
# (unless -l option is provided).
467+
# Options:
468+
# -d Complete on dirs only.
469+
# -l Return paths escaped with one backslash instead of three.
467470
# @since 2.12
468471
# shellcheck disable=SC2120
469472
_comp_xfunc_scp_compgen_remote_files()
470473
{
474+
local _dirs_only=""
475+
local _less_escaping=""
476+
477+
local _flag OPTIND=1 OPTARG="" OPTERR=0
478+
while getopts "dl" _flag "$@"; do
479+
case $_flag in
480+
d) _dirs_only=set ;;
481+
l) _less_escaping=set ;;
482+
*)
483+
echo "bash_completion: $FUNCNAME: usage error: $*" >&2
484+
return 1
485+
;;
486+
esac
487+
done
488+
471489
# remove backslash escape from the first colon
472490
local cur=${cur/\\:/:}
473491

@@ -483,20 +501,25 @@ _comp_xfunc_scp_compgen_remote_files()
483501
_path=$(ssh -o 'Batchmode yes' "$_userhost" pwd 2>/dev/null)
484502
fi
485503

504+
local _escape_replacement='\\\\\\&'
505+
if [[ $_less_escaping ]]; then
506+
_escape_replacement='\\&'
507+
fi
508+
486509
local _files
487-
if [[ ${1-} == -d ]]; then
510+
if [[ $_dirs_only ]]; then
488511
# escape problematic characters; remove non-dirs
489512
# shellcheck disable=SC2090
490513
_files=$(ssh -o 'Batchmode yes' "$_userhost" \
491514
command ls -aF1dL "$_path*" 2>/dev/null |
492-
command sed -e 's/'"$_comp_cmd_scp__path_esc"'/\\\\\\&/g' -e '/[^\/]$/d')
515+
command sed -e 's/'"$_comp_cmd_scp__path_esc"'/'"$_escape_replacement"'/g' -e '/[^\/]$/d')
493516
else
494517
# escape problematic characters; remove executables, aliases, pipes
495518
# and sockets; add space at end of file names
496519
# shellcheck disable=SC2090
497520
_files=$(ssh -o 'Batchmode yes' "$_userhost" \
498521
command ls -aF1dL "$_path*" 2>/dev/null |
499-
command sed -e 's/'"$_comp_cmd_scp__path_esc"'/\\\\\\&/g' -e 's/[*@|=]$//g' \
522+
command sed -e 's/'"$_comp_cmd_scp__path_esc"'/'"$_escape_replacement"'/g' -e 's/[*@|=]$//g' \
500523
-e 's/[^\/]$/& /g')
501524
fi
502525
_comp_compgen -R split -l -- "$_files"

test/t/test_rsync.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pytest
22

3+
from conftest import assert_bash_exec, assert_complete
4+
35

46
@pytest.mark.bashcomp(ignore_env=r"^[+-]_comp_cmd_scp__path_esc=")
57
class TestRsync:
@@ -18,3 +20,56 @@ def test_3(self, completion):
1820
@pytest.mark.complete("rsync --", require_cmd=True)
1921
def test_4(self, completion):
2022
assert "--help" in completion
23+
24+
@pytest.mark.parametrize(
25+
"ver1,ver2,result",
26+
[
27+
("1", "1", "="),
28+
("1", "2", "<"),
29+
("2", "1", ">"),
30+
("1.1", "1.2", "<"),
31+
("1.2", "1.1", ">"),
32+
("1.1", "1.1.1", "<"),
33+
("1.1.1", "1.1", ">"),
34+
("1.1.1", "1.1.1", "="),
35+
("2.1", "2.2", "<"),
36+
("3.0.4.10", "3.0.4.2", ">"),
37+
("4.08", "4.08.01", "<"),
38+
("3.2.1.9.8144", "3.2", ">"),
39+
("3.2", "3.2.1.9.8144", "<"),
40+
("1.2", "2.1", "<"),
41+
("2.1", "1.2", ">"),
42+
("5.6.7", "5.6.7", "="),
43+
("1.01.1", "1.1.1", "="),
44+
("1.1.1", "1.01.1", "="),
45+
("1", "1.0", "="),
46+
("1.0", "1", "="),
47+
("1.0.2.0", "1.0.2", "="),
48+
("1..0", "1.0", "="),
49+
("1.0", "1..0", "="),
50+
],
51+
)
52+
def test_vercomp(self, bash, ver1, ver2, result):
53+
output = assert_bash_exec(
54+
bash,
55+
f"_comp_cmd_rsync__vercomp {ver1} {ver2}; echo $?",
56+
want_output=True,
57+
).strip()
58+
59+
if result == "=":
60+
assert output == "0"
61+
elif result == ">":
62+
assert output == "1"
63+
elif result == "<":
64+
assert output == "2"
65+
else:
66+
raise Exception(f"Unsupported comparison result: {result}")
67+
68+
def test_remote_path_with_spaces(self, bash):
69+
assert_bash_exec(bash, "ssh() { echo 'spaces in filename.txt'; }")
70+
completion = assert_complete(bash, "rsync remote_host:spaces")
71+
assert_bash_exec(bash, "unset -f ssh")
72+
assert (
73+
completion == r"\ in\ filename.txt"
74+
or completion == r"\\\ in\\\ filename.txt"
75+
)

test/t/test_scp.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from conftest import assert_bash_exec
5+
from conftest import assert_bash_exec, assert_complete
66

77
LIVE_HOST = "bash_completion"
88

@@ -95,3 +95,9 @@ def test_remote_path_with_nullglob(self, completion):
9595
)
9696
def test_remote_path_with_failglob(self, completion):
9797
assert not completion
98+
99+
def test_remote_path_with_spaces(self, bash):
100+
assert_bash_exec(bash, "ssh() { echo 'spaces in filename.txt'; }")
101+
completion = assert_complete(bash, "scp remote_host:spaces")
102+
assert_bash_exec(bash, "unset -f ssh")
103+
assert completion == r"\\\ in\\\ filename.txt"

0 commit comments

Comments
 (0)