diff --git a/.github/workflows/termux-deb.yml b/.github/workflows/termux-deb.yml new file mode 100644 index 000000000..b80e087ba --- /dev/null +++ b/.github/workflows/termux-deb.yml @@ -0,0 +1,78 @@ +name: Build rsync .deb for Termux + +# Cross-compiles a statically-linked rsync with the Android NDK and packages +# it as a Termux .deb for each Termux architecture. The .deb files are uploaded +# as workflow artifacts so users can download and install them on a device: +# dpkg -i rsync__.deb (or: apt install ./rsync__.deb) +# +# The binaries are cross-compiled, so the test suite can't run here; we sanity +# check that each binary is static and the right architecture, smoke-test +# `--version` under qemu-user, and verify the .deb metadata. + +on: + push: + branches: [ master, pr-termux-test ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/termux-deb.yml' + pull_request: + branches: [ master ] + paths-ignore: + - '.github/workflows/*.yml' + - '!.github/workflows/termux-deb.yml' + workflow_dispatch: + +env: + ANDROID_API: 24 # Android 7.0; runs on every modern phone, broad reach + +jobs: + build: + runs-on: ubuntu-latest + name: ${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + include: + - arch: aarch64 # modern 64-bit phones + qemu: qemu-aarch64-static + - arch: arm # older 32-bit phones + qemu: qemu-arm-static + - arch: x86_64 # 64-bit emulators / x86 tablets + qemu: qemu-x86_64-static + - arch: i686 # 32-bit emulators + qemu: qemu-i386-static + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install build prerequisites + run: sudo apt-get update && sudo apt-get install -y autoconf automake gawk qemu-user-static + + - name: Build and package (${{ matrix.arch }}) + run: packaging/build-termux-deb.sh ${{ matrix.arch }} "$ANDROID_API" dist + + - name: Verify + shell: bash + run: | + set -euo pipefail + file rsync + file rsync | grep -q "statically linked" + if file rsync | grep -q "dynamically linked"; then + echo "ERROR: binary is not static" >&2; exit 1 + fi + echo "=== .deb metadata ===" + dpkg-deb --info dist/rsync_*_${{ matrix.arch }}.deb + dpkg-deb --contents dist/rsync_*_${{ matrix.arch }}.deb + # Best-effort: confirm it runs under qemu-user. + ${{ matrix.qemu }} ./rsync --version | head -3 || \ + echo "WARNING: qemu smoke test did not run cleanly (check on a real device)" + + - name: Checksum + run: ( cd dist && sha256sum rsync_*_${{ matrix.arch }}.deb > rsync_${{ matrix.arch }}.deb.sha256 ) + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: rsync-termux-${{ matrix.arch }} + path: dist/ diff --git a/packaging/build-termux-deb.sh b/packaging/build-termux-deb.sh new file mode 100755 index 000000000..e2da4270c --- /dev/null +++ b/packaging/build-termux-deb.sh @@ -0,0 +1,107 @@ +#!/bin/sh +# Cross-build a statically-linked rsync for Termux and package it as a .deb. +# +# Usage: packaging/build-termux-deb.sh [api-level] [outdir] +# aarch64 | arm | x86_64 | i686 +# [api-level] Android API level to target (default 24 = Android 7.0) +# [outdir] where to write the .deb (default ./dist) +# +# Requirements: +# * Android NDK, located via $ANDROID_NDK_LATEST_HOME or $ANDROID_NDK_ROOT +# * dpkg-deb, autoconf, automake, gawk +# * run from a clean rsync git checkout (it builds in-tree) +# +# The result is a self-contained static binary installed under the Termux +# prefix (/data/data/com.termux/files/usr/bin/rsync), so it needs no other +# Termux packages at runtime. Install on a device with: +# dpkg -i rsync__.deb (or: apt install ./rsync__.deb) + +set -e + +arch=$1 +API=${2:-24} +OUTDIR=${3:-"$PWD/dist"} + +if [ -z "$arch" ]; then + echo "usage: $0 [api] [outdir]" >&2 + exit 2 +fi + +case "$arch" in + aarch64) triple=aarch64-linux-android ;; + arm) triple=armv7a-linux-androideabi ;; + x86_64) triple=x86_64-linux-android ;; + i686) triple=i686-linux-android ;; + *) echo "unknown Termux arch: $arch" >&2; exit 2 ;; +esac + +NDK=${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_ROOT} +if [ -z "$NDK" ] || [ ! -d "$NDK" ]; then + echo "Android NDK not found (set ANDROID_NDK_LATEST_HOME or ANDROID_NDK_ROOT)" >&2 + exit 2 +fi +TC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" + +CC="$TC/${triple}${API}-clang" +if [ ! -x "$CC" ]; then + echo "no NDK compiler for $triple at API $API: $CC" >&2 + exit 2 +fi +export CC +export AR="$TC/llvm-ar" RANLIB="$TC/llvm-ranlib" STRIP="$TC/llvm-strip" +export CFLAGS="-O2" LDFLAGS="-static" + +# Cross-compile cache values that configure cannot probe by running a test: +# - lchmod()/lutimes() link but aren't declared by Bionic until API 36, so +# force them off and let rsync use its fallbacks; +# - socketpair and mknod-FIFO/socket are present (Android runs a Linux +# kernel), so restore the values the run-tests would have found. +export ac_cv_func_lchmod=no ac_cv_func_lutimes=no \ + rsync_cv_HAVE_SOCKETPAIR=yes \ + rsync_cv_MKNOD_CREATES_FIFOS=yes \ + rsync_cv_MKNOD_CREATES_SOCKETS=yes + +echo "=== configure ($arch, API $API) ===" +./configure --host="$triple" --build=x86_64-pc-linux-gnu --enable-ipv6 \ + --disable-zstd --disable-lz4 --disable-xxhash --disable-openssl \ + --disable-iconv --disable-iconv-open --disable-acl-support \ + --disable-xattr-support --disable-md2man --disable-roll-simd \ + --with-included-popt --with-included-zlib + +# Generate the awk-built headers serially first so the parallel build can't +# race on proto.h <- daemon-parm.h. +make proto.h +echo "=== build ($arch) ===" +make -j"$(nproc)" rsync +"$STRIP" rsync + +VER=$(sed -n 's/.*RSYNC_VERSION "\([^"]*\)".*/\1/p' version.h) +echo "=== package rsync $VER for Termux/$arch ===" + +pkg=$(mktemp -d) +trap 'rm -rf "$pkg"' EXIT +chmod 755 "$pkg" # mktemp makes 0700; the package root should be world-readable +install -Dm755 rsync "$pkg/data/data/com.termux/files/usr/bin/rsync" +size=$(du -ks "$pkg/data" | cut -f1) + +mkdir -p "$pkg/DEBIAN" +cat > "$pkg/DEBIAN/control" < +Installed-Size: $size +Homepage: https://rsync.samba.org/ +Section: net +Priority: optional +Description: fast, versatile file-copying tool (static Termux build) + Statically linked rsync, cross-compiled from the rsync git tree with the + Android NDK for use on Termux. It has no external dependencies; the optional + zstd/lz4/xxhash/openssl/acl/xattr/iconv features are omitted in favour of a + single self-contained binary (md5/md4 checksums and bundled zlib remain). +EOF + +mkdir -p "$OUTDIR" +deb="$OUTDIR/rsync_${VER}_${arch}.deb" +dpkg-deb --root-owner-group --build "$pkg" "$deb" +echo "built $deb" diff --git a/syscall.c b/syscall.c index e317bccc3..61a086c3e 100644 --- a/syscall.c +++ b/syscall.c @@ -1692,11 +1692,72 @@ static int path_has_dotdot_component(const char *path) } #ifdef __linux__ +#include + +/* openat2(2) is invoked directly via syscall() (glibc lacked a wrapper + * for years). In a seccomp-restricted environment -- the Android app + * sandbox, a hardened container, or systemd's SystemCallFilter -- a + * disallowed syscall raises SIGSYS and kills the process rather than + * failing with ENOSYS, so checking errno after the fact is too late. + * Probe openat2 once behind a temporary SIGSYS handler; if it is missing + * or blocked, secure_relative_open_linux() reports ENOSYS so the caller + * falls back to the portable per-component O_NOFOLLOW walk. */ +static sigjmp_buf openat2_probe_env; + +static void openat2_probe_handler(int signo) +{ + (void)signo; + siglongjmp(openat2_probe_env, 1); +} + +static int openat2_usable(void) +{ + static int cached = -1; + struct sigaction sa, old_sa; + + if (cached >= 0) + return cached; + + memset(&sa, 0, sizeof sa); + sa.sa_handler = openat2_probe_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGSYS, &sa, &old_sa) != 0) + return cached = 0; + + if (sigsetjmp(openat2_probe_env, 1) != 0) { + /* SIGSYS delivered: openat2 is blocked by a seccomp filter. */ + cached = 0; + } else { + struct open_how how; + int fd; + memset(&how, 0, sizeof how); + how.flags = O_RDONLY | O_DIRECTORY; + how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; + fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how); + if (fd >= 0) { + close(fd); + cached = 1; + } else { + /* ENOSYS = kernel too old; any other errno means the + * syscall is wired up and reachable, so it is usable. */ + cached = errno != ENOSYS; + } + } + + sigaction(SIGSYS, &old_sa, NULL); + return cached; +} + static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) { struct open_how how; int dirfd, retfd; + if (!openat2_usable()) { + errno = ENOSYS; + return -1; + } + memset(&how, 0, sizeof how); how.flags = flags; how.mode = mode; diff --git a/t_chmod_secure.c b/t_chmod_secure.c index 7c57dbbca..583ce19bd 100644 --- a/t_chmod_secure.c +++ b/t_chmod_secure.c @@ -20,6 +20,8 @@ #ifdef __linux__ #include #include +#include +#include #endif int dry_run = 0; @@ -42,23 +44,49 @@ static int errs = 0; * the running kernel, 0 otherwise. The probe opens "." (a directory * the helper has just chdir'd into) so it can't fail for any reason * other than the kernel rejecting the requested confinement flag. */ +#ifdef __linux__ +static sigjmp_buf rb_probe_env; +static void rb_probe_handler(int signo) +{ + (void)signo; + siglongjmp(rb_probe_env, 1); +} +#endif + static int kernel_resolve_beneath_supported(void) { int fd; #ifdef __linux__ { - struct open_how how; - memset(&how, 0, sizeof how); - how.flags = O_RDONLY | O_DIRECTORY; - how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; - fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how); - if (fd >= 0) { - close(fd); - return 1; + struct sigaction sa, old_sa; + /* In a seccomp sandbox (Android/Termux, hardened containers) + * openat2() is blocked with SIGSYS, which kills the helper, + * rather than failing with ENOSYS. Probe behind a temporary + * SIGSYS handler so a blocked openat2 reports "unsupported" + * and we fall through to the O_RESOLVE_BENEATH / per-component + * path, matching what secure_relative_open() itself does. */ + memset(&sa, 0, sizeof sa); + sa.sa_handler = rb_probe_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGSYS, &sa, &old_sa) == 0) { + if (sigsetjmp(rb_probe_env, 1) == 0) { + struct open_how how; + memset(&how, 0, sizeof how); + how.flags = O_RDONLY | O_DIRECTORY; + how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; + fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how); + if (fd >= 0) { + close(fd); + sigaction(SIGSYS, &old_sa, NULL); + return 1; + } + } + sigaction(SIGSYS, &old_sa, NULL); } - /* ENOSYS = kernel < 5.6. Fall through to the O_RESOLVE_BENEATH - * probe in case we're a Linux build running on a kernel that - * gained O_RESOLVE_BENEATH via some out-of-tree backport. */ + /* ENOSYS = kernel < 5.6, or seccomp-blocked. Fall through to + * the O_RESOLVE_BENEATH probe in case we're a Linux build + * running on a kernel that gained it via an out-of-tree + * backport. */ } #endif #ifdef O_RESOLVE_BENEATH 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/protected-regular_test.py b/testsuite/protected-regular_test.py index f3e0485f0..d7cbe1f5c 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") 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 f151fd5e1..2227c4cd7 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -430,6 +430,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() diff --git a/testsuite/symlink-dirlink-basis_test.py b/testsuite/symlink-dirlink-basis_test.py index b952b4de2..0c18415b6 100644 --- a/testsuite/symlink-dirlink-basis_test.py +++ b/testsuite/symlink-dirlink-basis_test.py @@ -16,6 +16,7 @@ import os import platform import subprocess +import sys import time from rsyncfns import ( @@ -30,6 +31,37 @@ f"{platform.system()}; issue #715 still affects this platform" ) + +def _resolve_beneath_works() -> bool: + """The issue #715 fix relies on openat2(RESOLVE_BENEATH). Where that is + unavailable (kernel < 5.6) or blocked by a seccomp filter (the Android + app sandbox, hardened containers), secure_relative_open() uses the + per-component fallback, which can't follow a dir-symlink basedir -- so + #715 still applies and this test must skip, exactly as it does on the + non-Linux fallback platforms above. Probe in a subprocess so a seccomp + SIGSYS kills the child rather than this test.""" + code = ( + "import ctypes, sys\n" + "class H(ctypes.Structure):\n" + " _fields_ = [('f', ctypes.c_uint64), ('m', ctypes.c_uint64),\n" + " ('r', ctypes.c_uint64)]\n" + "libc = ctypes.CDLL(None, use_errno=True)\n" + "h = H(0, 0, 0x08) # resolve = RESOLVE_BENEATH\n" + "fd = libc.syscall(437, -100, b'.', ctypes.byref(h), ctypes.sizeof(h))\n" + "sys.exit(0 if fd >= 0 else 1)\n" + ) + return subprocess.run([sys.executable, '-c', code]).returncode == 0 + + +# Termux's Python reports 'Android', not 'Linux'; both run the Linux +# kernel where syscall 437 is openat2, so probe on either. (Darwin and +# FreeBSD use O_RESOLVE_BENEATH and must not run the Linux-specific probe.) +if platform.system() in ('Linux', 'Android') and not _resolve_beneath_works(): + test_skipped( + "openat2(RESOLVE_BENEATH) is unavailable here (old kernel or a " + "seccomp filter); issue #715's dir-symlink-basis fix relies on it" + ) + os.environ['RSYNC_RSH'] = str(SRCDIR / 'support' / 'lsh.sh') # HOME -> SCRATCHDIR is set up by rsyncfns import.