diff --git a/meta/conf/distro/include/ptest-packagelists.inc b/meta/conf/distro/include/ptest-packagelists.inc
index 11a894a..31cb1b3 100644
--- a/meta/conf/distro/include/ptest-packagelists.inc
+++ b/meta/conf/distro/include/ptest-packagelists.inc
@@ -139,6 +139,7 @@ PTESTS_SLOW = "\
     python3-cryptography \
     python3-numpy \
     python3-xmltodict \
+    rsync \
     strace \
     tar \
     tcl \
diff --git a/meta/recipes-devtools/rsync/files/run-ptest b/meta/recipes-devtools/rsync/files/run-ptest
new file mode 100644
index 0000000..50f0641
--- /dev/null
+++ b/meta/recipes-devtools/rsync/files/run-ptest
@@ -0,0 +1,23 @@
+#!/bin/sh
+cd "$(dirname "$0")"
+
+# rsync's runtests.sh expects (per upstream Makefile.in installcheck target):
+#   rsync_bin   path to rsync to test (we use the installed one)
+#   srcdir      directory containing the testsuite/ subdir
+#   TOOLDIR     directory containing the test helper binaries (tls, ...)
+# runtests.sh also sources ./shconfig (generated by configure at build time).
+#
+# rsync emits "PASS    name", "FAIL    name", "SKIP    name (reason)",
+# "XFAIL   name"; ptest-runner expects "PASS: name" / "FAIL: name" /
+# "SKIP: name". Transform on the fly. XFAIL is rsync's "expected fail"
+# and is counted as failure upstream, but it is a known-bad test and not
+# a regression, so report it as SKIP here so ptest does not fail on it.
+POSIXLY_CORRECT=1 \
+rsync_bin="$(command -v rsync)" \
+srcdir="$(pwd)" \
+TOOLDIR="$(pwd)" \
+./runtests.sh 2>&1 | sed \
+    -e 's/^PASS    \(.*\)/PASS: \1/' \
+    -e 's/^FAIL    \(.*\)/FAIL: \1/' \
+    -e 's/^XFAIL   \(.*\)/SKIP: \1 (xfail)/' \
+    -e 's/^SKIP    \(.*\)/SKIP: \1/'
diff --git a/meta/recipes-devtools/rsync/rsync_3.4.1.bb b/meta/recipes-devtools/rsync/rsync_3.4.1.bb
index 697cdee..8b3c563 100644
--- a/meta/recipes-devtools/rsync/rsync_3.4.1.bb
+++ b/meta/recipes-devtools/rsync/rsync_3.4.1.bb
@@ -16,12 +16,13 @@ SRC_URI = "https://download.samba.org/pub/${BPN}/src/${BP}.tar.gz \
            file://determism.patch \
            file://0001-Add-missing-prototypes-to-function-declarations.patch \
            file://CVE-2025-10158.patch \
+           file://run-ptest \
            "
 SRC_URI[sha256sum] = "2924bcb3a1ed8b551fc101f740b9f0fe0a202b115027647cf69850d65fd88c52"
 
 # Out-of-tree builds don't install the documentation currently
 # https://github.com/RsyncProject/rsync/issues/846
-inherit autotools-brokensep
+inherit autotools-brokensep ptest
 
 PACKAGECONFIG ??= "acl attr \
     ${@bb.utils.filter('DISTRO_FEATURES', 'ipv6', d)} \
@@ -63,4 +64,65 @@ do_install:append() {
 	install -m 0644 ${UNPACKDIR}/rsyncd.conf ${D}${sysconfdir}
 }
 
+# runtests.sh invokes these helper binaries from $TOOLDIR; upstream only builds
+# them via the make check/installcheck targets, so build them explicitly here.
+# CHECK_SYMLINKS are test variants that upstream's Makefile also creates only
+# for the check targets, so build them alongside the helpers to keep this list
+# in sync with upstream automatically on future rsync upgrades.
+RSYNC_PTEST_HELPERS = "tls getgroups getfsdev testrun trimslash t_unsafe wildtest"
+RSYNC_PTEST_CHECK_SYMLINKS = "testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test"
+
+# wildtest.c in 3.4.1 declares `typedef char bool;` which collides with the
+# native bool keyword added in C23. Pin to gnu17 for the helper compile so
+# upstream builds cleanly against gcc 15+ defaults. rsync's Makefile does
+# not set per-target CFLAGS for the helper binaries, so overriding CFLAGS
+# on the oe_runmake command line is safe here (nothing to preserve).
+do_compile_ptest() {
+	oe_runmake ${RSYNC_PTEST_HELPERS} CFLAGS="${CFLAGS} -std=gnu17"
+	oe_runmake ${RSYNC_PTEST_CHECK_SYMLINKS}
+}
+
+PTEST_BUILD_HOST_FILES += "shconfig"
+
+do_install_ptest() {
+	install -d ${D}${PTEST_PATH}/testsuite
+	install -d ${D}${PTEST_PATH}/support
+	install -m 0755 ${S}/runtests.sh ${D}${PTEST_PATH}/
+	install -m 0644 ${B}/shconfig ${D}${PTEST_PATH}/
+	# runtests.sh greps config.h for HAVE_LUTIMES / CHOWN_MODIFIES_SYMLINK to
+	# derive TLS_ARGS. Without config.h those stay empty and some symlink-
+	# and lutime-sensitive tests fail spuriously.
+	install -m 0644 ${B}/config.h ${D}${PTEST_PATH}/
+	cp -a ${S}/testsuite/. ${D}${PTEST_PATH}/testsuite/
+	# Tests consume a handful of named source leaves via $srcdir:
+	#   *.c          - hands_setup uses `cat $srcdir/*.c` as a text corpus
+	#   rsync.h      - mkpath.test, itemize.test
+	#   configure.ac - itemize.test
+	#   config.sub   - itemize.test
+	#   wildtest.txt - wildmatch.test
+	# (Enumerated explicitly rather than globbed so future rsync releases
+	# that add/rename top-level files don't silently change the ptest
+	# package contents.)
+	install -m 0644 ${S}/*.c ${D}${PTEST_PATH}/
+	install -m 0644 ${S}/rsync.h ${D}${PTEST_PATH}/
+	install -m 0644 ${S}/configure.ac ${D}${PTEST_PATH}/
+	install -m 0644 ${S}/config.sub ${D}${PTEST_PATH}/
+	install -m 0644 ${S}/wildtest.txt ${D}${PTEST_PATH}/
+	# Only support/lsh.sh is referenced by the testsuite (as an RSYNC_RSH
+	# wrapper that emulates ssh-to-localhost via sh).
+	install -m 0755 ${S}/support/lsh.sh ${D}${PTEST_PATH}/support/
+	for prog in ${RSYNC_PTEST_HELPERS}; do
+		install -m 0755 ${B}/${prog} ${D}${PTEST_PATH}/
+	done
+	# shconfig hardcodes SHELL_PATH + FAKEROOT_PATH from the build host;
+	# retarget SHELL_PATH to /bin/sh (buildpaths QA) and blank out
+	# FAKEROOT_PATH so tests that guard with `[ -e "$FAKEROOT_PATH" ]`
+	# skip cleanly on target instead of resolving a build-host path.
+	sed -i -e 's|^SHELL_PATH=.*|SHELL_PATH="/bin/sh"|' \
+	       -e 's|^FAKEROOT_PATH=.*|FAKEROOT_PATH=""|' \
+	       ${D}${PTEST_PATH}/shconfig
+}
+
+RDEPENDS:${PN}-ptest += "bash coreutils findutils sed grep diffutils"
+
 BBCLASSEXTEND = "native nativesdk"
