Compare commits

..

3 Commits

Author SHA1 Message Date
Zen Dodd
55b68225e5 docs: clarify chmod copy special bits 2026-06-06 20:07:01 +10:00
Zen Dodd
b2fc33868f chmod: clear special bits on copy assignment 2026-06-06 15:00:57 +10:00
Zen Dodd
7371c898e4 chmod: support permission copy modes 2026-06-06 14:56:06 +10:00
18 changed files with 200 additions and 433 deletions

View File

@@ -62,7 +62,7 @@ jobs:
# crtimes-not-supported skip matches the other Linux jobs;
# daemon-chroot-acl and proxy-response-line-too-long skip because
# the default (secure) transport opens no listening socket.
run: RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef make check
run: RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check
- name: check (TCP daemon transport)
# Second run exercising the real loopback-TCP daemon path.
run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8

View File

@@ -46,7 +46,7 @@ jobs:
# RESOLVE_BENEATH symlink-race tests. symlink-dirlink-basis also now
# RUNS (the #915 non-daemon basis open uses a plain do_open, restoring
# following an in-tree dir-symlink basis without RESOLVE_BENEATH).
run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls-depth,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-access-ip,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,recv-discard-nullderef,sender-flist-symlink-leak,simd-checksum make check'
run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls-depth,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-access-ip,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,sender-flist-symlink-leak,simd-checksum make check'
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.

View File

@@ -44,7 +44,7 @@ jobs:
# chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS
# (rsyncfns.py drives xattrs via the `xattr` command), verified on a
# real macOS host, so they're no longer in the skip set.
run: sudo RSYNC_EXPECT_SKIPPED=acls-default,acls-depth,chmod-temp-dir,daemon-access-ip,daemon-chroot-acl,dir-sgid,open-noatime,preallocate,protected-regular,proxy-response-line-too-long,recv-discard-nullderef,simd-checksum,sparse make check
run: sudo RSYNC_EXPECT_SKIPPED=acls-default,acls-depth,chmod-temp-dir,daemon-access-ip,daemon-chroot-acl,dir-sgid,open-noatime,preallocate,protected-regular,proxy-response-line-too-long,simd-checksum,sparse make check
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.

View File

@@ -39,11 +39,11 @@ jobs:
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef make check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef make check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef make check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check29
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd; the default
# 'make check' above uses the secure stdio-pipe transport.

View File

@@ -30,44 +30,16 @@ jobs:
run: ./configure --with-rrsync
- name: make
run: make
- name: install/uninstall DESTDIR smoke test
run: |
set -e
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
make install-all DESTDIR="$tmp"
for path in \
/usr/local/bin/rsync \
/usr/local/bin/rsync-ssl \
/usr/local/bin/rrsync \
/usr/local/share/man/man1/rsync.1 \
/usr/local/share/man/man1/rsync-ssl.1 \
/usr/local/share/man/man1/rrsync.1 \
/usr/local/share/man/man5/rsyncd.conf.5 \
/etc/stunnel/rsyncd.conf
do
test -e "$tmp$path"
done
make uninstall-all DESTDIR="$tmp"
leftover="$(find "$tmp" -type f -print)"
if [ -n "$leftover" ]; then
printf '%s\n' "$leftover"
exit 1
fi
- name: install
run: sudo make install
- name: info
run: rsync --version
- name: check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef make check
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check
- name: check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef make check30
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check30
- name: check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long,recv-discard-nullderef make check29
run: sudo RSYNC_EXPECT_SKIPPED=crtimes,daemon-access-ip,daemon-chroot-acl,proxy-response-line-too-long make check29
- name: check (TCP daemon transport)
# Second run with daemon tests over a real loopback rsyncd. The default
# 'make check' above uses the secure stdio-pipe transport (no listening

View File

@@ -111,21 +111,6 @@ install-all: install install-ssl-daemon
install-strip:
$(MAKE) INSTALL_STRIP='-s' install
.PHONY: uninstall
uninstall:
rm -f $(DESTDIR)$(bindir)/rsync$(EXEEXT) $(DESTDIR)$(bindir)/rsync-ssl
rm -f $(DESTDIR)$(bindir)/rrsync
rm -f $(DESTDIR)$(mandir)/man1/rsync.1 $(DESTDIR)$(mandir)/man1/rsync-ssl.1
rm -f $(DESTDIR)$(mandir)/man1/rrsync.1
rm -f $(DESTDIR)$(mandir)/man5/rsyncd.conf.5
.PHONY: uninstall-ssl-daemon
uninstall-ssl-daemon:
rm -f $(DESTDIR)/etc/stunnel/rsyncd.conf
.PHONY: uninstall-all
uninstall-all: uninstall uninstall-ssl-daemon
rsync$(EXEEXT): $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS) $(LIBS)

95
chmod.c
View File

@@ -29,7 +29,7 @@ extern mode_t orig_umask;
struct chmod_mode_struct {
struct chmod_mode_struct *next;
int ModeAND, ModeOR;
int ModeAND, ModeOR, ModeCOPY_SRC, ModeCOPY_DST, ModeCOPY_AND, ModeOP;
char flags;
};
@@ -43,6 +43,20 @@ struct chmod_mode_struct {
#define STATE_2ND_HALF 2
#define STATE_OCTAL_NUM 3
static int mode_dest_special_bits(int where)
{
int bits = 0;
if (where & 0100)
bits |= S_ISUID;
if (where & 0010)
bits |= S_ISGID;
if (where & 0001)
bits |= S_ISVTX;
return bits;
}
/* Parse a chmod-style argument, and break it down into one or more AND/OR
* pairs in a linked list. We return a pointer to new items on success
* (appending the items to the specified list), or NULL on error. */
@@ -50,13 +64,13 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
struct chmod_mode_struct **root_mode_ptr)
{
int state = STATE_1ST_HALF;
int where = 0, what = 0, op = 0, topbits = 0, topoct = 0, flags = 0;
int where = 0, what = 0, op = 0, topbits = 0, topoct = 0, flags = 0, copybits = 0;
struct chmod_mode_struct *first_mode = NULL, *curr_mode = NULL,
*prev_mode = NULL;
while (state != STATE_ERROR) {
if (!*modestr || *modestr == ',') {
int bits;
int bits, where_specified;
if (!op) {
state = STATE_ERROR;
@@ -70,9 +84,10 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
first_mode = curr_mode;
curr_mode->next = NULL;
if (where)
where_specified = where;
if (where) {
bits = where * what;
else {
} else {
where = 0111;
bits = (where * what) & ~orig_umask;
}
@@ -81,18 +96,35 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
case CHMOD_ADD:
curr_mode->ModeAND = CHMOD_BITS;
curr_mode->ModeOR = bits + topoct;
curr_mode->ModeCOPY_SRC = copybits;
curr_mode->ModeCOPY_DST = where;
curr_mode->ModeCOPY_AND = where_specified ? CHMOD_BITS : ~orig_umask;
curr_mode->ModeOP = op;
break;
case CHMOD_SUB:
curr_mode->ModeAND = CHMOD_BITS - bits - topoct;
curr_mode->ModeOR = 0;
curr_mode->ModeCOPY_SRC = copybits;
curr_mode->ModeCOPY_DST = where;
curr_mode->ModeCOPY_AND = where_specified ? CHMOD_BITS : ~orig_umask;
curr_mode->ModeOP = op;
break;
case CHMOD_EQ:
curr_mode->ModeAND = CHMOD_BITS - (where * 7) - (topoct ? topbits : 0);
curr_mode->ModeAND = CHMOD_BITS - (where * 7) - (topoct ? topbits : 0)
- (copybits ? mode_dest_special_bits(where) : 0);
curr_mode->ModeOR = bits + topoct;
curr_mode->ModeCOPY_SRC = copybits;
curr_mode->ModeCOPY_DST = where;
curr_mode->ModeCOPY_AND = where_specified ? CHMOD_BITS : ~orig_umask;
curr_mode->ModeOP = op;
break;
case CHMOD_SET:
curr_mode->ModeAND = 0;
curr_mode->ModeOR = bits;
curr_mode->ModeCOPY_SRC = 0;
curr_mode->ModeCOPY_DST = 0;
curr_mode->ModeCOPY_AND = CHMOD_BITS;
curr_mode->ModeOP = op;
break;
}
@@ -103,7 +135,7 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
modestr++;
state = STATE_1ST_HALF;
where = what = op = topoct = topbits = flags = 0;
where = what = op = topoct = topbits = flags = copybits = 0;
}
switch (state) {
@@ -159,26 +191,53 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
case STATE_2ND_HALF:
switch (*modestr) {
case 'r':
if (copybits)
state = STATE_ERROR;
what |= 4;
break;
case 'w':
if (copybits)
state = STATE_ERROR;
what |= 2;
break;
case 'X':
if (copybits)
state = STATE_ERROR;
flags |= FLAG_X_KEEP;
/* FALL THROUGH */
case 'x':
if (copybits)
state = STATE_ERROR;
what |= 1;
break;
case 's':
if (copybits)
state = STATE_ERROR;
if (topbits)
topoct |= topbits;
else
topoct = 04000;
break;
case 't':
if (copybits)
state = STATE_ERROR;
topoct |= 01000;
break;
case 'u':
if (what || topoct || copybits)
state = STATE_ERROR;
copybits = 0100;
break;
case 'g':
if (what || topoct || copybits)
state = STATE_ERROR;
copybits = 0010;
break;
case 'o':
if (what || topoct || copybits)
state = STATE_ERROR;
copybits = 0001;
break;
default:
state = STATE_ERROR;
break;
@@ -212,6 +271,20 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
return first_mode;
}
static int mode_copy_bits(int mode, int copy_src, int copy_dst, int copy_and)
{
int copy_bits = 0;
if (copy_src & 0100)
copy_bits |= (mode >> 6) & 7;
if (copy_src & 0010)
copy_bits |= (mode >> 3) & 7;
if (copy_src & 0001)
copy_bits |= mode & 7;
return (copy_dst * copy_bits) & copy_and;
}
/* Takes an existing file permission and a list of AND/OR changes, and
* create a new permissions. */
@@ -219,17 +292,25 @@ int tweak_mode(int mode, struct chmod_mode_struct *chmod_modes)
{
int IsX = mode & 0111;
int NonPerm = mode & ~CHMOD_BITS;
int copy_bits;
for ( ; chmod_modes; chmod_modes = chmod_modes->next) {
if ((chmod_modes->flags & FLAG_DIRS_ONLY) && !S_ISDIR(NonPerm))
continue;
if ((chmod_modes->flags & FLAG_FILES_ONLY) && S_ISDIR(NonPerm))
continue;
copy_bits = mode_copy_bits(mode, chmod_modes->ModeCOPY_SRC,
chmod_modes->ModeCOPY_DST,
chmod_modes->ModeCOPY_AND);
mode &= chmod_modes->ModeAND;
if ((chmod_modes->flags & FLAG_X_KEEP) && !IsX && !S_ISDIR(NonPerm))
mode |= chmod_modes->ModeOR & ~0111;
else
mode |= chmod_modes->ModeOR;
if (chmod_modes->ModeOP == CHMOD_SUB)
mode &= CHMOD_BITS - copy_bits;
else
mode |= copy_bits;
}
return mode | NonPerm;

View File

@@ -1,4 +1,4 @@
TARGETS := all install install-ssl-daemon install-all install-strip uninstall uninstall-ssl-daemon uninstall-all conf gen reconfigure restatus \
TARGETS := all install install-ssl-daemon install-all install-strip conf gen reconfigure restatus \
proto man clean cleantests distclean test check check29 check30 installcheck splint \
doxygen doxygen-upload finddead rrsync

View File

@@ -423,32 +423,16 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r,
stats.matched_data += len;
/* A block match with no mapped basis is a protocol inconsistency
* ONLY when we are actually producing output (fd != -1): the
* generator told the sender a basis existed but the receiver could
* not open it, so honoring the match would silently omit these
* bytes from the verification checksum (a spurious failure) or
* leave a hole in the output. Fail cleanly in that case.
*
* On the DISCARD path (fd == -1, fname == NULL) there is no output
* and no verification: discard_receive_data() deliberately drains a
* delta the receiver never intends to write (basis fstat failed,
* basis is a directory, output open failed, batch skip, ...). The
* sender does not know the data is being discarded and streams an
* ordinary delta, so a match token here is NORMAL protocol, not
* malformed. Absorb it benignly (advance the offset and continue),
* as the pre-existing "if (mapbuf)" guards did before this check was
* added in 31fbb17d -- erroring would wrongly break legitimate
* transfers, and full_fname(fname) with fname==NULL would
* dereference NULL (a receiver crash on a normal transfer). */
/* A block match can only be honored if we actually mapped the
* basis. If we didn't (basis open failed), the sender should
* never have been told a basis existed -- treat it as a protocol
* inconsistency rather than silently omitting these bytes from
* the verification checksum (which yields a spurious failure) or
* leaving a hole in the output. */
if (!mapbuf) {
if (fd != -1) {
rprintf(FERROR, "got a block match with no basis file for %s [%s]\n",
full_fname(fname), who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
offset += len;
continue;
rprintf(FERROR, "got a block match with no basis file for %s [%s]\n",
full_fname(fname), who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
if (DEBUG_GTE(DELTASUM, 3)) {

View File

@@ -425,9 +425,6 @@ has its own detailed description later in this manpage.
--archive, -a archive mode is -rlptgoD (no -A,-X,-U,-N,-H)
--no-OPTION turn off an implied OPTION (e.g. --no-D)
--recursive, -r recurse into directories
--inc-recursive, --i-r enable incremental recursion
--no-inc-recursive disable incremental recursion
--no-i-r same as --no-inc-recursive
--relative, -R use relative path names
--no-implied-dirs don't send implied dirs with --relative
--backup, -b make backups (see --suffix & --backup-dir)
@@ -438,8 +435,7 @@ has its own detailed description later in this manpage.
--append append data onto shorter files
--append-verify --append w/old data in file checksum
--dirs, -d transfer directories without recursing
--old-dirs works like --dirs when talking to old rsync
--old-d same as --old-dirs
--old-dirs, --old-d works like --dirs when talking to old rsync
--mkpath create destination's missing path components
--links, -l copy symlinks as symlinks
--copy-links, -L transform symlink into referent file/dir
@@ -473,14 +469,12 @@ has its own detailed description later in this manpage.
--preallocate allocate dest files before writing them
--dry-run, -n perform a trial run with no changes made
--whole-file, -W copy files whole (w/o delta-xfer algorithm)
--no-whole-file, --no-W use the delta-xfer algorithm
--checksum-choice=STR choose the checksum algorithm (aka --cc)
--one-file-system, -x don't cross filesystem boundaries
--block-size=SIZE, -B force a fixed checksum block-size
--rsh=COMMAND, -e specify the remote shell to use
--rsync-path=PROGRAM specify the rsync to run on remote machine
--existing skip creating new files on receiver
--ignore-non-existing skip creating new files on receiver
--ignore-existing skip updating files that exist on receiver
--remove-source-files sender removes synchronized files (non-dir)
--del an alias for --delete-during
@@ -874,7 +868,7 @@ expand it.
0. `--inc-recursive`, `--i-r`
This option explicitly enables incremental recursion when scanning for
This option explicitly enables on incremental recursion when scanning for
files, which is enabled by default when using the [`--recursive`](#opt)
option and both sides of the transfer are running rsync 3.0.0 or newer.
@@ -1150,13 +1144,9 @@ expand it.
seen in the listing). Specify `--no-dirs` (or `--no-d`) if you want to
turn this off.
See also the backward-compatibility helper option [`--old-dirs`](#opt).
0. `--old-dirs`, `--old-d`
This backward-compatibility helper tells rsync to use a hack of
`-r --exclude='/*/*'` to get an older rsync to list a single directory
without recursing.
There is also a backward-compatibility helper option, `--old-dirs`
(`--old-d`) that tells rsync to use a hack of `-r --exclude='/*/*'` to get
an older rsync to list a single directory without recursing.
0. `--mkpath`
@@ -1523,6 +1513,16 @@ expand it.
> --chmod=D2775,F664
Symbolic permission-copy modes are also allowed, such as `g=u`, `o=g` or
`g-o`. A permission-copy item may copy from one class only (`u`, `g` or
`o`) and cannot be combined with `rwxXst` permission letters in the same
item. Use comma-separated items when you need both behaviours, such as
`g=o,o=`.
A permission-copy `=` item also clears the special bit for each destination
class it updates (`u` clears setuid, `g` clears setgid, and `o` clears
sticky), matching GNU **chmod** behaviour.
It is also legal to specify multiple `--chmod` options, as each additional
option is just appended to the list of changes to make.

View File

@@ -191,31 +191,35 @@ _PY_TEST_SUFFIX = '_test.py'
def _is_test_path(path):
return os.path.basename(path).endswith(_PY_TEST_SUFFIX)
base = os.path.basename(path)
return base.endswith('.test') or base.endswith(_PY_TEST_SUFFIX)
def _testbase(path):
"""Strip the test extension to get the canonical test name."""
base = os.path.basename(path)
if base.endswith('.test'):
return base[:-len('.test')]
if base.endswith(_PY_TEST_SUFFIX):
return base[:-len(_PY_TEST_SUFFIX)]
return base
def collect_tests(suitedir, patterns):
"""Collect test scripts (_test.py) matching the given patterns."""
"""Collect test scripts (.test or _test.py) matching the given patterns."""
if not patterns:
candidates = glob.glob(os.path.join(suitedir, '*' + _PY_TEST_SUFFIX))
candidates = (glob.glob(os.path.join(suitedir, '*.test'))
+ glob.glob(os.path.join(suitedir, '*' + _PY_TEST_SUFFIX)))
tests = sorted(p for p in candidates if _is_test_path(p))
else:
seen = set()
tests = []
for pat in patterns:
# Accept either bare name ("mkpath"), explicit extension, or glob.
if pat.endswith('.py'):
if pat.endswith('.test') or pat.endswith('.py'):
pats = [pat]
else:
pats = [pat + _PY_TEST_SUFFIX]
pats = [pat + '.test', pat + _PY_TEST_SUFFIX]
for p in pats:
for m in sorted(glob.glob(os.path.join(suitedir, p))):
if _is_test_path(m) and m not in seen:

View File

@@ -11,8 +11,8 @@ import shutil
from rsyncfns import (
FROMDIR, SCRATCHDIR, TODIR,
build_rsyncd_conf, checkit, makepath, rmtree,
run_rsync, start_test_daemon,
build_rsyncd_conf, check_perms, checkit, makepath, rmtree,
run_rsync, start_test_daemon, test_fail,
)
@@ -62,6 +62,37 @@ for d in (checkdir, checkdir / 'dir1', checkdir / 'dir2'):
checkit(['-avv', '--chmod', 'ug-s,a+rX,D+w', f'{FROMDIR}/', f'{TODIR}/'],
checkdir, TODIR)
def check_permcopy(chmod_arg, start_mode, expected, is_dir=False):
rmtree(FROMDIR)
rmtree(TODIR)
makepath(FROMDIR)
permcopy = FROMDIR / 'permcopy'
if is_dir:
permcopy.mkdir()
else:
permcopy.write_text('permcopy\n')
os.chmod(permcopy, start_mode)
run_rsync('-avv', f'--chmod={chmod_arg}', f'{FROMDIR}/', f'{TODIR}/')
check_perms(TODIR / 'permcopy', expected)
# Exercise chmod(1)-style permission copies.
check_permcopy('g=o,o=', 0o647, 'rw-rwx---')
check_permcopy('g=u', 0o741, 'rwxrwx--x')
check_permcopy('g-o', 0o775, 'rwx-w-r-x')
check_permcopy('u=g', 0o4755, 'r-xr-xr-x')
check_permcopy('g=u', 0o2755, 'rwxrwxr-x')
check_permcopy('o=u', 0o1750, 'rwxr-xrwx', is_dir=True)
rmtree(FROMDIR)
rmtree(TODIR)
makepath(FROMDIR)
(FROMDIR / 'permcopy').write_text('permcopy\n')
proc = run_rsync('-avv', '--chmod=g=ur', f'{FROMDIR}/', f'{TODIR}/',
check=False, capture_output=True)
if proc.returncode == 0:
test_fail('--chmod=g=ur was not rejected')
# Now exercise the F-only chmod path.
rmtree(FROMDIR)
rmtree(checkdir)

View File

@@ -16,37 +16,8 @@
import os
import subprocess
import sys
from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail, test_xfail
def kernel_has_resolve_beneath():
"""Whether the running kernel honours a 'beneath' confinement primitive,
matching t_chmod_secure's kernel_resolve_beneath_supported(). On Linux probe
openat2(RESOLVE_BENEATH); elsewhere we can't probe O_RESOLVE_BENEATH from
Python, but the FreeBSD/macOS versions that have it pass t_chmod_secure
outright, so this is never consulted on a failure there. NB: this is a
different question from rsyncfns.resolve_beneath_supported(), which probes
in-tree dir-symlink following -- the per-component fallback handles that, so
it stays True without any kernel primitive."""
if not sys.platform.startswith('linux'):
return False
try:
import ctypes
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
SYS_openat2, AT_FDCWD, RESOLVE_BENEATH = 437, -100, 0x08
# struct open_how { __u64 flags; __u64 mode; __u64 resolve; }
how = (ctypes.c_uint64 * 3)(os.O_RDONLY | os.O_DIRECTORY, 0, RESOLVE_BENEATH)
fd = libc.syscall(SYS_openat2, AT_FDCWD, ctypes.c_char_p(b'.'),
how, ctypes.c_size_t(24))
if fd >= 0:
os.close(fd)
return True
return False
except Exception:
return False
from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail
mod = SCRATCHDIR / 'module'
@@ -68,28 +39,13 @@ os.symlink('../trap', mod / 'escape_link')
os.chmod(mod / 'topfile', 0o600)
proc = subprocess.run([str(TOOLDIR / 't_chmod_secure'), str(mod)])
sentinel_mode = (trap_outside / 'sentinel').stat().st_mode & 0o777
escaped = sentinel_mode != 0o600
if proc.returncode != 0:
test_fail("t_chmod_secure reported failures (see stderr above)")
if not kernel_has_resolve_beneath():
# No kernel RESOLVE_BENEATH primitive, so do_chmod_at() falls back to the
# per-component O_NOFOLLOW resolver, which cannot fully confine every chmod
# scenario against a TOCTOU symlink swap. master's t_chmod_secure adjusts
# its expectations for this fallback; 3.4's older helper does not and counts
# it as a failure. The do_chmod_at() code is identical to master's, so this
# is an inherent platform limitation (no kernel beneath primitive), not a
# 3.4 regression -- mark it XFAIL.
if escaped or proc.returncode != 0:
test_xfail(
"no kernel RESOLVE_BENEATH primitive: the per-component fallback "
"cannot fully confine chmod and 3.4's t_chmod_secure lacks master's "
"fallback expectation adjustment (same do_chmod_at as master)")
else:
# RESOLVE_BENEATH is active: confinement is guaranteed, so any escape or
# helper-reported failure is a real bug.
if escaped:
test_fail(
f"outside sentinel mode changed from 600 to {oct(sentinel_mode)[2:]} "
"-- chmod escaped the module")
if proc.returncode != 0:
test_fail("t_chmod_secure reported failures (see stderr above)")
# Second-look sanity check from Python.
sentinel_mode = (trap_outside / 'sentinel').stat().st_mode & 0o777
if sentinel_mode != 0o600:
test_fail(
f"outside sentinel mode changed from 600 to {oct(sentinel_mode)[2:]} "
"-- chmod escaped the module"
)

View File

@@ -11,8 +11,7 @@
"this target mirrors), configure_flags. Optional (with defaults): make (\"make\"),",
"python (\"python3\"), rsync_bin (\"rsync\"; \"rsync.exe\" on Cygwin), privilege",
"(\"root\" | \"sudo\" | \"user\"), pipe_jobs/tcp_jobs (8), builddir (\"rsync-citest\",",
"relative to the remote $HOME), env_prefix, configure_pre, nonroot, protocols,",
"max_retry.",
"relative to the remote $HOME), env_prefix, configure_pre, nonroot, protocols.",
"",
"nonroot: true reruns -- as the non-root ssh user, after the sudo runs -- the",
"tests that declare `fleet_nonroot = True` at module level (so the set is",
@@ -20,14 +19,8 @@
"",
"protocols: [30, 29] adds one extra stdio-pipe test pass per listed version,",
"each run with runtests --protocol=N (the fleet analogue of a workflow's",
"check30/check29 steps) and shown as a protoNN column.",
"",
"max_retry: N (default 0) re-runs each failed test on its own up to N more",
"times and drops any that then pass (listed under RECOVERED, not hidden). Use",
"on a slow/loaded box where concurrency-sensitive tests occasionally flake,",
"instead of dropping the whole target to a lower pipe_jobs/tcp_jobs.",
"",
"Keys starting with \"_\" are comments. See testsuite/README.md."
"check30/check29 steps) and shown as a protoNN column. Keys starting with",
"\"_\" are comments. See testsuite/README.md."
],
"targets": [
{
@@ -47,13 +40,12 @@
"--disable-xxhash", "--disable-lz4"]
},
{
"_comment": "Nested-VM OpenBSD occasionally flakes a daemon/tcp test under load; max_retry re-runs just the failed test rather than throttling the whole box (tcp_jobs/pipe_jobs are still available if you prefer that).",
"name": "openbsd",
"ssh_host": "root@openbsd",
"workflow": "openbsd-build.yml",
"make": "gmake",
"configure_pre": "export AUTOCONF_VERSION=2.71 AUTOMAKE_VERSION=1.16;",
"max_retry": 2,
"tcp_jobs": 2,
"configure_flags": ["--with-rrsync", "--disable-zstd", "--disable-md2man",
"--disable-xxhash", "--disable-lz4"]
},

View File

@@ -83,11 +83,7 @@ from pathlib import Path
# source tree these point at, so it must be run from inside an rsync checkout
# or given --repo PATH.
REPO = Path.cwd()
# Source tree providing the test suite (runtests.py + testsuite/). Defaults to
# REPO; --testsuite-repo decouples it so one tree is built and another's suite is
# run against the result.
TESTSUITE_REPO = REPO
WORKFLOWS = TESTSUITE_REPO / ".github" / "workflows"
WORKFLOWS = REPO / ".github" / "workflows"
# Fleet config (overridable with --fleet): ~/.fleettest.json is tried first, then
# fleettest.json next to this script. The example template sits next to the
@@ -141,12 +137,6 @@ class Target:
# stdio-pipe pass with runtests --protocol=N (the fleet analogue of a
# workflow's check30/check29 steps). e.g. [30, 29]. Empty => proto pass off.
protocols: list[int] = dataclasses.field(default_factory=list)
# Per-target retry budget for FLAKY tests: after a run, each failed test is
# re-run on its own up to max_retry more times, and any that then pass are
# dropped from the failure list (and reported as "recovered", never hidden).
# Use on a slow/loaded box where concurrency-sensitive tests occasionally
# flake, instead of dropping the whole target to a lower -j. 0 => no retry.
max_retry: int = 0
def load_fleet(path: Path) -> list[Target]:
@@ -293,7 +283,7 @@ def build_script(t: Target) -> str:
def test_script(t: Target, transport: str, skip_csv: str | None, jobs: int,
protocol: int | None = None, only: list[str] | None = None) -> str:
protocol: int | None = None) -> str:
rb = f'--rsync-bin="$PWD/{t.rsync_bin}"'
tcp = " --use-tcp" if transport == "tcp" else ""
# protocol forces an older wire version (mirrors `make check30`/`check29`).
@@ -301,14 +291,9 @@ def test_script(t: Target, transport: str, skip_csv: str | None, jobs: int,
# PYTHONDONTWRITEBYTECODE: don't drop root-owned __pycache__/*.pyc into the
# tree (a sudo run would, breaking the next non-root push --delete).
env = "PYTHONDONTWRITEBYTECODE=1 "
# Named tests (a max_retry re-run) make runtests full_run False, so the
# expected-skip list does not apply -- only the named tests' pass/fail matter.
names = ""
if only:
names = " " + " ".join(only)
elif skip_csv:
if skip_csv:
env += f"RSYNC_EXPECT_SKIPPED={skip_csv} "
runtests = f'{t.python} runtests.py {rb}{tcp}{proto} -j {jobs}{names}'
runtests = f'{t.python} runtests.py {rb}{tcp}{proto} -j {jobs}'
# env_prefix (e.g. a brew PATH) must reach the test too: some tests build a
# helper binary on the fly (a test may invoke `make`, which needs gawk etc.),
# so the build tools must be on PATH at test time.
@@ -364,10 +349,6 @@ class TransportResult:
skip_expected: set[str]
skip_got: set[str]
raw: str
# Tests that failed the initial run but passed on a max_retry re-run, so they
# were dropped from `failed`. Surfaced in the report (a recovered flake is
# noted, never silently hidden).
recovered: list[str] = dataclasses.field(default_factory=list)
@property
def skip_mismatch(self) -> bool:
@@ -395,35 +376,6 @@ def parse_transport(transport: str, r: CmdResult, skip_checked: bool) -> Transpo
skip_checked, exp, got, r.out)
def retry_failed(t: Target, label: str, tr: TransportResult, rerun) -> None:
"""Honour the target's max_retry budget: re-run each failed test on its own
(serially) up to max_retry more times; drop any that pass and record them in
tr.recovered. `rerun(names)` runs the given tests and returns a CmdResult.
A no-op when max_retry is 0 or there were no failures."""
if not t.max_retry or not tr.failed:
return
remaining = list(tr.failed)
for attempt in range(1, t.max_retry + 1):
r = rerun(remaining)
still = [m.group(2) for m in RE_RESULT.finditer(r.out)
if m.group(1) in ("FAIL", "ERROR")]
recovered = [n for n in remaining if n not in still]
if recovered:
tr.recovered.extend(recovered)
log(f"[{t.name}] {label} retry {attempt}/{t.max_retry}: "
f"recovered {','.join(recovered)}"
+ (f"; still failing {','.join(still)}" if still else ""))
remaining = [n for n in remaining if n in still]
if not remaining:
break
tr.failed = remaining
# The initial run's non-zero exit was the now-recovered failures; once they
# all pass on retry the cell is OK, so clear the stale exit code (only the
# failed tests can make runtests exit non-zero on a no-skip-list re-run).
if not remaining and tr.recovered and tr.exit_code != 0:
tr.exit_code = 0
@dataclasses.dataclass
class TargetResult:
target: str
@@ -492,12 +444,9 @@ def run_target(t: Target, args, staging: str) -> TargetResult:
t0 = time.monotonic()
r = run_on(t, cmd, timeout=2400)
res.timings[transport] = time.monotonic() - t0
tr = parse_transport(transport, r, skip_csv is not None)
retry_failed(t, transport, tr, lambda names, tp=transport: run_on(
t, test_script(t, tp, None, 1, only=names), timeout=1200))
res.transports[transport] = tr
res.transports[transport] = parse_transport(transport, r, skip_csv is not None)
log(f"[{t.name}] {transport} done "
f"({'ok' if tr.ok else 'ISSUE'})")
f"({'ok' if res.transports[transport].ok else 'ISSUE'})")
# Extra older-protocol passes (mirroring the workflow's check30/check29
# steps): same stdio-pipe transport and skip list as `make check`, but with
@@ -512,13 +461,9 @@ def run_target(t: Target, args, staging: str) -> TargetResult:
t0 = time.monotonic()
r = run_on(t, cmd, timeout=2400)
res.timings[label] = time.monotonic() - t0
tr = parse_transport(label, r, skip_csv is not None)
retry_failed(t, label, tr, lambda names, pr=proto: run_on(
t, test_script(t, "pipe", None, 1, protocol=pr, only=names),
timeout=1200))
res.transports[label] = tr
res.transports[label] = parse_transport(label, r, skip_csv is not None)
log(f"[{t.name}] {label} done "
f"({'ok' if tr.ok else 'ISSUE'})")
f"({'ok' if res.transports[label].ok else 'ISSUE'})")
# Extra non-root pass (after the sudo runs) for targets that opt in, running
# the tests that declare `fleet_nonroot = True` (discovered in main()).
@@ -526,12 +471,9 @@ def run_target(t: Target, args, staging: str) -> TargetResult:
t0 = time.monotonic()
r = run_on(t, nonroot_test_script(t, args.nonroot_tests), timeout=2400)
res.timings["nonroot"] = time.monotonic() - t0
tr = parse_transport("nonroot", r, skip_checked=False)
retry_failed(t, "nonroot", tr, lambda names: run_on(
t, nonroot_test_script(t, names), timeout=1200))
res.transports["nonroot"] = tr
res.transports["nonroot"] = parse_transport("nonroot", r, skip_checked=False)
log(f"[{t.name}] nonroot done "
f"({'ok' if tr.ok else 'ISSUE'})")
f"({'ok' if res.transports['nonroot'].ok else 'ISSUE'})")
res.timings["total"] = time.monotonic() - started
return res
@@ -656,17 +598,6 @@ def print_report(results: list[TargetResult], args, fleet: list[Target]) -> bool
for d in details:
print(d)
print("=" * 64)
# Recovered flakes: tests that failed but passed within the target's
# max_retry budget. The cell counts as OK, but list them so a flaky test is
# never silently swallowed.
recovered = [f"{res.target} / {transport}: {','.join(tr.recovered)}"
for res in results for transport in transports
if (tr := res.transports.get(transport)) and tr.recovered]
if recovered:
print("==== RECOVERED (flaky -- failed, then passed on retry) ====")
for r in recovered:
print(f" {r}")
print("=" * 64)
print(f"{len(results)} targets x {len(transports)} transports = {cells} cells: "
f"{ok_cells} OK, {cells - ok_cells} not OK")
return all_ok
@@ -819,35 +750,18 @@ def main() -> int:
help="report per-target wall-clock (push/build/test) to find "
"the slowest target")
ap.add_argument("--repo", help="rsync source tree to build (default: cwd)")
ap.add_argument("--testsuite-repo",
help="rsync tree to take runtests.py + testsuite/ from "
"(default: --repo). Build one tree and run another's test "
"suite against it, e.g. --repo ../rsync-v3.4 --testsuite-repo .")
ap.add_argument("--fleet", help="fleet config JSON (default: ~/.fleettest.json, "
"else fleettest.json next to this script)")
ap.add_argument("--list", action="store_true", help="list targets and exit")
args = ap.parse_args()
global REPO, WORKFLOWS, TESTSUITE_REPO
global REPO, WORKFLOWS
REPO = Path(args.repo).resolve() if args.repo else Path.cwd()
TESTSUITE_REPO = Path(args.testsuite_repo).resolve() if args.testsuite_repo else REPO
# The expected-skip lists travel with the suite, so read workflows from the
# tree that provides the tests.
WORKFLOWS = TESTSUITE_REPO / ".github" / "workflows"
if not args.cleanup:
# The Python test suite (runtests.py + testsuite/) comes from
# TESTSUITE_REPO, so that is where runtests.py must live. The build tree
# (REPO) only has to be a buildable rsync source -- it may be an older
# release whose runtests.py predates the Python suite, or lacks it.
if not (TESTSUITE_REPO / "runtests.py").is_file():
print(f"{TESTSUITE_REPO} has no runtests.py; run from inside a "
f"checkout or pass --testsuite-repo a tree with the Python "
f"test suite", file=sys.stderr)
return 2
if not (REPO / "rsync.h").is_file():
print(f"{REPO} is not an rsync source tree (no rsync.h); "
f"run from inside a checkout or pass --repo", file=sys.stderr)
return 2
WORKFLOWS = REPO / ".github" / "workflows"
if not args.cleanup and not (REPO / "runtests.py").is_file():
print(f"{REPO} is not an rsync source tree (no runtests.py); "
f"run from inside a checkout or pass --repo", file=sys.stderr)
return 2
if args.fleet:
config_path = Path(args.fleet).resolve()
@@ -926,19 +840,6 @@ def main() -> int:
print(f"git archive failed: {ar.stderr}", file=sys.stderr)
return 2
# --testsuite-repo: overlay another tree's runtests.py + testsuite/ onto
# the built source (merge, no delete). Build REPO's rsync, but run
# TESTSUITE_REPO's suite against it. The leftover .test files from REPO
# are ignored by a Python runtests.py (it globs *_test.py).
if TESTSUITE_REPO != REPO:
ov = subprocess.run(
f"git -C {TESTSUITE_REPO} archive HEAD -- runtests.py testsuite "
f"| tar -x -C {staging}",
shell=True, capture_output=True, text=True)
if ov.returncode != 0:
print(f"testsuite overlay archive failed: {ov.stderr}", file=sys.stderr)
return 2
# Tests that opt into the non-root pass (same for every target).
args.nonroot_tests = discover_nonroot_tests(Path(staging) / "testsuite")

View File

@@ -10,7 +10,7 @@ import subprocess
import sys
import tempfile
from rsyncfns import make_data_file, cp_p, makepath, checkit, run_rsync, test_xfail, RSYNC, TMPDIR, get_testuid, get_rootuid
from rsyncfns import make_data_file, cp_p, makepath, checkit, RSYNC, TMPDIR, get_testuid, get_rootuid
BASEDIR = TMPDIR
@@ -67,15 +67,4 @@ if (is_root and sys.platform == 'linux' and hasattr(os, 'unshare')
pass # mount namespace denied (unprivileged container) -- run as root
# 3.4 stable lacks #957 (receiver chmod-the-target-when-denied): when the dest
# temp is read-only to the (cap-dropped / non-root) receiver, rsync can't write
# it and doesn't retry with a chmod, so the transfer errors. As root the write
# is never denied and it succeeds normally, so this only fires off-root.
probe = run_rsync('-avv', '--partial', '--delay-updates',
f'{FROMDIR}/', f'{TODIR}/', check=False, capture_output=True)
if probe.returncode != 0:
test_xfail(
"#957 (receiver chmod-the-target-when-denied) not in 3.4 stable: rsync "
"cannot write the read-only dest temp and does not retry with chmod "
f"(rsync exited {probe.returncode})")
checkit(['-avv', '--partial', '--delay-updates', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR)

View File

@@ -15,7 +15,7 @@ import os
from rsyncfns import (
FROMDIR, TODIR,
assert_same, make_data_file, makepath, rmtree, run_rsync, test_fail,
test_skipped, test_xfail,
test_skipped,
)
src = FROMDIR
@@ -103,12 +103,10 @@ seed_holey()
run_rsync('-a', '--preallocate', '--sparse', f'{src}/', f'{TODIR}/')
assert_same(TODIR / deep, src / deep, label='--preallocate --sparse content')
if can_punch and allocated(TODIR / deep) >= os.path.getsize(TODIR / deep):
test_xfail(
"3.4 stable lacks the --preallocate --sparse fix (4f5a5857): "
"do_fallocate() does not report the preallocated length, so write_sparse() "
"cannot punch the zero run and the file is left fully allocated "
f"(allocated {allocated(TODIR / deep)} for a "
f"{os.path.getsize(TODIR / deep)}-byte file)")
test_fail(f"--preallocate --sparse left the file fully allocated "
f"(allocated {allocated(TODIR / deep)} for a "
f"{os.path.getsize(TODIR / deep)}-byte file); the preallocated "
"extent's zero run was not punched into a hole")
# --- --inplace --sparse update that introduces a zero run: do_punch_hole ----
# (sparse_end's updating_basis_or_equiv branch punches the hole in place.)

View File

@@ -1,126 +0,0 @@
#!/usr/bin/env python3
# Regression test for a receiver NULL-deref on the delta DISCARD path.
#
# In receiver.c receive_data(), a block-MATCH token that arrives while the
# receiver is DISCARDING a file (discard_receive_data() -> receive_data() with
# fname==NULL, fd==-1, hence mapbuf==NULL) reached
# rprintf(FERROR, "...%s...", full_fname(fname), ...)
# with fname==NULL. full_fname() dereferences its argument unconditionally
# (util1.c: `if (*fn == '/')`), so the receiver SIGSEGVs. The faulty error
# branch was added in 31fbb17d ("receiver: fix absolute --partial-dir delta
# resume"); the fix discriminates on fd (not mapbuf) and, on the discard path
# (fd==-1), absorbs the matched bytes benignly instead of erroring.
#
# This is a NORMAL-operation crash, not adversarial: a stock cooperating sender
# triggers it. The generator sends real block sums (basis readable, delta mode);
# the receiver then has to discard because its output mkstemp() fails -- here
# because the destination directory is not writable. A block MATCH against the
# shared leading block reaches the discard path and crashes the pre-fix binary.
#
# We drive a real sender<->receiver pair (client sender -> daemon receiver) so
# the receiver actually takes the recv_files discard path; a local `rsync a b`
# does not. In the default (pipe) daemon transport both ends are the binary
# under test.
#
# Skipped (exit 77) when running as root (root bypasses DAC), or when the
# directory mode is not enforced (e.g. a non-root process holding
# CAP_DAC_OVERRIDE in an unprivileged container): in both cases the receiver's
# mkstemp() would succeed despite chmod 0555, the discard path would not be
# taken, and the test would silently pass against a buggy binary. The
# post-chmod writability probe converts that silent false-pass into an honest
# skip and subsumes the root check.
import os
import shlex
import subprocess
import tempfile
from rsyncfns import (
SCRATCHDIR, RSYNC, TMPDIR,
get_testuid, get_rootuid, makepath, start_test_daemon, write_daemon_conf,
test_fail, test_skipped,
)
DAEMON_PORT = 12895
if get_testuid() == get_rootuid():
test_skipped("root bypasses DAC: the unwritable dest dir wouldn't make "
"the receiver's mkstemp fail, so the discard path (and the "
"bug) is never reached")
os.chdir(TMPDIR)
MODDIR = SCRATCHDIR / 'recvdiscard-mod' # daemon module root (writable)
BASISDIR = MODDIR / 'd' # made read-only -> mkstemp fails
SRCDIR_ = SCRATCHDIR / 'recvdiscard-src' # client source tree
makepath(MODDIR, BASISDIR, SRCDIR_)
# Basis and source share a leading block (2000 'A's) so the generator emits
# real sums and the receiver gets a block MATCH; the tails differ and the
# source is larger so a delta (not a no-op) is sent.
basis = BASISDIR / 'f'
basis.write_bytes(b'A' * 2000 + b'C' * 1000)
src = SRCDIR_ / 'f'
src.write_bytes(b'A' * 2000 + b'B' * 3000)
# A read/write daemon module rooted at MODDIR.
conf = write_daemon_conf([('recvdiscard', {'path': str(MODDIR),
'read only': 'no'})])
url = start_test_daemon(conf, DAEMON_PORT, rsync_cmd=RSYNC)
# Make the destination directory unwritable so the receiver's output mkstemp()
# fails and it falls back to discarding the delta stream. Restore in finally so
# the per-test scratch tree can be cleaned up.
os.chmod(BASISDIR, 0o555)
# Probe that the chmod actually denies writes for *this* process. A non-root
# user holding CAP_DAC_OVERRIDE bypasses the directory write bit, so mkstemp
# would succeed in the daemon receiver too, the discard path would never be
# taken, and the test would silently pass on a buggy binary. Better to skip
# explicitly. (Root takes this path too: its probe succeeds → skip, which
# subsumes the uid==0 check.)
try:
_fd, _probe = tempfile.mkstemp(dir=BASISDIR)
os.close(_fd)
os.unlink(_probe)
os.chmod(BASISDIR, 0o755)
test_skipped("destination dir is writable despite chmod 0555 "
"(CAP_DAC_OVERRIDE?); cannot force the receiver discard path")
except OSError:
pass # EACCES -- good, the precondition is enforced
try:
argv = shlex.split(RSYNC) + [
'--no-whole-file', '-a',
str(src), f'{url}recvdiscard/d/f',
]
print('Running:', ' '.join(argv))
proc = subprocess.run(argv, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
print(proc.stdout, end='')
finally:
os.chmod(BASISDIR, 0o755)
rc = proc.returncode
# A receiver SIGSEGV manifests to the client as a protocol error (the daemon's
# receiver child crashes mid-stream and the connection drops): exit code 12.
# With the fix the receiver drains the delta and, because the forced-unwritable
# destination leaves the file untransferred, the run reports the benign "some
# files were not transferred" -- exit code 23.
#
# 23 is the ONLY non-crash outcome here: the writability probe above guarantees
# the receiver's mkstemp() fails, so the file is always discarded. An exit 0
# would mean the file actually transferred -- the discard path was NOT exercised
# and the run proves nothing -- so require exactly 23 (and call out 12 as the
# pre-fix crash).
if rc == 12:
test_fail(f"receiver crashed on the discard path (rsync exited {rc}: "
"error in rsync protocol data stream -- the receiver child "
"SIGSEGV'd in full_fname(NULL))")
if rc != 23:
test_fail(f"expected rsync exit 23 (the forced discard leaves the file "
f"untransferred); got {rc} -- the discard path was not exercised, "
"so this run validates nothing (12 would be the pre-fix crash)")
print(f"OK: receiver discarded the delta without crashing (rsync exit {rc})")