diff --git a/sshfs/spec.py b/sshfs/spec.py index f8f5940..177675c 100644 --- a/sshfs/spec.py +++ b/sshfs/spec.py @@ -261,6 +261,15 @@ async def _get_file( @wrap_exceptions async def _cp_file(self, lpath, rpath, **kwargs): + # Server-side copy through the copy-data extension (asyncssh >= + # 2.19 with an OpenSSH >= 9.0 server) needs no shell access and + # keeps the data on the server. remote_only guards against + # asyncssh silently copying through the client instead. Without + # the extension, fall back to a shell cp. + async with self._pool.get() as channel: + if getattr(channel, "supports_remote_copy", False): + return await channel.copy(lpath, rpath, remote_only=True) + cmd = f"cp {shlex.quote(lpath)} {shlex.quote(rpath)}" await self._execute(cmd) diff --git a/tests/test_sshfs.py b/tests/test_sshfs.py index 141baf7..1359412 100644 --- a/tests/test_sshfs.py +++ b/tests/test_sshfs.py @@ -211,6 +211,66 @@ def test_copy(fs, remote_dir): assert strip_keys(initial_info) == strip_keys(secondary_info) +class _FakeChannelPool: + def __init__(self, channel): + self.channel = channel + + def get(self): + pool = self + + class _Ctx: + async def __aenter__(self): + return pool.channel + + async def __aexit__(self, *exc): + return False + + return _Ctx() + + +def test_cp_file_remote_copy(fs, monkeypatch): + # A channel advertising copy-data must be used with remote_only=True + # (otherwise asyncssh silently copies through the client) and the + # shell fallback must not run. + calls = {} + + class Channel: + supports_remote_copy = True + + async def copy(self, lpath, rpath, remote_only=False): + calls["copy"] = (lpath, rpath, remote_only) + + async def no_shell(*args, **kwargs): + raise AssertionError("shell fallback must not run") + + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + monkeypatch.setattr(fs, "_execute", no_shell) + + fs.cp_file("/src", "/dst") + assert calls["copy"] == ("/src", "/dst", True) + + +def test_cp_file_shell_fallback(fs, monkeypatch): + # Without copy-data support (or on asyncssh < 2.19, where the + # attribute does not exist), the shell cp path is used. + calls = {} + + class Channel: + supports_remote_copy = False + + async def copy(self, *args, **kwargs): + raise AssertionError("copy-data must not be attempted") + + async def record_shell(cmd, **kwargs): + calls["cmd"] = cmd + + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + monkeypatch.setattr(fs, "_execute", record_shell) + + fs.cp_file("/src", "/dst") + assert calls["cmd"] == "cp /src /dst" + + def test_rm(fs, remote_dir): fs.touch(remote_dir + "/a.txt") fs.rm(remote_dir + "/a.txt")