Compare commits

..

4 Commits

Author SHA1 Message Date
Andrew Tridgell
88d9793bb2 testsuite: xfail the tests that 3.4 stable is expected to fail
Rather than removing them, mark the spots where 3.4 lacks the fix/capability as
XFAIL so the tests still run -- and still pass where the fix or kernel primitive
is present:
 - preallocate: --preallocate --sparse leaves the file fully allocated without
   the do_fallocate-length fix (4f5a5857).
 - partial_nowrite: off-root, 3.4 can't write a denied dest temp and doesn't
   retry with chmod (lacks #957); as root it succeeds.
 - chmod-symlink-race: where the kernel has no RESOLVE_BENEATH primitive, 3.4's
   t_chmod_secure helper predates the per-component-fallback expectation
   adjustment and reports it as a failure (no actual escape: sentinel unchanged).
2026-06-07 19:08:28 +10:00
Andrew Tridgell
bd4bf5dcf0 fleettest: require runtests.py in --testsuite-repo, not the build tree
When --testsuite-repo provides the suite, the build tree (--repo) need not
carry runtests.py -- it may be an older release whose shell testsuite predates
the Python runtests.py (e.g. a 3.4.1 backport branch built and tested with the
current suite).  Check runtests.py in TESTSUITE_REPO and only require the build
tree to be rsync source (rsync.h).
2026-06-07 18:59:18 +10:00
Andrew Tridgell
e5d718741f fleettest: add --testsuite-repo to run another tree's suite against this build
--repo couples the built source and the test suite that exercises it.
--testsuite-repo PATH overlays runtests.py + testsuite/ from a second tree onto
the staged build tree, and sources the expected-skip workflows from it, so one
can build an older release (e.g. a 3.4.x stable branch) and run the current
comprehensive suite against that binary. Defaults to --repo, so the existing
single-tree behaviour is unchanged.
2026-06-07 13:56:17 +10:00
Andrew Tridgell
7afff20964 runtests: stop discovering obsolete *.test shell tests
The shell testsuite was removed in 1f689ec0 (rewritten in Python); only
*_test.py remain, yet collect_tests still globbed *.test and _testbase mapped
foo.test and foo_test.py to the same canonical name. Harmless on a master tree
(no .test files), but when an older tree's *.test files are present -- e.g.
fleettest --testsuite-repo building a 3.4.x release whose shell suite still
exists -- both glob to the same test name and scratch dir and race under -j,
producing spurious failures. Drop .test discovery entirely.
2026-06-07 12:59:49 +10:00
45 changed files with 156 additions and 4143 deletions

4
.github/FUNDING.yml vendored
View File

@@ -1,4 +0,0 @@
# These are supported funding model platforms
github: RsyncProject
patreon: AndrewTridgell

View File

@@ -18,7 +18,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/almalinux-8-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -21,7 +21,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/android-static-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
workflow_dispatch:
env:

View File

@@ -1,72 +0,0 @@
name: rsync ASan+UBSan (clang)
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/asan-build.yml'
pull_request:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/asan-build.yml'
schedule:
# Weekly (Mon 09:42 UTC): catch breakage from a moving ubuntu-latest/clang
# toolchain (a new clang can add a UBSan check, or change ASan behaviour)
# that no code push would otherwise trigger. Push/PR already gate every
# code change, so daily would just re-run an unchanged tree.
- cron: '42 9 * * 1'
workflow_dispatch:
jobs:
asan:
runs-on: ubuntu-latest
name: rsync ASan+UBSan (clang)
env:
# rsync intentionally leaks small allocations at process exit, so leak
# detection would be all noise; chase only memory-safety errors.
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1
# UBSan is a gate: -fno-sanitize-recover=undefined (below) aborts on the
# first finding and halt_on_error=1 makes that fatal, so any undefined
# behaviour fails the run. This needs the tree to be UBSan-clean: the
# remaining findings are fixed in code (hashtable/mdfour shifts, xattrs,
# and log.c's file_struct, kept aligned via rounding.h); only byteorder.h's
# intentional unaligned accessors are suppressed, with no_sanitize.
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y clang acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev openssl
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
# -DNDEBUG builds as a shipped release does (assert() compiled out), so
# AddressSanitizer catches the over-reads/over-writes that an "assert()
# instead of a real bounds check" bug would cause in a production build.
# UBSan rides along on the same build; -fno-sanitize-recover=undefined
# makes any undefined behaviour abort (and thus fail the run) instead of
# merely printing it.
run: |
CC=clang \
CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1 -DNDEBUG" \
LDFLAGS="-fsanitize=address,undefined" \
./configure --with-rrsync --disable-md2man
- name: make
# check-progs builds rsync plus the test helper programs (tls, trimslash,
# t_unsafe, ...) that runtests.py requires; plain "make" builds only rsync
# and runtests aborts on the missing helpers.
run: make check-progs
- name: info
run: ./rsync --version
- name: check (stdio-pipe transport)
# ASan+UBSan-instrumented coverage of the transfer, daemon, sender,
# receiver and metadata paths over the default stdio-pipe transport.
run: ./runtests.py --rsync-bin="$PWD/rsync" -j8
- name: check (TCP daemon transport)
# --use-tcp also exercises the loopback rsyncd listener and the client's
# TCP connection path.
run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j8

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/coverage.yml'
schedule:
- cron: '42 9 * * 1'
- cron: '42 9 * * *'
workflow_dispatch:
jobs:

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/cygwin-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/freebsd-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/macos-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/netbsd-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/openbsd-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -1,51 +0,0 @@
name: rsync scan-build (clang analyzer)
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/scan-build.yml'
pull_request:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/scan-build.yml'
workflow_dispatch:
jobs:
scan-build:
runs-on: ubuntu-latest
name: rsync scan-build (clang analyzer)
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y clang clang-tools acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev openssl
- name: configure (under scan-build)
# Run configure under scan-build so its analyzer compiler-wrapper is baked
# into the Makefile's $(CC); --disable-md2man avoids the doc toolchain.
run: scan-build ./configure --with-rrsync --disable-md2man
- name: scan-build (informational)
# Static analysis only -- INFORMATIONAL, not a gate. rsync currently has
# a fair number of reports that are overwhelmingly known false positives
# (e.g. unix.Chroot "no chdir after chroot", core.NonNullParamChecker
# against functions that can't actually receive NULL). We publish the
# HTML report as an artifact and print the bug count to the run summary,
# but do NOT pass --status-bugs, so this surfaces new analyzer findings
# without going red on arrival. check-progs builds rsync + the test
# helpers without needing the man-page toolchain.
run: |
scan-build -o "$PWD/scan-report" make check-progs -j"$(nproc)" 2>&1 | tee scan-build.out
echo '## scan-build summary' >>"$GITHUB_STEP_SUMMARY"
grep -E 'scan-build: .* bugs? found|scan-build: No bugs found' scan-build.out >>"$GITHUB_STEP_SUMMARY" || true
- name: upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: scan-build-report
path: scan-report
if-no-files-found: ignore

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/solaris-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -16,7 +16,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-22.04-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -12,7 +12,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-build.yml'
schedule:
- cron: '42 8 * * 1'
- cron: '42 8 * * *'
jobs:
test:

View File

@@ -33,7 +33,7 @@ on:
- '.github/workflows/*.yml'
- '!.github/workflows/ubuntu-version-mix.yml'
schedule:
- cron: '52 8 * * 1'
- cron: '52 8 * * *'
jobs:
version-mix:

View File

@@ -1,96 +0,0 @@
name: Valgrind memcheck
on:
push:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/valgrind.yml'
pull_request:
branches: [ master ]
paths-ignore:
- '.github/workflows/*.yml'
- '!.github/workflows/valgrind.yml'
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
jobs:
memcheck:
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
privilege: [ user, root ]
transport: [ pipe, tcp ]
name: memcheck (${{ matrix.privilege }}, ${{ matrix.transport }})
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: prep
run: |
sudo apt-get update
sudo apt-get install -y valgrind acl libacl1-dev attr libattr1-dev \
liblz4-dev libzstd-dev libxxhash-dev python3-cmarkgfm openssl
echo "/usr/local/bin" >>"$GITHUB_PATH"
- name: configure
run: ./configure --with-rrsync --enable-debug
- name: make
run: make check-progs # builds rsync + the test helper programs runtests.py needs
- name: info
run: ./rsync --version
# Run the whole suite under valgrind. We gate on memory *errors* (uninit
# reads, invalid read/write, bad frees, uninit syscall params), not leaks:
# rsync deliberately leaves file-list/socket/option memory unfreed at exit
# (short-lived process; the OS reclaims), so --leak-check=no avoids a sea of
# by-design "definitely lost" reports. Functional pass/fail is covered by
# the other workflows, so the suite is allowed to finish regardless of
# per-test results; the scan step below is the gate. --error-exitcode=0
# keeps valgrind from perturbing test exit codes; the bundled
# testsuite/valgrind.supp silences known-benign reports.
- name: run testsuite under valgrind
run: |
SUDO=
[ "${{ matrix.privilege }}" = root ] && SUDO="sudo -E"
TCP=
[ "${{ matrix.transport }}" = tcp ] && TCP="--use-tcp"
$SUDO ./runtests.py --valgrind \
--valgrind-opts="--leak-check=no --error-exitcode=0" \
$TCP -j8 --preserve-scratch || true
- name: scan for unsuppressed valgrind errors
run: |
sudo chown -R "$USER" testtmp 2>/dev/null || true
mapfile -t logs < <(find testtmp -name 'valgrind.*.log' 2>/dev/null)
if [ "${#logs[@]}" -eq 0 ]; then
echo "::error::no valgrind logs were produced -- the suite did not run"
exit 1
fi
echo "scanned ${#logs[@]} valgrind log(s)"
bad=()
for f in "${logs[@]}"; do
grep -qE 'ERROR SUMMARY: [1-9][0-9]* errors' "$f" && bad+=("$f")
done
if [ "${#bad[@]}" -ne 0 ]; then
echo "::error::valgrind reported unsuppressed errors in ${#bad[@]} run(s)"
for f in "${bad[@]}"; do
echo "===== $f ====="
sed 's/==[0-9]*== //' "$f" | grep -A18 \
-E 'depends on uninitialised|points to uninitialised|Invalid (read|write|free)|lost in loss record|Mismatched free' \
| head -60
done
exit 1
fi
echo "valgrind clean: no unsuppressed errors"
- name: upload valgrind logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: valgrind-logs-${{ matrix.privilege }}-${{ matrix.transport }}
path: testtmp/**/valgrind.*.log
if-no-files-found: ignore
retention-days: 7

View File

@@ -139,7 +139,6 @@ usage.o: version.h latest-year.h help-rsync.h help-rsyncd.h git-version.h defaul
loadparm.o: default-dont-compress.h daemon-parm.h
flist.o: rounding.h
log.o: rounding.h
default-cvsignore.h default-dont-compress.h: rsync.1.md define-from-md.awk
$(AWK) -f $(srcdir)/define-from-md.awk -v hfile=$@ $(srcdir)/rsync.1.md

View File

@@ -68,26 +68,10 @@ SIVAL64(char *buf, int pos, int64 val)
#else /* !CAREFUL_ALIGNMENT */
/* We don't want false positives about alignment from UBSAN, see:
https://github.com/WayneD/rsync/issues/427#issuecomment-1375132291
*/
/* From https://gcc.gnu.org/onlinedocs/cpp/Common-Predefined-Macros.html */
#ifndef GCC_VERSION
#define GCC_VERSION (__GNUC__ * 10000 \
+ __GNUC_MINOR__ * 100 \
+ __GNUC_PATCHLEVEL__)
#endif
/* This handles things for architectures like the 386 that can handle alignment errors.
* WARNING: This section is dependent on the length of an int32 (and thus a uint32)
* being correct (4 bytes)! Set CAREFUL_ALIGNMENT if it is not. */
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline uint32
IVALu(const uchar *buf, int pos)
{
@@ -99,11 +83,6 @@ IVALu(const uchar *buf, int pos)
return *u.num;
}
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline void
SIVALu(uchar *buf, int pos, uint32 val)
{
@@ -115,11 +94,6 @@ SIVALu(uchar *buf, int pos, uint32 val)
*u.num = val;
}
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline int64
IVAL64(const char *buf, int pos)
{
@@ -131,11 +105,6 @@ IVAL64(const char *buf, int pos)
return *u.num;
}
#ifdef __clang__
__attribute__((no_sanitize("undefined")))
#elif GCC_VERSION >= 409
__attribute__((no_sanitize_undefined))
#endif
static inline void
SIVAL64(char *buf, int pos, int64 val)
{

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, ModeCOPY_SRC, ModeCOPY_DST, ModeCOPY_AND, ModeOP;
int ModeAND, ModeOR;
char flags;
};
@@ -43,20 +43,6 @@ 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. */
@@ -64,13 +50,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, copybits = 0;
int where = 0, what = 0, op = 0, topbits = 0, topoct = 0, flags = 0;
struct chmod_mode_struct *first_mode = NULL, *curr_mode = NULL,
*prev_mode = NULL;
while (state != STATE_ERROR) {
if (!*modestr || *modestr == ',') {
int bits, where_specified;
int bits;
if (!op) {
state = STATE_ERROR;
@@ -84,10 +70,9 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
first_mode = curr_mode;
curr_mode->next = NULL;
where_specified = where;
if (where) {
if (where)
bits = where * what;
} else {
else {
where = 0111;
bits = (where * what) & ~orig_umask;
}
@@ -96,35 +81,18 @@ 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)
- (copybits ? mode_dest_special_bits(where) : 0);
curr_mode->ModeAND = CHMOD_BITS - (where * 7) - (topoct ? topbits : 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;
}
@@ -135,7 +103,7 @@ struct chmod_mode_struct *parse_chmod(const char *modestr,
modestr++;
state = STATE_1ST_HALF;
where = what = op = topoct = topbits = flags = copybits = 0;
where = what = op = topoct = topbits = flags = 0;
}
switch (state) {
@@ -191,53 +159,26 @@ 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;
@@ -271,20 +212,6 @@ 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. */
@@ -292,25 +219,17 @@ 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

@@ -718,7 +718,8 @@ static void sum_sizes_sqroot(struct sum_struct *sum, int64 len)
else {
int32 max_blength = protocol_version < 30 ? OLD_MAX_BLOCK_SIZE : MAX_BLOCK_SIZE;
int32 c;
for (c = 1, l = len; l >>= 2; c <<= 1) {}
int cnt;
for (c = 1, l = len, cnt = 0; l >>= 2; c <<= 1, cnt++) {}
if (c < 0 || c >= max_blength)
blength = max_blength;
else {
@@ -1229,7 +1230,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx,
static int need_fuzzy_dirlist = 0;
struct file_struct *fuzzy_file = NULL;
int fd = -1, f_copy = -1;
stat_x sx = {0}, real_sx;
stat_x sx, real_sx;
STRUCT_STAT partial_st;
struct file_struct *back_file = NULL;
int statret, real_ret, stat_errno;
@@ -1627,10 +1628,6 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx,
|| (preserve_specials && ftype == FT_SPECIAL)) {
dev_t rdev;
int del_for_flag;
/* Whether the dest existed, captured before the type-mismatch
* flip below clears statret -- so atomic_create() gets a delete
* flag (and reads sx.st) only when sx.st was actually stat'd. */
int dest_existed = (statret == 0);
if (ftype == FT_DEVICE) {
uint32 *devp = F_RDEV_P(file);
rdev = MAKEDEV(DEV_MAJOR(devp), DEV_MINOR(devp));
@@ -1677,7 +1674,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx,
fname, (int)file->mode,
(long)major(rdev), (long)minor(rdev));
}
if (atomic_create(file, fname, NULL, NULL, rdev, &sx, dest_existed ? del_for_flag : 0)) {
if (atomic_create(file, fname, NULL, NULL, rdev, &sx, del_for_flag)) {
set_file_attrs(fname, file, NULL, NULL, 0);
if (itemizing) {
itemize(fnamecmp, file, ndx, statret, &sx,

View File

@@ -351,7 +351,7 @@ void *hashtable_find(struct hashtable *tbl, int64 key, void *data_when_new)
*/
#define NON_ZERO_32(x) ((x) ? (x) : (uint32_t)1)
#define NON_ZERO_64(x, y) ((x) || (y) ? (y) | (uint64_t)(x) << 32 | (y) : (int64)1)
#define NON_ZERO_64(x, y) ((x) || (y) ? (y) | (int64)(x) << 32 | (y) : (int64)1)
uint32_t hashlittle(const void *key, size_t length)
{

View File

@@ -34,9 +34,7 @@
#endif
.text
/* .balign = N bytes everywhere; bare .align means 2^N on Mach-O (would ask
* for 64KB alignment and trip a macOS linker warning). */
.balign 16
.align 16
.globl md5_process_asm
md5_process_asm:

View File

@@ -89,8 +89,8 @@ static void copy64(uint32 *M, const uchar *in)
int i;
for (i = 0; i < MD4_DIGEST_LEN; i++) {
M[i] = ((uint32)in[i*4+3] << 24) | ((uint32)in[i*4+2] << 16)
| ((uint32)in[i*4+1] << 8) | ((uint32)in[i*4+0] << 0);
M[i] = (in[i*4+3] << 24) | (in[i*4+2] << 16)
| (in[i*4+1] << 8) | (in[i*4+0] << 0);
}
}

8
log.c
View File

@@ -22,7 +22,6 @@
#include "rsync.h"
#include "itypes.h"
#include "inums.h"
#include "rounding.h" /* EXTRA_ROUNDING, so log_delete() aligns its file_struct */
extern int dry_run;
extern int am_daemon;
@@ -298,12 +297,7 @@ void rwrite(enum logcode code, const char *buf, int len, int is_utf8)
in_block = 1;
if (!log_initialised)
log_init(0);
/* buf holds exactly len bytes and is not necessarily NUL-terminated
* (e.g. a forwarded MSG_* payload from read_a_msg), so copy by length
* rather than strlcpy(), which would strlen() past the end of buf. */
int mlen = MIN((int)sizeof msg - 1, len);
memcpy(msg, buf, mlen);
msg[mlen] = '\0';
strlcpy(msg, buf, MIN((int)sizeof msg, len + 1));
logit(priority, msg);
in_block = 0;

View File

@@ -104,8 +104,8 @@ def require_samba_host():
def require_top_of_checkout():
if not os.path.isfile('packaging/release.py'):
die("Run this script from the top of your rsync checkout.")
if not os.path.exists('.git'):
die("There is no .git in the current directory (run from the top of a git checkout or worktree).")
if not os.path.isdir('.git'):
die("There is no .git dir in the current directory.")
def replace_or_die(regex, repl, txt, die_msg):
@@ -636,8 +636,6 @@ If you have a 'samba' remote configured (git.samba.org:/data/git/rsync.git):
Then upload the tarball + .asc to the GitHub release for {v_ver},
and announce on rsync-announce@, rsync@, and Discord.
NOTE! Also update the PPAs if needed
""")

View File

@@ -26,58 +26,6 @@ License</A> and is currently being maintained by
<img src="badge.svg">
</a></div>
<h3>Rsync version 3.4.4 released</h3>
<i class=date>June 8th, 2026</i>
<p>Rsync version 3.4.4 has been released. This is a regression fix
release for the issues that have been reported with the 3.4.3
security release. Many thanks to everyone who reported the issues
(see <a href="https://download.samba.org/pub/rsync/NEWS#3.4.4">NEWS.md</a>
for credits).<p>
The 3.4.3 release had so many issues for two main reasons:
<ul>
<li>the 3.4 testsuite did not have broad enough coverage to
catch the regressions notices by users
<li>the nature of a security release prevents wide beta testing,
resulting in not enough manual testing in disparate environments
</ul>
To fix this for future releases we have greatly expanded the test
suite for 3.5 (currently in master) and grown the development
team, especially with more people with security expertise.
Thanks for your patience!
<p>See the <a href="https://download.samba.org/pub/rsync/NEWS#3.4.4">3.4.4 NEWS</a> for a detailed changelog.
The latest manpages are also available for:<ul>
<li><a href="https://download.samba.org/pub/rsync/rsync.1"><b>rsync</b>(1)</a>
<li><a href="https://download.samba.org/pub/rsync/rsync-ssl.1"><b>rsync-ssl</b>(1)</a>
<li><a href="https://download.samba.org/pub/rsync/rsyncd.conf.5"><b>rsyncd.conf</b>(5)</a>
<li><a href="https://download.samba.org/pub/rsync/rrsync.1"><b>rrsync</b>(1)</a>
</ul>
<p>The source tar is available here:
<b><a href="https://download.samba.org/pub/rsync/src/rsync-3.4.4.tar.gz">rsync-3.4.4.tar.gz</a>
(<a href="https://download.samba.org/pub/rsync/src/rsync-3.4.4.tar.gz.asc">signature</a>)</b>,
and the diffs from version 3.4.3 are available here:
<b><a href="https://download.samba.org/pub/rsync/src-diffs/rsync-3.4.3-3.4.4.diffs.gz">rsync-3.4.3-3.4.4.diffs.gz</a>
(<a href="https://download.samba.org/pub/rsync/src-diffs/rsync-3.4.3-3.4.4.diffs.gz.asc">signature</a>)</b>.
<p>Patch sets are also available for the older stable series, for
distributors not yet able to move to 3.4.4. Each is GPG signed. The
<i>full</i> set applies to a pristine release tarball; the <i>update</i>
set has only the patches added since the previous security release:<ul>
<li>rsync 3.2.7:
<b><a href="https://download.samba.org/pub/rsync/src/rsync-3.2.7-full.tar.gz">rsync-3.2.7-full.tar.gz</a>
(<a href="https://download.samba.org/pub/rsync/src/rsync-3.2.7-full.tar.gz.asc">signature</a>)</b>,
<b><a href="https://download.samba.org/pub/rsync/src/rsync-3.2.7-update.tar.gz">rsync-3.2.7-update.tar.gz</a>
(<a href="https://download.samba.org/pub/rsync/src/rsync-3.2.7-update.tar.gz.asc">signature</a>)</b>
<li>rsync 3.4.1:
<b><a href="https://download.samba.org/pub/rsync/src/rsync-3.4.1-full.tar.gz">rsync-3.4.1-full.tar.gz</a>
(<a href="https://download.samba.org/pub/rsync/src/rsync-3.4.1-full.tar.gz.asc">signature</a>)</b>,
<b><a href="https://download.samba.org/pub/rsync/src/rsync-3.4.1-update.tar.gz">rsync-3.4.1-update.tar.gz</a>
(<a href="https://download.samba.org/pub/rsync/src/rsync-3.4.1-update.tar.gz.asc">signature</a>)</b>
</ul>
<h3>Rsync version 3.4.3 released</h3>
<i class=date>May 20th, 2026</i>

View File

@@ -1523,16 +1523,6 @@ 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

@@ -266,19 +266,8 @@ def build_rsync_cmd(rsync_bin, args, scratchbase):
"""Build the RSYNC command string for tests."""
parts = []
if args.valgrind:
# Logs go in a world-writable+sticky subdir so that rsync children
# which drop privileges (the setpriv cap-drop in partial_nowrite, a
# daemon dropping to the module's uid) can still create their log file
# even when scratchbase itself is root-owned.
vgdir = os.path.join(scratchbase, 'valgrind-logs')
os.makedirs(vgdir, exist_ok=True)
os.chmod(vgdir, 0o1777)
vlog = os.path.join(vgdir, 'valgrind.%p.log')
vlog = os.path.join(scratchbase, 'valgrind.%p.log')
vopts = f'--log-file={vlog}'
supp = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'testsuite', 'valgrind.supp')
if os.path.exists(supp):
vopts += f' --suppressions={supp}'
if args.valgrind_opts:
vopts += ' ' + args.valgrind_opts
parts.append(f'valgrind {vopts}')
@@ -461,7 +450,7 @@ def main():
print(f' os={subprocess.check_output(["uname", "-a"], text=True).strip()}')
print(f' preserve_scratch={"yes" if args.preserve_scratch else "no"}')
if args.valgrind:
print(f' valgrind=enabled (logs in valgrind-logs/valgrind.*.log)')
print(f' valgrind=enabled (logs in valgrind.*.log)')
if args.parallel > 1:
print(f' parallel={args.parallel}')
print(f' daemon_transport={"tcp (loopback)" if args.use_tcp else "pipe (secure default)"}')
@@ -627,7 +616,7 @@ def main():
# Check valgrind logs for errors
vg_errors = 0
if args.valgrind:
for vlog in sorted(glob.glob(os.path.join(scratchbase, 'valgrind-logs', 'valgrind.*.log'))):
for vlog in sorted(glob.glob(os.path.join(scratchbase, 'valgrind.*.log'))):
try:
with open(vlog) as f:
content = f.read()
@@ -651,7 +640,7 @@ def main():
if skipped > 0:
print(f' {skipped} skipped')
if vg_errors > 0:
print(f' {vg_errors} valgrind error(s) found (see logs in {os.path.join(scratchbase, "valgrind-logs")})')
print(f' {vg_errors} valgrind error(s) found (see logs in {scratchbase})')
if expect is not None:
# Version-mixing mode: the run is judged purely on whether each test's

View File

@@ -317,21 +317,6 @@ __attribute__ ((target("sse2"))) MVSTATIC int32 get_checksum1_sse2_32(schar* buf
extern "C" __attribute__ ((target("avx2"))) int32 get_checksum1_avx2_asm(schar* buf, int32 len, int32 i, uint32* ps1, uint32* ps2);
/* The asm routine is AVX2-only and, unlike the multi-versioned intrinsic
* paths, has no compiler-generated fallback, so it must not be called on a
* CPU without AVX2 (it would fault with SIGILL). Gate it on a cached runtime
* check; when AVX2 is absent we skip it and the SSSE3/SSE2/scalar steps,
* which are safe everywhere, do all the work. */
static int roll_asm_have_avx2(void)
{
static int have = -1;
if (have < 0) {
__builtin_cpu_init();
have = __builtin_cpu_supports("avx2") ? 1 : 0;
}
return have;
}
#else /* } { */
/*
@@ -476,8 +461,7 @@ static inline uint32 get_checksum1_cpp(char *buf1, int32 len)
// multiples of 64 bytes using AVX2 (if available)
#ifdef USE_ROLL_ASM
if (roll_asm_have_avx2())
i = get_checksum1_avx2_asm((schar*)buf1, len, i, &s1, &s2);
i = get_checksum1_avx2_asm((schar*)buf1, len, i, &s1, &s2);
#else
i = get_checksum1_avx2_64((schar*)buf1, len, i, &s1, &s2);
#endif
@@ -595,10 +579,7 @@ static uint32 checksum_via_avx2(char *buf, int32 len)
int32 i;
uint32 s1 = 0, s2 = 0;
#ifdef USE_ROLL_ASM
if (roll_asm_have_avx2())
i = get_checksum1_avx2_asm((schar*)buf, len, 0, &s1, &s2);
else
i = 0;
i = get_checksum1_avx2_asm((schar*)buf, len, 0, &s1, &s2);
#else
i = get_checksum1_avx2_64((schar*)buf, len, 0, &s1, &s2);
#endif

View File

@@ -1,21 +0,0 @@
#!/bin/sh
# abdiff helper: a "remote shell" that emulates an sshd forced-command of
# `rrsync DIR`. rsync invokes a remote shell as:
# <shell-words...> [ssh-opts] <host> <rsync --server ...>
# so when used as -e "sh rrsh.sh <RRSYNC> <DIR>" rsync calls us as:
# sh rrsh.sh <RRSYNC> <DIR> [opts] lh rsync --server ...
# We hand the server command to rrsync via SSH_ORIGINAL_COMMAND (exactly as
# sshd would) and exec the restricted wrapper, so abdiff can A/B the rrsync
# path itself. Only the pretend hosts "lh"/"localhost" are accepted.
RRSYNC="$1"; DIR="$2"; shift 2
while [ $# -gt 0 ]; do
case "$1" in
-l) shift 2 ;;
lh|localhost) shift; break ;;
-*) shift ;;
*) break ;;
esac
done
SSH_ORIGINAL_COMMAND="$*"
export SSH_ORIGINAL_COMMAND
exec "$RRSYNC" "$DIR"

View File

@@ -184,53 +184,3 @@ Each target must be provisioned with the build toolchain its workflow installs
(autoconf, automake, a C compiler, perl, a python3 markdown module such as
cmarkgfm or commonmark unless the flags pass `--disable-md2man`, and the dev
libraries its configure flags enable). A missing piece shows up as `BUILD-FAIL`.
## Differential regression hunting (abdiff.py)
`testsuite/abdiff.py` is a developer tool — **not** a `*_test.py`, so `runtests.py`
ignores it. It hunts *regressions* by running the **same benign transfer** with
two rsync binaries (`A` = the build under test, `B` = a baseline) and comparing
the OUTCOME. The oracle is: for a benign input, a correctness/behaviour change
between the builds must be **invisible**, so A and B must produce an identical
result. Any divergence is a regression candidate to investigate and, if real,
minimize into a `*_test.py`.
It compares exit code, stderr (error markers + normalised text), `--stats`
"Literal data", the destination tree (content + full metadata: mode/uid/gid/
mtime/size/symlink target/xattrs/ACLs/hardlink grouping), the `--itemize` list,
and — with `--cost` — peak process-group RSS (a resource-regression oracle that
functional comparison misses). A **stability gate** runs each binary several
times and escalates on a candidate diff; nondeterministic scenarios are
quarantined `FLAKY`, never reported as regressions.
Run it from the build directory (so `./rsync` and `old_versions/` resolve):
```sh
testsuite/abdiff.py # default: ./rsync vs old_versions/rsync_3.4.1
testsuite/abdiff.py --sweep all -j5 # broad single pass, 5-way parallel
testsuite/abdiff.py --loop --timelimit 3600 --cost # hunt for an hour, resource oracle on
testsuite/abdiff.py --list --sweep all # list scenarios without running
```
Each finding is classed `DIFF` (regression candidate), `ALLOW` (an intentional,
documented behaviour change listed in the tool's allowlist), `BETTER` (A succeeds
where B fails), `FLAKY`, or `TIMEOUT`. Findings are printed and appended to a
per-run `abdiff-log_<TIME>.txt` (and the curated `--findings` log).
Key options: `-j N` parallelism; `--sweep NAME|all`; `--loop` (endless
random + systematic-combo stream) bounded by `--timelimit SECS`; `--cost`
(+`--scale N` for the large-tree fixtures); `--repeat N` (stability samples);
`--rsync-a`/`--rsync-b` the two binaries. Run **as root** to fold in the
owner/device/specials/fake-super and chroot-daemon sweeps automatically.
Transport lanes (a feature broken only over the wire is invisible to a local
copy): local, an ssh split (`support/lsh.sh`), a stdio-pipe daemon, a **real TCP
daemon** (bound port + greeting/handshake, and an auth challenge-response
variant), and the restricted **rrsync** wrapper (`support/rrsh.sh`). rrsync's
behaviour ships in the *script*, so pair each binary with its own version's
rrsync via `--rrsync-a`/`--rrsync-b` (give B's rrsync, e.g. one extracted from
that release's `support/rrsync`).
Cross-version baselines are the static binaries already in `old_versions/`;
`old_versions/build_static.sh` builds more from a git tag (and you can grab a
matching `support/rrsync` from the same tag for the rrsync lane).

View File

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@ import shutil
from rsyncfns import (
FROMDIR, SCRATCHDIR, TODIR,
build_rsyncd_conf, check_perms, checkit, makepath, rmtree,
run_rsync, start_test_daemon, test_fail,
build_rsyncd_conf, checkit, makepath, rmtree,
run_rsync, start_test_daemon,
)
@@ -62,37 +62,6 @@ 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,8 +16,37 @@
import os
import subprocess
import sys
from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail
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
mod = SCRATCHDIR / 'module'
@@ -39,13 +68,28 @@ os.symlink('../trap', mod / 'escape_link')
os.chmod(mod / 'topfile', 0o600)
proc = subprocess.run([str(TOOLDIR / 't_chmod_secure'), str(mod)])
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"
)
escaped = sentinel_mode != 0o600
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)")

View File

@@ -5,7 +5,6 @@
# from source to destination (other permission changes ignored), while a
# normal copy without -E should leave the destination permissions alone.
import errno
import os
from rsyncfns import FROMDIR, TODIR, check_perms, run_rsync, test_skipped
@@ -16,14 +15,11 @@ FROMDIR.mkdir(parents=True, exist_ok=True)
(FROMDIR / '2').write_text("#!/bin/sh\necho 'Program Two!'\n")
# Setuid-and-rwx for owner, nothing else. Some platforms reject 1700 for
# non-root callers (no permission to set sticky); FreeBSD rejects it with
# EFTYPE rather than EPERM. Only skip on those; re-raise anything unexpected.
_STICKY_SKIP_ERRNOS = {errno.EPERM, errno.EACCES, getattr(errno, 'EFTYPE', None)}
# non-root callers (no permission to set sticky); the shell test treats
# that case as a skip.
try:
os.chmod(FROMDIR / '1', 0o1700)
except OSError as e:
if e.errno not in _STICKY_SKIP_ERRNOS:
raise
except PermissionError:
test_skipped("Can't chmod")
os.chmod(FROMDIR / '2', 0o600)

View File

@@ -31,11 +31,8 @@ without interfering: each pushes, builds and tests in isolation. The run dir is
removed when the run ends -- on success or failure, and best-effort on
Ctrl-C/kill (pass --keep to retain it for inspection). A run that is hard-killed
(SIGKILL), or signalled mid-push, or whose ssh dies during cleanup can leave a
stray <builddir>-<id> behind -- plus an orphaned path-flipper or test rsyncd on
platforms without a parent-death backstop; sweep all of those (root-owned files
included, via sudo -n) with `fleettest.py --cleanup` (optionally scoped with
--targets). Run --cleanup between runs, not during one: its process kills are
host-global and would also catch a concurrent run's flipper/daemon. Because each
stray <builddir>-<id> behind; sweep those with `fleettest.py --cleanup`
(optionally scoped with --targets). Because each
run starts from a fresh dir, every build is a full configure + build.
PROVISIONING: each target must have the build toolchain its workflow's prepare
@@ -774,94 +771,33 @@ def _on_signal(signum, frame):
os._exit(130 if signum == signal.SIGINT else 143)
# sweep() counts a pattern, kills it (best effort; sudo -n retry for processes a
# root-running test left), then RE-counts after a settle so we report what
# actually died (KILLED = before-after) and flag any survivor (SURVIVED, sets
# fail) instead of claiming success when pkill couldn't reach it. The patterns
# use the pgrep self-exclusion trick -- 'r[e]name'/'det[a]ch' match a real
# process's "rename"/"detach" but not the bracketed literal in this script's own
# argv (run_on passes the whole script as the remote argv), so we never signal
# ourselves. @BASE@ is substituted with the target's run-dir prefix.
_CLEANUP_SCRIPT = r'''fail=0
sweep() {
command -v pgrep >/dev/null 2>&1 || return 0
before=$(pgrep -f "$2" 2>/dev/null | wc -l | tr -d ' ')
[ "$before" -gt 0 ] 2>/dev/null || return 0
pkill -f "$2" 2>/dev/null
sudo -n pkill -f "$2" 2>/dev/null
sleep 1
after=$(pgrep -f "$2" 2>/dev/null | wc -l | tr -d ' ')
killed=$((before - after))
[ "$killed" -gt 0 ] 2>/dev/null && echo "KILLED $killed stray $1(s)"
if [ "$after" -gt 0 ] 2>/dev/null; then
echo "SURVIVED $after stray $1(s)"
fail=1
fi
}
sweep flipper 'r[e]name.*r[e]name.*r[e]name'
sweep daemon 'det[a]ch --address=127.0.0.1'
for d in @BASE@-*; do
[ -e "$d" ] || continue
if rm -rf -- "$d" 2>/dev/null || sudo -n rm -rf -- "$d" 2>/dev/null; then
echo "REMOVED $d"
else
echo "FAILED $d"
fail=1
fi
done
exit $fail
'''
def cleanup_remnants(targets: list[Target]) -> int:
"""--cleanup mode: on each target, kill the stray processes a killed run can
leave behind, then remove every <base>-* run dir, reporting what went.
Returns a process exit code. Only suffixed run dirs are swept -- a bare
<base> is left alone.
A run that is SIGKILLed (or whose ssh drops) can strand two kinds of process
on platforms without a parent-death backstop: the TOCTOU path-flipper (a
busy `python -c` rename loop that pins a CPU) and an orphaned test rsyncd
(`--no-detach --address=127.0.0.1`, which then squats its fixed port -- the
very wedge claim_ports()' bind-probe now reports). Both are killed best
effort (sudo -n retry for root-owned ones); a kill is verified by re-counting
afterwards, and a process that survives is reported and fails the run.
CAVEAT: the kill patterns are host-global, not scoped to a particular run, so
--cleanup assumes no *other* fleettest run is active on the target -- it
would also kill a concurrent run's flipper/daemon (and any manual `rsync
--daemon --no-detach --address=127.0.0.1`). Run it between runs, not during
one. Run dirs whose contents a root test owns are removed via a `sudo -n rm`
fallback; only a dir that survives even that is a failure."""
"""--cleanup mode: remove every <base>-* run dir on each target, reporting
what each removed. Returns a process exit code. Only suffixed run dirs are
swept -- a bare <base> is left alone."""
rc = 0
for t in targets:
base = t.builddir
if _unsafe_builddir(base):
log(f"[{t.name}] skipped (unsafe builddir {base!r})")
continue
# Structured markers (KILLED/SURVIVED/REMOVED/FAILED) keep the report
# clean even though run_on() folds stderr into stdout.
r = run_on(t, _CLEANUP_SCRIPT.replace("@BASE@", base), timeout=120)
lines = r.out.splitlines()
removed = [ln.split(" ", 1)[1] for ln in lines if ln.startswith("REMOVED ")]
failed = [ln.split(" ", 1)[1] for ln in lines if ln.startswith("FAILED ")]
killed = [ln.replace("KILLED ", "killed ", 1)
for ln in lines if ln.startswith("KILLED ")]
survived = [ln.replace("SURVIVED ", "still alive: ", 1)
for ln in lines if ln.startswith("SURVIVED ")]
msgs = killed[:]
if removed:
msgs.append("removed: " + " ".join(removed))
if survived:
# Echo each match before removing it so the harness can report what
# went; an unmatched glob stays literal and is skipped by the -e test.
script = (f'set -e\n'
f'for d in {base}-*; do\n'
f' [ -e "$d" ] || continue\n'
f' echo "$d"\n'
f' rm -rf -- "$d"\n'
f'done\n')
r = run_on(t, script, timeout=120)
removed = [ln for ln in r.out.splitlines() if ln.strip()]
if r.rc != 0:
rc = 1
msgs += survived
if failed:
rc = 1
msgs.append("could not remove (even with sudo): " + " ".join(failed))
if r.rc not in (0, 1):
rc = 1
msgs.append(f"cleanup error rc={r.rc}: {r.out.strip()[:160]}")
log(f"[{t.name}] " + ("; ".join(msgs) if msgs else "nothing to remove"))
log(f"[{t.name}] cleanup error (rc={r.rc}): {r.out.strip()[:200]}")
elif removed:
log(f"[{t.name}] removed: {' '.join(removed)}")
else:
log(f"[{t.name}] nothing to remove")
return rc
@@ -877,10 +813,7 @@ def main() -> int:
ap.add_argument("--keep", action="store_true",
help="keep each run's build dir (default: remove it at exit)")
ap.add_argument("--cleanup", action="store_true",
help="kill stray flippers/test daemons and remove stray "
"<builddir>-* run dirs (root-owned via sudo -n) on the "
"targets, then exit; run between runs, not during one "
"(kills are host-global)")
help="remove stray <builddir>-* run dirs on the targets, then exit")
ap.add_argument("--jobs", type=int, help="override -j for both transports")
ap.add_argument("--timing", action="store_true",
help="report per-target wall-clock (push/build/test) to find "

View File

@@ -10,7 +10,7 @@ import subprocess
import sys
import tempfile
from rsyncfns import make_data_file, cp_p, makepath, checkit, RSYNC, TMPDIR, get_testuid, get_rootuid
from rsyncfns import make_data_file, cp_p, makepath, checkit, run_rsync, test_xfail, RSYNC, TMPDIR, get_testuid, get_rootuid
BASEDIR = TMPDIR
@@ -67,4 +67,15 @@ 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

@@ -1,506 +0,0 @@
#!/usr/bin/env python3
"""Compare the transfer performance of two rsync binaries (local <-> local).
This is a standalone dev tool (run it directly, not via runtests.py) for
spotting performance regressions between rsync releases. Given two rsync
binaries it builds one test tree, then runs the two binaries ALTERNATELY for a
number of loops, timing each transfer, and reports the mean and standard
deviation of the transfer time for each binary.
Two transfers are timed each loop (see --mode):
* full -- a fresh copy into an emptied destination (end-to-end read+write).
* noop -- a re-run against an already-synced destination (rsync's own
scan / file-list / stat overhead, where many regressions hide).
The first measured run of each binary is dropped (see --warmup) because it
cold-loads the source into the page cache and is an outlier.
The test tree's shape (heavy-tailed file sizes, a directory spine, symlinks,
hard links and a spread of permission modes) follows the gentestdata.py
generator; it is deterministic for a given --seed.
Examples:
# Quick smoke run, same binary twice (means should match, no regression).
./perftest.py --files 200 --total-size 5M -n 3 ./rsync ./rsync
# Compare a released binary against a fresh build over 8 loops.
./perftest.py -n 8 ../old_versions/rsync_3.4.0 ./rsync
# Heavier tree, no-op (scan-overhead) timing only.
./perftest.py --files 50000 --total-size 2G --mode noop OLD/rsync NEW/rsync
"""
import argparse
import dataclasses
import math
import os
import random
import shlex
import shutil
import statistics
import struct
import subprocess
import sys
import tempfile
import time
# ---------------------------------------------------------------------------
# Test-tree generation (ported from gentestdata.py, kept self-contained).
# ---------------------------------------------------------------------------
# Marker file at the tree root; safe_rmtree only deletes a tree carrying it.
MARKER = ".perftest"
# Permission modes drawn at random for regular files (execs + read-only).
FILE_MODES = [0o644, 0o644, 0o600, 0o640, 0o664, 0o444, 0o755, 0o750, 0o700]
# Directory modes; owner always keeps r-x so the tree stays traversable.
DIR_MODES = [0o755, 0o755, 0o775, 0o750, 0o700, 0o555]
SIZE_SIGMA = 1.8 # sigma of the underlying lognormal size distribution
BASE_BUF_SIZE = 1 << 20 # 1 MiB shared random buffer for file content
def parse_size(s):
"""Parse a human size like 500M, 1.5GiB, 200KB, or a bare byte count."""
s = s.strip()
units = {
"": 1, "B": 1,
"K": 1024, "KIB": 1024, "KB": 1000,
"M": 1024**2, "MIB": 1024**2, "MB": 1000**2,
"G": 1024**3, "GIB": 1024**3, "GB": 1000**3,
"T": 1024**4, "TIB": 1024**4, "TB": 1000**4,
}
num, suffix = s, ""
while num and not (num[-1].isdigit() or num[-1] == "."):
suffix = num[-1] + suffix
num = num[:-1]
suffix = suffix.upper()
if suffix not in units:
raise argparse.ArgumentTypeError(f"unknown size suffix in {s!r}")
try:
value = float(num)
except ValueError:
raise argparse.ArgumentTypeError(f"invalid size {s!r}")
return int(value * units[suffix])
def human(n):
"""Format a byte count for the summary output."""
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(n) < 1024 or unit == "TiB":
return f"{n:.1f}{unit}" if unit != "B" else f"{n}B"
n /= 1024
def gen_sizes(n, total, rng):
"""Return n heavy-tailed file sizes (bytes) summing to exactly `total`."""
if n == 0:
return []
weights = [math.exp(rng.gauss(0.0, SIZE_SIGMA)) for _ in range(n)]
wsum = sum(weights)
sizes = [int(w / wsum * total) for w in weights]
drift = total - sum(sizes)
if drift and sizes:
i = max(range(n), key=lambda k: sizes[k])
sizes[i] += drift
return sizes
def build_dirs(root, num_dirs, max_depth, rng):
"""Create `num_dirs` dirs under root, up to `max_depth` deep; return them."""
os.makedirs(root)
dirs = [root]
depth_of = {root: 0}
candidates = [root] if max_depth > 0 else []
counter = 0
cur = root
for d in range(1, max_depth + 1):
cur = os.path.join(cur, f"d{d}")
os.mkdir(cur)
dirs.append(cur)
depth_of[cur] = d
if d < max_depth:
candidates.append(cur)
while len(dirs) < num_dirs and candidates:
parent = rng.choice(candidates)
counter += 1
child = os.path.join(parent, f"dir{counter}")
os.mkdir(child)
d = depth_of[parent] + 1
dirs.append(child)
depth_of[child] = d
if d < max_depth:
candidates.append(child)
return dirs
def write_file(path, size, index, base):
"""Write a regular file of exactly `size` bytes (index/size in first 16)."""
with open(path, "wb") as f:
remaining = size
if remaining >= 16:
f.write(struct.pack("<QQ", index, size))
remaining -= 16
blen = len(base)
while remaining > 0:
chunk = base if remaining >= blen else base[:remaining]
f.write(chunk)
remaining -= len(chunk)
def rel_symlink(target, link_path):
"""Create a relative symlink at link_path pointing at target."""
rel = os.path.relpath(target, os.path.dirname(link_path))
os.symlink(rel, link_path)
def safe_rmtree(path):
"""Remove a tree, even one containing read-only directories."""
for dirpath, _dirnames, _filenames in os.walk(path):
try:
os.chmod(dirpath, 0o700)
except OSError:
pass
shutil.rmtree(path)
def generate_tree(root, args):
"""Build the deterministic source tree at `root`; return a summary string."""
n = args.files
num_dirs = args.dirs if args.dirs is not None else max(args.depth, n // 20, 1)
n_sym = args.symlinks if args.symlinks is not None else (max(1, n // 20) if n else 0)
n_hard = args.hardlinks if args.hardlinks is not None else (max(1, n // 20) if n else 0)
rng = random.Random(args.seed)
base = rng.randbytes(BASE_BUF_SIZE)
dirs = build_dirs(root, num_dirs, args.depth, rng)
with open(os.path.join(root, MARKER), "w") as f:
f.write(f"generated by perftest.py seed={args.seed} files={n} "
f"total={args.total_size}\n")
sizes = gen_sizes(n, args.total_size, rng)
files = []
for i in range(n):
path = os.path.join(rng.choice(dirs), f"file{i}.dat")
write_file(path, sizes[i], i, base)
files.append(path)
hard_made = 0
if files:
for i in range(n_hard):
tgt = rng.choice(files)
link = os.path.join(rng.choice(dirs), f"hlink{i}_{os.path.basename(tgt)}")
try:
os.link(tgt, link)
hard_made += 1
except OSError:
pass
sym_made = 0
for i in range(n_sym):
link = os.path.join(rng.choice(dirs), f"sym{i}")
roll = rng.random()
try:
if roll < 0.15 or not files:
os.symlink(f"../broken-target-{i}", link)
elif roll < 0.30:
rel_symlink(rng.choice(dirs), link)
else:
rel_symlink(rng.choice(files), link)
sym_made += 1
except OSError:
pass
for path in files:
os.chmod(path, rng.choice(FILE_MODES))
for path in sorted((d for d in dirs if d != root),
key=lambda p: p.count(os.sep), reverse=True):
os.chmod(path, rng.choice(DIR_MODES))
return (f"files={n} dirs={len(dirs)} symlinks={sym_made} hardlinks={hard_made} "
f"total={human(sum(sizes))} biggest={human(max(sizes) if sizes else 0)} "
f"seed={args.seed}")
# ---------------------------------------------------------------------------
# Benchmark.
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class Binary:
label: str # "A" / "B"
path: str # absolute path to the rsync binary
version: str # first line of `rsync --version`
def rsync_version(path):
"""Return the first line of `<rsync> --version`, or a placeholder."""
try:
r = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15)
line = (r.stdout or r.stderr or "").splitlines()
return line[0].strip() if line else "(no --version output)"
except (OSError, subprocess.TimeoutExpired) as e:
return f"(version unavailable: {e})"
def drop_caches():
"""Best-effort: flush dirty pages and drop the page/dentry/inode caches.
Needs root to write /proc/sys/vm/drop_caches; returns True on success.
"""
subprocess.run(["sync"], check=False)
try:
with open("/proc/sys/vm/drop_caches", "w") as f:
f.write("3\n")
return True
except OSError:
return False
def time_transfer(binary, rsync_args, src, dest, timeout):
"""Run one `rsync <args> src/ dest/` and return its wall-clock seconds.
Raises RuntimeError if rsync exits non-zero (a failed transfer can't be
timed meaningfully).
"""
argv = [binary.path, *rsync_args, src + "/", dest + "/"]
t0 = time.monotonic()
r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
elapsed = time.monotonic() - t0
if r.returncode != 0:
raise RuntimeError(
f"{binary.label} ({binary.path}) rsync exited {r.returncode}:\n"
f" cmd: {shlex.join(argv)}\n"
f" {(r.stderr or r.stdout).strip()}")
return elapsed
def run_benchmark(binaries, args, src, dest_full, dest_noop):
"""Run the alternating loops; return {label: {mode: [all samples]}}."""
do_full = args.mode in ("both", "full")
do_noop = args.mode in ("both", "noop")
# Pre-populate the shared no-op destination so every timed no-op run finds
# nothing to do. Use binary A; its content is identical for B.
if do_noop:
time_transfer(binaries[0], args.rsync_args, src, dest_noop, args.timeout)
samples = {b.label: {m: [] for m in ("full", "noop")} for b in binaries}
total_loops = args.warmup + args.runs
for loop in range(total_loops):
tag = "warmup" if loop < args.warmup else f"run {loop - args.warmup + 1}/{args.runs}"
# Alternate which binary goes first to cancel first-mover/thermal drift.
order = binaries if loop % 2 == 0 else list(reversed(binaries))
for b in order:
if do_full:
safe_rmtree(dest_full) if os.path.exists(dest_full) else None
os.mkdir(dest_full)
if args.drop_caches:
drop_caches()
t = time_transfer(b, args.rsync_args, src, dest_full, args.timeout)
samples[b.label]["full"].append(t)
_progress(b, "full", tag, t)
if do_noop:
if args.drop_caches:
drop_caches()
t = time_transfer(b, args.rsync_args, src, dest_noop, args.timeout)
samples[b.label]["noop"].append(t)
_progress(b, "noop", tag, t)
return samples
def _progress(binary, mode, tag, t):
excl = " (warmup, excluded)" if tag == "warmup" else ""
print(f" [{tag:>10}] {binary.label} {mode:<4} {t:8.3f}s{excl}")
# ---------------------------------------------------------------------------
# Reporting.
# ---------------------------------------------------------------------------
def _stats(times):
"""(n, mean, stddev, min, median) over the timing samples."""
n = len(times)
if n == 0:
return (0, 0.0, 0.0, 0.0, 0.0)
return (n, statistics.mean(times),
statistics.stdev(times) if n > 1 else 0.0,
min(times), statistics.median(times))
def report(binaries, samples, args):
"""Print the per-binary tables and the A-vs-B comparison; return exit code."""
print("\n" + "=" * 72)
for b in binaries:
print(f"{b.label}: {b.path}\n {b.version}")
print(f"rsync args: {' '.join(args.rsync_args)} "
f"(note: a full copy is not fsync'd unless you add --fsync)")
print("=" * 72)
modes = [m for m in ("full", "noop") if any(samples[b.label][m] for b in binaries)]
hdr = f"{'binary':<7}{'mode':<6}{'runs':>5}{'mean':>11}{'stddev':>11}{'min':>11}{'median':>11}"
for mode in modes:
print(f"\n{hdr}\n{'-' * len(hdr)}")
st = {}
for b in binaries:
# Drop the leading warm-up samples before computing statistics.
kept = samples[b.label][mode][args.warmup:]
st[b.label] = _stats(kept)
n, mean, sd, mn, md = st[b.label]
print(f"{b.label:<7}{mode:<6}{n:>5}{mean:>10.3f}s{sd:>10.3f}s"
f"{mn:>10.3f}s{md:>10.3f}s")
a, c = binaries[0].label, binaries[1].label
(na, ma, sda, *_), (nc, mc, sdc, *_) = st[a], st[c]
if na and nc and ma > 0:
delta = mc - ma
pct = delta / ma * 100.0
noise = max(sda, sdc)
# Flag only when B is slower beyond the run-to-run noise and a small
# relative threshold, so jitter doesn't cry "regression".
if delta > noise and pct > args.threshold:
verdict = f"REGRESSION (slower): {c} is {pct:+.1f}% vs {a}"
elif delta < -noise and -pct > args.threshold:
verdict = f"faster: {c} is {pct:+.1f}% vs {a}"
else:
verdict = f"no significant change: {pct:+.1f}% (within noise)"
print(f" {mode}: {a} {ma:.3f}s vs {c} {mc:.3f}s -> {verdict}")
if args.csv:
_write_csv(args.csv, binaries, samples)
print(f"\nraw per-run timings written to {args.csv}")
return 0
def _write_csv(path, binaries, samples):
with open(path, "w") as f:
f.write("binary,path,mode,run,warmup,seconds\n")
for b in binaries:
for mode in ("full", "noop"):
for i, t in enumerate(samples[b.label][mode]):
f.write(f"{b.label},{b.path},{mode},{i},{int(i == 0)},{t:.6f}\n")
# ---------------------------------------------------------------------------
# Main.
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("rsync_a", help="path to the first rsync binary (labelled A)")
ap.add_argument("rsync_b", help="path to the second rsync binary (labelled B)")
ap.add_argument("-n", "--runs", type=int, default=10,
help="measured loops per binary (default: 10)")
ap.add_argument("--warmup", type=int, default=1,
help="leading runs per binary dropped from the stats to "
"reduce cache impact (default: 1)")
ap.add_argument("--mode", choices=("both", "full", "noop"), default="both",
help="full=clean-dest copy, noop=re-sync scan overhead, "
"both (default)")
ap.add_argument("--rsync-args", default="-aH",
help="rsync flags for the timed transfer (default: -aH)")
ap.add_argument("--threshold", type=float, default=2.0,
help="percent slowdown above run-to-run noise before a "
"regression is flagged (default: 2.0)")
# Tree-generation knobs (mirror gentestdata.py).
ap.add_argument("--src", default=None,
help="benchmark this existing tree instead of generating one")
ap.add_argument("-f", "--files", type=int, default=10000,
help="number of regular files to generate (default: 10000)")
ap.add_argument("-s", "--total-size", type=parse_size, default="500M",
help="total size of all regular files (default: 500M)")
ap.add_argument("-d", "--depth", type=int, default=10,
help="maximum directory tree depth (default: 10)")
ap.add_argument("--dirs", type=int, default=None,
help="number of directories (default: max(depth, files/20))")
ap.add_argument("--symlinks", type=int, default=None,
help="number of symlinks (default: files/20)")
ap.add_argument("--hardlinks", type=int, default=None,
help="number of hard links (default: files/20)")
ap.add_argument("--seed", type=int, default=1,
help="PRNG seed for a reproducible tree (default: 1)")
ap.add_argument("--workdir", default=None,
help="scratch root for src/dest dirs (default: a tempdir)")
ap.add_argument("--drop-caches", action="store_true",
help="sync + drop page/dentry/inode caches before each timed "
"run (needs root; cold-cache measurement)")
ap.add_argument("--timeout", type=float, default=3600.0,
help="seconds before a single rsync run is abandoned "
"(default: 3600)")
ap.add_argument("--keep", action="store_true",
help="keep the scratch tree on exit (default: remove it)")
ap.add_argument("--csv", default=None,
help="write raw per-run timings to this CSV file")
args = ap.parse_args()
if args.runs < 2:
ap.error("--runs must be >= 2 (need >=2 samples for a stddev)")
args.rsync_args = shlex.split(args.rsync_args)
binaries = []
for label, p in (("A", args.rsync_a), ("B", args.rsync_b)):
path = os.path.abspath(p)
if not (os.path.isfile(path) and os.access(path, os.X_OK)):
ap.error(f"rsync {label} is not an executable file: {p}")
binaries.append(Binary(label, path, rsync_version(path)))
workdir = tempfile.mkdtemp(prefix="rsync-perftest-",
dir=args.workdir) if not args.keep or not args.workdir \
else os.path.join(args.workdir, "rsync-perftest")
os.makedirs(workdir, exist_ok=True)
dest_full = os.path.join(workdir, "dest_full")
dest_noop = os.path.join(workdir, "dest_noop")
os.makedirs(dest_noop, exist_ok=True)
generated = None
if args.src:
src = os.path.abspath(args.src)
if not os.path.isdir(src):
ap.error(f"--src is not a directory: {args.src}")
print(f"using existing source tree {src}")
else:
src = os.path.join(workdir, "src")
print(f"generating source tree in {src} ...")
t0 = time.monotonic()
summary = generate_tree(src, args)
generated = src
print(f" {summary} ({time.monotonic() - t0:.1f}s)")
print(f"\nbenchmarking: warmup={args.warmup} runs={args.runs} mode={args.mode} "
f"drop_caches={args.drop_caches}\n")
rc = 1
try:
samples = run_benchmark(binaries, args, src, dest_full, dest_noop)
rc = report(binaries, samples, args)
except RuntimeError as e:
print(f"\nbenchmark aborted: {e}", file=sys.stderr)
rc = 2
except KeyboardInterrupt:
print("\ninterrupted", file=sys.stderr)
rc = 130
finally:
if args.keep:
print(f"\nkept scratch tree: {workdir}")
else:
for d in (dest_full, dest_noop, generated):
if d and os.path.exists(d):
safe_rmtree(d)
# Remove the workdir itself if it is now empty (i.e. we made it).
try:
os.rmdir(workdir)
except OSError:
pass
sys.exit(rc)
if __name__ == "__main__":
main()
# vim: sw=4 et ft=python

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_skipped, test_xfail,
)
src = FROMDIR
@@ -103,10 +103,12 @@ 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_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")
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)")
# --- --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

@@ -57,11 +57,8 @@ def peer_client(args, label):
"""Run the OLD client (RSYNC_PEER) and return (sent, received) wire bytes
parsed from rsync's summary line. Fails the test on non-zero exit."""
argv = shlex.split(RSYNC_PEER) + args
# Force C locale: rsync groups the "sent/received N bytes" numbers per the
# locale (e.g. de_DE uses '.' for thousands), which would break parsing.
proc = subprocess.run(argv, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True,
env={**os.environ, 'LC_ALL': 'C'})
stderr=subprocess.STDOUT, text=True)
print(proc.stdout, end='')
if proc.returncode != 0:
test_fail(f"{label}: old client exited {proc.returncode}")

View File

@@ -175,42 +175,6 @@ def _open_lock_file() -> int:
return fd
def _probe_bindable(port: int) -> 'None':
"""Confirm `port` is actually free once we hold its claim_ports() lock.
The byte-range lock only coordinates *live* test drivers, and the kernel
releases it the instant the holding process dies -- even if that driver left
an orphaned daemon still bound to the port. That happens when a run is
SIGKILLed (or its ssh drops) on a platform with no parent-death backstop:
rsyncfns only arms PR_SET_PDEATHSIG, which is Linux-only, so on the
BSDs/Solaris/macOS a killed fleettest run can strand its rsyncd, which then
squats the fixed test port forever. A later run wins the (now-free) lock but
the socket is still taken, and the daemon dies with a cryptic "bind() failed:
Address already in use" / the client "did not see server greeting".
So actually try to bind it. SO_REUSEADDR is used so a port merely in
TIME_WAIT (recently and cleanly closed) is NOT a false positive; only a
live bound/listening socket -- a real squatter -- makes the bind fail, and
then we stop here with an actionable message instead of failing obscurely
later. The probe socket is closed immediately, freeing the port for the
daemon that is about to bind it.
"""
s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
s.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
try:
s.bind(('127.0.0.1', port))
except OSError as e:
test_fail(
f"port {port} was claimed for this run but something is still bound "
f"to 127.0.0.1:{port} ({e.strerror}). The claim_ports() lock only "
"serializes live test runs, so a still-bound port almost always "
"means an orphaned 'rsync --daemon' from a previously killed run "
f"(find it with `fstat | grep {port}` / `netstat -an | grep {port}` "
"and kill it, or run `fleettest.py --cleanup`), then retry.")
finally:
s.close()
def claim_ports(*ports: int) -> 'None':
"""Reserve the given TCP port numbers for the rest of this process.
@@ -246,9 +210,6 @@ def claim_ports(*ports: int) -> 'None':
# F_SETLKW via fcntl.lockf(LOCK_EX, length, start): exclusive
# byte-range lock on byte `port`, blocking until acquired.
fcntl.lockf(_port_lock_fd, fcntl.LOCK_EX, 1, port)
# The lock only proves no other live test run owns the port; an orphaned
# daemon from a killed run can still squat it (see _probe_bindable).
_probe_bindable(port)
# --- standalone rsyncd helpers ---------------------------------------------

View File

@@ -1,48 +0,0 @@
# Valgrind suppressions for the rsync test suite.
#
# Every stanza here is a known-benign report, not a defect in rsync's own
# logic. They are suppressed so that a --valgrind run fails only on a *real*
# error. Pass to valgrind with --suppressions=testsuite/valgrind.supp
# (runtests.py --valgrind does this automatically).
#
# The valgrind CI gate runs with --leak-check=no and so checks memory *errors*
# only: rsync intentionally leaves file-list/socket/option memory unfreed at
# exit, which makes a full leak check inherently noisy. The two Memcheck:Leak
# stanzas below therefore only matter for a manual leak audit (--leak-check=
# full); they cover the two leaks that are not rsync's own at-exit slack.
# popt alias strings are strdup'd while parsing arguments and only reclaimed
# at process exit. One-time, bounded, unreachable-at-exit; bundled popt.
{
rsync-popt-alias-leak
Memcheck:Leak
match-leak-kinds: definite
fun:malloc
fun:my_alloc
fun:my_strdup
fun:popt_unalias
fun:parse_arguments
fun:main
}
# libxxhash returns an internally-aligned XXH3 state whose pointer is offset
# from the malloc base, so valgrind sees only an interior pointer and reports
# the one-time checksum state as "possibly lost". Alignment artifact.
{
xxhash-createstate-aligned
Memcheck:Leak
match-leak-kinds: possible
fun:malloc
fun:XXH3_createState
}
# libfakeroot's own SysV message padding in send_fakem(); the uninitialised
# bytes are inside libfakeroot's mtext buffer, not rsync memory.
{
fakeroot-msgsnd-padding
Memcheck:Param
msgsnd(msgp->mtext)
fun:msgsnd
...
obj:*libfakeroot*
}

12
token.c
View File

@@ -292,10 +292,14 @@ static int32 simple_recv_token(int f, char **data)
int32 i = read_int(f);
if (i <= 0)
return i;
/* A literal run may exceed CHUNK_SIZE: some peers (e.g. the
* acrosync library) use a 64k block size. The loop below reads
* the run CHUNK_SIZE bytes at a time, so read_buf never writes
* past the static CHUNK_SIZE buffer regardless of i. */
/* simple_send_token caps each literal chunk at CHUNK_SIZE;
* reject anything larger so a hostile peer cannot drive the
* read_buf below past our static CHUNK_SIZE buffer. */
if (i > CHUNK_SIZE) {
rprintf(FERROR, "invalid uncompressed token length %ld [%s]\n",
(long)i, who_am_i());
exit_cleanup(RERR_PROTOCOL);
}
residue = i;
}

View File

@@ -163,17 +163,14 @@ main(int argc, char **argv)
flag[i] = 0;
else
flag[i] = -1;
if (!*s || (*++s != ' ' && *s != '\t'))
if (*++s != ' ' && *s != '\t')
flag[i] = -1;
if (flag[i] < 0) {
fprintf(stderr, "Invalid flag syntax on line %d of %s:\n%s",
line, *argv, buf);
exit(1);
}
if (*s)
s++;
while (*s == ' ' || *s == '\t')
s++;
while (*++s == ' ' || *s == '\t') {}
}
for (i = 0; i <= 1; i++) {
if (*s == '\'' || *s == '"' || *s == '`') {
@@ -197,10 +194,7 @@ main(int argc, char **argv)
while (*++s && *s != ' ' && *s != '\t' && *s != '\n') {}
end[i] = s;
}
if (*s)
s++;
while (*s == ' ' || *s == '\t')
s++;
while (*++s == ' ' || *s == '\t') {}
}
*end[0] = *end[1] = '\0';
run_test(line, flag[0],

View File

@@ -295,12 +295,8 @@ static int rsync_xal_get(const char *fname, item_list *xalp)
rxa = xalp->items;
if (count > 1)
qsort(rxa, count, sizeof (rsync_xa), rsync_xal_compare_names);
/* Guard count==0: rxa is then xalp->items (possibly NULL) and the
* "rxa += count-1" init would form NULL-1 (undefined). */
if (count) {
for (rxa += count-1; count; count--, rxa--)
rxa->num = count;
}
for (rxa += count-1; count; count--, rxa--)
rxa->num = count;
return 0;
}
@@ -385,19 +381,17 @@ static int64 xattr_lookup_hash(const item_list *xalp)
{
const rsync_xa *rxas = xalp->items;
size_t i;
/* Accumulate unsigned: the summed hash values routinely overflow a
* signed int64 (UB), and we only care about the resulting bit pattern. */
uint64_t key = (uint64_t)hashlittle2(&xalp->count, sizeof xalp->count);
int64 key = hashlittle2(&xalp->count, sizeof xalp->count);
for (i = 0; i < xalp->count; i++) {
key += (uint64_t)hashlittle2(rxas[i].name, rxas[i].name_len);
key += hashlittle2(rxas[i].name, rxas[i].name_len);
if (rxas[i].datum_len > MAX_FULL_DATUM)
key += (uint64_t)hashlittle2(rxas[i].datum, xattr_sum_len);
key += hashlittle2(rxas[i].datum, xattr_sum_len);
else
key += (uint64_t)hashlittle2(rxas[i].datum, rxas[i].datum_len);
key += hashlittle2(rxas[i].datum, rxas[i].datum_len);
}
return (int64)key;
return key;
}
static int find_matching_xattr(const item_list *xalp)
@@ -466,9 +460,7 @@ static int rsync_xal_store(item_list *xalp)
* entire initial-count, not just enough space for one new item. */
*new_list = empty_xa_list;
(void)EXPAND_ITEM_LIST(&new_list->xa_items, rsync_xa, xalp->count);
/* xalp->items is NULL for an empty list; memcpy(dst, NULL, 0) is UB. */
if (xalp->count)
memcpy(new_list->xa_items.items, xalp->items, xalp->count * sizeof (rsync_xa));
memcpy(new_list->xa_items.items, xalp->items, xalp->count * sizeof (rsync_xa));
new_list->xa_items.count = xalp->count;
xalp->count = 0;