- name: info
run: bash -c '/usr/local/bin/rsync --version'
- name: check
- run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check'
+ # The fake-super / xattr tests (chown-fake, devices-fake, xattrs,
+ # xattrs-hlink) skip on Cygwin: the Python rewrite currently does the
+ # xattr operations via os.setxattr, which CPython only provides on
+ # Linux. (Restoring non-Linux xattr coverage is a follow-up.)
+ run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chown-fake,chown,daemon-chroot-acl,devices-fake,devices,dir-sgid,open-noatime,protected-regular,proxy-response-line-too-long,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis,xattrs-hlink,xattrs 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.
# Baseline copy of source.
checkit(['-avv', f'{FROMDIR}/', f'{checkdir}/'], FROMDIR, checkdir)
+# Pin umask to 002 for the rest of the test and DO NOT restore it: rsync's
+# --chmod `D+w` honours the process umask, so the expected tree (built just
+# below) and the rsync run that follows must use the same umask -- exactly
+# as the shell test did (it set `umask 002` and left it in effect). Without
+# this the test fails under a different ambient umask (e.g. 077).
+os.umask(0o002)
+
# Manually apply the mode transform that --chmod ug-s,a+rX,D+w should
# produce on the destination, then verify rsync's transform matches.
-old_umask = os.umask(0o002)
-try:
- for entry in checkdir.iterdir():
- # ug-s,a+rX: clear setuid/setgid; add r everywhere; add x where
- # any existing x or the entry is a dir.
- st = entry.stat()
- mode = st.st_mode & ~0o6000
- mode |= 0o444 # a+r
- if entry.is_dir() or (st.st_mode & 0o111):
- mode |= 0o111 # a+X
- os.chmod(entry, mode)
- # `chmod +w` with no explicit who: adds w for every category not
- # masked by the current umask. Under umask 002 that's u+w AND g+w.
- plus_w = 0o222 & ~0o002
- for d in (checkdir, checkdir / 'dir1', checkdir / 'dir2'):
- st = d.stat()
- os.chmod(d, st.st_mode | plus_w)
-finally:
- os.umask(old_umask)
+for entry in checkdir.iterdir():
+ # ug-s,a+rX: clear setuid/setgid; add r everywhere; add x where
+ # any existing x or the entry is a dir.
+ st = entry.stat()
+ mode = st.st_mode & ~0o6000
+ mode |= 0o444 # a+r
+ if entry.is_dir() or (st.st_mode & 0o111):
+ mode |= 0o111 # a+X
+ os.chmod(entry, mode)
+# `chmod +w` with no explicit who: adds w for every category not masked by
+# the current umask. Under umask 002 that's u+w AND g+w.
+plus_w = 0o222 & ~0o002
+for d in (checkdir, checkdir / 'dir1', checkdir / 'dir2'):
+ st = d.stat()
+ os.chmod(d, st.st_mode | plus_w)
checkit(['-avv', '--chmod', 'ug-s,a+rX,D+w', f'{FROMDIR}/', f'{TODIR}/'],
checkdir, TODIR)
)
if expected_modules not in out:
test_fail("module list via lsh.sh did not contain the expected modules")
+# test-hidden is `list = no`; it must NOT appear in the module listing.
+if 'test-hidden' in out:
+ print(out)
+ test_fail("module list via lsh.sh leaked the `list = no` test-hidden module")
print('====')
# Same module list via the test daemon (pipe transport by default; real
out = run_and_check(['-v', f'{daemon_url}/'], expected_modules, "module list via daemon")
if expected_modules not in out:
test_fail("module list via daemon did not contain the expected modules")
+# test-hidden is `list = no`; it must NOT appear in the module listing.
+if 'test-hidden' in out:
+ print(out)
+ test_fail("module list via daemon leaked the `list = no` test-hidden module")
print('====')
# test-hidden: a recursive listing of the module, with file/dir/date
import shlex
import shutil
import socket as _socket
+import stat
import subprocess
import sys
import time
TOOLDIR = Path(_required('TOOLDIR'))
SUITEDIR = Path(os.environ.get('suitedir', SRCDIR / 'testsuite'))
+# rsync.fns set `umask 022` for every shell test, so the suite's expected
+# file/dir modes are computed against that baseline. Mirror it here so the
+# Python tests are deterministic regardless of the caller's ambient umask
+# (e.g. a CI runner with umask 077) -- several permission tests depend on
+# newly-created dirs being 0755. Individual tests may still narrow it (e.g.
+# chmod-option uses 002 for its --chmod comparison).
+os.umask(0o022)
+
# rsync.fns overrides HOME to $scratchdir; tests that exercise ssh-style
# transfers with no path component (e.g. localhost: at end of args) rely on
# HOME pointing at the per-test scratch dir.
_port_lock_fd = None
+def _open_lock_file() -> int:
+ """Open (or create) the host-wide port-lock file, defending against a
+ local attacker who pre-plants the well-known /tmp path. CI runs some
+ tests under sudo, so we must never let root open/chmod an attacker-
+ controlled target.
+
+ Strategy:
+ * Try an O_EXCL|O_CREAT create. If we win, the file is brand-new,
+ regular, owned by us and nlink==1 -- the ONLY case where we widen
+ the mode to 0o666 (so a second user sharing the lock can open it
+ RDWR; the create mode is otherwise narrowed by umask).
+ * If it already exists, open it WITHOUT O_CREAT, WITHOUT chmod, and
+ with O_NOFOLLOW so a planted symlink fails (ELOOP) rather than
+ being followed. Then require a pristine regular file with nlink==1,
+ rejecting a hard link to some other (e.g. root-owned 0600) file --
+ O_NOFOLLOW alone does not catch hard links.
+ """
+ nofollow = getattr(os, 'O_NOFOLLOW', 0)
+
+ # Path 1: we create it ourselves, exclusively.
+ try:
+ fd = os.open(_PORT_LOCK_PATH,
+ os.O_CREAT | os.O_EXCL | os.O_RDWR | nofollow, 0o666)
+ except FileExistsError:
+ fd = None
+ if fd is not None:
+ try:
+ os.fchmod(fd, 0o666) # we own this fresh file; undo umask
+ except OSError:
+ pass
+ return fd
+
+ # Path 2: it already exists -- open without creating or chmod'ing.
+ try:
+ fd = os.open(_PORT_LOCK_PATH, os.O_RDWR | nofollow)
+ except OSError as e:
+ test_fail(f"cannot open lock file {_PORT_LOCK_PATH}: {e} "
+ "(refusing to follow a symlink -- possible tampering)")
+ st = os.fstat(fd)
+ if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
+ os.close(fd)
+ test_fail(f"lock file {_PORT_LOCK_PATH} is not a pristine regular "
+ f"file (type/nlink check failed -- possible tampering)")
+ return fd
+
+
def claim_ports(*ports: int) -> 'None':
"""Reserve the given TCP port numbers for the rest of this process.
"""
global _port_lock_fd
if _port_lock_fd is None:
- _port_lock_fd = os.open(
- _PORT_LOCK_PATH,
- os.O_CREAT | os.O_RDWR,
- 0o666,
- )
- # The mode arg to os.open is masked by umask; on a runner with a
- # restrictive umask the lock file ends up 0o644, and a second user
- # sharing the machine can't open it RDWR. Force 0o666 explicitly.
- # EPERM is fine: we're not the owner and the bits were already
- # broad enough that the first owner's create satisfied us.
- try:
- os.fchmod(_port_lock_fd, 0o666)
- except PermissionError:
- pass
+ _port_lock_fd = _open_lock_file()
for port in sorted(ports):
# F_SETLKW via fcntl.lockf(LOCK_EX, length, start): exclusive
# byte-range lock on byte `port`, blocking until acquired.