From 1f3a7065e3b1acf3eeec91d59defaa8738288905 Mon Sep 17 00:00:00 2001 From: Miao Wang Date: Wed, 3 Jun 2026 20:33:55 +0800 Subject: [PATCH] receiver: try to chmod the target file when denied opening 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 | 33 ++++++++++++++- testsuite/partial_nowrite_test.py | 70 +++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 testsuite/partial_nowrite_test.py diff --git a/receiver.c b/receiver.c index cb7978419..84bb151aa 100644 --- a/receiver.c +++ b/receiver.c @@ -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 index 000000000..107a6ed2e --- /dev/null +++ b/testsuite/partial_nowrite_test.py @@ -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)