Skip to content

Commit bd78fa9

Browse files
shchekleinclaude
andcommitted
tests: negotiate SFTP v4 for real and cover the new contracts
The v4 coverage was false: the client asked for v4 while the test server only offered v3, so both parameters ran v3. The server fixture now runs the suite once per protocol generation and a test asserts the negotiated version, so the claim cannot silently regress again. New coverage: an unknown source mode creates a private destination, a source without owner-write keeps its group and other bits, a move falls back to the standard rename and keeps a symlink a symlink, and a denied copy-data request does not disable the extension for later copies. The fixtures skip on asyncssh < 2.19 instead of erroring, and the zero-size test uses unique names so reruns cannot collide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 64ed812 commit bd78fa9

2 files changed

Lines changed: 171 additions & 30 deletions

File tree

tests/conftest.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ def fstat(self, file_obj):
4242
return self._zero(super().fstat(file_obj))
4343

4444

45-
def _serve(root, sftp_server=asyncssh.SFTPServer):
45+
def _serve(root, sftp_server=asyncssh.SFTPServer, sftp_version=3):
4646
"""Start an authenticated SFTP server chrooted to `root` on its own
47-
event loop thread. Yields (host, port, root)."""
47+
event loop thread. Yields (host, port, root, sftp_version)."""
48+
if not hasattr(asyncssh.SFTPClient, "supports_remote_copy"):
49+
pytest.skip("asyncssh without copy-data support (< 2.19)")
4850
loop = asyncio.new_event_loop()
4951
thread = threading.Thread(target=loop.run_forever, daemon=True)
5052
thread.start()
@@ -56,11 +58,12 @@ async def _listen():
5658
server_host_keys=[_USER_KEY],
5759
server_factory=_TestSSHServer,
5860
sftp_factory=lambda chan: sftp_server(chan, chroot=str(root)),
61+
sftp_version=sftp_version,
5962
)
6063

6164
server = asyncio.run_coroutine_threadsafe(_listen(), loop).result(30)
6265
try:
63-
yield "127.0.0.1", server.get_port(), root
66+
yield "127.0.0.1", server.get_port(), root, sftp_version
6467
finally:
6568

6669
async def _shutdown():
@@ -83,13 +86,19 @@ async def _shutdown():
8386
loop.close()
8487

8588

86-
@pytest.fixture(scope="session")
87-
def asyncssh_server(tmp_path_factory):
89+
@pytest.fixture(scope="session", params=[3, 4], ids=["sftpv3", "sftpv4"])
90+
def asyncssh_server(tmp_path_factory, request):
8891
"""SFTP server that, unlike the paramiko-based mockssh fixture,
8992
implements the copy-data and limits extensions. Authenticated with
9093
the test user key and chrooted to a fresh directory; yields
91-
(host, port, root) where the remote "/" maps to root."""
92-
yield from _serve(tmp_path_factory.mktemp("asyncssh-root"))
94+
(host, port, root, version) where the remote "/" maps to root.
95+
Runs once per
96+
SFTP protocol generation: v4+ moves the file type out of the
97+
permission bits."""
98+
yield from _serve(
99+
tmp_path_factory.mktemp(f"asyncssh-root-v{request.param}"),
100+
sftp_version=request.param,
101+
)
93102

94103

95104
@pytest.fixture(scope="session")

tests/test_sshfs.py

Lines changed: 155 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from datetime import datetime, timedelta, timezone
1010
from pathlib import Path
1111
from types import SimpleNamespace
12+
from unittest import mock
1213

1314
import fsspec
1415
import pytest
@@ -81,6 +82,11 @@ def fs_hard_queue(ssh_server, user="user"):
8182
)
8283

8384

85+
async def _channel_version(fs):
86+
async with fs._pool.get() as channel:
87+
return channel.version
88+
89+
8490
def strip_keys(info):
8591
for key in ["name", "time", "mtime", "atime"]:
8692
info.pop(key, None)
@@ -242,20 +248,17 @@ async def __aexit__(self, *exc):
242248
return _Ctx()
243249

244250

245-
@pytest.fixture(scope="session", params=[None, 4], ids=["sftpv3", "sftpv4"])
246-
def copydata_fs(asyncssh_server, request):
247-
# v4+ separates the file type from `permissions`, so both protocol
248-
# generations must satisfy the same contract.
249-
host, port, _root = asyncssh_server
250-
extra = {}
251-
if request.param is not None:
252-
extra["sftp_client_kwargs"] = {"sftp_version": request.param}
251+
@pytest.fixture(scope="session")
252+
def copydata_fs(asyncssh_server):
253+
# The server fixture runs the whole suite once per SFTP protocol
254+
# generation; the client negotiates up to whatever it offers.
255+
host, port, _root, version = asyncssh_server
253256
fs = SSHFileSystem(
254257
host=host,
255258
port=port,
256259
username="user",
257260
client_keys=[USERS["user"]],
258-
**extra,
261+
sftp_client_kwargs={"sftp_version": version},
259262
)
260263
yield fs
261264
# Close the connection so the server fixture can shut its loop
@@ -266,7 +269,7 @@ def copydata_fs(asyncssh_server, request):
266269

267270
@pytest.fixture
268271
def copydata_dir(asyncssh_server, request):
269-
_host, _port, root = asyncssh_server
272+
_host, _port, root, _version = asyncssh_server
270273
# unique per invocation so pytest-rerunfailures retries get a
271274
# fresh directory
272275
local = root / f"{request.node.name}-{secrets.token_hex(4)}"
@@ -311,27 +314,29 @@ def test_cp_file_copy_data_ignores_reported_size(zero_size_server):
311314
# Sources whose stat lies about the size (procfs, sysfs) must be
312315
# copied whole: the copy runs to the source's real end of file and
313316
# never sizes the destination from a stat snapshot.
314-
host, port, root = zero_size_server
317+
host, port, root, _version = zero_size_server
315318
fs = SSHFileSystem(
316319
host=host, port=port, username="user", client_keys=[USERS["user"]]
317320
)
318321
try:
319-
(root / "src").write_bytes(b"payload" * 1000)
320-
assert fs.info("/src")["size"] == 0
322+
name = secrets.token_hex(4)
323+
(root / f"src-{name}").write_bytes(b"payload" * 1000)
324+
assert fs.info(f"/src-{name}")["size"] == 0
321325

322-
fs.cp_file("/src", "/dst")
326+
fs.cp_file(f"/src-{name}", f"/dst-{name}")
323327
assert fs._supports_remote_copy is True
324-
assert (root / "dst").read_bytes() == b"payload" * 1000
328+
assert (root / f"dst-{name}").read_bytes() == b"payload" * 1000
325329
finally:
326330
with suppress(Exception):
327331
sync(fs.loop, fs._stack.aclose, timeout=5)
328332

329333

330334
def test_remote_copy_keeps_mode_zero(fs, monkeypatch):
331335
# A mode of 0 is a valid mode, not a missing one: it must be
332-
# requested as-is instead of falling back to the server's default
336+
# carried over instead of falling back to the server's default
333337
# (SFTP v4+ reports it as permissions == 0, since the file type
334-
# lives in a separate field).
338+
# lives in a separate field). Only owner-write is added, so no
339+
# group or other bit appears.
335340
opened = []
336341

337342
class _File:
@@ -362,7 +367,7 @@ async def remote_copy(self, src, dst):
362367

363368
fs.cp_file("/src", "/dst")
364369
_dst_path, dst_args = opened[-1]
365-
assert dst_args[1].permissions == 0
370+
assert dst_args[1].permissions == 0o200
366371

367372

368373
@requires_copy_data
@@ -413,6 +418,7 @@ def test_cp_file_copy_data_never_redirects(copydata_fs, copydata_dir):
413418
assert not (local / "missing").exists()
414419

415420

421+
@requires_copy_data
416422
def test_mv_hardlink_alias(copydata_fs, copydata_dir):
417423
# POSIX rename between two names of the same inode is a no-op:
418424
# the move succeeds with both names surviving and no data lost.
@@ -427,9 +433,132 @@ def test_mv_hardlink_alias(copydata_fs, copydata_dir):
427433
assert (local / "hard").read_bytes() == b"payload"
428434

429435

436+
@requires_copy_data
437+
def test_copydata_server_negotiates_expected_version(
438+
copydata_fs, asyncssh_server
439+
):
440+
# The functional suite claims to cover both protocol generations,
441+
# so the negotiated version must actually be the server's.
442+
_host, _port, _root, version = asyncssh_server
443+
negotiated = sync(
444+
copydata_fs.loop, _channel_version, copydata_fs, timeout=10
445+
)
446+
assert negotiated == version
447+
448+
449+
@requires_copy_data
450+
def test_cp_file_copy_data_unreadable_source_mode(copydata_fs, copydata_dir):
451+
# A source without owner-write keeps its group/other bits and
452+
# gains only owner-write, so the shell fallback could still write
453+
# the file if the copy were denied.
454+
fs = copydata_fs
455+
local, remote = copydata_dir
456+
(local / "src").write_bytes(b"payload")
457+
(local / "src").chmod(0o400)
458+
459+
fs.cp_file(remote + "/src", remote + "/dst")
460+
assert (local / "dst").read_bytes() == b"payload"
461+
assert ((local / "dst").stat().st_mode & 0o077) == 0
462+
463+
464+
@requires_copy_data
465+
def test_mv_uses_standard_rename(copydata_fs, copydata_dir):
466+
# Without posix-rename, a plain rename still keeps the object's
467+
# identity: a symlink must stay a symlink instead of being
468+
# flattened into a copy of its target.
469+
fs = copydata_fs
470+
local, remote = copydata_dir
471+
(local / "target").write_bytes(b"payload")
472+
(local / "link").symlink_to("target")
473+
474+
async def _no_posix_rename(*args, **kwargs):
475+
raise SFTPOpUnsupported("posix-rename not supported")
476+
477+
with mock.patch.object(SFTPClient, "posix_rename", _no_posix_rename):
478+
fs.mv(remote + "/link", remote + "/moved")
479+
480+
assert (local / "moved").is_symlink()
481+
assert os.readlink(local / "moved") == "target"
482+
483+
484+
def test_remote_copy_unknown_mode_is_private(fs, monkeypatch):
485+
# A server that denies fstat must not cause the destination to be
486+
# created with the server's default (world-readable) mode.
487+
opened = []
488+
489+
class _File:
490+
async def stat(self):
491+
raise SFTPPermissionDenied("fstat denied")
492+
493+
async def close(self):
494+
pass
495+
496+
class Channel:
497+
supports_remote_copy = True
498+
499+
def encode(self, path):
500+
return path.encode() if isinstance(path, str) else path
501+
502+
async def isdir(self, path):
503+
return False
504+
505+
async def open(self, path, *args, **kwargs):
506+
opened.append((path, args))
507+
return _File()
508+
509+
async def remote_copy(self, src, dst):
510+
pass
511+
512+
monkeypatch.setattr(fs, "_supports_remote_copy", True)
513+
monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel()))
514+
515+
fs.cp_file("/src", "/dst")
516+
_path, args = opened[-1]
517+
assert args[1].permissions == 0o600
518+
519+
520+
def test_cp_file_copy_data_denied_is_not_cached(fs, monkeypatch):
521+
# A denial can depend on the operands, so it must not disable the
522+
# extension for every later copy on the connection.
523+
events = []
524+
525+
class _File:
526+
async def stat(self):
527+
return SFTPAttrs(permissions=0o100644)
528+
529+
async def close(self):
530+
pass
531+
532+
class Channel:
533+
supports_remote_copy = True
534+
535+
def encode(self, path):
536+
return path.encode() if isinstance(path, str) else path
537+
538+
async def isdir(self, path):
539+
return False
540+
541+
async def open(self, path, *args, **kwargs):
542+
return _File()
543+
544+
async def remote_copy(self, src, dst):
545+
raise SFTPPermissionDenied("denied for these operands")
546+
547+
async def record_shell(cmd, **kwargs):
548+
events.append(cmd)
549+
550+
monkeypatch.setattr(fs, "_supports_remote_copy", None)
551+
monkeypatch.setattr(fs, "_pool", _FakeChannelPool(Channel()))
552+
monkeypatch.setattr(fs, "_execute", record_shell)
553+
554+
fs.cp_file("/denied", "/dst")
555+
assert events == ["cp -- /denied /dst"]
556+
assert fs._supports_remote_copy is True
557+
558+
430559
def test_cp_file_copy_data_denied(fs, monkeypatch):
431-
# copy-data advertised but denied by server policy: the capability
432-
# is re-cached as unsupported and the copy falls back to the shell,
560+
# copy-data advertised but not implemented: the capability is
561+
# re-cached as unsupported and the copy falls back to the shell,
433562
# which overwrites the empty file created by the exclusive open.
434563
events = []
435564

@@ -470,7 +599,7 @@ def open(self, path, *args, **kwargs):
470599
return _OpenResult()
471600

472601
async def remote_copy(self, src, dst):
473-
raise SFTPPermissionDenied("denied by policy")
602+
raise SFTPOpUnsupported("advertised but not implemented")
474603

475604
async def record_shell(cmd, **kwargs):
476605
events.append(("shell", cmd))
@@ -480,7 +609,7 @@ async def record_shell(cmd, **kwargs):
480609
monkeypatch.setattr(fs, "_execute", record_shell)
481610

482611
fs.cp_file("/src", "/dst")
483-
assert ("shell", "cp /src /dst") in events
612+
assert ("shell", "cp -- /src /dst") in events
484613
assert fs._supports_remote_copy is False
485614

486615

@@ -491,6 +620,9 @@ class Channel:
491620
async def posix_rename(self, lpath, rpath):
492621
raise SFTPOpUnsupported("posix-rename not supported")
493622

623+
async def rename(self, lpath, rpath):
624+
raise SFTPFailure("destination exists")
625+
494626
removed = []
495627

496628
async def failing_cp(*args, **kwargs):
@@ -544,7 +676,7 @@ async def record_shell(cmd, **kwargs):
544676

545677
fs.cp_file("/src", "/dst")
546678
fs.cp_file("/src2", "/dst2")
547-
assert calls == ["cp /src /dst", "cp /src2 /dst2"]
679+
assert calls == ["cp -- /src /dst", "cp -- /src2 /dst2"]
548680
assert len(pool_uses) == 1
549681

550682

0 commit comments

Comments
 (0)