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)