99from datetime import datetime , timedelta , timezone
1010from pathlib import Path
1111from types import SimpleNamespace
12+ from unittest import mock
1213
1314import fsspec
1415import 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+
8490def 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
268271def 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
330334def 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
416422def 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+
430559def 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