]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
receiver: try to chmod the target file when denied opening
authorMiao Wang <shankerwangmiao@users.noreply.github.com>
Wed, 3 Jun 2026 12:33:55 +0000 (20:33 +0800)
committerAndrew Tridgell <andrew@tridgell.net>
Fri, 5 Jun 2026 04:31:46 +0000 (14:31 +1000)
When the target file exists but its permission modes prevent us from
opening it for writing, we can try first to chmod it and then open it.

receiver.c
testsuite/partial_nowrite_test.py [new file with mode: 0644]

index cb7978419606362da6952337360db1ebc2673dd0..84bb151aa1b951fd41854488059ca878ccac2a18 100644 (file)
@@ -963,11 +963,40 @@ int recv_files(int f_in, int f_out, char *local_name)
                        if (fd2 == -1 && errno == EACCES) {
                                /* Maybe the error was due to protected_regular setting? */
                                if (use_secure_symlinks)
-                                       fd2 = secure_relative_open(NULL, fname, O_WRONLY, 0600);
+                                       fd2 = secure_relative_open(NULL, fnametmp, O_WRONLY, 0600);
                                else
-                                       fd2 = do_open(fname, O_WRONLY, 0600);
+                                       fd2 = do_open(fnametmp, O_WRONLY, 0600);
                        }
 #endif
+                       if (fd2 == -1 && errno == EACCES) {
+                               /* A read-only existing file: make it writable, then retry
+                                * (its mode is restored after the transfer).  On a
+                                * non-chroot daemon fchmod() a no-follow fd rather than
+                                * chmod the path, so a symlink raced into fnametmp can't
+                                * redirect the chmod (do_chmod_at follows the final link). */
+                               int errno_save = errno, chmod_ok;
+                               if (use_secure_symlinks) {
+#ifdef O_NOFOLLOW
+                                       int cfd = secure_relative_open(NULL, fnametmp, O_RDONLY|O_NOFOLLOW, 0);
+                                       chmod_ok = cfd != -1 && fchmod(cfd, 0600) == 0;
+                                       if (cfd != -1)
+                                               close(cfd);
+#else
+                                       /* Without O_NOFOLLOW the resolver's oldest fallback would
+                                        * follow a raced symlink, so fail closed rather than
+                                        * chmod through it. */
+                                       chmod_ok = 0;
+#endif
+                               } else
+                                       chmod_ok = do_chmod_at(fnametmp, 0600) == 0;
+                               if (chmod_ok) {
+                                       if (use_secure_symlinks)
+                                               fd2 = secure_relative_open(NULL, fnametmp, O_WRONLY, 0600);
+                                       else
+                                               fd2 = do_open(fnametmp, O_WRONLY, 0600);
+                               } else
+                                       errno = errno_save;
+                       }
                        if (fd2 == -1) {
                                rsyserr(FERROR_XFER, errno, "open %s failed",
                                        full_fname(fnametmp));
diff --git a/testsuite/partial_nowrite_test.py b/testsuite/partial_nowrite_test.py
new file mode 100644 (file)
index 0000000..107a6ed
--- /dev/null
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+#
+# Test that --partial and --delay-updates work as expected when then
+# permissions of the destination file prevent writing to it.
+
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from rsyncfns import make_data_file, cp_p, makepath, checkit, RSYNC, TMPDIR, get_testuid, get_rootuid
+
+BASEDIR = TMPDIR
+
+FROMDIR = BASEDIR / 'from'
+TODIR = BASEDIR / 'to'
+
+makepath(FROMDIR)
+makepath(TODIR)
+
+makepath(FROMDIR)
+make_data_file(FROMDIR / 'some_file', 1 * 1024 * 1024)
+os.chmod(FROMDIR / 'some_file', 0o444)
+
+makepath(TODIR / '.~tmp~')
+os.chmod(TODIR / '.~tmp~', 0o700)
+cp_p(FROMDIR / 'some_file', TODIR / '.~tmp~' / 'some_file')
+
+is_root = get_testuid() == get_rootuid()
+
+# As root the read-only dest temp wouldn't deny the write (root bypasses DAC),
+# so the EACCES path under test never fires.  On Linux we can drop
+# CAP_DAC_OVERRIDE with setpriv inside a private mount namespace to force it;
+# where that isn't possible -- non-Linux, Python < 3.12, no mount privilege, or
+# a build dir the cap-dropped root can't even traverse (owned by an
+# unprivileged user with restrictive perms, e.g. a CI tree owned by the ssh
+# user at 0700) -- just run as root: the transfer still succeeds, it merely
+# doesn't exercise the chmod-retry path here (non-root runs do).
+_cwd_st = os.stat(os.getcwd())
+_cwd_traversable = ((_cwd_st.st_uid == 0 and _cwd_st.st_mode & 0o100)
+                    or _cwd_st.st_mode & 0o001)
+if (is_root and sys.platform == 'linux' and hasattr(os, 'unshare')
+        and shutil.which('setpriv') and _cwd_traversable):
+    try:
+        cwd = Path(os.getcwd())
+        chown_target = None
+        for p in reversed(cwd.parents):
+            st = p.stat()
+            if not (st.st_uid == 0 or st.st_mode & 0o005):
+                chown_target = p
+                break
+        if chown_target is not None:
+            os.unshare(os.CLONE_NEWNS)
+            subprocess.run(['mount', '--make-rprivate', '/'], check=True)
+            tempdir = tempfile.mkdtemp()
+            subprocess.run(['mount', '--bind', cwd, tempdir], check=True)
+            subprocess.run(['mount', '-t', 'tmpfs', '-o', 'mode=0755', 'tmpfs', chown_target], check=True)
+            makepath(cwd)
+            subprocess.run(['mount', '--bind', tempdir, cwd], check=True)
+            subprocess.run(['umount', tempdir], check=True)
+            os.rmdir(tempdir)
+        import rsyncfns
+        rsyncfns.RSYNC = "setpriv --inh-caps -all --bounding-set -all " + RSYNC
+    except (OSError, subprocess.CalledProcessError):
+        pass  # mount namespace denied (unprivileged container) -- run as root
+
+
+checkit(['-avv', '--partial', '--delay-updates', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)