From b216dafa640a3d974fffecfb279ea65313f1e101 Mon Sep 17 00:00:00 2001 From: Matt Robinson Date: Tue, 26 May 2026 19:54:30 +0100 Subject: [PATCH 1/5] Run tests under Android Emulator if appropriate Adjust the check target so that if we are cross-compiling and targeting Android, instead of calling runtests.py directly we call a new test-android helper script. If Android Emulator is running, test-android: * Downloads and installs the latest version of Termux (if Termux is not already installed), launches it (to trigger the bootstrapping process) * Installs python (if not already installed) * Transfers the necessary files and then runs runtests.py within the emulator. --- Makefile.in | 7 ++++++- configure.ac | 2 ++ test-android | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100755 test-android diff --git a/Makefile.in b/Makefile.in index bde2c5897..17787ff4b 100644 --- a/Makefile.in +++ b/Makefile.in @@ -353,7 +353,12 @@ COVERAGE_EXCLUDE = -e '(^|/)zlib/' -e '(^|/)popt/' \ .PHONY: check check: all $(CHECK_PROGS) $(CHECK_SYMLINKS) - $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) + @case "@cross_compiling@-@host_os@" in \ + yes-*android*) \ + $(srcdir)/test-android -j $(CHECK_J) ;; \ + *) \ + $(srcdir)/runtests.py --rsync-bin=`pwd`/rsync$(EXEEXT) -j $(CHECK_J) ;; \ + esac .PHONY: check29 check29: all $(CHECK_PROGS) $(CHECK_SYMLINKS) diff --git a/configure.ac b/configure.ac index 4faab5fcb..2428df641 100644 --- a/configure.ac +++ b/configure.ac @@ -1311,6 +1311,8 @@ AC_SUBST(MAKE_RRSYNC) AC_SUBST(MAKE_RRSYNC_1) AC_SUBST(GEN_RRSYNC) AC_SUBST(MAKE_MAN) +AC_SUBST(cross_compiling) +AC_SUBST(host_os) AC_CHECK_FUNCS(_acl __acl _facl __facl) ################################################# diff --git a/test-android b/test-android new file mode 100755 index 000000000..f282e790f --- /dev/null +++ b/test-android @@ -0,0 +1,46 @@ +#!/bin/sh -e + +# Copyright © 2026 Matt Robinson +# +# SPDX-License-Identifier: GPL-3.0-or-later + +if [ $# -eq 0 ]; then + echo 'A list of helper names was expected' >&2 + exit 1 +fi + +if ! adb -e get-state > /dev/null 2>&1; then + echo 'Unable to find a running Android emulator' >&2 + exit 1 +fi + +if [ -z "$(adb shell pm list packages com.termux)" ]; then + tempdir=$(mktemp -d) + trap 'rm -rf "$tempdir"' EXIT + + gh release download --repo termux/termux-app --dir "$tempdir" \ + --pattern 'termux-app_v*-debug_x86_64.apk' + adb install "$tempdir"/termux-app_*.apk + + # Launch Termux so that it bootstraps the environment + adb shell monkey -p com.termux 1 + + # termux.properties appears to be created at the end of the bootstrapping + until adb shell run-as com.termux \ + test -f files/home/.termux/termux.properties; do + sleep 1 + done +fi + +tar -c --exclude-vcs --exclude-backup --exclude="*.o" . | \ + adb shell run-as com.termux sh -c "'cat > files/home/transfer.tar'" + +adb shell run-as com.termux files/usr/bin/bash -le < /dev/null || pkg install -y --no-install-recommends python +tar -xf transfer.tar +python ./runtests.py "$@" +EOF From 3b044c1f8aaf65f2588868dc889a3f1b42fe0410 Mon Sep 17 00:00:00 2001 From: Matt Robinson Date: Tue, 26 May 2026 20:42:00 +0100 Subject: [PATCH 2/5] Run Android tests as part of CI workflow Add x86_64 to the Android build job matrix and add a couple of steps just for this target arch to run Android within an emulator (using the reactivecircus/android-emulator-runner action) and run the test suite within it. --- .github/workflows/android-static-build.yml | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android-static-build.yml b/.github/workflows/android-static-build.yml index 57a318829..7a53e3963 100644 --- a/.github/workflows/android-static-build.yml +++ b/.github/workflows/android-static-build.yml @@ -1,13 +1,11 @@ -name: Build static rsync for Android +name: Test rsync on Android # Cross-compiles statically-linked rsync binaries with the Android NDK, # suitable for dropping onto a phone (adb push / Termux) with no shared # libraries. arm64-v8a covers all modern phones; armeabi-v7a covers older # 32-bit devices. The binaries are uploaded as workflow artifacts. # -# These are cross-compiled, so the test suite can't run here; we sanity -# check that each binary is the right architecture, is static, and that -# it executes (`--version`) under qemu-user. +# Run the test suite within Termux under the Android Emulator. on: push: @@ -43,6 +41,8 @@ jobs: - abi: armeabi-v7a # older 32-bit phones triple: armv7a-linux-androideabi qemu: qemu-arm-static + - abi: x86_64 + triple: x86_64-linux-android steps: - uses: actions/checkout@v4 with: @@ -89,6 +89,7 @@ jobs: "$STRIP" rsync - name: Verify binary + if: matrix.abi != 'x86_64' shell: bash run: | set -euo pipefail @@ -102,6 +103,24 @@ jobs: ${{ matrix.qemu }} ./rsync --version | head -3 || \ echo "WARNING: qemu smoke test did not run cleanly (check on a real device)" + - name: Enable hardware acceleration for emulator + if: matrix.abi == 'x86_64' + run: echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", + OPTIONS+="static_node=kvm"' + | sudo tee /etc/udev/rules.d/99-kvm4all.rules; + sudo udevadm control --reload-rules; + sudo udevadm trigger --name-match=kvm + + - name: Run Tests + if: matrix.abi == 'x86_64' + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a + with: + api-level: 30 + arch: x86_64 + script: make check + env: + GH_TOKEN: ${{ github.token }} + - name: Package shell: bash run: | From 0fdc70d97c7f19e90b270278bb4e1f786c7d6943 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 22 May 2026 15:33:41 +1000 Subject: [PATCH 3/5] testsuite: skip hard-link tests where hard links are unavailable Termux's Python is built without os.link, and Android app storage rejects link(2) outright, so tests that build hard links crashed with AttributeError instead of skipping. Add hardlinks_supported() (a cached probe) and make_hardlink() (raises OSError, never AttributeError) to rsyncfns.py. The dedicated hardlinks test now skips cleanly; hands and relative guard their incidental hard links (the rest of each test still runs); itemize skips (its expected itemized output assumes the link). Co-Authored-By: Claude Opus 4.7 (1M context) --- testsuite/hands_test.py | 10 ++++++--- testsuite/hardlinks_test.py | 10 ++++----- testsuite/itemize_test.py | 7 ++++++- testsuite/relative_test.py | 9 +++++--- testsuite/rsyncfns.py | 41 +++++++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/testsuite/hands_test.py b/testsuite/hands_test.py index b693cb0a7..171783bc1 100644 --- a/testsuite/hands_test.py +++ b/testsuite/hands_test.py @@ -10,7 +10,8 @@ import os import shutil -from rsyncfns import FROMDIR, TMPDIR, TODIR, checkit, hands_setup, run_rsync +from rsyncfns import (FROMDIR, TMPDIR, TODIR, checkit, hands_setup, + hardlinks_supported, run_rsync) hands_setup() @@ -23,8 +24,11 @@ checkit(['-av', f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR) # 2. hard links — link filelist into dir/ then transfer with -H so the -# receiver should recreate the link relationship. -os.link(FROMDIR / 'filelist', FROMDIR / 'dir' / 'filelist') +# receiver should recreate the link relationship. Skip just this step +# where hard links aren't available (e.g. Android/Termux); the -H +# transfer below is still a valid no-op there. +if hardlinks_supported(): + os.link(FROMDIR / 'filelist', FROMDIR / 'dir' / 'filelist') print("Test hard links:") checkit(['-avH', '--bwlimit=0', DEBUG_OPTS, f'{FROMDIR}/', str(TODIR)], FROMDIR, TODIR) diff --git a/testsuite/hardlinks_test.py b/testsuite/hardlinks_test.py index 9084899d7..c3216461e 100644 --- a/testsuite/hardlinks_test.py +++ b/testsuite/hardlinks_test.py @@ -13,7 +13,7 @@ from rsyncfns import ( CHKDIR, FROMDIR, OUTFILE, RSYNC, SRCDIR, TODIR, - checkit, makepath, rsync_argv, test_fail, test_skipped, + checkit, make_hardlink, makepath, rsync_argv, test_fail, test_skipped, ) @@ -26,11 +26,11 @@ name4 = FROMDIR / 'name4' name1.write_text("This is the file\n") try: - os.link(name1, name2) + make_hardlink(name1, name2) except OSError: test_skipped("Can't create hardlink") try: - os.link(name2, name3) + make_hardlink(name2, name3) except OSError: test_fail("Can't create hardlink") shutil.copy(name2, name4) @@ -61,7 +61,7 @@ for y in chars: (cdir / f'{x}{y}').touch() -os.link(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file') +make_hardlink(name1, FROMDIR / 'subdir' / 'down' / 'deep' / 'new-file') (TODIR / 'text').unlink() checkit(['-aHivve', SSH, '--debug=HLINK5', f'--rsync-path={RSYNC}', @@ -79,7 +79,7 @@ # stays single-linked -- and re-sync with --checksum. (FROMDIR / 'solo').write_text("This is another file\n") try: - os.link(FROMDIR / 'solo', CHKDIR / 'solo') + make_hardlink(FROMDIR / 'solo', CHKDIR / 'solo') except OSError: test_fail("Can't create hardlink") diff --git a/testsuite/itemize_test.py b/testsuite/itemize_test.py index 9cf9aa972..4e31cd557 100644 --- a/testsuite/itemize_test.py +++ b/testsuite/itemize_test.py @@ -11,7 +11,8 @@ from rsyncfns import ( CHKFILE, FROMDIR, RSYNC, SCRATCHDIR, SRCDIR, TMPDIR, TODIR, all_plus, allspace, dots, - checkdiff, cp_p, makepath, run_rsync, v_filt, + checkdiff, cp_p, hardlinks_supported, makepath, run_rsync, test_skipped, + v_filt, ) @@ -31,6 +32,10 @@ finally: os.umask(old_umask) +# The expected itemized output below assumes the 'extra' hard link +# exists, so skip the whole test where hard links aren't available. +if not hardlinks_supported(): + test_skipped("hard links not supported on this filesystem") os.link(FROMDIR / 'foo' / 'config1', FROMDIR / 'foo' / 'extra') if to2dir.is_file(): to2dir.unlink() diff --git a/testsuite/relative_test.py b/testsuite/relative_test.py index 123189c12..cb3883394 100644 --- a/testsuite/relative_test.py +++ b/testsuite/relative_test.py @@ -12,7 +12,7 @@ from rsyncfns import ( CHKDIR, FROMDIR, OUTFILE, TMPDIR, TODIR, - checkit, hands_setup, makepath, rsync_argv, + checkit, hands_setup, hardlinks_supported, makepath, rsync_argv, run_rsync, test_fail, ) @@ -59,8 +59,11 @@ # Add a hard link inside the source and the chk dir; mirror it on both # sides so the --delete pass below doesn't see it as new on either tree. -os.link(deepdir / 'filelist', deepdir / 'dir' / 'filelist') -os.link(CHKDIR / deepstr / 'filelist', CHKDIR / deepstr / 'dir' / 'filelist') +# Where hard links aren't available (e.g. Android/Termux) skip both so +# the two trees stay symmetric and the rest of the test still runs. +if hardlinks_supported(): + os.link(deepdir / 'filelist', deepdir / 'dir' / 'filelist') + os.link(CHKDIR / deepstr / 'filelist', CHKDIR / deepstr / 'dir' / 'filelist') # Re-touch both dirs so the inner-dir time matches. src_t = (deepdir / 'dir').stat().st_mtime os.utime(deepdir / 'dir', (src_t, src_t)) diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index 3a3b37b19..7bebb3f46 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -442,6 +442,47 @@ def make_text_file(path, lines: int = 100) -> 'None': f.write(content) +_hardlinks_ok = None + + +def hardlinks_supported() -> bool: + """Cached check for whether hard links work in the scratch tree. + + Some platforms can't create them: Termux's Python is built without + os.link, and Android app storage rejects link(2) outright. Tests that + need hard links call this to skip cleanly rather than crash. + """ + global _hardlinks_ok + if _hardlinks_ok is not None: + return _hardlinks_ok + if not hasattr(os, 'link'): + _hardlinks_ok = False + return _hardlinks_ok + SCRATCHDIR.mkdir(parents=True, exist_ok=True) + a = SCRATCHDIR / '.hardlink-probe-a' + b = SCRATCHDIR / '.hardlink-probe-b' + try: + a.write_text('probe') + b.unlink(missing_ok=True) + os.link(a, b) + _hardlinks_ok = True + except OSError: + _hardlinks_ok = False + finally: + a.unlink(missing_ok=True) + b.unlink(missing_ok=True) + return _hardlinks_ok + + +def make_hardlink(src, dst) -> 'None': + """Create a hard link, raising OSError (never AttributeError) when the + platform's Python lacks os.link, so a caller's `except OSError` can + treat it the same as a runtime link() failure.""" + if not hasattr(os, 'link'): + raise OSError("os.link is not available on this platform") + os.link(src, dst) + + def get_testuid() -> int: return os.getuid() From 8f5e37a07e677ae1b6d42cda3a69c7a19e1c60ac Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 22 May 2026 15:33:41 +1000 Subject: [PATCH 4/5] testsuite: skip protected-regular when /proc/sys is unreadable Python 3.13's Path.is_file() propagates PermissionError, which Android's app sandbox raises for /proc/sys/fs/protected_regular, crashing the test before its guarded read_text(). Wrap is_file()+read_text() in one try/except OSError -> test_skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- testsuite/protected-regular_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/testsuite/protected-regular_test.py b/testsuite/protected-regular_test.py index b7d982582..1d8ca6ffe 100644 --- a/testsuite/protected-regular_test.py +++ b/testsuite/protected-regular_test.py @@ -16,10 +16,12 @@ pr_path = Path('/proc/sys/fs/protected_regular') -if not pr_path.is_file(): - test_skipped("Can't find protected_regular setting (only available on Linux)") - +# is_file() and read_text() can both raise (e.g. PermissionError when an +# app sandbox such as Android/Termux denies access to /proc/sys); treat +# any OSError as "can't determine the setting" and skip. try: + if not pr_path.is_file(): + test_skipped("Can't find protected_regular setting (only available on Linux)") pr_lvl = pr_path.read_text().strip() except OSError: test_skipped("Can't check if fs.protected_regular is enabled") From 1199ebdd1c40eff86a4af91dbadacf8113076d03 Mon Sep 17 00:00:00 2001 From: Matt Robinson Date: Sat, 30 May 2026 21:11:58 +0100 Subject: [PATCH 5/5] Skip hard link parts of new tests if unsupported The alt-dest-deep and hardlinks-deep tests added in d6124a82a4 fail when run in an environment that does not support hard links. Use the new make_hardlink helper in hardlinks-deep and skip the test if it throws OSError. Skip over the link-dest part of alt-dest-deep if hardlinks_supported returns false. --- testsuite/alt-dest-deep_test.py | 19 ++++++++++--------- testsuite/hardlinks-deep_test.py | 9 +++++++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/testsuite/alt-dest-deep_test.py b/testsuite/alt-dest-deep_test.py index 1054948e8..6a6e3d2ed 100644 --- a/testsuite/alt-dest-deep_test.py +++ b/testsuite/alt-dest-deep_test.py @@ -23,7 +23,7 @@ from rsyncfns import ( FROMDIR, SCRATCHDIR, TODIR, assert_exists, assert_hardlinked, assert_not_exists, assert_not_hardlinked, - assert_same, make_tree, rmtree, run_rsync, walk_files, + assert_same, hardlinks_supported, make_tree, rmtree, run_rsync, walk_files, ) src = FROMDIR @@ -55,14 +55,15 @@ def run_to(opt): # --- --link-dest: unchanged -> hardlink to ref; changed -> fresh copy ------- -run_to('link-dest') -for rel in rels: - d, r = TODIR / rel, ref / rel - if str(rel) == changed: - assert_not_hardlinked(d, r, label=f'link-dest changed {rel}') - assert_same(d, src / rel, label=f'link-dest changed {rel}') - else: - assert_hardlinked(d, r, label=f'link-dest unchanged {rel}') +if hardlinks_supported(): + run_to('link-dest') + for rel in rels: + d, r = TODIR / rel, ref / rel + if str(rel) == changed: + assert_not_hardlinked(d, r, label=f'link-dest changed {rel}') + assert_same(d, src / rel, label=f'link-dest changed {rel}') + else: + assert_hardlinked(d, r, label=f'link-dest unchanged {rel}') # --- --copy-dest: every file copied (never linked), dest complete ----------- run_to('copy-dest') diff --git a/testsuite/hardlinks-deep_test.py b/testsuite/hardlinks-deep_test.py index def0aa26b..345e132ac 100644 --- a/testsuite/hardlinks-deep_test.py +++ b/testsuite/hardlinks-deep_test.py @@ -10,7 +10,8 @@ from rsyncfns import ( FROMDIR, TODIR, - assert_hardlinked, assert_not_hardlinked, makepath, rmtree, run_rsync, + assert_hardlinked, assert_not_hardlinked, make_hardlink, makepath, rmtree, + run_rsync, test_skipped, ) import os @@ -22,7 +23,11 @@ rmtree(TODIR) makepath(src / 'a' / 'aa', src / 'b' / 'bb') (src / a).write_text("shared content across directories\n") -os.link(src / a, src / b) # one inode, two names in different dirs + +try: + make_hardlink(src / a, src / b) # one inode, two names in different dirs +except OSError: + test_skipped("Can't create hardlink") # -H preserves the cross-directory hard link. run_rsync('-aH', f'{src}/', f'{TODIR}/')