diff --git a/sshfs/spec.py b/sshfs/spec.py index f8f5940..25f484d 100644 --- a/sshfs/spec.py +++ b/sshfs/spec.py @@ -8,7 +8,17 @@ from typing import Optional import asyncssh -from asyncssh.sftp import SFTPOpUnsupported +from asyncssh.sftp import ( + FILEXFER_TYPE_DIRECTORY, + FILEXFER_TYPE_REGULAR, + FILEXFER_TYPE_SYMLINK, + FILEXFER_TYPE_UNKNOWN, + SFTPError, + SFTPFailure, + SFTPFileAlreadyExists, + SFTPOpUnsupported, + SFTPPermissionDenied, +) from fsspec.asyn import ( AsyncFileSystem, FSTimeoutError, @@ -29,6 +39,12 @@ async_methods.append("_mv") +_FILE_TYPES = { + FILEXFER_TYPE_REGULAR: "file", + FILEXFER_TYPE_DIRECTORY: "directory", + FILEXFER_TYPE_SYMLINK: "link", +} + # Always allocate 2 channels for shell operations # and the rest (generally 8) for SFTP. _SHELL_CHANNELS = 2 @@ -79,6 +95,8 @@ def __init__( self._stack = AsyncExitStack() self.active_executors = 0 + # None means "not probed yet"; resolved on first _cp_file call. + self._supports_remote_copy = None self._client, self._pool = self.connect( host, pool_type, @@ -166,7 +184,13 @@ def client(self): return self._client def _decode_attributes(self, attributes): - if stat.S_ISDIR(attributes.permissions): + # SFTP v4 and later carry the file type in its own field and + # leave only the permission bits in `permissions`, so the type + # has to be read from there when the server reports one. + file_type = getattr(attributes, "type", FILEXFER_TYPE_UNKNOWN) + if file_type != FILEXFER_TYPE_UNKNOWN: + kind = _FILE_TYPES.get(file_type, "unknown") + elif stat.S_ISDIR(attributes.permissions): kind = "directory" elif stat.S_ISREG(attributes.permissions): kind = "file" @@ -216,18 +240,35 @@ async def _modified(self, path: str, **kwargs) -> datetime: @wrap_exceptions async def _mv(self, lpath, rpath, **kwargs): async with self._pool.get() as channel: + # posix-rename is an extension to the original SFTP + # protocol, but it is the only form that can replace an + # existing destination atomically. with suppress(SFTPOpUnsupported): return await channel.posix_rename(lpath, rpath) - # Some systems doesn't natively support posix_rename - # which is an extension to the original SFTP protocol. - # In that case we are going to copy the file and delete - # it. - - try: - await self._cp_file(lpath, rpath) - finally: - await self._rm_file(lpath) + # The standard rename keeps the object's identity + # (symlinks stay symlinks, hardlinks keep their inode and + # their special bits) and cannot lose data. Only the + # statuses that a rename returns for "cannot rename these + # operands" -- an existing destination, a cross-device + # move, an unimplemented request -- fall through to the + # shell below; permission, missing-file and transport + # errors are the caller's answer. + with suppress( + SFTPFailure, SFTPFileAlreadyExists, SFTPOpUnsupported + ): + return await channel.rename(lpath, rpath) + + # Neither rename applies, so let the remote mv do it. Copying + # and deleting the source here would be unsafe: a successful + # copy only proves that the bytes reached an open handle, not + # that the destination path still names it, so the source could + # be removed after its data ended up somewhere unreachable. On + # a server without shell access this raises instead, leaving + # the source untouched. + lpath, rpath = self._shell_paths(lpath, rpath) + cmd = f"mv -- {shlex.quote(lpath)} {shlex.quote(rpath)}" + await self._execute(cmd) @wrap_exceptions async def _put_file( @@ -259,9 +300,144 @@ async def _get_file( progress_handler=as_progress_handler(callback), ) + @staticmethod + def _shell_paths(*paths): + # Shell commands are text. Byte paths are decoded as UTF-8; + # paths that are not valid UTF-8 can only be used with the + # operations that stay on the SFTP channel. + decoded = [] + for path in paths: + if isinstance(path, bytes): + try: + path = path.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError( + f"{path!r} is not valid UTF-8 and cannot be used " + "in a shell command" + ) from exc + decoded.append(path) + return decoded + + async def _remote_copy_file(self, channel, lpath, rpath): + """Copy over the copy-data extension. Returns False when the + copy must be handled by the shell fallback instead.""" + # The remote copy only ever CREATES the destination (FXF_EXCL) + # and copies until the source's end of file. Everything an + # in-place overwrite would need is unsolvable over SFTP: a + # stale pre-copy size corrupts sources whose stat lies (procfs) + # or that change while copying, a post-copy truncate can be + # denied (fsetstat policy) after the destination was already + # modified, a pre-copy truncate destroys sources aliased behind + # an undetectable hardlink, and replacing the directory entry + # loses the inode. Existing destinations therefore go to the + # shell fallback, whose cp implements those semantics natively. + # FXF_EXCL also refuses to create through a dangling symlink. + # + # A failed or interrupted copy may leave a partial destination + # behind, exactly like an interrupted cp. It is deliberately + # never unlinked: FXF_EXCL proves this client created the + # inode, but over SFTP the pathname cannot be re-verified to + # still name that inode at cleanup time, so removing it could + # delete an unrelated file that raced onto the same name. + + # A directory destination means "copy into": like cp, resolve + # it against the source's basename. isdir() checks the file + # type on every SFTP version (v4+ attributes carry no type bits + # in `permissions`) and is False for missing paths. + if await channel.isdir(rpath): + rpath = posixpath.join( + channel.encode(rpath), + posixpath.basename(channel.encode(lpath)), + ) + + # The source is opened before the destination is created so + # that a missing source cannot leave an empty destination. + src_file = await channel.open(lpath, "rb", block_size=0) + try: + # Like cp for new files: the source's mode without the + # special bits, which the server then filters through its + # umask. A mode of 0 is a valid mode, so only a genuinely + # missing one is replaced -- and it is replaced with 0600, + # never with the server's default: servers may deny fstat + # (OpenSSH -P fstat) while allowing the copy, and defaulting + # to a world-readable mode would publish the contents of a + # private source. + mode = 0o600 + with suppress(OSError, SFTPError): + src_attrs = await src_file.stat() + if src_attrs.permissions is not None: + mode = src_attrs.permissions & 0o777 + attrs = asyncssh.SFTPAttrs(permissions=mode) + try: + dst_file = await channel.open( + rpath, + asyncssh.FXF_WRITE + | asyncssh.FXF_CREAT + | asyncssh.FXF_EXCL, + attrs, + block_size=0, + ) + except (OSError, SFTPError): + # Existing destination (any alias of the source is one), + # dangling symlink, missing parent, trailing slash on a + # file: the shell fallback owns these forms. + return False + + try: + await channel.remote_copy(src_file, dst_file) + except SFTPOpUnsupported: + # Advertised but not actually implemented: stop trying + # the extension on this connection and let the shell cp + # overwrite the empty file just created. + self._supports_remote_copy = False + with suppress(OSError, SFTPError): + await dst_file.close() + return False + except SFTPPermissionDenied: + # Denied for these operands (an OpenSSH allow/deny + # policy can depend on the paths involved), which says + # nothing about the next copy: fall back for this one + # without disabling the extension connection-wide. + with suppress(OSError, SFTPError): + await dst_file.close() + return False + except BaseException: + with suppress(OSError, SFTPError): + await dst_file.close() + raise + # A close failure after writing must surface: the data may + # not be durable. + await dst_file.close() + finally: + with suppress(OSError, SFTPError): + await src_file.close() + return True + @wrap_exceptions async def _cp_file(self, lpath, rpath, **kwargs): - cmd = f"cp {shlex.quote(lpath)} {shlex.quote(rpath)}" + # 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. The capability is + # per-connection, so it is cached after the first probe and the + # shell fallback never touches the channel pool again. The + # extension path only creates new destinations; everything else + # falls back to a shell cp. + if self._supports_remote_copy is not False: + async with self._pool.get() as channel: + if self._supports_remote_copy is None: + self._supports_remote_copy = getattr( + channel, "supports_remote_copy", False + ) + if ( + self._supports_remote_copy + and await self._remote_copy_file(channel, lpath, rpath) + ): + return + + lpath, rpath = self._shell_paths(lpath, rpath) + # `--` keeps a relative path starting with a dash from being + # parsed as an option. + cmd = f"cp -- {shlex.quote(lpath)} {shlex.quote(rpath)}" await self._execute(cmd) @wrap_exceptions diff --git a/sshfs/utils.py b/sshfs/utils.py index fb2b02d..e504961 100644 --- a/sshfs/utils.py +++ b/sshfs/utils.py @@ -4,7 +4,7 @@ from asyncssh import ProcessError from asyncssh.misc import PermissionDenied -from asyncssh.sftp import SFTPFailure, SFTPNoSuchFile +from asyncssh.sftp import SFTPFailure, SFTPNoSuchFile, SFTPPermissionDenied from fsspec.asyn import sync_wrapper _NOT_FOUND = os.strerror(errno.ENOENT) @@ -27,6 +27,8 @@ async def wrapper(*args, **kwargs): return await func(*args, **kwargs) except PermissionDenied as exc: raise PermissionError(exc.reason) from exc + except SFTPPermissionDenied as exc: + raise PermissionError(errno.EACCES, exc.reason) from exc except SFTPNoSuchFile as exc: raise FileNotFoundError(errno.ENOENT, _NOT_FOUND) from exc except ProcessError as exc: diff --git a/tests/conftest.py b/tests/conftest.py index cd5eba3..9bff731 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,112 @@ +import asyncio import threading +from contextlib import suppress +from pathlib import Path from queue import Queue +import asyncssh import mockssh.server +import pytest + +_STATIC = (Path(__file__).parent / "static").resolve() +_USER_KEY = asyncssh.read_private_key(str(_STATIC / "user.key")) + + +class _TestSSHServer(asyncssh.SSHServer): + def begin_auth(self, username): + return True + + def public_key_auth_supported(self): + return True + + def validate_public_key(self, username, key): + return key == _USER_KEY.convert_to_public() + + +class _ZeroSizeSFTPServer(asyncssh.SFTPServer): + """Reports every file as empty, like procfs and sysfs do, while + still serving the real content.""" + + def _zero(self, result): + attrs = asyncssh.SFTPAttrs.from_local(result) + attrs.size = 0 + return attrs + + def stat(self, path): + return self._zero(super().stat(path)) + + def lstat(self, path): + return self._zero(super().lstat(path)) + + def fstat(self, file_obj): + return self._zero(super().fstat(file_obj)) + + +def _serve(root, sftp_server=asyncssh.SFTPServer, sftp_version=3): + """Start an authenticated SFTP server chrooted to `root` on its own + event loop thread. Yields (host, port, root, sftp_version).""" + if not hasattr(asyncssh.SFTPClient, "supports_remote_copy"): + pytest.skip("asyncssh without copy-data support (< 2.19)") + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + + async def _listen(): + return await asyncssh.listen( + "127.0.0.1", + 0, + server_host_keys=[_USER_KEY], + server_factory=_TestSSHServer, + sftp_factory=lambda chan: sftp_server(chan, chroot=str(root)), + sftp_version=sftp_version, + ) + + server = asyncio.run_coroutine_threadsafe(_listen(), loop).result(30) + try: + yield "127.0.0.1", server.get_port(), root, sftp_version + finally: + + async def _shutdown(): + server.close() + await server.wait_closed() + tasks = [ + task + for task in asyncio.all_tasks() + if task is not asyncio.current_task() + ] + for task in tasks: + task.cancel() + if tasks: + await asyncio.wait(tasks, timeout=5) + + with suppress(Exception): + asyncio.run_coroutine_threadsafe(_shutdown(), loop).result(30) + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + loop.close() + + +@pytest.fixture(scope="session", params=[3, 4], ids=["sftpv3", "sftpv4"]) +def asyncssh_server(tmp_path_factory, request): + """SFTP server that, unlike the paramiko-based mockssh fixture, + implements the copy-data and limits extensions. Authenticated with + the test user key and chrooted to a fresh directory; yields + (host, port, root, version) where the remote "/" maps to root. + Runs once per + SFTP protocol generation: v4+ moves the file type out of the + permission bits.""" + yield from _serve( + tmp_path_factory.mktemp(f"asyncssh-root-v{request.param}"), + sftp_version=request.param, + ) + + +@pytest.fixture(scope="session") +def zero_size_server(tmp_path_factory): + """Like asyncssh_server, but every stat reports size 0.""" + yield from _serve( + tmp_path_factory.mktemp("zero-size-root"), _ZeroSizeSFTPServer + ) def _handler_run(self): diff --git a/tests/test_sshfs.py b/tests/test_sshfs.py index 141baf7..2e1b1a6 100644 --- a/tests/test_sshfs.py +++ b/tests/test_sshfs.py @@ -1,16 +1,27 @@ import hashlib +import os import posixpath import secrets import tempfile import warnings from concurrent import futures +from contextlib import suppress from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace +from unittest import mock import fsspec import pytest -from asyncssh.sftp import SFTPAttrs, SFTPFailure +from asyncssh.misc import ChannelOpenError +from asyncssh.sftp import ( + SFTPAttrs, + SFTPClient, + SFTPFailure, + SFTPOpUnsupported, + SFTPPermissionDenied, +) +from fsspec.asyn import sync from importlib_metadata import entry_points from sshfs import SSHFileSystem @@ -71,9 +82,15 @@ def fs_hard_queue(ssh_server, user="user"): ) +async def _channel_version(fs): + async with fs._pool.get() as channel: + return channel.version + + def strip_keys(info): for key in ["name", "time", "mtime", "atime"]: info.pop(key, None) + return info def test_fsspec_registration(ssh_server): @@ -199,7 +216,9 @@ def test_move(fs, remote_dir): def test_copy(fs, remote_dir): - fs.touch(remote_dir + "/a.txt") + data = b"data to copy" + with fs.open(remote_dir + "/a.txt", "wb") as stream: + stream.write(data) initial_info = fs.info(remote_dir + "/a.txt") fs.copy(remote_dir + "/a.txt", remote_dir + "/b.txt") @@ -207,10 +226,502 @@ def test_copy(fs, remote_dir): assert fs.exists(remote_dir + "/a.txt") assert fs.exists(remote_dir + "/b.txt") + assert fs.cat_file(remote_dir + "/b.txt") == data 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() + + +@pytest.fixture(scope="session") +def copydata_fs(asyncssh_server): + # The server fixture runs the whole suite once per SFTP protocol + # generation; the client negotiates up to whatever it offers. + host, port, _root, version = asyncssh_server + fs = SSHFileSystem( + host=host, + port=port, + username="user", + client_keys=[USERS["user"]], + sftp_client_kwargs={"sftp_version": version}, + ) + yield fs + # Close the connection so the server fixture can shut its loop + # down without cancelling live connection tasks. + with suppress(Exception): + sync(fs.loop, fs._stack.aclose, timeout=5) + + +@pytest.fixture +def copydata_dir(asyncssh_server, request): + _host, _port, root, _version = asyncssh_server + # unique per invocation so pytest-rerunfailures retries get a + # fresh directory + local = root / f"{request.node.name}-{secrets.token_hex(4)}" + local.mkdir() + # the server is chrooted to `root`, so `local` is served as this + # remote path + yield local, "/" + local.name + + +requires_copy_data = pytest.mark.skipif( + not hasattr(SFTPClient, "supports_remote_copy"), + reason="asyncssh without copy-data support (< 2.19)", +) + + +@requires_copy_data +def test_cp_file_copy_data_creates(copydata_fs, copydata_dir): + fs = copydata_fs + local, remote = copydata_dir + (local / "src").write_bytes(b"payload") + (local / "src").chmod(0o666) + + umask = os.umask(0) + os.umask(umask) + + fs.cp_file(remote + "/src", remote + "/dst") + # the copy-data path was actually taken, not the shell fallback + assert fs._supports_remote_copy is True + assert (local / "dst").read_bytes() == b"payload" + # a new file gets the source's mode filtered by the server's + # umask, like cp + assert ((local / "dst").stat().st_mode & 0o7777) == 0o666 & ~umask + + # "copy into" an existing directory creates the source's basename + (local / "d").mkdir() + fs.cp_file(remote + "/src", remote + "/d") + assert (local / "d" / "src").read_bytes() == b"payload" + + +@requires_copy_data +def test_cp_file_copy_data_ignores_reported_size(zero_size_server): + # Sources whose stat lies about the size (procfs, sysfs) must be + # copied whole: the copy runs to the source's real end of file and + # never sizes the destination from a stat snapshot. + host, port, root, _version = zero_size_server + fs = SSHFileSystem( + host=host, + port=port, + username="user", + client_keys=[USERS["user"]], + skip_instance_cache=True, + ) + try: + name = secrets.token_hex(4) + (root / f"src-{name}").write_bytes(b"payload" * 1000) + assert fs.info(f"/src-{name}")["size"] == 0 + + fs.cp_file(f"/src-{name}", f"/dst-{name}") + assert fs._supports_remote_copy is True + assert (root / f"dst-{name}").read_bytes() == b"payload" * 1000 + finally: + with suppress(Exception): + sync(fs.loop, fs._stack.aclose, timeout=5) + + +def test_remote_copy_keeps_mode_zero(fs, monkeypatch): + # A mode of 0 is a valid mode, not a missing one: it must be + # carried over instead of falling back to the server's default + # (SFTP v4+ reports it as permissions == 0, since the file type + # lives in a separate field). + opened = [] + + class _File: + async def stat(self): + return SFTPAttrs(permissions=0) + + async def close(self): + pass + + class Channel: + supports_remote_copy = True + + def encode(self, path): + return path.encode() if isinstance(path, str) else path + + async def isdir(self, path): + return False + + async def open(self, path, *args, **kwargs): + opened.append((path, args)) + return _File() + + async def remote_copy(self, src, dst): + pass + + monkeypatch.setattr(fs, "_supports_remote_copy", True) + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + + fs.cp_file("/src", "/dst") + _dst_path, dst_args = opened[-1] + assert dst_args[1].permissions == 0 + + +@requires_copy_data +def test_cp_file_copy_data_existing_destinations(copydata_fs, copydata_dir): + # The extension path only creates destinations. Anything existing + # -- including every alias of the source -- is left to the shell + # fallback, which this server does not offer: the copy must fail + # without touching a single byte. + fs = copydata_fs + local, remote = copydata_dir + src = local / "src" + src.write_bytes(b"payload") + + (local / "existing").write_bytes(b"old") + (local / "link").symlink_to("src") + os.link(src, local / "hard") + + for dst in ["/src", "/existing", "/link", "/hard"]: + with pytest.raises((OSError, ChannelOpenError)): + fs.cp_file(remote + "/src", remote + dst) + with pytest.raises((OSError, ChannelOpenError)): + fs.cp_file((remote + "/src").encode(), remote + "/src") + + assert src.read_bytes() == b"payload" + assert (local / "existing").read_bytes() == b"old" + assert (local / "hard").stat().st_ino == src.stat().st_ino + # "copy into" resolving to an existing file is refused the same way + (local / "d").mkdir() + os.link(src, local / "d" / "src") + with pytest.raises((OSError, ChannelOpenError)): + fs.cp_file(remote + "/src", remote + "/d") + assert (local / "d" / "src").read_bytes() == b"payload" + + +@requires_copy_data +def test_cp_file_copy_data_never_redirects(copydata_fs, copydata_dir): + # FXF_EXCL refuses to create through a dangling symlink, so the + # copy cannot be redirected to the link's target. (Trailing-slash + # handling is the server's path resolution and is not asserted: + # this server normalizes it away, OpenSSH rejects it.) + fs = copydata_fs + local, remote = copydata_dir + (local / "src").write_bytes(b"payload") + (local / "dangling").symlink_to("missing") + + with pytest.raises((OSError, ChannelOpenError)): + fs.cp_file(remote + "/src", remote + "/dangling") + assert not (local / "missing").exists() + + +@requires_copy_data +def test_mv_hardlink_alias(copydata_fs, copydata_dir): + # POSIX rename between two names of the same inode is a no-op: + # the move succeeds with both names surviving and no data lost. + fs = copydata_fs + local, remote = copydata_dir + src = local / "src" + src.write_bytes(b"payload") + os.link(src, local / "hard") + + fs.mv(remote + "/src", remote + "/hard") + assert src.read_bytes() == b"payload" + assert (local / "hard").read_bytes() == b"payload" + + +@requires_copy_data +def test_copydata_server_negotiates_expected_version( + copydata_fs, asyncssh_server +): + # The functional suite claims to cover both protocol generations, + # so the negotiated version must actually be the server's. + _host, _port, _root, version = asyncssh_server + negotiated = sync( + copydata_fs.loop, _channel_version, copydata_fs, timeout=10 + ) + assert negotiated == version + + +@requires_copy_data +def test_info_reports_type_on_every_version(copydata_fs, copydata_dir): + # SFTP v4+ reports the file type in its own field and leaves only + # the permission bits in `permissions`, so the type must not be + # decoded from the mode alone. + fs = copydata_fs + local, remote = copydata_dir + (local / "file").write_bytes(b"payload") + (local / "dir").mkdir() + (local / "link").symlink_to("file") + + assert fs.info(remote + "/file")["type"] == "file" + assert fs.info(remote + "/dir")["type"] == "directory" + assert fs.isdir(remote + "/dir") + assert fs.isfile(remote + "/file") + assert {i["type"] for i in fs.ls(remote)} == { + "file", + "directory", + "link", + } + + +@requires_copy_data +def test_cp_file_copy_data_read_only_source_mode(copydata_fs, copydata_dir): + # A read-only source keeps its exact mode, like cp: the copy must + # not widen it just because the extension was used. + fs = copydata_fs + local, remote = copydata_dir + (local / "src").write_bytes(b"payload") + (local / "src").chmod(0o400) + + fs.cp_file(remote + "/src", remote + "/dst") + assert (local / "dst").read_bytes() == b"payload" + assert ((local / "dst").stat().st_mode & 0o7777) == 0o400 + + +@requires_copy_data +def test_mv_uses_standard_rename(copydata_fs, copydata_dir): + # Without posix-rename, a plain rename still keeps the object's + # identity: a symlink must stay a symlink instead of being + # flattened into a copy of its target. + fs = copydata_fs + local, remote = copydata_dir + (local / "target").write_bytes(b"payload") + (local / "link").symlink_to("target") + + async def _no_posix_rename(*args, **kwargs): + raise SFTPOpUnsupported("posix-rename not supported") + + with mock.patch.object(SFTPClient, "posix_rename", _no_posix_rename): + fs.mv(remote + "/link", remote + "/moved") + + assert (local / "moved").is_symlink() + assert os.readlink(local / "moved") == "target" + + +def test_remote_copy_unknown_mode_is_private(fs, monkeypatch): + # A server that denies fstat must not cause the destination to be + # created with the server's default (world-readable) mode. + opened = [] + + class _File: + async def stat(self): + raise SFTPPermissionDenied("fstat denied") + + async def close(self): + pass + + class Channel: + supports_remote_copy = True + + def encode(self, path): + return path.encode() if isinstance(path, str) else path + + async def isdir(self, path): + return False + + async def open(self, path, *args, **kwargs): + opened.append((path, args)) + return _File() + + async def remote_copy(self, src, dst): + pass + + monkeypatch.setattr(fs, "_supports_remote_copy", True) + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + + fs.cp_file("/src", "/dst") + _path, args = opened[-1] + assert args[1].permissions == 0o600 + + +def test_cp_file_copy_data_denied_is_not_cached(fs, monkeypatch): + # A denial can depend on the operands, so it must not disable the + # extension for every later copy on the connection. + events = [] + + class _File: + async def stat(self): + return SFTPAttrs(permissions=0o100644) + + async def close(self): + pass + + class Channel: + supports_remote_copy = True + + def encode(self, path): + return path.encode() if isinstance(path, str) else path + + async def isdir(self, path): + return False + + async def open(self, path, *args, **kwargs): + return _File() + + async def remote_copy(self, src, dst): + raise SFTPPermissionDenied("denied for these operands") + + async def record_shell(cmd, **kwargs): + events.append(cmd) + + monkeypatch.setattr(fs, "_supports_remote_copy", None) + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + monkeypatch.setattr(fs, "_execute", record_shell) + + fs.cp_file("/denied", "/dst") + assert events == ["cp -- /denied /dst"] + assert fs._supports_remote_copy is True + + +def test_cp_file_copy_data_denied(fs, monkeypatch): + # copy-data advertised but not implemented: the capability is + # re-cached as unsupported and the copy falls back to the shell, + # which overwrites the empty file created by the exclusive open. + events = [] + + class _File: + async def stat(self): + return SFTPAttrs(permissions=0o100644) + + async def close(self): + events.append("close") + + class _OpenResult: + def __init__(self): + self.file = _File() + + def __await__(self): + async def _result(): + return self.file + + return _result().__await__() + + async def __aenter__(self): + return self.file + + async def __aexit__(self, *exc): + return False + + class Channel: + supports_remote_copy = True + + def encode(self, path): + return path.encode() if isinstance(path, str) else path + + async def isdir(self, path): + return False + + def open(self, path, *args, **kwargs): + events.append(("open", path)) + return _OpenResult() + + async def remote_copy(self, src, dst): + raise SFTPOpUnsupported("advertised but not implemented") + + async def record_shell(cmd, **kwargs): + events.append(("shell", cmd)) + + monkeypatch.setattr(fs, "_supports_remote_copy", None) + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + monkeypatch.setattr(fs, "_execute", record_shell) + + fs.cp_file("/src", "/dst") + assert ("shell", "cp -- /src /dst") in events + assert fs._supports_remote_copy is False + + +def test_mv_falls_back_to_remote_mv(fs, monkeypatch): + # When neither rename applies, the move is handed to the remote mv + # rather than copied and deleted: a copy only proves that bytes + # reached an open handle, so deleting the source afterwards could + # destroy the only remaining copy of the data. + events = [] + + class Channel: + async def posix_rename(self, lpath, rpath): + raise SFTPOpUnsupported("posix-rename not supported") + + async def rename(self, lpath, rpath): + raise SFTPFailure("destination exists") + + async def fail_cp(*args, **kwargs): + raise AssertionError("mv must not copy and delete") + + async def fail_rm(*args, **kwargs): + raise AssertionError("mv must not remove the source itself") + + async def record_shell(cmd, **kwargs): + events.append(cmd) + + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + monkeypatch.setattr(fs, "_cp_file", fail_cp) + monkeypatch.setattr(fs, "_rm_file", fail_rm) + monkeypatch.setattr(fs, "_execute", record_shell) + + fs.mv("/src", "/dst") + assert events == ["mv -- /src /dst"] + + +def test_mv_propagates_rename_errors(fs, monkeypatch): + # A denied rename is an answer, not a reason to try something with + # different semantics. + class Channel: + async def posix_rename(self, lpath, rpath): + raise SFTPOpUnsupported("posix-rename not supported") + + async def rename(self, lpath, rpath): + raise SFTPPermissionDenied("rename denied by policy") + + async def fail_shell(*args, **kwargs): + raise AssertionError("a denied rename must not reach the shell") + + monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel())) + monkeypatch.setattr(fs, "_execute", fail_shell) + + with pytest.raises(PermissionError): + fs.mv("/src", "/dst") + + +@pytest.mark.parametrize("legacy_asyncssh", [False, True]) +def test_cp_file_shell_fallback(fs, monkeypatch, legacy_asyncssh): + # Channels without copy-data support -- and asyncssh < 2.19 + # channels, which lack the supports_remote_copy attribute entirely + # -- must use the shell cp path, and only the first call may touch + # the channel pool (the probed capability is cached). + calls = [] + pool_uses = [] + + class Channel: + async def copy(self, *args, **kwargs): + raise AssertionError("copy-data must not be attempted") + + if not legacy_asyncssh: + Channel.supports_remote_copy = False + + async def record_shell(cmd, **kwargs): + calls.append(cmd) + + pool = _FakeChannelPool(Channel()) + _orig_get = pool.get + pool.get = lambda: pool_uses.append(1) or _orig_get() + + monkeypatch.setattr(fs, "_supports_remote_copy", None) + monkeypatch.setattr(fs, "_pool", pool) + monkeypatch.setattr(fs, "_execute", record_shell) + + fs.cp_file("/src", "/dst") + fs.cp_file("/src2", "/dst2") + assert calls == ["cp -- /src /dst", "cp -- /src2 /dst2"] + assert len(pool_uses) == 1 + + def test_rm(fs, remote_dir): fs.touch(remote_dir + "/a.txt") fs.rm(remote_dir + "/a.txt")