]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: post-review fixes and lock-file hardening
authorAndrew Tridgell <andrew@tridgell.net>
Thu, 21 May 2026 04:14:13 +0000 (14:14 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Fri, 22 May 2026 04:34:52 +0000 (14:34 +1000)
  * chmod-option: pin umask to the suite-wide 022 baseline (mirroring the
    old rsync.fns) so rsync's --chmod `D+w` is computed and applied under
    the same umask -- fixes failures under a different ambient umask (077).
  * daemon module-list test: assert the `list = no` module does NOT leak
    into the listing (the substring check alone missed regressions).
  * claim_ports() lock file: open with O_NOFOLLOW and only fchmod a file we
    O_EXCL-created, rejecting a symlink OR hard link planted at the
    well-known /tmp path -- which, with the TCP tests running under sudo in
    CI, could otherwise chmod an arbitrary root-owned target. Require a
    pristine (regular, nlink==1) file.
  * CI: extend the Linux/Cygwin expected-skip lists for the gated tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.github/workflows/cygwin-build.yml
testsuite/chmod-option_test.py
testsuite/daemon_test.py
testsuite/rsyncfns.py

index 7f766f07fb7f77d05443c170b959c092e2728d47..268a4792f00ca10ffc09c0bd7b1570044cf3d049 100644 (file)
@@ -39,7 +39,11 @@ jobs:
     - 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.
index b16e7816a0a723dcbb842a38994b50b44ed3ca07..57be6486da451df9df951b03ac726adde406022f 100644 (file)
@@ -34,27 +34,30 @@ os.chmod(FROMDIR / 'dir2', 0o770)
 # 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)
index 2dcee1a10be5bd76a13b7bd15392836d4ae92c53..9643e941dc685dd1c35df155ef7809ca6d47fadc 100644 (file)
@@ -85,6 +85,10 @@ out = run_and_check(
 )
 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
@@ -94,6 +98,10 @@ daemon_url = start_test_daemon(conf, DAEMON_PORT).rstrip('/')
 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
index 95029ff1bc0efd25d1d22a74bba3684202e02b9f..375d0d4afc0cd5bf4d0c4d75286fa17296c57e34 100644 (file)
@@ -23,6 +23,7 @@ import os
 import shlex
 import shutil
 import socket as _socket
+import stat
 import subprocess
 import sys
 import time
@@ -47,6 +48,14 @@ SRCDIR = Path(_required('srcdir'))
 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.
@@ -107,6 +116,52 @@ _PORT_LOCK_PATH = '/tmp/rsync_test.lck'
 _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.
 
@@ -137,20 +192,7 @@ def claim_ports(*ports: int) -> 'None':
     """
     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.