From f50180c4c470786a8b749c1ca2936ed52c9e2389 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Fri, 23 Oct 2026 17:31:43 -0700 Subject: [PATCH] accept RENAME_NOREPLACE in rename macFUSE's FSKit backend sets it on every rename to a new name, so all such renames failed with EINVAL. --- sshfs.c | 33 +++++++++++++++++++++++++++---- test/test_sshfs.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/sshfs.c b/sshfs.c index 6f1f094c..79de2ef0 100644 --- a/sshfs.c +++ b/sshfs.c @@ -61,6 +61,10 @@ # define MAP_ANONYMOUS MAP_ANON #endif +#ifndef RENAME_NOREPLACE +# define RENAME_NOREPLACE (1 << 0) +#endif + #define SSH_FXP_INIT 1 #define SSH_FXP_VERSION 2 @@ -2708,15 +2712,35 @@ static int sshfs_rename(const char *from, const char *to, unsigned int flags) { int err; struct conntab_entry *ce; + int noreplace = flags & RENAME_NOREPLACE; - if(flags != 0) + if (flags & ~RENAME_NOREPLACE) return -EINVAL; - if (sshfs.ext_posix_rename) + if (noreplace && sshfs.follow_symlinks) { + struct buffer buf; + struct buffer outbuf; + + /* getattr follows symlinks, so the kernel misses a dangling target */ + buf_init(&buf, 0); + buf_add_path(&buf, to); + err = sftp_request(get_conn(NULL, NULL), SSH_FXP_LSTAT, &buf, + SSH_FXP_ATTRS, &outbuf); + buf_free(&buf); + if (!err) { + buf_free(&outbuf); + return -EEXIST; + } + if (err != -ENOENT) + return err; + } + + /* posix-rename and the rename workaround both replace the target */ + if (sshfs.ext_posix_rename && !noreplace) err = sshfs_ext_posix_rename(from, to); else err = sshfs_do_rename(from, to); - if (err == -EPERM && sshfs.rename_workaround) { + if (err == -EPERM && sshfs.rename_workaround && !noreplace) { size_t tolen = strlen(to); if (tolen + RENAME_TEMP_CHARS < PATH_MAX) { int tmperr; @@ -2733,7 +2757,8 @@ static int sshfs_rename(const char *from, const char *to, unsigned int flags) } } } - if (err == -EPERM && sshfs.renamexdev_workaround) + /* EXDEV would make mv fall back to copying over the target */ + if (err == -EPERM && sshfs.renamexdev_workaround && !noreplace) err = -EXDEV; if (!err && sshfs.max_conns > 1) { diff --git a/test/test_sshfs.py b/test/test_sshfs.py index 1425ced3..1ac23652 100755 --- a/test/test_sshfs.py +++ b/test/test_sshfs.py @@ -7,6 +7,7 @@ sys.exit(pytest.main([__file__] + sys.argv[1:])) import subprocess +import ctypes import os import sys import pytest @@ -151,6 +152,7 @@ def test_sshfs( tst_rmdir(src_dir, mnt_dir, cache_timeout) tst_rename(mnt_dir) tst_rename_over(mnt_dir) + tst_rename_noreplace(mnt_dir) tst_chmod(mnt_dir) tst_fsync(src_dir, mnt_dir) tst_unlink(src_dir, mnt_dir, cache_timeout) @@ -264,6 +266,22 @@ def tst_rename_over(mnt_dir): os.unlink(dst_name) +def tst_rename_noreplace(mnt_dir): + # macFUSE's FSKit backend sets RENAME_NOREPLACE on every rename to a new + # name. The kernel rejects a target it can see, so that is not tested here. + libc = ctypes.CDLL(None, use_errno=True) + src, dst = pjoin(mnt_dir, name_generator()), pjoin(mnt_dir, name_generator()) + with open(src, "wb") as fh: + fh.write(b"old\n") + # AT_FDCWD, RENAME_NOREPLACE + if libc.renameat2(-100, os.fsencode(src), -100, os.fsencode(dst), 1): + raise OSError(ctypes.get_errno(), "renameat2") + assert not os.path.exists(src) + with open(dst, "rb") as fh: + assert fh.read() == b"old\n" + os.unlink(dst) + + def tst_chmod(mnt_dir): filename = pjoin(mnt_dir, name_generator()) with open(filename, "wb") as fh: @@ -866,6 +884,36 @@ def test_follow_symlinks(tmpdir, capfd): with open(mnt_link, "rb") as fh: assert fh.read() == b"symlink target data" + # A dangling symlink looks absent through follow_symlinks, so the + # kernel lets RENAME_NOREPLACE through and sshfs must refuse it. + libc = ctypes.CDLL(None, use_errno=True) + dangling = name_generator() + os.symlink("missing", pjoin(src_dir, dangling)) + with pytest.raises(FileNotFoundError): + os.lstat(pjoin(mnt_dir, dangling)) + # AT_FDCWD, RENAME_NOREPLACE + ret = libc.renameat2( + -100, os.fsencode(mnt_link), -100, os.fsencode(pjoin(mnt_dir, dangling)), 1 + ) + assert ret == -1 and ctypes.get_errno() == errno.EEXIST + assert os.readlink(pjoin(src_dir, dangling)) == "missing" + assert os.readlink(link) == target_name + os.unlink(pjoin(src_dir, dangling)) + + # A genuinely absent target is still renamed + fresh, moved = name_generator(), name_generator() + os.symlink(target_name, pjoin(src_dir, fresh)) + ret = libc.renameat2( + -100, + os.fsencode(pjoin(mnt_dir, fresh)), + -100, + os.fsencode(pjoin(mnt_dir, moved)), + 1, + ) + assert ret == 0 + assert os.readlink(pjoin(src_dir, moved)) == target_name + os.unlink(pjoin(src_dir, moved)) + os.unlink(link) os.unlink(target) except Exception: