Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions syscall.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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). */
Expand Down
77 changes: 77 additions & 0 deletions testsuite/search-only-destination_test.py
Original file line number Diff line number Diff line change
@@ -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')
1 change: 1 addition & 0 deletions testsuite/skiplist/cygwin.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions testsuite/skiplist/macos.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading