diff options
| author | Lena <lena@omega> | 2026-08-16 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-16 00:00:00 +0000 |
| commit | 8c6390571d28ff21a98ed3802458aceddbb6d2a0 (patch) | |
| tree | 1903e6a19389b127d4857f20a8cf06195d68daae /ci | |
| parent | 0e8a87f2ad2bd40d34996374aebb6dff070e4b5f (diff) | |
| download | rsend-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-x | ci/build.sh | 13 | ||||
| -rwxr-xr-x | ci/setup-toolchain.sh | 290 | ||||
| -rwxr-xr-x | ci/test.sh | 152 | ||||
| -rwxr-xr-x | ci/verify-apk.sh | 194 | ||||
| -rwxr-xr-x | ci/verify-repro.sh | 51 |
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" @@ -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 |