mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-06-08 22:26:01 -04:00
Compare commits
3 Commits
v34-stable
...
fix/chmod-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b68225e5 | ||
|
|
b2fc33868f | ||
|
|
7371c898e4 |
2
.github/workflows/almalinux-8-build.yml
vendored
2
.github/workflows/almalinux-8-build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/cygwin-build.yml
vendored
2
.github/workflows/cygwin-build.yml
vendored
@@ -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.
|
||||
|
||||
2
.github/workflows/macos-build.yml
vendored
2
.github/workflows/macos-build.yml
vendored
@@ -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.
|
||||
|
||||
6
.github/workflows/ubuntu-22.04-build.yml
vendored
6
.github/workflows/ubuntu-22.04-build.yml
vendored
@@ -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.
|
||||
|
||||
34
.github/workflows/ubuntu-build.yml
vendored
34
.github/workflows/ubuntu-build.yml
vendored
@@ -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
|
||||
|
||||
15
Makefile.in
15
Makefile.in
@@ -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
95
chmod.c
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
34
receiver.c
34
receiver.c
@@ -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)) {
|
||||
|
||||
30
rsync.1.md
30
rsync.1.md
@@ -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.
|
||||
|
||||
|
||||
14
runtests.py
14
runtests.py
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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})")
|
||||
Reference in New Issue
Block a user