diff --git a/syscall.c b/syscall.c index 5e92edca0..de2c521f4 100644 --- a/syscall.c +++ b/syscall.c @@ -290,6 +290,11 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz * fallback in syscall.c so a build without it still compiles. */ #ifndef O_CLOEXEC #define O_CLOEXEC 0 +#endif +#ifdef O_PATH + const int dir_traverse_flags = O_PATH | O_DIRECTORY | O_CLOEXEC; +#else + const int dir_traverse_flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC; #endif if (!path || !*path) { errno = EINVAL; @@ -348,7 +353,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz /* Absolute path: pin "/" as the starting dfd. */ if (remaining[0] == '/') { - dfd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + dfd = open("/", dir_traverse_flags); if (dfd < 0) return -1; dfd_owns = 1; @@ -435,7 +440,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz if (target[0] == '/') { if (dfd_owns) close(dfd); - dfd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + dfd = open("/", dir_traverse_flags); if (dfd < 0) { saved_errno = errno; dfd_owns = 0; @@ -490,7 +495,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = ELOOP; goto out; } - int next = openat(dfd, comp, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + int next = openat(dfd, comp, dir_traverse_flags | O_NOFOLLOW); if (next < 0) { saved_errno = errno; goto out; @@ -510,12 +515,12 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz } /* Path resolved entirely to a directory (no leaf component left). - * If the caller wanted O_DIRECTORY we already hold the dirfd we - * built up; otherwise it's an EISDIR. */ + * Reopen the held traversal fd with the caller's requested access mode; + * an O_PATH fd is sufficient for traversal and fchdir but not operations + * such as fchmod. */ if (flags & O_DIRECTORY) { - retfd = dfd; - dfd_owns = 0; /* caller now owns it */ - saved_errno = 0; + retfd = openat(dfd, ".", flags | O_NOFOLLOW, mode); + saved_errno = retfd < 0 ? errno : 0; if (out_abs && out_cap) /* Root-resolved (".." popped abspath empty) tracked daemon walk: * hand back "/" so owner_walk_parent still leaf-checks (path=/ bypass). */ diff --git a/testsuite/search-only-destination_test.py b/testsuite/search-only-destination_test.py new file mode 100644 index 000000000..46108e2f5 --- /dev/null +++ b/testsuite/search-only-destination_test.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""The receiver must traverse a searchable but unreadable destination parent. + +Android exposes /sdcard through such a path, so the race-safe destination walk +must use directory descriptors that require search permission only. +""" + +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from rsyncfns import SCRATCHDIR, rmtree, rsync_argv, test_fail, test_skipped + +if not sys.platform.startswith('linux'): + test_skipped('search-only-destination is Linux-specific') + +launcher = [] +if os.geteuid() == 0: + setpriv = shutil.which('setpriv') + if setpriv is None: + test_skipped('setpriv is unavailable for the root-run testsuite') + launcher = [setpriv, '--reuid=65534', '--regid=65534', '--clear-groups'] + +external_base = os.geteuid() == 0 +if external_base: + base = Path(tempfile.mkdtemp(prefix='rsync-search-only-')) + base.chmod(0o755) +else: + base = SCRATCHDIR / 'search-only-destination' +src = base / 'src' +parent = base / 'search-only' +dest = parent / 'dest' +rmtree(base) +src.mkdir(parents=True) +dest.mkdir(parents=True) +(src / 'probe').write_text('search-only destination\n') + +if os.geteuid() == 0: + for path in (src, src / 'probe', dest): + os.chown(path, 65534, 65534) + +try: + parent.chmod(0o111) + try: + probe = subprocess.run( + launcher + ['test', '-r', str(parent)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if probe.returncode == 0: + test_skipped('filesystem does not enforce the search-only test mode') + if probe.returncode != 1: + test_fail(f'search-only permission probe failed with exit {probe.returncode}') + + proc = subprocess.run( + launcher + rsync_argv('-a', f'{src}/', f'{dest}/'), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + finally: + parent.chmod(0o755) + copied = (dest / 'probe').read_text() if (dest / 'probe').is_file() else None +finally: + if external_base: + rmtree(base) + +if proc.returncode != 0: + test_fail( + 'receiver could not enter a destination below a searchable, unreadable ' + f'parent (exit {proc.returncode}): {proc.stderr.strip()}' + ) +if copied != 'search-only destination\n': + test_fail('receiver did not copy into the search-only destination') diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index ba499cc54..347b69b51 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -56,6 +56,7 @@ rename-mixed-parent-transfer rrsync-sender-leaf-flip rrsync-sender-parent-pin rrsync-symlink +search-only-destination sender-remove-source-root-anchor simd-checksum source-change-size-continues diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 3e7653ce8..ce415be9c 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -26,6 +26,7 @@ readonly-partial-abort-mode-regression # rrsync-sender-leaf-flip rrsync-sender-parent-pin rrsync-symlink +search-only-destination sender-remove-source-root-anchor simd-checksum source-change-size-continues