diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0338887 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate-examples: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate shell syntax + run: | + bash -n \ + scripts/publish-android.sh \ + scripts/validate-android-native-symbols.sh \ + examples/generic/*.sh \ + tests/*.sh + + - name: Run Android native-symbol fixtures + run: tests/test-validate-android-native-symbols.sh + + - name: Parse YAML examples + run: | + python3 -m pip install --disable-pip-version-check 'pyyaml==6.0.2' + python3 - <<'PY' + import glob + import yaml + + paths = [ + "publish-android/action.yml", + *glob.glob("examples/github-actions/*.yml"), + *glob.glob("examples/gitlab-ci/*.yml"), + *glob.glob(".github/workflows/*.yml"), + ] + for path in paths: + with open(path, encoding="utf-8") as handle: + yaml.safe_load(handle) + PY diff --git a/README.md b/README.md index 5ab46fe..1b41b06 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,67 @@ Copy-ready CI examples and reusable actions for publishing app builds to Hands is the release and distribution layer. Your own build system remains responsible for compiling and signing the APK, IPA, or other artifact. +## Android native symbols are part of the build + +If an APK contains the Hands Android SDK's `libhandscrash.so`, its exact +`native-symbols` classifier is a required release artifact. The AAR and APK +contain a stripped library; repacking that file after the build does **not** +restore function or source information. + +Add a resolvable classifier configuration beside the dependency in your Android +app module. This GitHub Packages example deliberately uses one version variable +for both artifacts: + +```kotlin +val handsAndroidSdkVersion = "0.11.2" // keep this as your single source of truth +val handsNativeSymbols by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + isTransitive = false +} + +val copyHandsNativeSymbols by tasks.registering(Sync::class) { + from(handsNativeSymbols) + into(layout.buildDirectory.dir("outputs/hands-native-symbols")) +} + +dependencies { + implementation("build.hands:hands-android-sdk:$handsAndroidSdkVersion") + add( + handsNativeSymbols.name, + "build.hands:hands-android-sdk:$handsAndroidSdkVersion:native-symbols@zip", + ) +} +``` + +For JitPack, keep the AAR and classifier on JitPack rather than mixing channels: + +```kotlin +implementation("com.github.botiverse:hands:android-sdk-v$handsAndroidSdkVersion") +add( + handsNativeSymbols.name, + "com.github.botiverse:hands:android-sdk-v$handsAndroidSdkVersion:native-symbols@zip", +) +``` + +Run `assembleRelease` and `copyHandsNativeSymbols` in the same build. The +validator then enforces all of the following before Hands receives anything: + +- every APK ABI containing `libhandscrash.so` exists in the archive; +- APK, archive, and `manifest.json` ELF build IDs match per ABI; +- manifest SHA-256 values match the unstripped files; +- each symbol file contains `.debug_info`. + +The reusable action defaults to `native-symbols-policy: auto`: an APK containing +`libhandscrash.so` fails when `symbols` is missing. APKs without the Hands native +crash library do not need this archive. `disabled` is an explicit opt-out that +leaves future native crashes unsymbolicatable; do not use it to make a release +green. + +For split or external build jobs, transport the APK and classifier archive as +one immutable artifact set. Never download an APK and generate “symbols” from +its stripped libraries later. + ## GitHub Actions ### Reusable Android publish action @@ -39,6 +100,8 @@ uploads an already-built, signed APK and creates a draft release: version-name: ${{ github.ref_name }} version-code: ${{ github.run_number }} changelog-file: changelog.txt + symbols: path/to/the-resolved/hands-native-symbols.zip + native-symbols-policy: required hands-token: ${{ secrets.HANDS_BEARER_TOKEN }} ``` @@ -51,8 +114,8 @@ deliberate review step. Gradle Android project, signs it through the project's existing release configuration, and publishes the resulting APK as a Hands draft. - [`publish-existing-apk.yml`](examples/github-actions/publish-existing-apk.yml) - publishes an APK produced by an earlier job or downloaded from another build - system. + publishes an APK and its exact native-symbol archive produced by an earlier + job or downloaded from another build system. - [`tauri-publish.yml`](examples/github-actions/tauri-publish.yml) builds signed Tauri v2 updater bundles on macOS, Linux, and Windows, then publishes one multi-platform Hands draft on a single channel. @@ -74,7 +137,9 @@ export HANDS_BEARER_TOKEN='' --channel preview \ --version-name 1.0.0 \ --version-code 1000000 \ - --changelog ./changelog.txt + --changelog ./changelog.txt \ + --symbols ./hands-android-sdk-native-symbols.zip \ + --native-symbols-policy required ``` ## Security boundary @@ -82,6 +147,8 @@ export HANDS_BEARER_TOKEN='' - Use an app-scoped `publisher` deploy token for CI. - Store tokens only in the CI platform's encrypted secret store. - Examples create draft releases by default. +- Android examples validate exact native symbols before upload; validation runs + locally and does not expose symbol bytes or credentials to another service. - Never commit signing material, deploy tokens, or generated credentials. ## Reference @@ -93,7 +160,8 @@ export HANDS_BEARER_TOKEN='' ## GitLab CI - [`examples/gitlab-ci/android-publish.gitlab-ci.yml`](examples/gitlab-ci/android-publish.gitlab-ci.yml) - — build stage placeholder + draft publish with `hands builds publish-android`. + — same-build APK + exact classifier transport, build-ID validation, then draft + publish with `hands builds publish-android`. - [`examples/gitlab-ci/ios-publish.gitlab-ci.yml`](examples/gitlab-ci/ios-publish.gitlab-ci.yml) — IPA + dSYM draft publish; TestFlight upload happens server-side in Hands afterwards. @@ -136,5 +204,6 @@ export HANDS_BEARER_TOKEN='' - [`examples/generic/publish-electron.sh`](examples/generic/publish-electron.sh) - [`examples/generic/publish-tauri.sh`](examples/generic/publish-tauri.sh) -Plain shell, driven by environment variables — drop into Jenkins, Buildkite, -or anything that can run bash and npm. +Plain shell, driven by environment variables. Keep the Android wrapper beside +this repository's `scripts/` directory so it can run the shared validator; the +other platform scripts can be copied independently. diff --git a/examples/generic/publish-android.sh b/examples/generic/publish-android.sh index 6bc3fb0..cd48792 100755 --- a/examples/generic/publish-android.sh +++ b/examples/generic/publish-android.sh @@ -1,15 +1,23 @@ #!/usr/bin/env bash # Publish a signed Android APK to Hands from any CI. # Env: HANDS_BEARER_TOKEN (deploy token), HANDS_APP_SLUG, APK_PATH, -# VERSION_NAME, VERSION_CODE, optional HANDS_CHANNEL (default preview). +# VERSION_NAME, VERSION_CODE, optional HANDS_CHANNEL (default preview), +# NATIVE_SYMBOLS_PATH, MAPPING_PATH, HANDS_NATIVE_SYMBOLS_POLICY. +# Keep this file beside the repository's scripts/ directory; the canonical +# publisher validates APK/native-symbol build IDs before uploading. set -euo pipefail : "${HANDS_BEARER_TOKEN:?}" "${HANDS_APP_SLUG:?}" "${APK_PATH:?}" "${VERSION_NAME:?}" "${VERSION_CODE:?}" -npm install -g @botiverse/hands-cli@0.5.1 >/dev/null +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" git log --no-merges --pretty='- %s' -15 > changelog.txt 2>/dev/null || echo "- release ${VERSION_NAME}" > changelog.txt -hands builds publish-android "${HANDS_APP_SLUG}" \ - --apk "${APK_PATH}" \ - --channel "${HANDS_CHANNEL:-preview}" \ - --version-name "${VERSION_NAME}" \ - --version-code "${VERSION_CODE}" \ - --changelog-file ./changelog.txt \ - --draft +args=( + --app "$HANDS_APP_SLUG" + --apk "$APK_PATH" + --channel "${HANDS_CHANNEL:-preview}" + --version-name "$VERSION_NAME" + --version-code "$VERSION_CODE" + --changelog ./changelog.txt + --native-symbols-policy "${HANDS_NATIVE_SYMBOLS_POLICY:-auto}" +) +test -z "${NATIVE_SYMBOLS_PATH:-}" || args+=(--symbols "$NATIVE_SYMBOLS_PATH") +test -z "${MAPPING_PATH:-}" || args+=(--mapping "$MAPPING_PATH") +"${repo_root}/scripts/publish-android.sh" "${args[@]}" diff --git a/examples/github-actions/android-gradle.yml b/examples/github-actions/android-gradle.yml index 5dc26ed..83f480d 100644 --- a/examples/github-actions/android-gradle.yml +++ b/examples/github-actions/android-gradle.yml @@ -28,9 +28,24 @@ jobs: cache: gradle # Configure your normal release signing before this task. Do not place - # signing keys directly in the workflow file. - - name: Build signed release APK - run: ./gradlew :app:assembleRelease + # signing keys directly in the workflow file. copyHandsNativeSymbols is + # the fail-closed classifier task documented in the repository README; + # it must resolve the classifier from the same Maven channel as the AAR. + - name: Build signed release APK and exact Hands native symbols + id: android-artifacts + shell: bash + run: | + set -euo pipefail + ./gradlew :app:assembleRelease :app:copyHandsNativeSymbols + symbols="$(find app/build/outputs/hands-native-symbols -maxdepth 1 -type f -name '*native-symbols*.zip' -print -quit)" + test -n "$symbols" || { echo "Hands native-symbols classifier was not resolved" >&2; exit 1; } + echo "symbols=$symbols" >> "$GITHUB_OUTPUT" + mapping="app/build/outputs/mapping/release/mapping.txt" + if test -s "$mapping"; then + echo "mapping=$mapping" >> "$GITHUB_OUTPUT" + else + echo "mapping=" >> "$GITHUB_OUTPUT" + fi - name: Write changelog run: git log --no-merges --pretty='- %s' -20 > changelog.txt @@ -44,6 +59,7 @@ jobs: version-name: ${{ inputs.version_name }} version-code: ${{ inputs.version_code }} changelog-file: changelog.txt - mapping: app/build/outputs/mapping/release/mapping.txt + mapping: ${{ steps.android-artifacts.outputs.mapping }} + symbols: ${{ steps.android-artifacts.outputs.symbols }} + native-symbols-policy: required hands-token: ${{ secrets.HANDS_BEARER_TOKEN }} - diff --git a/examples/github-actions/publish-existing-apk.yml b/examples/github-actions/publish-existing-apk.yml index 284d0df..3c3f460 100644 --- a/examples/github-actions/publish-existing-apk.yml +++ b/examples/github-actions/publish-existing-apk.yml @@ -21,12 +21,14 @@ jobs: steps: - uses: actions/checkout@v4 - # Replace this step with actions/download-artifact, a release download, - # or your build system's artifact fetch command. - - name: Fetch signed APK + # Replace this step with actions/download-artifact or your build system's + # artifact fetch command. Fetch the APK and native-symbols archive from + # the same immutable build; symbols cannot be reconstructed from an APK. + - name: Fetch signed APK and exact native symbols run: | mkdir -p dist cp path/to/your/signed-app.apk dist/app-release.apk + cp path/to/the-same-build/native-symbols.zip dist/native-symbols.zip - name: Publish draft to Hands uses: botiverse/hands-examples/publish-android@v1 @@ -36,5 +38,6 @@ jobs: channel: preview version-name: ${{ inputs.version_name }} version-code: ${{ inputs.version_code }} + symbols: dist/native-symbols.zip + native-symbols-policy: required hands-token: ${{ secrets.HANDS_BEARER_TOKEN }} - diff --git a/examples/gitlab-ci/android-publish.gitlab-ci.yml b/examples/gitlab-ci/android-publish.gitlab-ci.yml index acf48d5..ff0ef3a 100644 --- a/examples/gitlab-ci/android-publish.gitlab-ci.yml +++ b/examples/gitlab-ci/android-publish.gitlab-ci.yml @@ -14,22 +14,32 @@ build_apk: stage: build image: eclipse-temurin:17 script: - # Replace with your own build + signing. Only requirement: a signed APK. - - ./gradlew :app:assembleRelease + # copyHandsNativeSymbols is documented in the repository README. It must + # resolve the classifier from the same Maven channel as the SDK AAR. + - ./gradlew :app:assembleRelease :app:copyHandsNativeSymbols + - mkdir -p dist + - cp app/build/outputs/apk/release/app-release.apk dist/app-release.apk + - cp "$(find app/build/outputs/hands-native-symbols -maxdepth 1 -type f -name '*native-symbols*.zip' -print -quit)" dist/native-symbols.zip artifacts: paths: - - app/build/outputs/apk/release/app-release.apk + - dist/app-release.apk + - dist/native-symbols.zip expire_in: 1 day publish_hands: stage: publish image: node:22 script: - - npm install -g @botiverse/hands-cli@0.5.1 + - apt-get update && apt-get install -y --no-install-recommends binutils curl python3 unzip && rm -rf /var/lib/apt/lists/* + - curl -fsSLo /tmp/validate-android-native-symbols.sh https://raw.githubusercontent.com/botiverse/hands-examples/v1/scripts/validate-android-native-symbols.sh + - chmod +x /tmp/validate-android-native-symbols.sh + - /tmp/validate-android-native-symbols.sh --apk dist/app-release.apk --symbols dist/native-symbols.zip --policy required + - npm install -g @botiverse/hands-cli@0.5.4 - git log --no-merges --pretty='- %s' -15 > changelog.txt - > hands builds publish-android "$HANDS_APP_SLUG" - --apk app/build/outputs/apk/release/app-release.apk + --apk dist/app-release.apk + --symbols dist/native-symbols.zip --channel "${HANDS_CHANNEL:-preview}" --version-name "$VERSION_NAME" --version-code "$VERSION_CODE" diff --git a/publish-android/action.yml b/publish-android/action.yml index 251a180..716f991 100644 --- a/publish-android/action.yml +++ b/publish-android/action.yml @@ -27,9 +27,13 @@ inputs: required: false default: "" symbols: - description: Optional native symbols archive. + description: Exact Hands native-symbols classifier archive for this APK. required: false default: "" + native-symbols-policy: + description: auto requires symbols when the APK contains libhandscrash.so; required always requires them; disabled opts out explicitly. + required: false + default: auto metadata: description: Optional JSON metadata file. required: false @@ -48,7 +52,7 @@ inputs: cli-version: description: Version of @botiverse/hands-cli to install. required: false - default: 0.5.1 + default: 0.5.4 runs: using: composite @@ -58,13 +62,22 @@ runs: env: INPUT_APK: ${{ inputs.apk }} INPUT_DRAFT: ${{ inputs.draft }} + INPUT_MAPPING: ${{ inputs.mapping }} + INPUT_SYMBOLS: ${{ inputs.symbols }} + INPUT_METADATA: ${{ inputs.metadata }} + INPUT_NATIVE_SYMBOLS_POLICY: ${{ inputs.native-symbols-policy }} run: | set -euo pipefail test -s "$INPUT_APK" || { echo "APK not found or empty: $INPUT_APK" >&2; exit 1; } + test -z "$INPUT_MAPPING" || test -s "$INPUT_MAPPING" || { echo "Mapping not found or empty: $INPUT_MAPPING" >&2; exit 1; } + test -z "$INPUT_METADATA" || test -s "$INPUT_METADATA" || { echo "Metadata not found or empty: $INPUT_METADATA" >&2; exit 1; } case "$INPUT_DRAFT" in true|false) ;; *) echo "draft must be true or false" >&2; exit 1 ;; esac + validate_args=(--apk "$INPUT_APK" --policy "$INPUT_NATIVE_SYMBOLS_POLICY") + test -z "$INPUT_SYMBOLS" || validate_args+=(--symbols "$INPUT_SYMBOLS") + "$GITHUB_ACTION_PATH/../scripts/validate-android-native-symbols.sh" "${validate_args[@]}" - name: Install Hands CLI shell: bash @@ -102,4 +115,3 @@ runs: test -z "$INPUT_METADATA" || args+=(--metadata "$INPUT_METADATA") test "$INPUT_DRAFT" != true || args+=(--draft) hands "${args[@]}" - diff --git a/scripts/publish-android.sh b/scripts/publish-android.sh index 27b8b80..031b3a4 100755 --- a/scripts/publish-android.sh +++ b/scripts/publish-android.sh @@ -4,14 +4,16 @@ set -euo pipefail usage() { cat <<'USAGE' Usage: publish-android.sh --app SLUG --apk FILE --channel CHANNEL \ - --version-name VERSION --version-code CODE [--changelog FILE] [--publish] + --version-name VERSION --version-code CODE [--changelog FILE] \ + [--mapping FILE] [--symbols FILE] \ + [--native-symbols-policy auto|required|disabled] [--publish] Required environment: HANDS_BEARER_TOKEN App-scoped publisher deploy token. Optional environment: HANDS_API Defaults to https://hands.build. - HANDS_CLI_VERSION Defaults to 0.5.1. + HANDS_CLI_VERSION Defaults to 0.5.4. USAGE } @@ -21,6 +23,9 @@ channel="preview" version_name="" version_code="" changelog="" +mapping="" +symbols="" +native_symbols_policy="auto" draft=1 while [ "$#" -gt 0 ]; do @@ -31,6 +36,9 @@ while [ "$#" -gt 0 ]; do --version-name) version_name="$2"; shift 2 ;; --version-code) version_code="$2"; shift 2 ;; --changelog) changelog="$2"; shift 2 ;; + --mapping) mapping="$2"; shift 2 ;; + --symbols) symbols="$2"; shift 2 ;; + --native-symbols-policy) native_symbols_policy="$2"; shift 2 ;; --publish) draft=0; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; @@ -42,9 +50,16 @@ test -n "$app" || { echo "--app is required" >&2; exit 2; } test -s "$apk" || { echo "--apk must point to a non-empty file" >&2; exit 2; } test -n "$version_name" || { echo "--version-name is required" >&2; exit 2; } test -n "$version_code" || { echo "--version-code is required" >&2; exit 2; } +test -z "$changelog" || test -s "$changelog" || { echo "--changelog must point to a non-empty file" >&2; exit 2; } +test -z "$mapping" || test -s "$mapping" || { echo "--mapping must point to a non-empty file" >&2; exit 2; } + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +validate_args=(--apk "$apk" --policy "$native_symbols_policy") +test -z "$symbols" || validate_args+=(--symbols "$symbols") +"${script_dir}/validate-android-native-symbols.sh" "${validate_args[@]}" export HANDS_API="${HANDS_API:-https://hands.build}" -cli_version="${HANDS_CLI_VERSION:-0.5.1}" +cli_version="${HANDS_CLI_VERSION:-0.5.4}" if ! command -v hands >/dev/null 2>&1; then npm install --global "@botiverse/hands-cli@${cli_version}" @@ -58,7 +73,8 @@ args=( --version-code "$version_code" ) test -z "$changelog" || args+=(--changelog-file "$changelog") +test -z "$mapping" || args+=(--mapping "$mapping") +test -z "$symbols" || args+=(--symbols "$symbols") test "$draft" -eq 0 || args+=(--draft) hands "${args[@]}" - diff --git a/scripts/validate-android-native-symbols.sh b/scripts/validate-android-native-symbols.sh new file mode 100755 index 0000000..7daa92c --- /dev/null +++ b/scripts/validate-android-native-symbols.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: validate-android-native-symbols.sh --apk FILE [--symbols FILE] \ + [--policy auto|required|disabled] + +Policies: + auto Require exact symbols when the APK contains libhandscrash.so. + required Always require and validate the Hands native-symbols archive. + disabled Allow a missing archive, but still validate one when supplied. + +The archive must be the native-symbols classifier from the same Maven channel +and SDK build as the AAR packaged into the APK. Repacking stripped APK/AAR +libraries is not a valid substitute. +USAGE +} + +die() { + echo "error: $*" >&2 + exit 64 +} + +apk="" +symbols="" +policy="auto" + +while [ "$#" -gt 0 ]; do + case "$1" in + --apk) apk="$2"; shift 2 ;; + --symbols) symbols="$2"; shift 2 ;; + --policy) policy="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +test -s "$apk" || die "APK not found or empty: $apk" +case "$policy" in + auto|required|disabled) ;; + *) die "policy must be auto, required, or disabled" ;; +esac +if [ -n "$symbols" ]; then + test -s "$symbols" || die "native symbols archive not found or empty: $symbols" +fi + +find_readelf() { + local candidate + for candidate in \ + "${HANDS_LLVM_READELF:-}" \ + "${ANDROID_NDK_ROOT:-}/toolchains/llvm/prebuilt"/*/bin/llvm-readelf \ + "${ANDROID_HOME:-}/ndk"/*/toolchains/llvm/prebuilt/*/bin/llvm-readelf \ + llvm-readelf \ + readelf; do + test -n "$candidate" || continue + if [[ "$candidate" == */* ]]; then + test -x "$candidate" && printf '%s\n' "$candidate" && return 0 + elif command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + return 1 +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +apk_entries="$work/apk-entries.txt" +unzip -Z1 "$apk" | grep -E '^(lib|jni)/[^/]+/libhandscrash\.so$' | sort -u > "$apk_entries" || true + +if [ ! -s "$apk_entries" ]; then + if [ "$policy" = "required" ]; then + die "APK does not contain libhandscrash.so but native symbols are required" + fi + if [ -n "$symbols" ]; then + die "symbols were supplied, but the APK does not contain libhandscrash.so" + fi + echo "APK does not contain libhandscrash.so; Hands native symbols are not required." + exit 0 +fi + +if [ -z "$symbols" ]; then + if [ "$policy" = "disabled" ]; then + echo "warning: APK contains libhandscrash.so, but native symbol validation is explicitly disabled" >&2 + exit 0 + fi + die "APK contains libhandscrash.so; provide its exact native-symbols classifier archive" +fi + +readelf="$(find_readelf)" || die "llvm-readelf/readelf not found" +mkdir -p "$work/symbols" +unzip -q "$symbols" -d "$work/symbols" +manifest="$work/symbols/manifest.json" +test -f "$manifest" || die "manifest.json missing from symbols archive" + +python3 - "$manifest" "$work/manifest.tsv" <<'PY' +import json +import sys + +manifest_path, output_path = sys.argv[1:] +with open(manifest_path, encoding="utf-8") as handle: + payload = json.load(handle) +if payload.get("soname") != "libhandscrash.so": + raise SystemExit("manifest soname must be libhandscrash.so") +if not payload.get("sdk_version"): + raise SystemExit("manifest sdk_version is required") +source = payload.get("source") or {} +if source.get("repo") != "https://github.com/botiverse/hands" or not source.get("commit"): + raise SystemExit("manifest source repo/commit is invalid") +abis = payload.get("abis") or {} +if not abis: + raise SystemExit("manifest abis must not be empty") +with open(output_path, "w", encoding="utf-8") as handle: + for arch, item in sorted(abis.items()): + build_id = item.get("build_id", "") + sha256 = item.get("sha256", "") + if not build_id or not sha256: + raise SystemExit(f"manifest ABI {arch} is missing build_id or sha256") + handle.write(f"{arch}\t{build_id}\t{sha256}\n") +PY + +count=0 +while IFS= read -r apk_entry; do + arch="$(printf '%s' "$apk_entry" | cut -d/ -f2)" + apk_so="$work/apk/$arch/libhandscrash.so" + symbol_so="$work/symbols/$arch/libhandscrash.so" + mkdir -p "$(dirname "$apk_so")" + unzip -qp "$apk" "$apk_entry" > "$apk_so" + test -f "$symbol_so" || die "symbols archive does not cover APK ABI $arch" + + apk_id="$($readelf -n "$apk_so" | awk '/Build ID:/ {print $3; exit}')" + symbol_id="$($readelf -n "$symbol_so" | awk '/Build ID:/ {print $3; exit}')" + manifest_id="$(awk -F '\t' -v arch="$arch" '$1 == arch {print $2}' "$work/manifest.tsv")" + manifest_sha="$(awk -F '\t' -v arch="$arch" '$1 == arch {print $3}' "$work/manifest.tsv")" + actual_sha="$(sha256_file "$symbol_so")" + + test -n "$apk_id" || die "APK libhandscrash.so has no build ID for $arch" + test "$apk_id" = "$symbol_id" || die "build ID mismatch for $arch: apk=$apk_id symbols=$symbol_id" + test "$symbol_id" = "$manifest_id" || die "manifest build ID mismatch for $arch" + test "$actual_sha" = "$manifest_sha" || die "manifest sha256 mismatch for $arch" + if ! "$readelf" -S "$symbol_so" | awk '/\.debug_info/ { found=1 } END { exit(found ? 0 : 1) }'; then + die "symbol file is stripped for $arch" + fi + count=$((count + 1)) +done < "$apk_entries" + +echo "Verified $count Hands native symbol file(s) against APK build IDs." diff --git a/tests/test-validate-android-native-symbols.sh b/tests/test-validate-android-native-symbols.sh new file mode 100755 index 0000000..647e34b --- /dev/null +++ b/tests/test-validate-android-native-symbols.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +validator="$root/scripts/validate-android-native-symbols.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +mkdir -p "$work/apk/lib/x86_64" "$work/symbols/x86_64" +cat > "$work/handscrash.c" <<'C' +int hands_crash_fixture(int value) { return value + 42; } +C +gcc -shared -fPIC -g -Wl,--build-id=sha1 -o "$work/symbols/x86_64/libhandscrash.so" "$work/handscrash.c" +cp "$work/symbols/x86_64/libhandscrash.so" "$work/apk/lib/x86_64/libhandscrash.so" +objcopy --strip-debug "$work/apk/lib/x86_64/libhandscrash.so" + +build_id="$(readelf -n "$work/symbols/x86_64/libhandscrash.so" | awk '/Build ID:/ {print $3; exit}')" +sha="$(sha256sum "$work/symbols/x86_64/libhandscrash.so" | awk '{print $1}')" +cat > "$work/symbols/manifest.json" <"$work/$label.out" 2>&1; then + echo "expected failure: $label" >&2 + cat "$work/$label.out" >&2 + exit 1 + fi +} + +"$validator" --apk "$work/app.apk" --symbols "$work/symbols.zip" --policy required +expect_fail missing "$validator" --apk "$work/app.apk" --policy auto +"$validator" --apk "$work/plain.apk" --policy auto +expect_fail required-without-library "$validator" --apk "$work/plain.apk" --policy required + +mkdir -p "$work/stripped-symbols/x86_64" +cp "$work/apk/lib/x86_64/libhandscrash.so" "$work/stripped-symbols/x86_64/libhandscrash.so" +stripped_sha="$(sha256sum "$work/stripped-symbols/x86_64/libhandscrash.so" | awk '{print $1}')" +cat > "$work/stripped-symbols/manifest.json" < "$work/other.c" <<'C' +int other_fixture(int value) { return value + 99; } +C +gcc -shared -fPIC -g -Wl,--build-id=sha1 -o "$work/mismatch/x86_64/libhandscrash.so" "$work/other.c" +other_id="$(readelf -n "$work/mismatch/x86_64/libhandscrash.so" | awk '/Build ID:/ {print $3; exit}')" +other_sha="$(sha256sum "$work/mismatch/x86_64/libhandscrash.so" | awk '{print $1}')" +cat > "$work/mismatch/manifest.json" < "$work/bin/hands" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$HANDS_ARGS_LOG" +SH +chmod +x "$work/bin/hands" +export HANDS_ARGS_LOG="$work/hands-args.txt" +HANDS_BEARER_TOKEN=fixture PATH="$work/bin:$PATH" \ + "$root/scripts/publish-android.sh" \ + --app fixture \ + --apk "$work/app.apk" \ + --channel preview \ + --version-name 1.0.0 \ + --version-code 1000000 \ + --symbols "$work/symbols.zip" \ + --native-symbols-policy required +grep -Fx -- '--symbols' "$HANDS_ARGS_LOG" >/dev/null +grep -Fx -- "$work/symbols.zip" "$HANDS_ARGS_LOG" >/dev/null +grep -Fx -- '--draft' "$HANDS_ARGS_LOG" >/dev/null + +echo "Android native-symbol validation fixtures passed."