aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-16 00:00:00 +0000
committerLena <lena@omega>2026-08-16 00:00:00 +0000
commitbeaa0c970d6c3538119b8a34a3f2f754b45e54cf (patch)
tree0a4be0a63a5c5b40f3db66a7e2b6c603e60d8473
parent0890d139e5fa501561e469aca7854791a936fcb6 (diff)
downloadrsend-beaa0c970d6c3538119b8a34a3f2f754b45e54cf.tar.gz
native: harden rsync and SSH transport
Update rsync to 3.5.0 and Go to 1.26.6. Bound SSH handshakes, pin host-key types, and build 16 KB-aligned hardened executables.
-rwxr-xr-xrsh/build.sh36
-rw-r--r--rsh/e2e_test.go64
-rw-r--r--rsh/go.mod6
-rw-r--r--rsh/go.sum12
-rw-r--r--rsh/main.go222
-rw-r--r--rsh/sshserver_test.go42
-rw-r--r--rsh/transport_test.go305
-rwxr-xr-xrsync/build.sh74
-rw-r--r--rsync/rsync-3.4.1.tar.gz.sha2561
-rw-r--r--rsync/rsync-3.5.0.tar.gz.sha2561
-rw-r--r--versions10
11 files changed, 685 insertions, 88 deletions
diff --git a/rsh/build.sh b/rsh/build.sh
index a3b2cad..f91343a 100755
--- a/rsh/build.sh
+++ b/rsh/build.sh
@@ -9,9 +9,15 @@
# build targets glibc's linker and will not exec on a phone.
set -eu
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+# shellcheck source=versions
. "$root/versions"
+command -v bash >/dev/null 2>&1 || {
+ echo "rsh: bash is required by the Android NDK compiler launchers" >&2
+ exit 1
+}
+
# Locate the NDK toolchain.
ndk=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}
if [ -z "$ndk" ] && [ -n "${ANDROID_HOME:-}" ]; then
@@ -34,28 +40,44 @@ if [ ! -d "$tc" ]; then
echo "rsh: NDK toolchain not found at $tc" >&2
exit 1
fi
+output=
+trap 'if [ -n "$output" ]; then rm -f "$output"; fi' 0 1 2 3 15
+# -buildvcs=false as well as -trimpath: Go otherwise stamps the commit, the
+# commit time and a dirty flag into the binary, so the same source builds
+# differently depending on the checkout it was built from, or on whether git is
+# even installed. ci/verify-repro.sh cannot catch that, because it builds twice
+# from one tree.
ldflags="-s -w -buildid="
+# NDK r27 needs both page-size flags for 16 KB Android devices.
+android_ldflags="$ldflags -extldflags=-Wl,-z,relro,-z,now,-z,noexecstack,-z,max-page-size=16384,-z,common-page-size=16384"
cd "$root/rsh"
for abi in $ABIS; do
case "$abi" in
- arm64-v8a) goarch=arm64 goarm= cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;;
+ arm64-v8a) goarch=arm64 goarm='' cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;;
armeabi-v7a) goarch=arm goarm=7 cc="armv7a-linux-androideabi${ANDROID_MIN_SDK}-clang" ;;
- x86_64) goarch=amd64 goarm= cc="x86_64-linux-android${ANDROID_MIN_SDK}-clang" ;;
- x86) goarch=386 goarm= cc="i686-linux-android${ANDROID_MIN_SDK}-clang" ;;
+ x86_64) goarch=amd64 goarm='' cc="x86_64-linux-android${ANDROID_MIN_SDK}-clang" ;;
+ x86) goarch=386 goarm='' cc="i686-linux-android${ANDROID_MIN_SDK}-clang" ;;
*) echo "rsh: unknown ABI $abi" >&2; exit 1 ;;
esac
dest="$root/app/src/main/jniLibs/$abi"
mkdir -p "$dest"
echo "rsh: building $abi (android/$goarch)"
+ output="$dest/libxrsh.so.part.$$"
CGO_ENABLED=1 GOOS=android GOARCH="$goarch" GOARM="$goarm" \
- CC="$tc/$cc" GOFLAGS=-trimpath CGO_CFLAGS="-ffile-prefix-map=$root=." \
- go build -buildmode=pie -ldflags "$ldflags" -o "$dest/libxrsh.so" .
+ CC="$tc/$cc" GOFLAGS=-trimpath \
+ CGO_CFLAGS="-fstack-protector-strong -D_FORTIFY_SOURCE=2 -ffile-prefix-map=$root=." \
+ go build -buildvcs=false -buildmode=pie -ldflags "$android_ldflags" -o "$output" .
+ mv "$output" "$dest/libxrsh.so"
+ output=
done
# Host binary (no NDK, cgo off) for tests and manual debugging.
mkdir -p "$root/out"
echo "rsh: building host binary -> out/rsh"
-CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags "$ldflags" -o "$root/out/rsh" .
+output="$root/out/rsh.part.$$"
+CGO_ENABLED=0 GOFLAGS=-trimpath go build -buildvcs=false -ldflags "$ldflags" -o "$output" .
+mv "$output" "$root/out/rsh"
+output=
diff --git a/rsh/e2e_test.go b/rsh/e2e_test.go
index 88cd317..e6f82b9 100644
--- a/rsh/e2e_test.go
+++ b/rsh/e2e_test.go
@@ -2,6 +2,7 @@ package main
import (
"bytes"
+ "io"
"os"
"os/exec"
"path/filepath"
@@ -28,26 +29,69 @@ func TestEndToEndRealRsync(t *testing.T) {
}
keyPath, pub := genClientKey(t)
- srv := newTestServer(t, pub, shellExec)
+ remoteRoot := t.TempDir()
+ execute := func(cmd string, stdin io.Reader, stdout, stderr io.Writer) int {
+ return shellExecIn(remoteRoot, cmd, stdin, stdout, stderr)
+ }
+ srv := newTestServer(t, pub, execute)
kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey())
src := t.TempDir()
- dst := t.TempDir()
+ // A normal rsync-over-SSH destination may contain shell punctuation. rsync
+ // escapes the remote command before handing it to rsh; this catches any
+ // transport change that loses those escapes while joining the command.
+ marker := filepath.Join(remoteRoot, "injected")
+ dst := filepath.Join(t.TempDir(), "backup path;literal$dollar'quote$(touch injected)")
mustWrite(t, filepath.Join(src, "a.txt"), "alpha")
mustWrite(t, filepath.Join(src, "sub", "b.bin"), "\x00\x01\x02\x03beta")
mustWrite(t, filepath.Join(src, "sub", "c.txt"), "gamma gamma gamma")
- cmd := exec.Command(rsyncBin, "-a", "-e", rshBin, src+"/", "u@127.0.0.1:"+dst+"/")
- cmd.Env = append(os.Environ(),
- "RSH_KEY="+keyPath,
- "RSH_KNOWN_HOSTS="+kh,
- "RSH_PORT="+strconv.Itoa(srv.port()),
- )
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("rsync: %v\n%s", err, out)
+ runRsync := func(mirror bool) {
+ t.Helper()
+ args := []string{
+ "-rt", "--out-format=%o %n", "--partial-dir=.rsend-partial", "--timeout=300",
+ "--no-perms", "--no-owner", "--no-group", "--omit-dir-times", "-e", rshBin,
+ }
+ if mirror {
+ args = append(args, "--delete-after")
+ }
+ args = append(args, src+"/", "u@127.0.0.1:"+dst+"/")
+ cmd := exec.Command(rsyncBin, args...)
+ cmd.Env = append(os.Environ(),
+ "RSH_KEY="+keyPath,
+ "RSH_KNOWN_HOSTS="+kh,
+ "RSH_PORT="+strconv.Itoa(srv.port()),
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("rsync: %v\n%s", err, out)
+ }
}
+ runRsync(false)
checkSame(t, src, dst)
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
+ t.Fatalf("remote path was evaluated by the shell: %s", marker)
+ }
+
+ // An empty but readable source is a valid mirror. The app intentionally
+ // follows rsync here: every ordinary item in the destination is removed.
+ entries, err := os.ReadDir(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ if err := os.RemoveAll(filepath.Join(src, entry.Name())); err != nil {
+ t.Fatal(err)
+ }
+ }
+ runRsync(true)
+ entries, err = os.ReadDir(dst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("empty mirror left destination entries: %v", entries)
+ }
}
func mustWrite(t *testing.T, path, content string) {
diff --git a/rsh/go.mod b/rsh/go.mod
index 661c774..6c316a7 100644
--- a/rsh/go.mod
+++ b/rsh/go.mod
@@ -1,7 +1,7 @@
module rsend/rsh
-go 1.25.0
+go 1.26.6
-require golang.org/x/crypto v0.53.0
+require golang.org/x/crypto v0.54.0
-require golang.org/x/sys v0.46.0 // indirect
+require golang.org/x/sys v0.47.0 // indirect
diff --git a/rsh/go.sum b/rsh/go.sum
index 68cadc1..4dd5c85 100644
--- a/rsh/go.sum
+++ b/rsh/go.sum
@@ -1,6 +1,6 @@
-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
diff --git a/rsh/main.go b/rsh/main.go
index 68b6e49..9906966 100644
--- a/rsh/main.go
+++ b/rsh/main.go
@@ -3,11 +3,13 @@
// rsh [-l USER] [USER@]HOST CMD... rsync remote shell (rsync -e), strict
// rsh -keygen generate an ed25519 key, print the private key (PEM)
// rsh -pubkey print the pubkey for RSH_KEY_DATA/RSH_KEY
-// rsh -scan USER@HOST connect, print host-key fingerprint + line
+// rsh -scan USER@HOST connect, print host-key type + fingerprint + line
//
// Transport mode mirrors what ssh does for rsync: it dials the host, runs the
// remote command, and bridges stdin/stdout/stderr. Host keys are verified
-// strictly against RSH_KNOWN_HOSTS; an unknown or changed key fails loud.
+// strictly against RSH_KNOWN_HOSTS; an unknown or changed key fails loud, and
+// only the already-pinned key type is offered, so a server that switches key
+// type fails at negotiation rather than presenting an unverified key.
// Key, known_hosts path, and port come from the environment because rsync owns
// the argument vector:
//
@@ -17,7 +19,9 @@
// RSH_KNOWN_HOSTS path to the known_hosts file (required in transport mode)
// RSH_PORT TCP port (optional, default 22)
//
-// Pure Go, no cgo.
+// The only non-standard Go code linked here is golang.org/x/crypto. The
+// Android binaries are built with cgo against Bionic (see rsh/build.sh); the
+// host binary is not.
package main
import (
@@ -37,7 +41,31 @@ import (
"golang.org/x/crypto/ssh/knownhosts"
)
-const dialTimeout = 30 * time.Second
+// defaultDialTimeout bounds the TCP connect and the SSH handshake. A firewall
+// that drops packets instead of refusing them makes a connect hang for the
+// whole budget, so keep it short: a reachable host completes the handshake
+// well inside this even on poor mobile networks.
+const defaultDialTimeout = 10 * time.Second
+
+// unreachablePrefix marks a failure to reach the host at all, as opposed to a
+// rejected key or a mismatched host key. The app greps rsync's merged output
+// for it so it can skip the remaining folders on the same dead remote instead
+// of paying the timeout once per folder.
+const unreachablePrefix = "unreachable: "
+
+// scanAlgos is the host-key preference used when first pinning a host. The
+// x/crypto default puts ssh-ed25519 last, behind ecdsa, rsa and dss, so a stock
+// server would get its ecdsa key pinned while the README tells the user to
+// verify the ed25519 one. Prefer ed25519, accept the other modern types if that
+// is all the server has, and never ssh-rsa (SHA-1) or ssh-dss.
+var scanAlgos = []string{
+ ssh.KeyAlgoED25519,
+ ssh.KeyAlgoECDSA256,
+ ssh.KeyAlgoECDSA384,
+ ssh.KeyAlgoECDSA521,
+ ssh.KeyAlgoRSASHA512,
+ ssh.KeyAlgoRSASHA256,
+}
func main() {
if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
@@ -94,7 +122,23 @@ func transport(args []string, in io.Reader, out, errw io.Writer) error {
if err != nil {
return fmt.Errorf("known_hosts: %w (run Test connection first)", err)
}
- client, err := dial(user, host, cb)
+ port, err := sshPort()
+ if err != nil {
+ return err
+ }
+ algos, err := pinnedAlgos(kh, knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))))
+ if err != nil {
+ return err
+ }
+ if len(algos) == 0 {
+ // No literal pin for this address. The file may still authorise the
+ // host through a hashed or wildcard entry, which the literal match
+ // deliberately does not understand, so fall back to the modern
+ // preference and let the callback decide. Verification stays strict:
+ // an unknown or changed key still fails loud, just after the dial.
+ algos = scanAlgos
+ }
+ client, err := dial(user, host, cb, algos)
if err != nil {
return err
}
@@ -124,23 +168,96 @@ func scan(target string, out io.Writer) error {
if err != nil {
return err
}
+ addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
+ // Prefer the key type already pinned for this host, then fall back to the
+ // general preference. A server usually offers several types; picking a
+ // different one than last time would reproduce a different line and read as
+ // a host-key change on a server nobody touched. Preferring rather than
+ // requiring matters: negotiation walks the client list in order, so an
+ // untouched server still reproduces its pinned line, while one whose key
+ // type was genuinely rotated presents the new key and reaches the "host key
+ // changed" prompt instead of failing with no common algorithm and no way to
+ // accept the new key short of deleting the remote.
+ algos := scanAlgos
+ if kh := os.Getenv("RSH_KNOWN_HOSTS"); kh != "" {
+ if pinned, perr := pinnedAlgos(kh, addr); perr == nil && len(pinned) > 0 {
+ algos = append(append([]string{}, pinned...), scanAlgos...)
+ }
+ }
var hostKey ssh.PublicKey
capture := func(_ string, _ net.Addr, key ssh.PublicKey) error {
hostKey = key
return nil
}
- client, err := dial(user, host, capture)
+ client, err := dial(user, host, capture, algos)
if err != nil {
return err
}
client.Close()
- addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
line := knownhosts.Line([]string{addr}, hostKey)
- fmt.Fprintf(out, "%s\n%s\n", ssh.FingerprintSHA256(hostKey), line)
+ // The type belongs next to the fingerprint: the user is told to compare it
+ // against a specific key file on the server, and "SHA256:..." alone does not
+ // say which one.
+ fmt.Fprintf(out, "%s %s\n%s\n", hostKey.Type(), ssh.FingerprintSHA256(hostKey), line)
return nil
}
+// pinnedAlgos returns the host-key algorithms to offer for addr: exactly the
+// key types already pinned for it. Offering only the pinned type turns a server
+// that switches key type into a loud negotiation failure instead of a silent
+// prompt to trust a key the user never verified.
+//
+// rsend writes this file itself from its own pins, one exact unhashed address
+// per line (knownhosts.Line), so matching the address literally is enough.
+func pinnedAlgos(khPath, addr string) ([]string, error) {
+ data, err := os.ReadFile(khPath)
+ if err != nil {
+ return nil, fmt.Errorf("known_hosts: %w", err)
+ }
+ var algos []string
+ seen := make(map[string]bool)
+ for rest := data; len(rest) > 0; {
+ marker, hosts, key, _, next, perr := ssh.ParseKnownHosts(rest)
+ if perr == io.EOF {
+ break
+ }
+ if perr != nil {
+ return nil, fmt.Errorf("known_hosts: %w", perr)
+ }
+ rest = next
+ // A @revoked line names a key that must never be accepted, and a
+ // @cert-authority line names a signing key rather than a host key.
+ // Neither says anything about what this host may present.
+ if marker != "" {
+ continue
+ }
+ for _, h := range hosts {
+ if h != addr {
+ continue
+ }
+ for _, a := range algosForKeyType(key.Type()) {
+ if !seen[a] {
+ seen[a] = true
+ algos = append(algos, a)
+ }
+ }
+ }
+ }
+ return algos, nil
+}
+
+// algosForKeyType maps a pinned known_hosts key type to the signature
+// algorithms a server may use with it. Only RSA differs: an "ssh-rsa" pin
+// names the same key as an rsa-sha2-* signature. Plain ssh-rsa is SHA-1 and is
+// left out, matching scanAlgos and OpenSSH's own default.
+func algosForKeyType(t string) []string {
+ if t == ssh.KeyAlgoRSA {
+ return []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}
+ }
+ return []string{t}
+}
+
// keygen generates an ed25519 key and prints the private key in PEM form to
// out. Nothing touches disk: the caller owns persistence (the app stores it
// encrypted) and derives the public key with -pubkey.
@@ -171,23 +288,33 @@ func pubkey(out io.Writer) error {
// loadSigner reads the private key from RSH_KEY_DATA (the key itself) or, if
// that is unset, the file named by RSH_KEY.
func loadSigner() (ssh.Signer, error) {
+ var signer ssh.Signer
+ var err error
if data := os.Getenv("RSH_KEY_DATA"); data != "" {
- return ssh.ParsePrivateKey([]byte(data))
- }
- keyPath := os.Getenv("RSH_KEY")
- if keyPath == "" {
- return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set")
+ signer, err = ssh.ParsePrivateKey([]byte(data))
+ } else {
+ keyPath := os.Getenv("RSH_KEY")
+ if keyPath == "" {
+ return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set")
+ }
+ pemBytes, readErr := os.ReadFile(keyPath)
+ if readErr != nil {
+ return nil, fmt.Errorf("read key: %w", readErr)
+ }
+ signer, err = ssh.ParsePrivateKey(pemBytes)
}
- pemBytes, err := os.ReadFile(keyPath)
if err != nil {
- return nil, fmt.Errorf("read key: %w", err)
+ return nil, err
+ }
+ if signer.PublicKey().Type() != ssh.KeyAlgoED25519 {
+ return nil, fmt.Errorf("client key must be %s", ssh.KeyAlgoED25519)
}
- return ssh.ParsePrivateKey(pemBytes)
+ return signer, nil
}
// dial opens an SSH connection authenticated with the configured key, verifying
-// the host key with hostKey.
-func dial(user, host string, hostKey ssh.HostKeyCallback) (*ssh.Client, error) {
+// the host key with hostKey and offering only the host-key algorithms in algos.
+func dial(user, host string, hostKey ssh.HostKeyCallback, algos []string) (*ssh.Client, error) {
signer, err := loadSigner()
if err != nil {
return nil, err
@@ -196,18 +323,23 @@ func dial(user, host string, hostKey ssh.HostKeyCallback) (*ssh.Client, error) {
if err != nil {
return nil, err
}
+ timeout, err := dialTimeout()
+ if err != nil {
+ return nil, err
+ }
cfg := &ssh.ClientConfig{
- User: user,
- Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
- HostKeyCallback: hostKey,
- Timeout: dialTimeout,
+ User: user,
+ Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
+ HostKeyCallback: hostKey,
+ HostKeyAlgorithms: algos,
+ Timeout: timeout,
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
- conn, err := net.DialTimeout("tcp", addr, dialTimeout)
+ conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("%s%w", unreachablePrefix, err)
}
- if err := conn.SetDeadline(time.Now().Add(dialTimeout)); err != nil {
+ if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
conn.Close()
return nil, err
}
@@ -246,22 +378,46 @@ func parseTransport(args []string) (user, host string, cmd []string, err error)
if i >= len(args) {
return "", "", nil, errors.New("missing host")
}
- host = args[i]
cmd = args[i+1:]
- if u, h := splitUserHost(host); u != "" {
- host = h
- if user == "" {
- user = u
- }
+ u, h := splitUserHost(args[i])
+ host = h
+ if u != "" && user == "" {
+ user = u
}
return user, host, cmd, nil
}
func splitUserHost(s string) (user, host string) {
if i := strings.Index(s, "@"); i >= 0 {
- return s[:i], s[i+1:]
+ return s[:i], unbracket(s[i+1:])
+ }
+ return "", unbracket(s)
+}
+
+// unbracket strips the brackets people habitually put around an IPv6 literal.
+// A bare literal already works, because net.JoinHostPort adds the brackets and
+// knownhosts.Normalize takes them back off; a pre-bracketed one would be
+// double-bracketed into an address that can be neither dialled nor matched
+// against a pin.
+func unbracket(h string) string {
+ if len(h) > 1 && h[0] == '[' && h[len(h)-1] == ']' {
+ return h[1 : len(h)-1]
+ }
+ return h
+}
+
+// dialTimeout returns the connect budget, overridable in whole seconds with
+// RSH_CONNECT_TIMEOUT for running rsh by hand against a slow or filtered host.
+func dialTimeout() (time.Duration, error) {
+ s := os.Getenv("RSH_CONNECT_TIMEOUT")
+ if s == "" {
+ return defaultDialTimeout, nil
+ }
+ n, err := strconv.Atoi(s)
+ if err != nil || n < 1 || n > 3600 {
+ return 0, fmt.Errorf("invalid RSH_CONNECT_TIMEOUT %q", s)
}
- return "", s
+ return time.Duration(n) * time.Second, nil
}
func sshPort() (int, error) {
diff --git a/rsh/sshserver_test.go b/rsh/sshserver_test.go
index 087bff4..f238e0b 100644
--- a/rsh/sshserver_test.go
+++ b/rsh/sshserver_test.go
@@ -1,7 +1,9 @@
package main
import (
+ "crypto/ecdsa"
"crypto/ed25519"
+ "crypto/elliptic"
"crypto/rand"
"errors"
"fmt"
@@ -20,14 +22,24 @@ type execHandler func(cmd string, stdin io.Reader, stdout, stderr io.Writer) int
// testServer is a minimal in-process SSH server for tests: one host key, one
// accepted client public key, and a pluggable exec handler. No sshd needed.
type testServer struct {
- ln net.Listener
- hostKey ssh.Signer
- authPub ssh.PublicKey
- handle execHandler
+ ln net.Listener
+ hostKey ssh.Signer
+ extraKey ssh.Signer
+ authPub ssh.PublicKey
+ handle execHandler
}
func newTestServer(t *testing.T, clientPub ssh.PublicKey, handle execHandler) *testServer {
t.Helper()
+ return newTestServerKeys(t, clientPub, handle, false)
+}
+
+// newTestServerKeys builds the server with an ed25519 host key and, when
+// alsoECDSA is set, an ECDSA one too. A server offering both is the stock sshd
+// case: x/crypto's default client preference would pick the ECDSA key, so this
+// is what proves rsh's host-key algorithm policy is doing its job.
+func newTestServerKeys(t *testing.T, clientPub ssh.PublicKey, handle execHandler, alsoECDSA bool) *testServer {
+ t.Helper()
_, hpriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
@@ -36,11 +48,21 @@ func newTestServer(t *testing.T, clientPub ssh.PublicKey, handle execHandler) *t
if err != nil {
t.Fatal(err)
}
+ var extra ssh.Signer
+ if alsoECDSA {
+ epriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if extra, err = ssh.NewSignerFromKey(epriv); err != nil {
+ t.Fatal(err)
+ }
+ }
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
- s := &testServer{ln: ln, hostKey: signer, authPub: clientPub, handle: handle}
+ s := &testServer{ln: ln, hostKey: signer, extraKey: extra, authPub: clientPub, handle: handle}
go s.serve()
t.Cleanup(func() { ln.Close() })
return s
@@ -58,6 +80,9 @@ func (s *testServer) serve() {
},
}
cfg.AddHostKey(s.hostKey)
+ if s.extraKey != nil {
+ cfg.AddHostKey(s.extraKey)
+ }
for {
nConn, err := s.ln.Accept()
if err != nil {
@@ -107,15 +132,16 @@ func (s *testServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request)
}
}
-// shellExec runs cmd via /bin/sh -c, exactly as sshd would, wiring the channel
-// to the process. Used by the real-rsync end-to-end test.
+// shellExecIn runs cmd via /bin/sh -c, exactly as sshd would, wiring the
+// channel to the process. Used by the real-rsync end-to-end test.
//
// stdin is fed through a detached goroutine rather than c.Stdin so that Wait
// does not block on the stdin copy: rsync's remote receiver exits while the
// client still holds the channel open, and a c.Run with a non-file Stdin would
// deadlock waiting for that copy to finish.
-func shellExec(cmd string, stdin io.Reader, stdout, stderr io.Writer) int {
+func shellExecIn(dir, cmd string, stdin io.Reader, stdout, stderr io.Writer) int {
c := exec.Command("/bin/sh", "-c", cmd)
+ c.Dir = dir
c.Stdout = stdout
c.Stderr = stderr
inPipe, err := c.StdinPipe()
diff --git a/rsh/transport_test.go b/rsh/transport_test.go
index 7ff74b1..d06f086 100644
--- a/rsh/transport_test.go
+++ b/rsh/transport_test.go
@@ -2,6 +2,10 @@ package main
import (
"bytes"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/pem"
"errors"
"io"
"net"
@@ -10,6 +14,7 @@ import (
"strconv"
"strings"
"testing"
+ "time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
@@ -126,6 +131,68 @@ func TestTransportPropagatesExitCode(t *testing.T) {
}
}
+func TestDialTimeoutFromEnv(t *testing.T) {
+ t.Setenv("RSH_CONNECT_TIMEOUT", "")
+ if d, err := dialTimeout(); err != nil || d != defaultDialTimeout {
+ t.Fatalf("unset: got %v, %v; want %v", d, err, defaultDialTimeout)
+ }
+ t.Setenv("RSH_CONNECT_TIMEOUT", "3")
+ if d, err := dialTimeout(); err != nil || d != 3*time.Second {
+ t.Fatalf("set: got %v, %v; want 3s", d, err)
+ }
+ for _, bad := range []string{"0", "-1", "abc", "3.5", "99999"} {
+ t.Setenv("RSH_CONNECT_TIMEOUT", bad)
+ if _, err := dialTimeout(); err == nil {
+ t.Fatalf("RSH_CONNECT_TIMEOUT=%q: expected error, got nil", bad)
+ }
+ }
+}
+
+// A host we cannot reach must be reported with the marker the app greps for,
+// so it can skip the rest of the folders bound to that remote.
+func TestUnreachableHostIsMarked(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+
+ // A port with nothing behind it: the connect fails before any handshake.
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ dead := l.Addr().(*net.TCPAddr).Port
+ l.Close()
+
+ // Pin the dead port so the setup is the realistic one: a host rsend has
+ // synced with before and now cannot reach.
+ kh := writeKnownHosts(t, "127.0.0.1", dead, srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, dead)
+ err = transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected an error dialing a closed port, got nil")
+ }
+ if !strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("error %q does not carry %q", err, unreachablePrefix)
+ }
+}
+
+// An auth or host-key failure is not "unreachable": the host answered. Marking
+// it would make the app skip folders that a retry could still fix.
+func TestHostKeyMismatchIsNotMarkedUnreachable(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+ _, wrong := genClientKey(t)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), wrong)
+ setEnv(t, keyPath, kh, srv.port())
+
+ err := transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected a host-key mismatch error, got nil")
+ }
+ if strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("host-key mismatch wrongly marked unreachable: %v", err)
+ }
+}
+
func TestPubkeyMatchesKeygen(t *testing.T) {
keyPath, pub := genClientKey(t)
keyData, err := os.ReadFile(keyPath)
@@ -156,6 +223,22 @@ func TestPubkeyRejectsMissingKey(t *testing.T) {
}
}
+func TestPubkeyRejectsNonEd25519Key(t *testing.T) {
+ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ block, err := ssh.MarshalPrivateKey(private, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("RSH_KEY", "")
+ t.Setenv("RSH_KEY_DATA", string(pem.EncodeToMemory(block)))
+ if err := pubkey(io.Discard); err == nil || !strings.Contains(err.Error(), "ssh-ed25519") {
+ t.Fatalf("expected ed25519-only error, got %v", err)
+ }
+}
+
func TestScanPrintsFingerprintAndLine(t *testing.T) {
keyPath, pub := genClientKey(t)
srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
@@ -170,8 +253,10 @@ func TestScanPrintsFingerprintAndLine(t *testing.T) {
if len(lines) != 2 {
t.Fatalf("want fingerprint + known_hosts line, got %q", out.String())
}
- if !strings.HasPrefix(lines[0], "SHA256:") {
- t.Errorf("fingerprint: %q", lines[0])
+ // Line 0 is "<keytype> SHA256:<base64>": the type is what the user compares
+ // against a specific key file on the server.
+ if lines[0] != "ssh-ed25519 "+ssh.FingerprintSHA256(srv.hostKey.PublicKey()) {
+ t.Errorf("type + fingerprint line: %q", lines[0])
}
// The printed known_hosts line must validate the real host key.
khPath := filepath.Join(t.TempDir(), "kh")
@@ -189,6 +274,216 @@ func TestScanPrintsFingerprintAndLine(t *testing.T) {
}
}
+// A stock sshd offers several host key types. x/crypto's default client
+// preference puts ssh-ed25519 last, behind ecdsa and rsa, so without an
+// explicit policy rsend would pin the ecdsa key while the README tells the user
+// to verify the ed25519 fingerprint. Scanning must pick ed25519.
+func TestScanPrefersEd25519(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServerKeys(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 }, true)
+ t.Setenv("RSH_KEY", keyPath)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ var out bytes.Buffer
+ if err := scan("u@127.0.0.1", &out); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2)
+ want := "ssh-ed25519 " + ssh.FingerprintSHA256(srv.hostKey.PublicKey())
+ if lines[0] != want {
+ t.Fatalf("scan pinned the wrong host key:\n got %q\nwant %q", lines[0], want)
+ }
+}
+
+// Once a type is pinned, only that type may be offered, so a server that also
+// has an ecdsa key cannot be negotiated onto it.
+func TestTransportOffersOnlyPinnedKeyType(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ echo := func(_ string, stdin io.Reader, stdout, _ io.Writer) int {
+ io.Copy(stdout, stdin)
+ return 0
+ }
+ srv := newTestServerKeys(t, pub, echo, true)
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, srv.port())
+
+ want := []byte("pinned to ed25519")
+ var out bytes.Buffer
+ if err := transport([]string{"-l", "u", "127.0.0.1", "cat"}, bytes.NewReader(want), &out, io.Discard); err != nil {
+ t.Fatalf("transport: %v", err)
+ }
+ if !bytes.Equal(out.Bytes(), want) {
+ t.Fatalf("bridged data mismatch: %q", out.Bytes())
+ }
+}
+
+// A pin for a key type the server no longer has must fail loud at negotiation
+// rather than fall through to some other key the user never verified.
+func TestTransportRejectsWhenPinnedTypeAbsent(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+
+ // Pin an ecdsa key; the server only has ed25519.
+ epriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ epub, err := ssh.NewPublicKey(&epriv.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), epub)
+ setEnv(t, keyPath, kh, srv.port())
+
+ err = transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected a failure when the pinned key type is unavailable, got nil")
+ }
+ if strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("a reachable host must not be marked unreachable: %v", err)
+ }
+}
+
+// With nothing pinned for this host:port the algorithm policy has nothing to
+// go on, so it falls back to the modern preference and lets the known_hosts
+// callback decide. The host must still be rejected, loudly, and not be mistaken
+// for unreachable.
+func TestTransportRejectsUnpinnedHost(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+ // Pin a different port, so the file parses but holds nothing for this one.
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port()+1, srv.hostKey.PublicKey())
+ setEnv(t, keyPath, kh, srv.port())
+
+ err := transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard)
+ if err == nil {
+ t.Fatal("expected an unknown-host error, got nil")
+ }
+ if strings.Contains(err.Error(), unreachablePrefix) {
+ t.Fatalf("a reachable host must not be marked unreachable: %v", err)
+ }
+}
+
+// Re-scanning a host that is already pinned must reproduce the pinned key type,
+// not the scan-time preference. Otherwise a config pinned by an older rsend
+// (which negotiated ecdsa) would scan ed25519 and look like a key change on a
+// server nobody touched.
+func TestScanReproducesThePinnedKeyType(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ srv := newTestServerKeys(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 }, true)
+
+ // Pin the ecdsa key, as an older rsend would have.
+ ecdsaPub := srv.extraKey.PublicKey()
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), ecdsaPub)
+ t.Setenv("RSH_KEY", keyPath)
+ t.Setenv("RSH_KNOWN_HOSTS", kh)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ var out bytes.Buffer
+ if err := scan("u@127.0.0.1", &out); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2)
+ want := ecdsaPub.Type() + " " + ssh.FingerprintSHA256(ecdsaPub)
+ if lines[0] != want {
+ t.Fatalf("scan did not reproduce the pinned key:\n got %q\nwant %q", lines[0], want)
+ }
+ // The reproduced known_hosts line must equal the stored pin exactly, or the
+ // app's pin comparison reports a change.
+ stored, err := os.ReadFile(kh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lines[1] != strings.TrimSpace(string(stored)) {
+ t.Fatalf("reproduced line differs from the pin:\n got %q\nwant %q", lines[1], strings.TrimSpace(string(stored)))
+ }
+}
+
+// A host whose key type was genuinely rotated must still be scannable, or the
+// app can never show its "host key changed" prompt and the user has to delete
+// and recreate the remote. Preferring the pinned type is right; requiring it is
+// not.
+func TestScanStillWorksAfterAKeyTypeRotation(t *testing.T) {
+ keyPath, pub := genClientKey(t)
+ // The server now has only ed25519, as if its ecdsa key had been removed.
+ srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })
+
+ // The stored pin is an ecdsa key the server no longer offers.
+ epriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ epub, err := ssh.NewPublicKey(&epriv.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ kh := writeKnownHosts(t, "127.0.0.1", srv.port(), epub)
+ t.Setenv("RSH_KEY", keyPath)
+ t.Setenv("RSH_KNOWN_HOSTS", kh)
+ t.Setenv("RSH_PORT", strconv.Itoa(srv.port()))
+
+ var out bytes.Buffer
+ if err := scan("u@127.0.0.1", &out); err != nil {
+ t.Fatalf("scan must reach the new key so the caller can warn about it: %v", err)
+ }
+ lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2)
+ want := "ssh-ed25519 " + ssh.FingerprintSHA256(srv.hostKey.PublicKey())
+ if lines[0] != want {
+ t.Fatalf("scan did not report the rotated key:\n got %q\nwant %q", lines[0], want)
+ }
+}
+
+func TestPinnedAlgos(t *testing.T) {
+ _, pub := genClientKey(t)
+ kh := writeKnownHosts(t, "10.0.0.1", 2222, pub)
+ addr := knownhosts.Normalize(net.JoinHostPort("10.0.0.1", "2222"))
+
+ got, err := pinnedAlgos(kh, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0] != ssh.KeyAlgoED25519 {
+ t.Fatalf("pinned algos: got %v want [%s]", got, ssh.KeyAlgoED25519)
+ }
+ // A host with no pin yields nothing rather than a default.
+ other, err := pinnedAlgos(kh, knownhosts.Normalize(net.JoinHostPort("10.0.0.2", "22")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(other) != 0 {
+ t.Fatalf("unpinned host: got %v want none", other)
+ }
+ // An ssh-rsa pin allows the SHA-2 signature algorithms and not SHA-1.
+ got = algosForKeyType(ssh.KeyAlgoRSA)
+ if len(got) != 2 || got[0] != ssh.KeyAlgoRSASHA512 || got[1] != ssh.KeyAlgoRSASHA256 {
+ t.Fatalf("rsa expansion: %v", got)
+ }
+ for _, a := range got {
+ if a == ssh.KeyAlgoRSA {
+ t.Fatal("plain ssh-rsa (SHA-1) must not be offered")
+ }
+ }
+}
+
+// A @revoked entry names a key that must never be trusted, and @cert-authority
+// names a signing key, not a host key. Neither may contribute an algorithm.
+func TestPinnedAlgosIgnoresMarkers(t *testing.T) {
+ _, pub := genClientKey(t)
+ addr := knownhosts.Normalize(net.JoinHostPort("10.0.0.9", "22"))
+ line := knownhosts.Line([]string{addr}, pub)
+ p := filepath.Join(t.TempDir(), "kh")
+ if err := os.WriteFile(p, []byte("@revoked "+line+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := pinnedAlgos(p, addr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("a revoked entry must contribute nothing, got %v", got)
+ }
+}
+
func TestParseTransport(t *testing.T) {
cases := []struct {
args []string
@@ -202,6 +497,12 @@ func TestParseTransport(t *testing.T) {
{[]string{"host"}, "", "host", "", false},
{[]string{"-l"}, "", "", "", true},
{[]string{"-p", "22", "host", "x"}, "", "", "", true},
+ // A bare IPv6 literal already works; a bracketed one must not survive
+ // into net.JoinHostPort, which would double-bracket it.
+ {[]string{"-l", "u", "2001:db8::1", "x"}, "u", "2001:db8::1", "x", false},
+ {[]string{"-l", "u", "[2001:db8::1]", "x"}, "u", "2001:db8::1", "x", false},
+ {[]string{"u@[2001:db8::1]", "x"}, "u", "2001:db8::1", "x", false},
+ {[]string{"[::1]"}, "", "::1", "", false},
}
for _, c := range cases {
u, h, cmd, err := parseTransport(c.args)
diff --git a/rsync/build.sh b/rsync/build.sh
index 0a7fcda..1af7fbb 100755
--- a/rsync/build.sh
+++ b/rsync/build.sh
@@ -5,9 +5,15 @@
# nothing outside Bionic; zlib and popt come from rsync's bundled copies.
set -eu
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+# shellcheck source=versions
. "$root/versions"
+command -v bash >/dev/null 2>&1 || {
+ echo "rsync: bash is required by the Android NDK compiler launchers" >&2
+ exit 1
+}
+
# Locate the NDK toolchain.
ndk=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}
if [ -z "$ndk" ] && [ -n "${ANDROID_HOME:-}" ]; then
@@ -34,21 +40,35 @@ fi
work="$root/rsync/work"
tarball="$root/rsync/rsync-${RSYNC_VERSION}.tar.gz"
src="$work/rsync-${RSYNC_VERSION}"
+part="$tarball.part.$$"
+output=
+trap 'if [ -n "$part" ]; then rm -f "$part"; fi; if [ -n "$output" ]; then rm -f "$output"; fi' 0 1 2 3 15
# Fetch and verify the pinned source tarball.
mkdir -p "$work"
+if [ -f "$tarball" ] && ! ( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" >/dev/null 2>&1 ); then
+ echo "rsync: removing corrupt $(basename "$tarball")" >&2
+ rm -f "$tarball"
+fi
if [ ! -f "$tarball" ]; then
echo "rsync: fetching $RSYNC_URL"
- curl -fsSL "$RSYNC_URL" -o "$tarball"
+ # Download aside and rename, so an interrupted fetch leaves no truncated
+ # tarball for the next run to pick up. Verify before publishing it.
+ curl --fail --silent --show-error --location --retry 3 \
+ --connect-timeout 30 --speed-limit 1024 --speed-time 60 \
+ "$RSYNC_URL" -o "$part"
+ want=$(awk 'NR == 1 { print $1 }' "$root/rsync/rsync-${RSYNC_VERSION}.tar.gz.sha256")
+ actual=$(sha256sum "$part")
+ [ "${actual%% *}" = "$want" ] || {
+ echo "rsync: checksum failed for $(basename "$tarball")" >&2
+ exit 1
+ }
+ mv "$part" "$tarball"
+ part=
fi
echo "rsync: verifying sha256"
( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" )
-# No system libraries, no docs (md2man needs python3), no iconv.
-flags="--disable-md2man --disable-openssl --disable-xxhash --disable-zstd \
---disable-lz4 --disable-iconv --disable-acl-support --disable-xattr-support \
---with-included-popt --with-included-zlib"
-
for abi in $ABIS; do
case "$abi" in
arm64-v8a) host=aarch64-linux-android cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;;
@@ -62,18 +82,44 @@ for abi in $ABIS; do
rm -rf "$src"
tar xzf "$tarball" -C "$work"
+ # CXX must be pinned like CC: rsync's SIMD checksum is C++, and configure
+ # otherwise falls back to the host g++, leaking a host-compiled object
+ # into the target binary (or failing where the host lacks ifunc).
echo "rsync: configuring $abi"
- ( cd "$src" && \
- CC="$tc/$cc" AR="$tc/llvm-ar" RANLIB="$tc/llvm-ranlib" \
- CFLAGS="-O2 -fPIE -ffile-prefix-map=$src=." LDFLAGS="-pie" \
- ./configure --host="$host" $flags >/dev/null )
+ ( cd "$src" &&
+ # No system libraries, generated docs, iconv, ACLs, or xattrs.
+ set -- --disable-md2man --disable-openssl --disable-xxhash \
+ --disable-zstd --disable-lz4 --disable-iconv \
+ --disable-acl-support --disable-xattr-support \
+ --with-included-popt --with-included-zlib \
+ --with-nobody-user=nobody --with-nobody-group=nobody
+ # NDK r27 needs both page-size flags for 16 KB Android devices.
+ # rsync parses peer-controlled data in C. Keep the compiler defenses
+ # explicit rather than relying on whichever defaults an NDK release
+ # happens to select.
+ CC="$tc/$cc" CXX="$tc/${cc}++" AR="$tc/llvm-ar" RANLIB="$tc/llvm-ranlib" \
+ rsync_cv_HAVE_SECURE_MKSTEMP=yes \
+ CFLAGS="-O2 -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -ffile-prefix-map=$src=." \
+ CXXFLAGS="-O2 -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -ffile-prefix-map=$src=." \
+ LDFLAGS="-pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384" \
+ ./configure --host="$host" "$@" >/dev/null )
+ # Bundled popt leaves conditional variables unused on Android. Append the
+ # two narrow suppressions after rsync's own -Wall flags.
+ build_cflags=$(sed -n 's/^CFLAGS=//p' "$src/Makefile")
+ if [ -z "$build_cflags" ]; then
+ echo "rsync: generated Makefile has no CFLAGS" >&2
+ exit 1
+ fi
echo "rsync: building $abi"
- ( cd "$src" && make -s rsync )
+ ( cd "$src" && make -s CFLAGS="$build_cflags -Wno-unused-variable -Wno-unused-but-set-variable" rsync )
dest="$root/app/src/main/jniLibs/$abi"
mkdir -p "$dest"
- cp "$src/rsync" "$dest/libxrsync.so"
- "$tc/llvm-strip" "$dest/libxrsync.so"
+ output="$dest/libxrsync.so.part.$$"
+ cp "$src/rsync" "$output"
+ "$tc/llvm-strip" "$output"
+ mv "$output" "$dest/libxrsync.so"
+ output=
echo "rsync: $abi -> jniLibs/$abi/libxrsync.so"
done
diff --git a/rsync/rsync-3.4.1.tar.gz.sha256 b/rsync/rsync-3.4.1.tar.gz.sha256
deleted file mode 100644
index 1c230fd..0000000
--- a/rsync/rsync-3.4.1.tar.gz.sha256
+++ /dev/null
@@ -1 +0,0 @@
-2924bcb3a1ed8b551fc101f740b9f0fe0a202b115027647cf69850d65fd88c52 rsync-3.4.1.tar.gz
diff --git a/rsync/rsync-3.5.0.tar.gz.sha256 b/rsync/rsync-3.5.0.tar.gz.sha256
new file mode 100644
index 0000000..a91a5d7
--- /dev/null
+++ b/rsync/rsync-3.5.0.tar.gz.sha256
@@ -0,0 +1 @@
+c7ffd1ef653e99540f661e47cb00b7f9cad1ee6b972399b16f93d672656e0d33 rsync-3.5.0.tar.gz
diff --git a/versions b/versions
index a778bb9..f6b2f7b 100644
--- a/versions
+++ b/versions
@@ -5,12 +5,14 @@
# Native rsync, built from source via the Android NDK. The tarball sha256 lives
# in rsync/rsync-${RSYNC_VERSION}.tar.gz.sha256, which the build verifies.
-RSYNC_VERSION=3.4.1
+RSYNC_VERSION=3.5.0
RSYNC_URL=https://download.samba.org/pub/rsync/src/rsync-${RSYNC_VERSION}.tar.gz
-# Go SSH transport (rsh). Matches the go.mod go directive; x/crypto needs 1.25+.
-GO_VERSION=1.25.0
-GO_SHA256=2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613
+# Go SSH transport (rsh). Matches the go.mod go directive.
+GO_VERSION=1.26.6
+GO_SHA256=708effb774be8237570d0add163225abbdfaf4fca28b2611df167beba4feef89
+X_CRYPTO_VERSION=0.54.0
+X_SYS_VERSION=0.47.0
# Android toolchain. ANDROID_NDK is the sdkmanager package revision.
# JDK_RELEASE is the exact Temurin GA build; JDK_VERSION its major.