aboutsummaryrefslogtreecommitdiff
path: root/ci
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
commit8c6390571d28ff21a98ed3802458aceddbb6d2a0 (patch)
tree1903e6a19389b127d4857f20a8cf06195d68daae /ci
parent0e8a87f2ad2bd40d34996374aebb6dff070e4b5f (diff)
downloadrsend-8c6390571d28ff21a98ed3802458aceddbb6d2a0.tar.gz
build: pin and verify release inputs
Support Alpine/musl and Debian/glibc hosts, lock downloaded and Gradle artifacts, and verify native and APK security properties.
Diffstat (limited to 'ci')
-rwxr-xr-xci/build.sh13
-rwxr-xr-xci/setup-toolchain.sh290
-rwxr-xr-xci/test.sh152
-rwxr-xr-xci/verify-apk.sh194
-rwxr-xr-xci/verify-repro.sh51
5 files changed, 612 insertions, 88 deletions
diff --git a/ci/build.sh b/ci/build.sh
index c99ec5f..24cd8fc 100755
--- a/ci/build.sh
+++ b/ci/build.sh
@@ -3,10 +3,15 @@
# Requires the pinned toolchain (see versions): Android SDK + NDK, a JDK, Go.
set -eu
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
make -C "$root" app
-git -C "$root" rev-parse HEAD > "$root/app/build/outputs/apk/release/.source-commit"
-echo "ci: APK at app/build/outputs/apk/release/"
-ls -l "$root"/app/build/outputs/apk/release/*.apk
+if [ -f "$root/keystore.properties" ]; then
+ apk="$root/app/build/outputs/apk/release/app-release.apk"
+else
+ apk="$root/app/build/outputs/apk/release/app-release-unsigned.apk"
+fi
+echo "ci: APK at $apk"
+ls -l "$apk"
+"$root/ci/verify-apk.sh" "$apk"
diff --git a/ci/setup-toolchain.sh b/ci/setup-toolchain.sh
index 6ed09c6..a62f633 100755
--- a/ci/setup-toolchain.sh
+++ b/ci/setup-toolchain.sh
@@ -1,88 +1,298 @@
#!/bin/sh
-# Provision the pinned Android build toolchain into TOOLCHAIN_DIR (default
-# $HOME/toolchains): JDK 17, Android cmdline-tools, SDK platform, build-tools,
-# NDK, and Gradle. Idempotent: re-running only fills what is missing. Writes an
-# env.sh to source before building. This is what CI runs to get a build host.
+# Provision the exact Android, Java, Go, and Gradle toolchain into
+# TOOLCHAIN_DIR. Standalone tools are installed from staging directories;
+# sdkmanager packages are checked for exact revisions after installation.
set -eu
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+# shellcheck source=versions
. "$root/versions"
dir=${TOOLCHAIN_DIR:-$HOME/toolchains}
sdk="$dir/android-sdk"
-mkdir -p "$dir/dl"
-jdk="$dir/jdk-$JDK_RELEASE"
cmdline="$sdk/cmdline-tools/$ANDROID_CMDLINE_TOOLS"
+gradle="$dir/gradle-$GRADLE_VERSION"
goroot="$dir/go-$GO_VERSION"
+stage=
+part=
+trap 'if [ -n "$stage" ]; then rm -rf "$stage"; fi; if [ -n "$part" ]; then rm -f "$part"; fi' 0 1 2 3 15
+
+if [ "$(uname -s)" != Linux ] || [ "$(uname -m)" != x86_64 ]; then
+ echo "setup: only Linux x86_64 build hosts are supported" >&2
+ exit 1
+fi
+
+# Temurin publishes separate x86_64 archives for glibc and musl. Detect the
+# host before naming the cache so an archive for one libc can never be reused
+# on the other.
+if getconf GNU_LIBC_VERSION >/dev/null 2>&1; then
+ jdk_libc=glibc
+ jdk_platform=linux
+ jdk_sha256=$JDK_GLIBC_SHA256
+else
+ musl_loader=
+ for candidate in /lib/ld-musl-x86_64.so.1 /lib64/ld-musl-x86_64.so.1 /usr/lib/ld-musl-x86_64.so.1; do
+ if [ -e "$candidate" ]; then
+ musl_loader=$candidate
+ break
+ fi
+ done
+ [ -n "$musl_loader" ] || {
+ echo "setup: host libc is neither glibc nor musl" >&2
+ exit 1
+ }
+ jdk_libc=musl
+ jdk_platform=alpine-linux
+ jdk_sha256=$JDK_MUSL_SHA256
+fi
+jdk="$dir/jdk-$JDK_RELEASE-$jdk_libc"
+jdk_mark="$JDK_RELEASE $jdk_libc"
+vulncheck="$dir/govulncheck-$GOVULNCHECK_VERSION-$jdk_libc"
+vulncheck_mark="$GOVULNCHECK_VERSION $GO_VERSION $jdk_libc"
+
+mkdir -p "$dir/dl"
download() {
url=$1
file=$2
want=$3
- curl -fsSL "$url" -o "$file"
- printf '%s %s\n' "$want" "$file" | sha256sum -c -
+ if [ -f "$file" ]; then
+ actual=$(sha256sum "$file")
+ if [ "${actual%% *}" = "$want" ]; then
+ return
+ fi
+ echo "setup: removing corrupt $(basename "$file")" >&2
+ rm -f "$file"
+ fi
+ part="$file.part.$$"
+ curl --fail --silent --show-error --location --retry 3 \
+ --connect-timeout 30 --speed-limit 1024 --speed-time 60 \
+ "$url" -o "$part"
+ actual=$(sha256sum "$part")
+ [ "${actual%% *}" = "$want" ] || {
+ echo "setup: checksum failed for $(basename "$file")" >&2
+ exit 1
+ }
+ mv "$part" "$file"
+ part=
+}
+
+mark_complete() {
+ cat <<EOF > "$1/.rsend-complete"
+$2
+EOF
+}
+
+marked() {
+ [ -f "$1/.rsend-complete" ] && [ "$(cat "$1/.rsend-complete")" = "$2" ]
+}
+
+java_works() {
+ java_root=$1
+ version_output=$("$java_root/bin/java" -version 2>&1) || return 1
+ case "$version_output" in
+ *"$JDK_RELEASE"*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+gradle_works() {
+ gradle_root=$1
+ version_output=$("$gradle_root/bin/gradle" --version 2>/dev/null) || return 1
+ grep -Fqx "Gradle $GRADLE_VERSION" <<EOF
+$version_output
+EOF
+}
+
+go_works() {
+ go_root=$1
+ version_output=$(GOROOT="$go_root" "$go_root/bin/go" version 2>/dev/null) || return 1
+ case "$version_output" in
+ *"go version go$GO_VERSION "*) return 0 ;;
+ *) return 1 ;;
+ esac
}
-# JDK (Temurin, exact pinned release; + must be %2B in the URL).
-if [ ! -x "$jdk/bin/java" ]; then
- echo "setup: JDK $JDK_RELEASE"
+govulncheck_works() {
+ vulncheck_root=$1
+ version_output=$("$vulncheck_root/bin/govulncheck" -version 2>/dev/null) || return 1
+ grep -Fqx "Scanner: govulncheck@v$GOVULNCHECK_VERSION" <<EOF
+$version_output
+EOF
+}
+
+java_ready() {
+ [ -x "$jdk/bin/java" ] && marked "$jdk" "$jdk_mark" && java_works "$jdk"
+}
+
+gradle_ready() {
+ [ -x "$gradle/bin/gradle" ] && marked "$gradle" "$GRADLE_VERSION" &&
+ gradle_works "$gradle"
+}
+
+go_ready() {
+ [ -x "$goroot/bin/go" ] && marked "$goroot" "$GO_VERSION" &&
+ go_works "$goroot"
+}
+
+govulncheck_ready() {
+ [ -x "$vulncheck/bin/govulncheck" ] && marked "$vulncheck" "$vulncheck_mark" &&
+ govulncheck_works "$vulncheck"
+}
+
+# JDK (Temurin, exact pinned release and host libc; + must be %2B in the URL).
+if ! java_ready; then
+ echo "setup: JDK $JDK_RELEASE ($jdk_libc)"
rel=$(printf '%s' "$JDK_RELEASE" | sed 's/+/%2B/')
- download "https://api.adoptium.net/v3/binary/version/jdk-${rel}/linux/x64/jdk/hotspot/normal/eclipse" \
- "$dir/dl/jdk-$JDK_RELEASE.tar.gz" "$JDK_SHA256"
- mkdir -p "$jdk"
- tar xzf "$dir/dl/jdk-$JDK_RELEASE.tar.gz" -C "$jdk" --strip-components=1
+ jdk_archive="$dir/dl/jdk-$JDK_RELEASE-$jdk_libc.tar.gz"
+ download "https://api.adoptium.net/v3/binary/version/jdk-${rel}/${jdk_platform}/x64/jdk/hotspot/normal/eclipse" \
+ "$jdk_archive" "$jdk_sha256"
+ stage=$(mktemp -d "$dir/.jdk-$JDK_RELEASE-$jdk_libc.XXXXXX")
+ tar xzf "$jdk_archive" -C "$stage" --strip-components=1
+ if [ ! -x "$stage/bin/java" ] || ! java_works "$stage"; then
+ echo "setup: JDK cannot run on this host" >&2
+ exit 1
+ fi
+ mark_complete "$stage" "$jdk_mark"
+ rm -rf "$jdk"
+ mv "$stage" "$jdk"
+ stage=
fi
export JAVA_HOME="$jdk"
export PATH="$JAVA_HOME/bin:$PATH"
-# Android cmdline-tools.
-if [ ! -x "$cmdline/bin/sdkmanager" ]; then
+# Android command-line tools.
+if [ ! -x "$cmdline/bin/sdkmanager" ] || ! marked "$cmdline" "$ANDROID_CMDLINE_TOOLS" ||
+ ! "$cmdline/bin/sdkmanager" --sdk_root="$sdk" --version >/dev/null 2>&1; then
echo "setup: cmdline-tools $ANDROID_CMDLINE_TOOLS"
download "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS}_latest.zip" \
"$dir/dl/clt-$ANDROID_CMDLINE_TOOLS.zip" "$ANDROID_CMDLINE_TOOLS_SHA256"
- rm -rf "$dir/dl/clt-$ANDROID_CMDLINE_TOOLS"
- python3 -m zipfile -e "$dir/dl/clt-$ANDROID_CMDLINE_TOOLS.zip" "$dir/dl/clt-$ANDROID_CMDLINE_TOOLS"
mkdir -p "$sdk/cmdline-tools"
+ stage=$(mktemp -d "$sdk/cmdline-tools/.cmdline-$ANDROID_CMDLINE_TOOLS.XXXXXX")
+ python3 -m zipfile -e "$dir/dl/clt-$ANDROID_CMDLINE_TOOLS.zip" "$stage"
+ chmod +x "$stage/cmdline-tools/bin/"*
+ [ -x "$stage/cmdline-tools/bin/sdkmanager" ] || {
+ echo "setup: command-line tools archive has no sdkmanager" >&2
+ exit 1
+ }
+ "$stage/cmdline-tools/bin/sdkmanager" --sdk_root="$sdk" --version >/dev/null
+ mark_complete "$stage/cmdline-tools" "$ANDROID_CMDLINE_TOOLS"
rm -rf "$cmdline"
- mv "$dir/dl/clt-$ANDROID_CMDLINE_TOOLS/cmdline-tools" "$cmdline"
- chmod +x "$cmdline/bin/"*
+ mv "$stage/cmdline-tools" "$cmdline"
+ rm -rf "$stage"
+ stage=
fi
-# SDK packages.
-echo "setup: sdk packages"
-yes | "$cmdline/bin/sdkmanager" --licenses >/dev/null
-"$cmdline/bin/sdkmanager" \
- "platforms;android-${ANDROID_PLATFORM}" \
- "build-tools;${ANDROID_BUILD_TOOLS}" \
- "ndk;${ANDROID_NDK}" >/dev/null
+# SDK packages. A complete pinned install needs no network on repeated runs.
+export ANDROID_HOME="$sdk"
+export ANDROID_SDK_ROOT="$sdk"
+sdk_packages_ready() {
+ [ -f "$sdk/platforms/android-$ANDROID_PLATFORM/android.jar" ] &&
+ grep -Fqx "AndroidVersion.ApiLevel=$ANDROID_PLATFORM" \
+ "$sdk/platforms/android-$ANDROID_PLATFORM/source.properties" &&
+ grep -Fqx "Pkg.Revision=$ANDROID_PLATFORM_REVISION" \
+ "$sdk/platforms/android-$ANDROID_PLATFORM/source.properties" &&
+ platform_sum=$(sha256sum "$sdk/platforms/android-$ANDROID_PLATFORM/android.jar") &&
+ [ "${platform_sum%% *}" = "$ANDROID_PLATFORM_JAR_SHA256" ] &&
+ [ -x "$sdk/build-tools/$ANDROID_BUILD_TOOLS/aapt" ] &&
+ grep -Fqx "Pkg.Revision=$ANDROID_BUILD_TOOLS" \
+ "$sdk/build-tools/$ANDROID_BUILD_TOOLS/source.properties" &&
+ [ -d "$sdk/ndk/$ANDROID_NDK" ] &&
+ grep -Fqx "Pkg.Revision = $ANDROID_NDK" \
+ "$sdk/ndk/$ANDROID_NDK/source.properties" &&
+ [ -s "$sdk/licenses/android-sdk-license" ]
+}
+if ! sdk_packages_ready; then
+ echo "setup: SDK packages"
+ license_answers="$dir/dl/android-license-answers"
+ awk 'BEGIN { for (i = 0; i < 100; i++) print "y" }' > "$license_answers"
+ "$cmdline/bin/sdkmanager" --sdk_root="$sdk" --licenses \
+ < "$license_answers" >/dev/null
+ "$cmdline/bin/sdkmanager" --sdk_root="$sdk" \
+ "platforms;android-${ANDROID_PLATFORM}" \
+ "build-tools;${ANDROID_BUILD_TOOLS}" \
+ "ndk;${ANDROID_NDK}" >/dev/null
+ sdk_packages_ready || {
+ echo "setup: Android SDK packages are incomplete" >&2
+ exit 1
+ }
+fi
+ndk_clang="$sdk/ndk/$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/clang"
+if [ ! -x "$ndk_clang" ] || ! "$ndk_clang" --version >/dev/null 2>&1; then
+ echo "setup: Android NDK cannot run on this host (Alpine needs gcompat)" >&2
+ exit 1
+fi
# Gradle.
-if [ ! -x "$dir/gradle-${GRADLE_VERSION}/bin/gradle" ]; then
+if ! gradle_ready; then
echo "setup: Gradle $GRADLE_VERSION"
download "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
"$dir/dl/gradle-$GRADLE_VERSION.zip" "$GRADLE_SHA256"
- python3 -m zipfile -e "$dir/dl/gradle-$GRADLE_VERSION.zip" "$dir"
- chmod +x "$dir/gradle-${GRADLE_VERSION}/bin/gradle"
+ stage=$(mktemp -d "$dir/.gradle-$GRADLE_VERSION.XXXXXX")
+ python3 -m zipfile -e "$dir/dl/gradle-$GRADLE_VERSION.zip" "$stage"
+ chmod +x "$stage/gradle-$GRADLE_VERSION/bin/gradle"
+ [ -x "$stage/gradle-$GRADLE_VERSION/bin/gradle" ] || {
+ echo "setup: Gradle archive has no gradle" >&2
+ exit 1
+ }
+ gradle_works "$stage/gradle-$GRADLE_VERSION" || {
+ echo "setup: Gradle cannot run on this host" >&2
+ exit 1
+ }
+ mark_complete "$stage/gradle-$GRADLE_VERSION" "$GRADLE_VERSION"
+ rm -rf "$gradle"
+ mv "$stage/gradle-$GRADLE_VERSION" "$gradle"
+ rm -rf "$stage"
+ stage=
fi
+export GRADLE_HOME="$gradle"
-# Go (pinned; builds rsh).
-if [ ! -x "$goroot/bin/go" ]; then
+# Go.
+if ! go_ready; then
echo "setup: Go $GO_VERSION"
download "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
"$dir/dl/go-$GO_VERSION.tar.gz" "$GO_SHA256"
- mkdir -p "$goroot"
- tar xzf "$dir/dl/go-$GO_VERSION.tar.gz" -C "$goroot" --strip-components=1
+ stage=$(mktemp -d "$dir/.go-$GO_VERSION.XXXXXX")
+ tar xzf "$dir/dl/go-$GO_VERSION.tar.gz" -C "$stage" --strip-components=1
+ if [ ! -x "$stage/bin/go" ] || ! go_works "$stage"; then
+ echo "setup: Go cannot run on this host" >&2
+ exit 1
+ fi
+ mark_complete "$stage" "$GO_VERSION"
+ rm -rf "$goroot"
+ mv "$stage" "$goroot"
+ stage=
+fi
+export GOROOT="$goroot"
+export PATH="$GOROOT/bin:$PATH"
+
+# Source-aware vulnerability scanner. It queries the public Go vulnerability
+# database when ci/test.sh invokes it; the source tree itself is not uploaded.
+if ! govulncheck_ready; then
+ echo "setup: govulncheck $GOVULNCHECK_VERSION"
+ stage=$(mktemp -d "$dir/.govulncheck-$GOVULNCHECK_VERSION-$jdk_libc.XXXXXX")
+ mkdir -p "$stage/bin"
+ GOBIN="$stage/bin" "$goroot/bin/go" install \
+ "golang.org/x/vuln/cmd/govulncheck@v${GOVULNCHECK_VERSION}"
+ if [ ! -x "$stage/bin/govulncheck" ] || ! govulncheck_works "$stage"; then
+ echo "setup: govulncheck install failed" >&2
+ exit 1
+ fi
+ mark_complete "$stage" "$vulncheck_mark"
+ rm -rf "$vulncheck"
+ mv "$stage" "$vulncheck"
+ stage=
fi
-# Emit the env to source before building.
-cat > "$dir/env.sh" <<EOF
+# Emit the environment to source before building.
+cat <<EOF > "$dir/env.sh"
export JAVA_HOME="$jdk"
export GOROOT="$goroot"
export ANDROID_HOME="$sdk"
export ANDROID_SDK_ROOT="$sdk"
export ANDROID_NDK_HOME="$sdk/ndk/${ANDROID_NDK}"
-export GRADLE_HOME="$dir/gradle-${GRADLE_VERSION}"
-export PATH="\$JAVA_HOME/bin:\$GOROOT/bin:\$GRADLE_HOME/bin:$cmdline/bin:$sdk/build-tools/${ANDROID_BUILD_TOOLS}:\$PATH"
+export GRADLE_HOME="$gradle"
+export PATH="\$JAVA_HOME/bin:\$GOROOT/bin:\$GRADLE_HOME/bin:$vulncheck/bin:$cmdline/bin:$sdk/build-tools/${ANDROID_BUILD_TOOLS}:\$PATH"
EOF
echo "setup: done; source $dir/env.sh"
diff --git a/ci/test.sh b/ci/test.sh
index 2e6b14d..e3e728c 100755
--- a/ci/test.sh
+++ b/ci/test.sh
@@ -1,54 +1,146 @@
#!/bin/sh
-# Full checks: source pins, Go formatting/vet/tests, real-rsync integration,
-# and Android JVM tests. Requires the provisioned toolchain and host rsync.
+# Full checks: exact source pins, shell syntax, Go analysis, integration tests,
+# vulnerability scanning, Android JVM tests, and Android lint.
set -eu
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+# shellcheck source=versions
. "$root/versions"
-command -v rsync >/dev/null 2>&1 || { echo "ci: rsync is required" >&2; exit 1; }
-command -v gradle >/dev/null 2>&1 || { echo "ci: gradle is required" >&2; exit 1; }
+for command in cmp git govulncheck gradle go gofmt rsync sha256sum shellcheck; do
+ command -v "$command" >/dev/null 2>&1 || {
+ echo "ci: $command is required" >&2
+ exit 1
+ }
+done
if [ -f "$root/rsync/rsync-${RSYNC_VERSION}.tar.gz" ]; then
echo "ci: verifying pinned rsync checksum"
( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" )
fi
-# The gradle files and go.mod repeat pins from ./versions (gradle cannot source
-# a shell file); fail loud if they drift.
+# These files repeat values from ./versions because Gradle and Go cannot source
+# a POSIX shell file. Exact source lines keep comments from satisfying a pin.
+line() {
+ grep -Fqx -e "$2" "$root/$1" || {
+ echo "ci: $1 does not contain exact pin: $2" >&2
+ exit 1
+ }
+}
+
echo "ci: version pins"
-pin() {
- grep -Fq "$2" "$root/$1" || { echo "ci: $1 does not pin '$2' (see versions)" >&2; exit 1; }
+line build.gradle " id 'com.android.application' version '${AGP_VERSION}' apply false"
+line build.gradle " id 'org.jetbrains.kotlin.android' version '${KOTLIN_VERSION}' apply false"
+line gradle/wrapper/gradle-wrapper.properties "distributionUrl=https\\://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip"
+line gradle/wrapper/gradle-wrapper.properties "distributionSha256Sum=${GRADLE_SHA256}"
+line app/build.gradle " compileSdk ${ANDROID_PLATFORM}"
+line app/build.gradle " buildToolsVersion '${ANDROID_BUILD_TOOLS}'"
+line app/build.gradle " minSdk ${ANDROID_MIN_SDK}"
+line app/build.gradle " targetSdk ${ANDROID_TARGET_SDK}"
+line app/build.gradle " versionCode ${APP_VERSION_CODE}"
+line app/build.gradle " versionName '${APP_VERSION}'"
+line app/build.gradle " implementation 'androidx.core:core-ktx:${ANDROIDX_CORE_VERSION}'"
+line app/build.gradle " implementation 'androidx.appcompat:appcompat:${ANDROIDX_APPCOMPAT_VERSION}'"
+line app/build.gradle " implementation 'androidx.work:work-runtime:${ANDROIDX_WORK_VERSION}'"
+line app/build.gradle " testImplementation 'junit:junit:${JUNIT_VERSION}'"
+line app/build.gradle " testImplementation 'org.json:json:${JSON_VERSION}'"
+line rsh/go.mod "go ${GO_VERSION}"
+line rsh/go.mod "require golang.org/x/crypto v${X_CRYPTO_VERSION}"
+line rsh/go.mod "require golang.org/x/sys v${X_SYS_VERSION} // indirect"
+line THIRD_PARTY "- rsync ${RSYNC_VERSION}, GPLv3. Source: https://download.samba.org/pub/rsync/src/."
+line THIRD_PARTY "- The Go standard library from Go ${GO_VERSION}, BSD 3-clause."
+line THIRD_PARTY "- golang.org/x/crypto ${X_CRYPTO_VERSION}, BSD 3-clause."
+line THIRD_PARTY "APK. golang.org/x/sys ${X_SYS_VERSION} is an indirect module-graph dependency but no"
+line THIRD_PARTY "- AndroidX Core ${ANDROIDX_CORE_VERSION}, AppCompat ${ANDROIDX_APPCOMPAT_VERSION}, and WorkManager ${ANDROIDX_WORK_VERSION},"
+cmp "$root/APACHE-2.0" "$root/app/src/main/assets/APACHE-2.0" || {
+ echo "ci: packaged APACHE-2.0 differs from repository APACHE-2.0" >&2
+ exit 1
+}
+cmp "$root/LICENSE" "$root/app/src/main/assets/LICENSE" || {
+ echo "ci: packaged LICENSE differs from repository LICENSE" >&2
+ exit 1
+}
+cmp "$root/THIRD_PARTY" "$root/app/src/main/assets/THIRD_PARTY" || {
+ echo "ci: packaged THIRD_PARTY differs from repository THIRD_PARTY" >&2
+ exit 1
+}
+[ -s "$root/gradle/verification-metadata.xml" ] || {
+ echo "ci: Gradle dependency verification metadata is missing" >&2
+ exit 1
+}
+tracked_files=$(git -C "$root" ls-files)
+tracked_secrets=
+if tracked_secrets=$(printf '%s\n' "$tracked_files" |
+ grep -E '(^|/)(keystore\.properties|[^/]*\.(jks|keystore))$'); then
+ :
+else
+ status=$?
+ [ "$status" -eq 1 ] || exit "$status"
+fi
+[ -z "$tracked_secrets" ] || {
+ echo "ci: signing secret is tracked:" >&2
+ printf '%s\n' "$tracked_secrets" >&2
+ exit 1
}
-pin build.gradle "version '${AGP_VERSION}'"
-pin build.gradle "version '${KOTLIN_VERSION}'"
-pin gradle/wrapper/gradle-wrapper.properties "gradle-${GRADLE_VERSION}-bin.zip"
-pin app/build.gradle "compileSdk ${ANDROID_PLATFORM}"
-pin app/build.gradle "buildToolsVersion '${ANDROID_BUILD_TOOLS}'"
-pin app/build.gradle "minSdk ${ANDROID_MIN_SDK}"
-pin app/build.gradle "targetSdk ${ANDROID_TARGET_SDK}"
-pin rsh/go.mod "go ${GO_VERSION}"
-pin app/build.gradle "androidx.core:core-ktx:${ANDROIDX_CORE_VERSION}"
-pin app/build.gradle "androidx.appcompat:appcompat:${ANDROIDX_APPCOMPAT_VERSION}"
-pin app/build.gradle "androidx.work:work-runtime-ktx:${ANDROIDX_WORK_VERSION}"
-pin app/build.gradle "junit:junit:${JUNIT_VERSION}"
-pin app/build.gradle "org.json:json:${JSON_VERSION}"
+abi_filters=
+for abi in $ABIS; do
+ if [ -z "$abi_filters" ]; then
+ abi_filters="'$abi'"
+ else
+ abi_filters="$abi_filters, '$abi'"
+ fi
+done
+line app/build.gradle " ndk { abiFilters $abi_filters }"
-echo "ci: gofmt"
-unformatted=$(gofmt -l "$root/rsh")
-if [ -n "$unformatted" ]; then
- echo "ci: gofmt needed for:" >&2
- echo "$unformatted" >&2
+checksum="$root/rsync/rsync-${RSYNC_VERSION}.tar.gz.sha256"
+[ -f "$checksum" ] || { echo "ci: missing $checksum" >&2; exit 1; }
+[ "$(awk 'NF == 2 { print $2 }' "$checksum")" = "rsync-${RSYNC_VERSION}.tar.gz" ] || {
+ echo "ci: checksum filename does not match rsync pin" >&2
+ exit 1
+}
+
+changelog="fastlane/metadata/android/en-US/changelogs/${APP_VERSION_CODE}.txt"
+[ -s "$root/$changelog" ] || { echo "ci: missing $changelog for version ${APP_VERSION}" >&2; exit 1; }
+chars=$(wc -c < "$root/$changelog")
+[ "$chars" -le 500 ] || { echo "ci: $changelog is $chars bytes, over F-Droid's 500 limit" >&2; exit 1; }
+
+echo "ci: shell syntax"
+shell_scripts=$(git -C "$root" ls-files --cached --others --exclude-standard '*.sh')
+printf '%s\n' "$shell_scripts" |
+while IFS= read -r script; do
+ sh -n "$root/$script"
+done
+( cd "$root" && shellcheck -x ci/*.sh rsync/build.sh rsh/build.sh )
+em_dash=$(printf '\342\200\224')
+if git -C "$root" grep --untracked -n -I "$em_dash"; then
+ echo "ci: em dash is not allowed" >&2
exit 1
+else
+ status=$?
+ [ "$status" -eq 1 ] || exit "$status"
fi
+echo "ci: gofmt"
+unformatted=$(gofmt -l "$root/rsh")
+[ -z "$unformatted" ] || { echo "ci: gofmt needed for:$unformatted" >&2; exit 1; }
+
+echo "ci: go module checksums"
+( cd "$root/rsh" && go mod verify )
+
echo "ci: go vet"
( cd "$root/rsh" && go vet ./... )
-echo "ci: go test"
-( cd "$root/rsh" && go test ./... )
+echo "ci: go test (race detector)"
+( cd "$root/rsh" && go test -race ./... )
+
+# govulncheck queries vuln.go.dev and sends module paths, not source code.
+echo "ci: govulncheck"
+( cd "$root/rsh" && govulncheck ./... )
echo "ci: Android unit tests"
-( cd "$root" && gradle :app:testReleaseUnitTest )
+( cd "$root" && gradle --no-daemon :app:testReleaseUnitTest )
+
+echo "ci: Android lint"
+( cd "$root" && gradle --no-daemon :app:lintRelease )
echo "ci: ok"
diff --git a/ci/verify-apk.sh b/ci/verify-apk.sh
new file mode 100755
index 0000000..fde1cb8
--- /dev/null
+++ b/ci/verify-apk.sh
@@ -0,0 +1,194 @@
+#!/bin/sh
+# Verify the release APK's identity, alignment, native payload, and signature.
+set -eu
+
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+# shellcheck source=versions
+. "$root/versions"
+apk=${1:-$root/app/build/outputs/apk/release/app-release-unsigned.apk}
+[ -f "$apk" ] || { echo "verify-apk: not found: $apk" >&2; exit 1; }
+command -v readelf >/dev/null 2>&1 || { echo "verify-apk: readelf is required" >&2; exit 1; }
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' 0 1 2 3 15
+
+entries=$(unzip -Z1 "$apk")
+actual=$(printf '%s\n' "$entries" | sed -n 's|^lib/\([^/]*\)/.*|\1|p' | sort -u | tr '\n' ' ' | sed 's/ $//')
+expected=$(printf '%s\n' "$ABIS" | tr ' ' '\n' | sed '/^$/d' | sort -u | tr '\n' ' ' | sed 's/ $//')
+[ "$actual" = "$expected" ] || {
+ echo "verify-apk: native ABIs are '$actual', expected '$expected'" >&2
+ exit 1
+}
+for abi in $ABIS; do
+ for lib in libxrsync.so libxrsh.so; do
+ path="lib/$abi/$lib"
+ printf '%s\n' "$entries" | grep -Fqx "$path" || {
+ echo "verify-apk: missing lib/$abi/$lib" >&2
+ exit 1
+ }
+ elf="$tmp/$abi-$lib"
+ unzip -p "$apk" "$path" > "$elf"
+ header=$(readelf -W -h "$elf")
+ printf '%s\n' "$header" | grep -Fq 'DYN (Position-Independent Executable file)' || {
+ echo "verify-apk: $path is not PIE" >&2
+ exit 1
+ }
+ case "$abi" in
+ arm64-v8a) machine=AArch64 ;;
+ x86_64) machine='Advanced Micro Devices X86-64' ;;
+ *) echo "verify-apk: no machine check for $abi" >&2; exit 1 ;;
+ esac
+ actual_machine=$(printf '%s\n' "$header" | sed -n 's/^[[:space:]]*Machine:[[:space:]]*//p')
+ [ "$actual_machine" = "$machine" ] || {
+ echo "verify-apk: $path has the wrong machine type" >&2
+ exit 1
+ }
+ program_headers=$(readelf -W -l "$elf")
+ printf '%s\n' "$program_headers" | grep -Fq 'GNU_RELRO' || {
+ echo "verify-apk: $path has no RELRO segment" >&2
+ exit 1
+ }
+ load_alignment=$(printf '%s\n' "$program_headers" |
+ awk '$1 == "LOAD" { print $NF }' | sort -u)
+ [ "$load_alignment" = "0x4000" ] || {
+ echo "verify-apk: $path LOAD alignment is '$load_alignment', expected 0x4000" >&2
+ exit 1
+ }
+ stack=$(printf '%s\n' "$program_headers" | sed -n '/GNU_STACK/p')
+ if [ -z "$stack" ] || printf '%s\n' "$stack" | grep -Eq 'RWE|RWX'; then
+ echo "verify-apk: $path has an executable or missing stack marker" >&2
+ exit 1
+ fi
+ dynamic=$(readelf -W -d "$elf")
+ printf '%s\n' "$dynamic" | grep -Fq 'BIND_NOW' || {
+ echo "verify-apk: $path does not use immediate binding" >&2
+ exit 1
+ }
+ ! printf '%s\n' "$dynamic" | grep -Eq 'RPATH|RUNPATH|TEXTREL' || {
+ echo "verify-apk: $path has an unsafe dynamic-linker flag" >&2
+ exit 1
+ }
+ needed=$(printf '%s\n' "$dynamic" | sed -n 's/.*Shared library: \[\(.*\)\]/\1/p' | LC_ALL=C sort)
+ if [ "$lib" = libxrsh.so ]; then
+ expected_needed=$(printf '%s\n' libc.so libdl.so liblog.so | LC_ALL=C sort)
+ else
+ expected_needed=$(printf '%s\n' libc.so libdl.so | LC_ALL=C sort)
+ fi
+ [ "$needed" = "$expected_needed" ] || {
+ echo "verify-apk: $path system libraries changed:$needed" >&2
+ exit 1
+ }
+ sections=$(readelf -W -S "$elf")
+ ! printf '%s\n' "$sections" | grep -Eq '\.debug_|\.symtab' || {
+ echo "verify-apk: $path is not stripped" >&2
+ exit 1
+ }
+ if [ "$lib" = libxrsync.so ]; then
+ symbols=$(readelf -W -s "$elf")
+ printf '%s\n' "$symbols" | grep -Fq '__stack_chk_fail' || {
+ echo "verify-apk: $path has no stack protector" >&2
+ exit 1
+ }
+ printf '%s\n' "$symbols" | grep -Fq '__memcpy_chk' || {
+ echo "verify-apk: $path has no fortified libc calls" >&2
+ exit 1
+ }
+ fi
+ done
+done
+native_entries=$(printf '%s\n' "$entries" | sed -n '/^lib\//p')
+unexpected=
+if unexpected=$(printf '%s\n' "$native_entries" |
+ grep -Ev '^lib/[^/]+/(libxrsync|libxrsh)\.so$'); then
+ :
+else
+ status=$?
+ [ "$status" -eq 1 ] || exit "$status"
+fi
+[ -z "$unexpected" ] || { echo "verify-apk: unexpected native files:$unexpected" >&2; exit 1; }
+! printf '%s\n' "$entries" | grep -Fqx META-INF/version-control-info.textproto || {
+ echo "verify-apk: APK contains a VCS stamp" >&2
+ exit 1
+}
+for notice in assets/APACHE-2.0 assets/LICENSE assets/THIRD_PARTY; do
+ printf '%s\n' "$entries" | grep -Fqx "$notice" || {
+ echo "verify-apk: missing $notice" >&2
+ exit 1
+ }
+ asset="$tmp/$(basename "$notice")"
+ unzip -p "$apk" "$notice" > "$asset"
+ cmp "$asset" "$root/$(basename "$notice")"
+done
+
+zipalign -c -P 16 4 "$apk" >/dev/null
+badging=$(aapt dump badging "$apk")
+printf '%s\n' "$badging" | grep -Fq "package: name='invalid.lena.rsend' versionCode='$APP_VERSION_CODE' versionName='$APP_VERSION'" || {
+ echo "verify-apk: package version does not match versions" >&2
+ exit 1
+}
+printf '%s\n' "$badging" | grep -Fqx "sdkVersion:'$ANDROID_MIN_SDK'" || {
+ echo "verify-apk: minimum SDK does not match versions" >&2
+ exit 1
+}
+printf '%s\n' "$badging" | grep -Fqx "targetSdkVersion:'$ANDROID_TARGET_SDK'" || {
+ echo "verify-apk: target SDK does not match versions" >&2
+ exit 1
+}
+! printf '%s\n' "$badging" | grep -Fq 'application-debuggable' || {
+ echo "verify-apk: release APK is debuggable" >&2
+ exit 1
+}
+permission_dump=$(aapt dump permissions "$apk")
+permissions=$(printf '%s\n' "$permission_dump" |
+ sed -n "s/^uses-permission: name='\(.*\)'$/\1/p" | LC_ALL=C sort)
+expected_permissions=$(cat <<EOF | LC_ALL=C sort
+android.permission.ACCESS_NETWORK_STATE
+android.permission.FOREGROUND_SERVICE
+android.permission.FOREGROUND_SERVICE_DATA_SYNC
+android.permission.INTERNET
+android.permission.MANAGE_EXTERNAL_STORAGE
+android.permission.POST_NOTIFICATIONS
+android.permission.RECEIVE_BOOT_COMPLETED
+android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
+android.permission.WAKE_LOCK
+invalid.lena.rsend.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
+EOF
+)
+[ "$permissions" = "$expected_permissions" ] || {
+ echo "verify-apk: permission set changed:$permissions" >&2
+ exit 1
+}
+
+signature="$tmp/signature"
+if apksigner verify --min-sdk-version "$ANDROID_MIN_SDK" --verbose "$apk" >"$signature" 2>&1; then
+ case "$(basename "$apk")" in
+ *-unsigned.apk)
+ echo "verify-apk: unsigned APK unexpectedly has a valid signature" >&2
+ exit 1
+ ;;
+ esac
+ # At API 30 apksigner reports only v3 as used, even when valid v1 and v2
+ # signatures are present. API 23 makes it verify v1 for that platform and
+ # v2/v3 for later platforms in the same pass.
+ schemes="$tmp/schemes"
+ apksigner verify --min-sdk-version 23 --verbose "$apk" >"$schemes" 2>&1
+ grep -Fq 'Verified using v1 scheme (JAR signing): true' "$schemes"
+ grep -Fq 'Verified using v2 scheme (APK Signature Scheme v2): true' "$schemes"
+ grep -Fq 'Verified using v3 scheme (APK Signature Scheme v3): true' "$schemes"
+ echo "verify-apk: signed APK verified (v1, v2, v3)"
+else
+ case "$(basename "$apk")" in
+ *-unsigned.apk) ;;
+ *)
+ cat "$signature" >&2
+ echo "verify-apk: signed APK failed verification" >&2
+ exit 1
+ ;;
+ esac
+ ! printf '%s\n' "$entries" | grep -Eq '^META-INF/.*\.(RSA|DSA|EC)$' || {
+ echo "verify-apk: unsigned APK contains a signature file" >&2
+ exit 1
+ }
+ echo "verify-apk: unsigned APK"
+fi
+
+echo "verify-apk: ok"
diff --git a/ci/verify-repro.sh b/ci/verify-repro.sh
index 580ccd0..605ec77 100755
--- a/ci/verify-repro.sh
+++ b/ci/verify-repro.sh
@@ -1,26 +1,49 @@
#!/bin/sh
-# Build the complete unsigned APK twice and compare it byte for byte.
+# Build the same committed source in two different absolute paths and compare
+# the unsigned APKs byte for byte.
set -eu
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-a=$(mktemp -d)
-b=$(mktemp -d)
-trap 'rm -rf "$a" "$b"' 0
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+# shellcheck source=versions
+. "$root/versions"
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' 0 1 2 3 15
+a="$tmp/a/src"
+b="$tmp/a-much-longer-path/b/src"
-[ ! -f "$root/keystore.properties" ] || {
- echo "verify-repro: keystore.properties must be absent" >&2
+git -C "$root" diff --quiet
+git -C "$root" diff --cached --quiet
+status=$(git -C "$root" status --porcelain --untracked-files=normal)
+[ -z "$status" ] || {
+ echo "verify-repro: tracked and untracked source must be clean" >&2
exit 1
}
-make -C "$root" app
-cp "$root/app/build/outputs/apk/release/app-release-unsigned.apk" "$a/app.apk"
+mkdir -p "$a" "$b"
+archive="$tmp/source.tar"
+git -C "$root" archive --format=tar HEAD > "$archive"
+tar -xf "$archive" -C "$a"
+tar -xf "$archive" -C "$b"
-rm -rf "$root/app/build" "$root/app/src/main/jniLibs" "$root/rsync/work" "$root/out"
-make -C "$root" app
-cp "$root/app/build/outputs/apk/release/app-release-unsigned.apk" "$b/app.apk"
+# Reuse a locally cached, verified source tarball when present. Otherwise the
+# first build fetches it and the second consumes that exact verified file.
+tarball="rsync/rsync-${RSYNC_VERSION}.tar.gz"
+if [ -f "$root/$tarball" ]; then
+ ( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" )
+ cp "$root/$tarball" "$a/$tarball"
+ cp "$root/$tarball" "$b/$tarball"
+fi
+
+make -C "$a" app
+if [ ! -f "$b/$tarball" ]; then
+ cp "$a/$tarball" "$b/$tarball"
+fi
+make -C "$b" app
-if cmp "$a/app.apk" "$b/app.apk"; then
- echo "verify-repro: APKs are bit-identical"
+first="$a/app/build/outputs/apk/release/app-release-unsigned.apk"
+second="$b/app/build/outputs/apk/release/app-release-unsigned.apk"
+if cmp "$first" "$second"; then
+ echo "verify-repro: APKs are bit-identical across different paths"
else
echo "verify-repro: NONDETERMINISM detected" >&2
exit 1