Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/termux-deb.yml
Original file line number Diff line number Diff line change
@@ -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_<ver>_<arch>.deb (or: apt install ./rsync_<ver>_<arch>.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/
107 changes: 107 additions & 0 deletions packaging/build-termux-deb.sh
Original file line number Diff line number Diff line change
@@ -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 <termux-arch> [api-level] [outdir]
# <termux-arch> 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_<ver>_<arch>.deb (or: apt install ./rsync_<ver>_<arch>.deb)

set -e

arch=$1
API=${2:-24}
OUTDIR=${3:-"$PWD/dist"}

if [ -z "$arch" ]; then
echo "usage: $0 <aarch64|arm|x86_64|i686> [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" <<EOF
Package: rsync
Version: $VER
Architecture: $arch
Maintainer: rsync project <rsync@lists.samba.org>
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"
61 changes: 61 additions & 0 deletions syscall.c
Original file line number Diff line number Diff line change
Expand Up @@ -1692,11 +1692,72 @@ static int path_has_dotdot_component(const char *path)
}

#ifdef __linux__
#include <setjmp.h>

/* 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;
Expand Down
50 changes: 39 additions & 11 deletions t_chmod_secure.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
#ifdef __linux__
#include <sys/syscall.h>
#include <linux/openat2.h>
#include <setjmp.h>
#include <signal.h>
#endif

int dry_run = 0;
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions testsuite/hands_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand Down
10 changes: 5 additions & 5 deletions testsuite/hardlinks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand All @@ -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)
Expand Down Expand Up @@ -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}',
Expand All @@ -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")

Expand Down
Loading
Loading