@@ -163,6 +163,11 @@ def test_sshfs(
163163 tst_truncate_path (mnt_dir )
164164 tst_truncate_fd (mnt_dir )
165165 tst_open_unlink (mnt_dir )
166+ tst_open_writeonly_read (mnt_dir )
167+ tst_access (mnt_dir )
168+ tst_mkdir_exist (mnt_dir )
169+ tst_readdir_repeated (mnt_dir )
170+ tst_rename_sibling (mnt_dir )
166171 except Exception as exc :
167172 cleanup (mount_process , mnt_dir )
168173 raise exc
@@ -224,6 +229,8 @@ def tst_rename(mnt_dir):
224229 os .rename (src_name , dst_name )
225230
226231 assert not os .path .exists (src_name )
232+ assert os .path .basename (src_name ) not in os .listdir (mnt_dir )
233+ assert os .path .basename (dst_name ) in os .listdir (mnt_dir )
227234 with open (dst_name , "rb" ) as fh :
228235 assert fh .read () == data
229236
@@ -244,6 +251,7 @@ def tst_rename_over(mnt_dir):
244251 os .rename (src_name , dst_name )
245252
246253 assert not os .path .exists (src_name )
254+ assert os .path .basename (src_name ) not in os .listdir (mnt_dir )
247255 with open (dst_name , "rb" ) as fh :
248256 assert fh .read () == src_data
249257
@@ -296,6 +304,9 @@ def tst_symlink(mnt_dir):
296304 assert fstat .st_nlink == 1
297305 assert linkname in os .listdir (mnt_dir )
298306
307+ os .unlink (fullname )
308+ assert linkname not in os .listdir (mnt_dir )
309+
299310
300311def tst_create (mnt_dir ):
301312 name = name_generator ()
@@ -392,7 +403,7 @@ def tst_open_unlink(mnt_dir):
392403 os .unlink (fullname )
393404 with pytest .raises (OSError ) as exc_info :
394405 os .stat (fullname )
395- assert exc_info .value .errno == errno .ENOENT
406+ assert exc_info .value .errno == errno .ENOENT
396407 assert name not in os .listdir (mnt_dir )
397408 fh .write (data2 )
398409 fh .seek (0 )
@@ -415,6 +426,83 @@ def tst_statvfs(src_dir, mnt_dir):
415426 assert vfs .f_namemax > 0
416427
417428
429+ def tst_open_writeonly_read (mnt_dir ):
430+ name = pjoin (mnt_dir , name_generator ())
431+ fd = os .open (name , os .O_CREAT | os .O_WRONLY )
432+ try :
433+ os .write (fd , b"hello" )
434+ with pytest .raises (OSError ) as exc_info :
435+ os .read (fd , 10 )
436+ assert exc_info .value .errno == errno .EBADF
437+ finally :
438+ os .close (fd )
439+ os .unlink (name )
440+
441+
442+ def tst_access (mnt_dir ):
443+ filename = pjoin (mnt_dir , name_generator ())
444+ with open (filename , "wb" ) as fh :
445+ fh .write (b"test" )
446+ os .chmod (filename , 0o644 )
447+ assert os .access (filename , os .R_OK )
448+ if os .getuid () != 0 :
449+ assert not os .access (filename , os .X_OK )
450+ os .unlink (filename )
451+
452+
453+ def tst_mkdir_exist (mnt_dir ):
454+ name = name_generator ()
455+ fullname = pjoin (mnt_dir , name )
456+ os .mkdir (fullname )
457+ with pytest .raises (OSError ) as exc_info :
458+ os .mkdir (fullname )
459+ assert exc_info .value .errno == errno .EEXIST
460+ os .rmdir (fullname )
461+
462+
463+ def tst_readdir_repeated (mnt_dir ):
464+ dirname = pjoin (mnt_dir , name_generator ())
465+ os .mkdir (dirname )
466+ names = []
467+ for i in range (5 ):
468+ n = name_generator ()
469+ names .append (n )
470+ with open (pjoin (dirname , n ), "wb" ) as fh :
471+ fh .write (b"x" )
472+
473+ # Verify repeated directory listings return consistent results
474+ listing1 = sorted (os .listdir (dirname ))
475+ listing2 = sorted (os .listdir (dirname ))
476+ assert listing1 == sorted (names )
477+ assert listing1 == listing2
478+
479+ for n in names :
480+ os .unlink (pjoin (dirname , n ))
481+ os .rmdir (dirname )
482+
483+
484+ def tst_rename_sibling (mnt_dir ):
485+ # Verify renaming one file doesn't break access to a sibling
486+ name_a = pjoin (mnt_dir , name_generator ())
487+ name_b = pjoin (mnt_dir , name_generator ())
488+ name_c = pjoin (mnt_dir , name_generator ())
489+
490+ with open (name_a , "wb" ) as fh :
491+ fh .write (b"aaa" )
492+ with open (name_b , "wb" ) as fh :
493+ fh .write (b"bbb" )
494+
495+ os .rename (name_a , name_c )
496+
497+ assert not os .path .exists (name_a )
498+ assert os .path .exists (name_b )
499+ with open (name_b , "rb" ) as fh :
500+ assert fh .read () == b"bbb"
501+
502+ os .unlink (name_b )
503+ os .unlink (name_c )
504+
505+
418506def tst_link (mnt_dir , cache_timeout ):
419507 name1 = pjoin (mnt_dir , name_generator ())
420508 name2 = pjoin (mnt_dir , name_generator ())
@@ -455,6 +543,10 @@ def tst_link(mnt_dir, cache_timeout):
455543 assert os .path .basename (name2 ) not in os .listdir (mnt_dir )
456544 with pytest .raises (FileNotFoundError ):
457545 os .lstat (name2 )
546+ if cache_timeout :
547+ safe_sleep (cache_timeout + 1 )
548+ fstat1 = os .lstat (name1 )
549+ assert fstat1 .st_nlink == 1
458550
459551 os .unlink (name1 )
460552
@@ -508,6 +600,10 @@ def tst_truncate_path(mnt_dir):
508600 with open (filename , "rb" ) as fh :
509601 assert fh .read (size ) == TEST_DATA [: size - 1024 ]
510602
603+ # Truncate to zero
604+ os .truncate (filename , 0 )
605+ assert os .stat (filename ).st_size == 0
606+
511607 os .unlink (filename )
512608
513609
@@ -533,6 +629,10 @@ def tst_truncate_fd(mnt_dir):
533629 fh .seek (0 )
534630 assert fh .read (size ) == TEST_DATA [: size - 1024 ]
535631
632+ # Truncate to zero via fd
633+ os .ftruncate (fd , 0 )
634+ assert os .fstat (fd ).st_size == 0
635+
536636
537637def tst_utimens (mnt_dir , tol = 0 ):
538638 filename = pjoin (mnt_dir , name_generator ())
@@ -573,7 +673,7 @@ def tst_utimens_now(mnt_dir):
573673def tst_passthrough (src_dir , mnt_dir , cache_timeout ):
574674 name = name_generator ()
575675 src_name = pjoin (src_dir , name )
576- mnt_name = pjoin (src_dir , name )
676+ mnt_name = pjoin (mnt_dir , name )
577677 assert name not in os .listdir (src_dir )
578678 assert name not in os .listdir (mnt_dir )
579679 with open (src_name , "w" ) as fh :
@@ -582,11 +682,16 @@ def tst_passthrough(src_dir, mnt_dir, cache_timeout):
582682 if cache_timeout :
583683 safe_sleep (cache_timeout + 1 )
584684 assert name in os .listdir (mnt_dir )
585- assert os .stat (src_name ) == os .stat (mnt_name )
685+ src_st = os .stat (src_name )
686+ mnt_st = os .stat (mnt_name )
687+ assert src_st .st_size == mnt_st .st_size
688+ assert src_st .st_uid == mnt_st .st_uid
689+ assert src_st .st_gid == mnt_st .st_gid
690+ assert abs (src_st .st_mtime - mnt_st .st_mtime ) <= 1
586691
587692 name = name_generator ()
588693 src_name = pjoin (src_dir , name )
589- mnt_name = pjoin (src_dir , name )
694+ mnt_name = pjoin (mnt_dir , name )
590695 assert name not in os .listdir (src_dir )
591696 assert name not in os .listdir (mnt_dir )
592697 with open (mnt_name , "w" ) as fh :
@@ -595,4 +700,154 @@ def tst_passthrough(src_dir, mnt_dir, cache_timeout):
595700 if cache_timeout :
596701 safe_sleep (cache_timeout + 1 )
597702 assert name in os .listdir (mnt_dir )
598- assert os .stat (src_name ) == os .stat (mnt_name )
703+ src_st = os .stat (src_name )
704+ mnt_st = os .stat (mnt_name )
705+ assert src_st .st_size == mnt_st .st_size
706+ assert src_st .st_uid == mnt_st .st_uid
707+ assert src_st .st_gid == mnt_st .st_gid
708+ assert abs (src_st .st_mtime - mnt_st .st_mtime ) <= 1
709+
710+
711+ def _check_ssh_localhost ():
712+ try :
713+ res = subprocess .call (
714+ ["ssh" , "-o" , "StrictHostKeyChecking=no" ,
715+ "-o" , "KbdInteractiveAuthentication=no" ,
716+ "-o" , "ChallengeResponseAuthentication=no" ,
717+ "-o" , "PasswordAuthentication=no" ,
718+ "localhost" , "--" , "true" ],
719+ stdin = subprocess .DEVNULL , timeout = 10 ,
720+ )
721+ except subprocess .TimeoutExpired :
722+ res = 1
723+ if res != 0 :
724+ pytest .fail ("Unable to ssh into localhost without password prompt." )
725+
726+
727+ _mount_ctr = [0 ]
728+
729+
730+ def _mount_sshfs (tmpdir , extra_opts = None ):
731+ """Helper to mount sshfs with custom options. Returns (mount_process, mnt_dir, src_dir)."""
732+ _check_ssh_localhost ()
733+ _mount_ctr [0 ] += 1
734+ mnt_dir = str (tmpdir .mkdir (f"mnt{ _mount_ctr [0 ]} " ))
735+ src_dir = str (tmpdir .mkdir (f"src{ _mount_ctr [0 ]} " ))
736+
737+ cmdline = base_cmdline + [
738+ pjoin (basename , "sshfs" ),
739+ "-f" ,
740+ f"localhost:{ src_dir } " ,
741+ mnt_dir ,
742+ "-o" , "entry_timeout=0" ,
743+ "-o" , "attr_timeout=0" ,
744+ ]
745+ if extra_opts :
746+ for opt in extra_opts :
747+ cmdline += ["-o" , opt ]
748+
749+ new_env = dict (os .environ )
750+ new_env ["G_DEBUG" ] = "fatal-warnings"
751+
752+ mount_process = subprocess .Popen (cmdline , env = new_env )
753+ try :
754+ wait_for_mount (mount_process , mnt_dir )
755+ except :
756+ cleanup (mount_process , mnt_dir )
757+ raise
758+ return mount_process , mnt_dir , src_dir
759+
760+
761+ def test_disable_hardlink (tmpdir , capfd ):
762+ capfd .register_output (r"^Warning: Permanently added 'localhost' .+" , count = 0 )
763+
764+ # Control: verify hardlinks work without disable_hardlink.
765+ # If the server lacks the extension, skip this test entirely.
766+ mount_process , mnt_dir , src_dir = _mount_sshfs (tmpdir , [])
767+ try :
768+ name1 = pjoin (mnt_dir , name_generator ())
769+ name2 = pjoin (mnt_dir , name_generator ())
770+ with open (name1 , "wb" ) as fh :
771+ fh .write (b"test" )
772+ try :
773+ os .link (name1 , name2 )
774+ except OSError :
775+ os .unlink (name1 )
776+ pytest .skip ("server does not support hardlink extension" )
777+ os .unlink (name2 )
778+ os .unlink (name1 )
779+ except Exception :
780+ cleanup (mount_process , mnt_dir )
781+ raise
782+ else :
783+ umount (mount_process , mnt_dir )
784+
785+ # Now test with disable_hardlink — links should fail
786+ mount_process , mnt_dir , src_dir = _mount_sshfs (tmpdir , ["disable_hardlink" ])
787+ try :
788+ name1 = pjoin (mnt_dir , name_generator ())
789+ name2 = pjoin (mnt_dir , name_generator ())
790+ with open (name1 , "wb" ) as fh :
791+ fh .write (b"test" )
792+ with pytest .raises (OSError ) as exc_info :
793+ os .link (name1 , name2 )
794+ assert exc_info .value .errno in (errno .ENOSYS , errno .EPERM )
795+ os .unlink (name1 )
796+ except Exception :
797+ cleanup (mount_process , mnt_dir )
798+ raise
799+ else :
800+ umount (mount_process , mnt_dir )
801+
802+
803+ def test_follow_symlinks (tmpdir , capfd ):
804+ capfd .register_output (r"^Warning: Permanently added 'localhost' .+" , count = 0 )
805+ mount_process , mnt_dir , src_dir = _mount_sshfs (tmpdir , ["follow_symlinks" ])
806+ try :
807+ target_name = name_generator ()
808+ target = pjoin (src_dir , target_name )
809+ with open (target , "wb" ) as fh :
810+ fh .write (b"symlink target data" )
811+
812+ link = pjoin (src_dir , name_generator ())
813+ os .symlink (target_name , link )
814+
815+ mnt_link = pjoin (mnt_dir , os .path .basename (link ))
816+ # With follow_symlinks, stat should return the target's attributes
817+ # and the entry should appear as a regular file, not a symlink
818+ fstat = os .lstat (mnt_link )
819+ assert stat .S_ISREG (fstat .st_mode )
820+ with open (mnt_link , "rb" ) as fh :
821+ assert fh .read () == b"symlink target data"
822+
823+ os .unlink (link )
824+ os .unlink (target )
825+ except Exception :
826+ cleanup (mount_process , mnt_dir )
827+ raise
828+ else :
829+ umount (mount_process , mnt_dir )
830+
831+
832+ def test_direct_io (tmpdir , capfd ):
833+ capfd .register_output (r"^Warning: Permanently added 'localhost' .+" , count = 0 )
834+ mount_process , mnt_dir , src_dir = _mount_sshfs (tmpdir , ["direct_io" ])
835+ try :
836+ name = name_generator ()
837+ mnt_name = pjoin (mnt_dir , name )
838+ src_name = pjoin (src_dir , name )
839+ data = b"direct io test data\n " * 100
840+
841+ with open (mnt_name , "wb" ) as fh :
842+ fh .write (data )
843+ with open (mnt_name , "rb" ) as fh :
844+ assert fh .read () == data
845+ with open (src_name , "rb" ) as fh :
846+ assert fh .read () == data
847+
848+ os .unlink (mnt_name )
849+ except Exception :
850+ cleanup (mount_process , mnt_dir )
851+ raise
852+ else :
853+ umount (mount_process , mnt_dir )
0 commit comments