From 9de8f6ab1f04cd61a7b2ef96facc7af55792bcfd Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 22 May 2026 15:13:11 +1000 Subject: [PATCH 1/6] syscall: survive a seccomp-blocked openat2() instead of dying on SIGSYS secure_relative_open() invokes openat2(2) directly via syscall() (glibc lacked a wrapper for years). In a seccomp-restricted environment a disallowed syscall raises SIGSYS and kills the process rather than failing with ENOSYS, so the existing "fall back on ENOSYS" path never runs. The Android app sandbox blocks openat2 exactly this way: on Termux every rsync transfer died the moment the receiver opened a basis file (receiver.c calls secure_relative_open unconditionally), with the peer seeing only "connection unexpectedly closed". The same hazard applies to hardened containers and systemd's SystemCallFilter. Probe openat2 once behind a temporary SIGSYS handler (sigsetjmp/ siglongjmp). If it is missing or blocked, secure_relative_open_linux() returns ENOSYS so callers use the portable per-component O_NOFOLLOW walk, which relies only on openat() and is never seccomp-blocked. Where openat2 is available (the normal case) behaviour is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- syscall.c | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) 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; From 2db2e5317abe5413d23fcd2dbac9bbcae606e7fc Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 22 May 2026 15:33:41 +1000 Subject: [PATCH 2/6] 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 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() From 5ebc771a70e2ed2365ac673187dbde3ce9e911fb Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 22 May 2026 15:33:41 +1000 Subject: [PATCH 3/6] 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 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") From 863da26e12244ef9ea35904889916cd7dbd81768 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 22 May 2026 15:33:41 +1000 Subject: [PATCH 4/6] t_chmod_secure: survive a seccomp-blocked openat2 probe kernel_resolve_beneath_supported() calls openat2 directly to detect RESOLVE_BENEATH support. In a seccomp sandbox (Android/Termux) that call is killed with SIGSYS rather than failing with ENOSYS, killing the helper and failing chmod-symlink-race. Probe behind a temporary SIGSYS handler (sigsetjmp/siglongjmp), reporting "unsupported" when blocked -- matching the secure_relative_open() fix in syscall.c. Co-Authored-By: Claude Opus 4.7 (1M context) --- t_chmod_secure.c | 50 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 11 deletions(-) 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 From acb4562b0b39fce853fc8fb30a58ecb6e182c153 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 22 May 2026 15:33:41 +1000 Subject: [PATCH 5/6] testsuite: skip dir-symlink-basis when openat2/RESOLVE_BENEATH is absent The issue #715 fix relies on openat2(RESOLVE_BENEATH); without it secure_relative_open() uses the per-component fallback, which can't follow a dir-symlink basedir, so #715 still applies. The test already skips on the non-Linux fallback platforms by name, but Android/Termux is a Linux kernel with openat2 blocked by seccomp, so it must skip too. Probe openat2(RESOLVE_BENEATH) at runtime in a subprocess (so a seccomp SIGSYS kills the child, not the test) and skip when it's unavailable. Also handle Termux's platform.system() == 'Android'. Co-Authored-By: Claude Opus 4.7 (1M context) --- testsuite/symlink-dirlink-basis_test.py | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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. From aa3466f730b93805fcc009a39bb1ffde920e4481 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 23 May 2026 09:06:10 +1000 Subject: [PATCH 6/6] packaging: build downloadable Termux .deb packages in CI Add packaging/build-termux-deb.sh, which cross-compiles a statically-linked rsync with the Android NDK and packages it as a Termux .deb (installed under the Termux prefix, /data/data/com.termux/files/usr/bin/rsync), and a termux-deb.yml workflow that runs it for every Termux architecture (aarch64, arm, x86_64, i686) and uploads the .deb files as artifacts. The binary is self-contained: optional external libraries (zstd/lz4/xxhash/ openssl/acl/xattr/iconv) are omitted, leaving md5/md4 and bundled zlib, so the .deb has no Termux dependencies and installs with `dpkg -i` (or `apt install ./rsync__.deb`) on any Termux version. The build forces the cross-compile cache values configure can't probe (lchmod/lutimes off; socketpair and mknod-FIFO/socket on; IPv6 enabled). The resulting binary includes the openat2-seccomp fallback, so transfers work in the Android app sandbox. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/termux-deb.yml | 78 ++++++++++++++++++++++ packaging/build-termux-deb.sh | 107 +++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 .github/workflows/termux-deb.yml create mode 100755 packaging/build-termux-deb.sh 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"