]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: regression for the receiver discard-path NULL deref
authorpterror <pterrorbird@gmail.com>
Fri, 5 Jun 2026 07:24:13 +0000 (17:24 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Sat, 6 Jun 2026 08:56:51 +0000 (18:56 +1000)
Drives a real sender<->receiver pair (client sender -> daemon receiver,
both the binary under test in the default pipe transport) so the receiver
actually takes the recv_files discard path -- a local `rsync a b` does
not. The basis and source share a leading block so the generator emits
real sums and the receiver gets a block MATCH; the destination directory
is made unwritable so the receiver's output mkstemp() fails and it
discards the delta. Pre-fix the receiver SIGSEGVs in full_fname(NULL),
which the client sees as a protocol-data-stream error (code 12); post-fix
it drains the delta and reports a benign code 23 (or 0).

Skips (exit 77) when run as root, since root bypasses DAC and the
unwritable destination would not make mkstemp() fail -- so the discard
path, and the bug, would never be reached.

Verified red-on-buggy / green-on-fixed against the 0d0399bb receiver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
testsuite/recv-discard-nullderef_test.py [new file with mode: 0755]

diff --git a/testsuite/recv-discard-nullderef_test.py b/testsuite/recv-discard-nullderef_test.py
new file mode 100755 (executable)
index 0000000..a091957
--- /dev/null
@@ -0,0 +1,123 @@
+#!/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). Pre-fix this is
+# code 12 (error in rsync protocol data stream); post-fix the receiver drains
+# the delta and reports a benign "could not transfer" (code 23), or succeeds.
+#
+# rsync's own exit codes are all < 128, so we can't read the receiver's signal
+# directly from the client. The discriminator is the PROTOCOL error: only a
+# crashed (or otherwise vanished) receiver produces code 12 here. A clean
+# discard yields 23 (file not transferred) or 0.
+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 not in (0, 23):
+    test_fail(f"unexpected rsync exit {rc} (expected 0 or 23, a benign "
+              "discard; 12 would be the crash)")
+
+print(f"OK: receiver discarded the delta without crashing (rsync exit {rc})")