From: Miao Wang Date: Wed, 3 Jun 2026 12:33:55 +0000 (+0800) Subject: receiver: try to chmod the target file when denied opening X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=c1d7b5c6f97db9707aca6be638b02d8dee8248c4;p=thirdparty%2Frsync.git 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. --- diff --git a/receiver.c b/receiver.c index cb797841..84bb151a 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 00000000..107a6ed2 --- /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)