Establish native Linux ARM64 Android CI and verification gates (#94)
Android ARM verification / android (push) Canceled after 0s
Android ARM verification / rust (push) Canceled after 3m29s

This commit is contained in:
hugo committed 2026-10-08 17:48:50 +02:00
1 parent 1abd75282e
commit cf801a44d4
28 files changed
+1626 -53

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Build-only orchestration; never shipped or invoked by the Android application."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import runpy
import shutil
import tarfile
import urllib.request
import zipfile
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
ABIS = ("arm64-v8a", "armeabi-v7a")
SDK = Path(os.environ.get("ANDROID_HOME", os.environ.get("ANDROID_SDK_ROOT", "")))
CARGO = os.environ.get("CARGO", "cargo")
def run(*args, capture=False, **kwargs):
return subprocess.run([str(a) for a in args], check=True, text=True,
stdout=subprocess.PIPE if capture else None, **kwargs)
def tool(*args):
return run(CARGO, "+1.97.1", "run", "--locked", "-p", "ironstorage-android-build", "--", *args)
def download(spec, path):
if not path.exists():
with urllib.request.urlopen(spec["url"], timeout=60) as response, path.open("wb") as output:
shutil.copyfileobj(response, output)
if hashlib.sha256(path.read_bytes()).hexdigest() != spec["sha256"]:
raise RuntimeError("CI toolchain checksum mismatch")
def prepare_linux():
if platform.system() != "Linux" or platform.machine() not in ("aarch64", "arm64"):
raise RuntimeError("Native CI toolchains require Linux ARM64")
SDK.mkdir(parents=True, exist_ok=True)
policy = json.loads((ROOT / "android/ci-toolchains.json").read_text())
with tempfile.TemporaryDirectory(prefix="ironstorage-tools-") as directory:
directory = Path(directory)
for key, spec in policy.items():
download(spec, directory / key)
cli = SDK / "cmdline-tools/latest"
if not cli.exists():
destination = SDK / "cmdline-tools"
with zipfile.ZipFile(directory / "sdk_cli") as archive:
archive.extractall(destination)
(destination / "cmdline-tools").rename(cli)
for path in (cli / "bin").iterdir():
path.chmod(0o755)
ndk = SDK / "ndk/30.0.16248370"
if not ndk.exists():
destination = SDK / "ndk"
destination.mkdir(parents=True, exist_ok=True)
with tarfile.open(directory / "ndk") as archive:
archive.extractall(destination, filter="data")
(destination / "android-ndk-r30").rename(ndk)
prebuilt = ndk / "toolchains/llvm/prebuilt"
alias = prebuilt / "linux-x86_64"
# cargo-ndk 4.1.2 hardcodes this directory name on Linux; binaries remain native ARM64.
if not alias.exists():
alias.symlink_to("linux-arm64", target_is_directory=True)
native = SDK / "native-arm64"
if not native.exists():
with zipfile.ZipFile(directory / "sdk_native") as archive:
archive.extractall(native)
for path in native.rglob("*"):
if path.is_file():
path.chmod(0o755)
run(cli / "bin/sdkmanager", "--licenses", input="y\n" * 100)
run(cli / "bin/sdkmanager", "platforms;android-37.0", "build-tools;36.0.0")
run(prebuilt / "linux-arm64/bin/clang", "--version")
run(prebuilt / "linux-arm64/bin/ld.lld", "--version")
run(native / "build-tools/aapt2", "version")
def gradle(*args):
options = [ROOT / "android/gradlew", "-p", ROOT / "android", "--no-configuration-cache", "--console=plain"]
if platform.system() == "Linux" and platform.machine() in ("aarch64", "arm64"):
options.append("-Pandroid.aapt2FromMavenOverride=" + str(ROOT / ".gitea/scripts/native-arm64/aapt2"))
return run(*options, *args, cwd=ROOT)
def select_symbols(symbols, output):
allowed = {f"{abi}/{lib}.dbg" for abi in ABIS for lib in ("libironstorage_android.so", "libjnidispatch.so")}
seen = set()
with zipfile.ZipFile(symbols) as source, zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as destination:
for name in source.namelist():
if name.split("/", 1)[0] in ABIS:
if name not in allowed or name in seen:
raise RuntimeError("Unreviewed or duplicate native debug symbol path")
seen.add(name)
with source.open(name) as original, destination.open(name, "w", force_zip64=True) as target:
shutil.copyfileobj(original, target)
if seen != allowed:
raise RuntimeError("Supported ARM debug symbols missing")
def checksum_artifacts(output):
checksums = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(output.iterdir()) if path.is_file() and path.name != "sha256.json"}
(output / "sha256.json").write_text(json.dumps(checksums, indent=2) + "\n")
def build():
gradle("androidVerification")
output = ROOT / "android/build/ci-artifacts"
output.mkdir(parents=True, exist_ok=True)
apk = ROOT / "android/app/build/outputs/apk/release/app-release-unsigned.apk"
symbols = ROOT / "android/app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip"
selected_symbols = output / "native-debug-symbols.zip"
select_symbols(symbols, selected_symbols)
tool("audit-release", apk, selected_symbols)
analyzer = SDK / "cmdline-tools/latest/bin/apkanalyzer"
for variant, path in [("debug", ROOT / "android/app/build/outputs/apk/debug/app-debug.apk"), ("release", apk)]:
manifest = output / (variant + "-manifest.xml")
manifest.write_text(run(analyzer, "manifest", "print", path, capture=True).stdout)
tool("audit-manifest", manifest, ROOT / "android/app/src/main/res/xml/backup_rules.xml", ROOT / "android/app/src/main/res/xml/data_extraction_rules.xml")
trees = []
for target in ("aarch64-linux-android", "armv7-linux-androideabi"):
trees.append(run(CARGO, "+1.97.1", "tree", "--locked", "-p", "ironstorage-android", "--target", target, "--edges", "normal", "--prefix", "none", "--format", "{p}|{l}|{r}", capture=True, cwd=ROOT).stdout)
tree = ROOT / "android/build/rust-dependencies.txt"
tree.write_text("\n".join(trees))
inventory = ROOT / "android/build/reports/android-dependencies.json"
rows = []
for module in ("app", "core", "features", "platform", "rust-bridge"):
rows.extend(json.loads((ROOT / "android" / module / "build/reports/android-dependencies.json").read_text()))
inventory.parent.mkdir(parents=True, exist_ok=True)
inventory.write_text(json.dumps(rows, sort_keys=True) + "\n")
tool("sbom", inventory, tree, ROOT / "Cargo.lock", ROOT / "android/dependency-policy.json", output / "sbom.cdx.json")
for module in ("app", "core", "features", "platform", "rust-bridge"):
reports = ROOT / "android" / module / "build/test-results"
if list(reports.rglob("TEST-*.xml")):
tool("safe-reports", reports, output / (module + "-unit-tests.json"))
files = [ROOT / "android/app/build/outputs/apk/debug/app-debug.apk", apk,
ROOT / "android/app/build/outputs/bundle/release/app-release.aab",
ROOT / "android/app/build/outputs/mapping/release/mapping.txt"]
for path in files:
shutil.copy2(path, output / path.name)
checksum_artifacts(output)
def emulator(serial):
adb = SDK / "platform-tools/adb"
def command(*args):
return run(adb, "-s", serial, *args, capture=True).stdout.strip()
abi = command("shell", "getprop", "ro.product.cpu.abi")
if abi not in ABIS:
raise RuntimeError("Only ARM emulators are in scope")
if command("shell", "getprop", "ro.kernel.qemu") != "1":
raise RuntimeError("Physical devices are outside validation scope")
api = int(command("shell", "getprop", "ro.build.version.sdk"))
try:
pages = int(command("shell", "getconf", "PAGE_SIZE"))
except subprocess.CalledProcessError:
sizes = {int(line.split()[1]) * 1024 for line in command("shell", "cat", "/proc/self/smaps").splitlines() if line.startswith("KernelPageSize:")}
if len(sizes) != 1:
raise RuntimeError("Cannot determine emulator kernel page size")
pages = sizes.pop()
if pages == 16384:
command("shell", "setprop", "bionic.linker.16kb.app_compat.enabled", "false")
command("shell", "setprop", "pm.16kb.app_compat.disabled", "true")
apk = ROOT / "android/app/build/outputs/apk/debug/app-debug.apk"
command("install", "-r", apk)
# Android's test runner and Rust fixtures cover typed errors, callbacks and cancellation.
suites = [("app", "de.rfc1437.ironstorage.test", None),
("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None)]
native_timings = []
for module, package, _ in suites:
test_apk = next((ROOT / "android" / module / "build/outputs/apk/androidTest/debug").glob("*.apk"))
command("install", "-r", test_apk)
if module == "rust-bridge":
for _ in range(5):
command("shell", "am", "force-stop", package)
measured = command("shell", "am", "instrument", "-w", "-r", "-e", "class", "de.rfc1437.ironstorage.bridge.NativeLoadPerformanceTest", package + "/androidx.test.runner.AndroidJUnitRunner")
samples = [int(line.split("=", 1)[1]) for line in measured.splitlines() if line.startswith("INSTRUMENTATION_STATUS: ironstorage_native_load_ns=")]
if len(samples) != 1 or "OK (1 test)" not in measured:
raise RuntimeError("Native cold-load measurement failed")
native_timings.extend(samples)
result = command("shell", "am", "instrument", "-w", "-r", package + "/androidx.test.runner.AndroidJUnitRunner")
if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result:
local = ROOT / "android/build/local-validation"
local.mkdir(parents=True, exist_ok=True)
local.chmod(0o700)
raw = local / f"{serial}-{module}-failure.txt"
raw.write_text(result)
raw.chmod(0o600)
raise RuntimeError("ARM emulator instrumentation failed (raw output retained locally only)")
# No activity state or application data is deleted between process recreation runs.
timings = []
for _ in range(5):
command("shell", "am", "force-stop", "de.rfc1437.ironstorage")
result = command("shell", "am", "start", "-W", "-n", "de.rfc1437.ironstorage/.MainActivity")
values = dict(line.split(":", 1) for line in result.splitlines() if ":" in line)
if values.get("Status", "").strip() != "ok":
raise RuntimeError("Cold start failed")
timings.append(int(values["TotalTime"].strip()))
output = ROOT / "android/build/ci-artifacts"
output.mkdir(parents=True, exist_ok=True)
(output / f"emulator-api{api}-{pages}.json").write_text(json.dumps({"api": api, "abi": abi, "page_size": pages, "cold_start_ms": timings, "native_load_ns": native_timings, "instrumentation": "passed"}, indent=2) + "\n")
checksum_artifacts(output)
print(f"ARM API {api}, {pages}-byte pages: instrumentation and five cold starts passed")
class SelfTest(unittest.TestCase):
def test_aapt2_adapter_preserves_paths_and_other_options(self):
adapter = runpy.run_path(ROOT / ".gitea/scripts/native-arm64/aapt2")
self.assertEqual(adapter["arguments"](["optimize", "input.apk", "--resource-path-shortening-map=/tmp/map with spaces=ok", "--shorten-resource-paths", "-o", "output.apk"]),
["optimize", "input.apk", "--resource-path-shortening-map", "/tmp/map with spaces=ok", "--shorten-resource-paths", "-o", "output.apk"])
self.assertEqual(adapter["arguments"](["daemon"]), ["daemon"])
def test_toolchain_checksum_rejects_changed_archive(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "fixture"
path.write_bytes(b"public archive fixture")
spec = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
download(spec, path)
path.write_bytes(b"changed")
with self.assertRaisesRegex(RuntimeError, "checksum mismatch"):
download(spec, path)
def test_symbol_selection_and_repeatable_checksum_manifest(self):
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
source = directory / "source.zip"
output = directory / "output.zip"
names = [f"{abi}/{lib}.dbg" for abi in ABIS for lib in ("libironstorage_android.so", "libjnidispatch.so")]
with zipfile.ZipFile(source, "w") as archive:
for name in names + ["unused/libjnidispatch.so.dbg"]:
archive.writestr(name, b"public symbols")
select_symbols(source, output)
with zipfile.ZipFile(output) as archive:
self.assertEqual(set(archive.namelist()), set(names))
checksum_artifacts(directory)
first = (directory / "sha256.json").read_bytes()
checksum_artifacts(directory)
self.assertEqual(first, (directory / "sha256.json").read_bytes())
self.assertNotIn("sha256.json", json.loads(first))
with zipfile.ZipFile(source, "w") as archive:
archive.writestr(names[0], b"public symbols")
with self.assertRaisesRegex(RuntimeError, "missing"):
select_symbols(source, output)
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("arm64-v8a/unreviewed.dbg", b"public symbols")
with self.assertRaisesRegex(RuntimeError, "Unreviewed"):
select_symbols(source, output)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["prepare-linux", "build", "emulator", "test"])
parser.add_argument("serial", nargs="?")
args = parser.parse_args()
if args.command != "test" and not (os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")):
parser.error("ANDROID_HOME or ANDROID_SDK_ROOT is required")
if args.command == "test":
unittest.main(argv=[__file__])
elif args.command == "prepare-linux":
prepare_linux()
elif args.command == "build":
build()
elif args.serial:
emulator(args.serial)
else:
parser.error("emulator requires an ADB serial")
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Adapt AGP's map argument to the pinned native ARM64 AAPT2 syntax."""
import os
from pathlib import Path
import sys
def arguments(args):
result = []
for arg in args:
if arg.startswith("--resource-path-shortening-map="):
option, value = arg.split("=", 1)
result.extend((option, value))
else:
result.append(arg)
return result
if __name__ == "__main__":
binary = Path(os.environ["ANDROID_HOME"]) / "native-arm64/build-tools/aapt2"
os.execv(binary, [str(binary), *arguments(sys.argv[1:])])
+79
View File
@@ -0,0 +1,79 @@
name: Android ARM verification
on:
push:
branches:
- main
- 'codex/**'
pull_request:
workflow_dispatch:
permissions:
code: read
actions: write
env:
CARGO_TERM_COLOR: always
CARGO_BUILD_JOBS: '2'
jobs:
rust:
runs-on: linux-arm64
timeout-minutes: 90
steps:
- uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install workspace system libraries
run: |
apt-get update
apt-get install -y --no-install-recommends pkg-config libfontconfig1-dev libasound2-dev libudev-dev libxkbcommon-dev libwayland-dev
- name: Install pinned Rust
uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af
with:
tool: rust@1.97.1
fallback: none
- name: Install formatting and Clippy components
run: rustup component add --toolchain 1.97.1 rustfmt clippy
- name: Formatting
run: cargo +1.97.1 fmt --all -- --check
- name: Warnings as errors
run: RUSTFLAGS="-D warnings" cargo +1.97.1 check --locked --workspace --all-targets
- name: Clippy
run: cargo +1.97.1 clippy --locked --workspace --all-targets -- -D warnings
- name: Workspace tests including deterministic clocks and secret backends
run: cargo +1.97.1 test --locked --workspace
android:
runs-on: linux-arm64
timeout-minutes: 90
env:
ANDROID_HOME: /opt/ironstorage-android-sdk
JAVA_HOME: /usr/lib/jvm/java-25-openjdk-arm64
GRADLE_OPTS: -Dorg.gradle.workers.max=2 -Dorg.gradle.jvmargs=-Xmx2g -Dkotlin.daemon.jvm.options=-Xmx1g
steps:
- uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install native ARM64 Java and build prerequisites
run: |
apt-get update
apt-get install -y --no-install-recommends openjdk-25-jdk python3 ca-certificates curl unzip xz-utils gcc pkg-config libfontconfig1-dev
- name: Install pinned Rust
uses: https://github.com/taiki-e/install-action@9534c84618278caac52cb373bb164ed464dbd8af
with:
tool: rust@1.97.1
fallback: none
- name: Install selected Android targets and cargo-ndk
run: |
rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi
cargo +1.97.1 install cargo-ndk --version 4.1.2 --locked
- name: Check build orchestration
run: python3 .gitea/scripts/android_ci.py test
- name: Install checksummed native Linux ARM64 SDK and NDK tools
run: python3 .gitea/scripts/android_ci.py prepare-linux
- name: Compile, test, lint and audit unsigned ARM packages
run: python3 .gitea/scripts/android_ci.py build
- name: Upload reviewed packages, matching symbols, safe test summaries and SBOM
uses: https://github.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-arm-verification
path: android/build/ci-artifacts/
if-no-files-found: error
retention-days: 14
Generated
+10 -5
View File
@@ -5450,6 +5450,11 @@ dependencies = [
"ironstorage",
"object",
"regex",
"roxmltree",
"serde",
"serde_json",
"sha2 0.10.9",
"toml 0.9.12+spec-1.1.0",
"uniffi",
"uniffi_bindgen",
"zip",
@@ -8748,9 +8753,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.43"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"once_cell",
@@ -8834,9 +8839,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -9879,7 +9884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
+18 -17
View File
@@ -1,9 +1,11 @@
# Native Android build
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and
[parity matrix](../docs/android/parity.md). The native foundation implements #89 and #92. Onboarding and secure storage
are implemented by their milestone issues. Both variants now invoke Rust through
the generated UniFFI API.
[parity matrix](../docs/android/parity.md). The native foundation implements #89–#94, including private storage, Keystore
callbacks and build gates. Both variants invoke Rust through the generated
UniFFI API. Onboarding and the feature screens remain owned by their milestone
issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
ARM-only runner/toolchain policy and artifact audits.
## Reproducible setup
@@ -63,10 +65,11 @@ Version code 1 and name 1.0.1 are deterministic initial metadata. Debug signing
uses the standard local debug key. Release has no configured signing key and
produces an unsigned APK; #112 configures protected upload signing. Do not add
signing secrets or store passwords to the repository. Native symbol level FULL
and the selected three ABIs are packaged. Native runtime packaging is described
and the selected two ARM ABIs are packaged. Native runtime packaging is described
below; no development network endpoint is included.
The manifest has only the exported launcher activity. Backup/cloud/device-transfer
The manifest exports the launcher and the reviewed AndroidX profile receiver
protected by the privileged DUMP permission. Backup/cloud/device-transfer
exclusions and cleartext denial are explicit. Feature-specific services/permissions
are added with their owning issues. Resources use values directories for future
localization; vector/adaptive/monochrome icons and R8 consumer rules are present.
@@ -93,14 +96,14 @@ Install the pinned build tools once:
```sh
rustup toolchain install 1.97.1 --profile minimal
rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi
cargo install cargo-ndk --version 4.1.2 --locked
```
Gradle checks the actual Cargo/cargo-ndk/NDK revisions before building. Each
variant compiles crates/android with cargo-ndk into its own generated jniLibs,
then verifies ELF architecture, 16 KB LOAD alignment, and byte-identical UniFFI
metadata across all three ABIs. A packaging step selects only the Android library
metadata across both ARM ABIs. A packaging step selects only the Android library
and rejects accidental inclusion of the dependency Apple cdylib. The thin Android crate re-exports the existing
UniFFI component; it duplicates no storage rules or converters. #91 owns the
platform-neutral contract extraction. No handwritten JNI or unsafe Rust is added.
@@ -126,15 +129,14 @@ newer state. Blocking fixture calls finish in Rust when a coroutine is cancelled
no thread is forcibly stopped. Long storage operations must use their Rust-owned
cancellation methods when their feature is integrated.
No Rust-to-Kotlin platform callbacks are installed by #92. When #93 adds them,
use generated UniFFI/JNA callback adapters only: JNA attaches a calling native
#93 installs Rust-to-Kotlin Keystore callbacks through generated UniFFI/JNA
adapters: JNA attaches a calling native
thread to the JVM; retain the callback for the full Rust owner lifetime, release
it after unregistering, never assume the callback runs on the Android main thread,
and marshal UI/Activity work onto Main. Keep storage decisions in Rust and avoid
blocking the main thread or calling back into a held Rust lock.
JNA 5.19.1 is packaged as its Android AAR, filtered to arm64-v8a, armeabi-v7a and
x86_64. The release set may only be narrowed by changing the support decision in
JNA 5.19.1 is packaged as its Android AAR, filtered to arm64-v8a, armeabi-v7a only. The release set is arm64-v8a and armeabi-v7a under the user amendment to
#88, not by silently omitting an ABI. All source/lock/config files are task inputs;
outputs live only under build/. Debug and release compile independently, and
unchanged builds reuse their outputs. R8 keeps generated/JNA reflection entry
@@ -144,17 +146,16 @@ that file and outputs/mapping/release/mapping.txt with the matching release.
AAB base/lib entries let Play produce per-ABI splits.
The auditNativePackages gate inspects every native dependency in both APKs and
the AAB, checks all three ABI directories and both Rust/JNA libraries, and rejects
the AAB, checks both ARM ABI directories and both Rust/JNA libraries, and rejects
ELF segments below 16 KB alignment or APK native entries that are compressed or
not ZIP-aligned to 16 KB. Follow the official [16 KB runtime validation](https://developer.android.com/guide/practices/page-sizes):
use an actual 16 KB system image, check adb shell getconf PAGE_SIZE is 16384,
disable page-size compatibility mode, and run both instrumented test suites.
An ELF/ZIP audit is not a substitute for that runtime test. Milestone validation
is emulator-only per the user instruction of 2026-10-08; physical devices are
outside the test scope for every issue. Keep x86_64 emulator evidence separate
from the Apple Silicon arm64 emulator run.
outside the test scope for every issue. The user removed x86_64 Android from the current support scope on 2026-10-08.
Validation recorded on 2026-10-08 for #92:
Historical validation recorded on 2026-10-08 for #92 (before the ARM-only scope update):
| Environment | Verified |
| --- | --- |
@@ -165,5 +166,5 @@ Validation recorded on 2026-10-08 for #92:
| Physical devices | Excluded from milestone testing by user instruction |
| x86_64 runtime | System image installed; QEMU2 rejects x86_64 on this aarch64 Mac, including with acceleration disabled |
The outstanding x86_64 emulator check needs a compatible host; cross-compilation
and static package checks do not establish x86_64 runtime coverage.
The historical x86_64 runtime gap is no longer a required check under the current
ARM-only support decision.
+7 -3
View File
@@ -18,7 +18,7 @@ android {
versionCode = 1
versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -41,12 +41,17 @@ android {
lint {
abortOnError = true
warningsAsErrors = true
// The user scoped this milestone to ARM Android; x86 ChromeOS is excluded.
disable += "ChromeOsAbiSupport"
}
}
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
@@ -69,7 +74,6 @@ dependencies {
androidTestImplementation(libs.compose.ui.test)
androidTestImplementation(libs.android.test.runner)
androidTestImplementation(libs.android.test.espresso)
debugImplementation(libs.compose.ui.test.manifest)
implementation(libs.coroutines.android)
testImplementation(libs.coroutines.test)
testImplementation(libs.junit)
-1
View File
@@ -51,7 +51,6 @@ androidx.compose.ui:ui-graphics:1.12.1=debugAndroidTestCompileClasspath,debugAnd
androidx.compose.ui:ui-test-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-manifest:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-test:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-text-android:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-text:1.12.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
+59 -1
View File
@@ -1,4 +1,7 @@
import groovy.json.JsonOutput
import java.io.File
import java.security.MessageDigest
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.artifacts.ExternalModuleDependency
import org.gradle.api.artifacts.ProjectDependency
@@ -39,8 +42,15 @@ subprojects {
listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3").none { group.startsWith(it) }
}) { "Domain libraries must stay in crates/storage: $group:$name" }
}
val enforceDomain = name.endsWith("RuntimeClasspath") || name.endsWith("CompileClasspath")
resolutionStrategy.eachDependency {
require(!requested.version.orEmpty().contains('+')) { "Dynamic versions are forbidden" }
require(requested.version.orEmpty().let { version ->
!version.contains('+') && !version.contains("SNAPSHOT") &&
!version.startsWith("latest.") && version.none { it in "[]()," }
}) { "Dynamic versions are forbidden" }
require(!enforceDomain || listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3", "org.pgpainless", "com.google.mlkit").none {
requested.group == it || requested.group.startsWith("$it.")
}) { "Transitive domain libraries must stay in crates/storage" }
}
}
}
@@ -79,3 +89,51 @@ tasks.register<Exec>("auditNativePackages") {
) + packages.map { it.asFile.absolutePath }
)
}
// Each project resolves its own configurations under Gradle's project lock.
subprojects {
val owner = project
tasks.register("dependencyInventory") {
val output = layout.buildDirectory.file("reports/android-dependencies.json")
outputs.file(output)
outputs.upToDateWhen { false }
doLast {
val rows = listOf("debugRuntimeClasspath", "releaseRuntimeClasspath", "debugAndroidTestRuntimeClasspath", "debugUnitTestRuntimeClasspath").flatMap { name ->
val configuration = owner.configurations.findByName(name)
if (configuration == null || !configuration.isCanBeResolved) emptyList() else {
configuration.incoming.artifactView {
componentFilter { it is ModuleComponentIdentifier }
}.artifacts.artifacts.mapNotNull { artifact ->
val id = artifact.id.componentIdentifier as? ModuleComponentIdentifier
id?.let {
mapOf(
"group" to it.group, "name" to it.module, "version" to it.version,
"sha256" to MessageDigest.getInstance("SHA-256").digest(artifact.file.readBytes())
.joinToString("") { byte -> "%02x".format(byte) },
"scope" to "${owner.path}:$name"
)
}
}
}
}.sortedWith(compareBy({ it.getValue("scope") }, { it.getValue("group") }, { it.getValue("name") }, { it.getValue("version") }, { it.getValue("sha256") }))
output.get().asFile.apply {
parentFile.mkdirs()
writeText(JsonOutput.prettyPrint(JsonOutput.toJson(rows)) + "\n")
}
}
notCompatibleWithConfigurationCache("Resolves reviewed artifact graphs")
}
}
tasks.register("dependencyInventory") {
dependsOn(subprojects.map { "${it.path}:dependencyInventory" })
}
tasks.register("androidVerification") {
dependsOn("checkArchitecture", "dependencyInventory", "auditNativePackages")
dependsOn(subprojects.map { "${it.path}:ktlintCheck" })
dependsOn(subprojects.map { "${it.path}:lintDebug" })
dependsOn(subprojects.map { "${it.path}:testDebugUnitTest" })
dependsOn(":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest")
}
+14
View File
@@ -0,0 +1,14 @@
{
"sdk_cli": {
"url": "https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip",
"sha256": "04453066b540409d975c676d781da1477479dde3761310f1a7eb92a1dfb15af7"
},
"ndk": {
"url": "https://github.com/HomuHomu833/android-ndk-custom/releases/download/r30/android-ndk-r30-aarch64-linux-gnu.tar.xz",
"sha256": "f8eeb06b7c97415d883a8ecdd0a725e759406edab4502e13b1a6a8561461a0c8"
},
"sdk_native": {
"url": "https://github.com/lzhiyong/android-sdk-tools/releases/download/35.0.2/android-sdk-tools-static-aarch64.zip",
"sha256": "db1cea2c4454d5f9c5a802646b2d1cf560b4ee7badbe23e51ab8e1881bb50fc2"
}
}
+4 -1
View File
@@ -25,7 +25,10 @@ android {
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
+165
View File
@@ -0,0 +1,165 @@
{
"android": {
"androidx.activity:activity-compose:1.12.0": "Apache-2.0",
"androidx.activity:activity-compose:1.13.0": "Apache-2.0",
"androidx.activity:activity-ktx:1.12.0": "Apache-2.0",
"androidx.activity:activity-ktx:1.13.0": "Apache-2.0",
"androidx.activity:activity-ktx:1.8.1": "Apache-2.0",
"androidx.activity:activity:1.12.0": "Apache-2.0",
"androidx.activity:activity:1.13.0": "Apache-2.0",
"androidx.activity:activity:1.8.1": "Apache-2.0",
"androidx.annotation:annotation-experimental:1.4.1": "Apache-2.0",
"androidx.annotation:annotation-experimental:1.5.1": "Apache-2.0",
"androidx.annotation:annotation-jvm:1.11.0": "Apache-2.0",
"androidx.appcompat:appcompat-resources:1.2.0": "Apache-2.0",
"androidx.appcompat:appcompat:1.2.0": "Apache-2.0",
"androidx.arch.core:core-common:2.2.0": "Apache-2.0",
"androidx.arch.core:core-runtime:2.2.0": "Apache-2.0",
"androidx.autofill:autofill:1.0.0": "Apache-2.0",
"androidx.biometric:biometric:1.1.0": "Apache-2.0",
"androidx.collection:collection-jvm:1.5.0": "Apache-2.0",
"androidx.collection:collection-jvm:1.6.0": "Apache-2.0",
"androidx.collection:collection-ktx:1.5.0": "Apache-2.0",
"androidx.collection:collection-ktx:1.6.0": "Apache-2.0",
"androidx.compose.animation:animation-android:1.12.1": "Apache-2.0",
"androidx.compose.animation:animation-core-android:1.12.1": "Apache-2.0",
"androidx.compose.foundation:foundation-android:1.12.1": "Apache-2.0",
"androidx.compose.foundation:foundation-layout-android:1.12.1": "Apache-2.0",
"androidx.compose.material3.adaptive:adaptive-android:1.3.0": "Apache-2.0",
"androidx.compose.material3:material3-android:1.4.0": "Apache-2.0",
"androidx.compose.material:material-ripple-android:1.12.1": "Apache-2.0",
"androidx.compose.runtime:runtime-android:1.12.1": "Apache-2.0",
"androidx.compose.runtime:runtime-annotation-android:1.12.1": "Apache-2.0",
"androidx.compose.runtime:runtime-retain-android:1.12.1": "Apache-2.0",
"androidx.compose.runtime:runtime-saveable-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-geometry-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-graphics-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-test-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-test-junit4-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-text-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-unit-android:1.12.1": "Apache-2.0",
"androidx.compose.ui:ui-util-android:1.12.1": "Apache-2.0",
"androidx.concurrent:concurrent-futures-ktx:1.2.0": "Apache-2.0",
"androidx.concurrent:concurrent-futures:1.1.0": "Apache-2.0",
"androidx.concurrent:concurrent-futures:1.2.0": "Apache-2.0",
"androidx.core:core-ktx:1.13.1": "Apache-2.0",
"androidx.core:core-ktx:1.16.0": "Apache-2.0",
"androidx.core:core-ktx:1.18.0": "Apache-2.0",
"androidx.core:core-viewtree:1.0.0": "Apache-2.0",
"androidx.core:core:1.13.1": "Apache-2.0",
"androidx.core:core:1.16.0": "Apache-2.0",
"androidx.core:core:1.18.0": "Apache-2.0",
"androidx.cursoradapter:cursoradapter:1.0.0": "Apache-2.0",
"androidx.customview:customview-poolingcontainer:1.0.0": "Apache-2.0",
"androidx.customview:customview:1.0.0": "Apache-2.0",
"androidx.drawerlayout:drawerlayout:1.0.0": "Apache-2.0",
"androidx.emoji2:emoji2:1.4.0": "Apache-2.0",
"androidx.fragment:fragment:1.5.1": "Apache-2.0",
"androidx.fragment:fragment:1.9.1": "Apache-2.0",
"androidx.graphics:graphics-path:1.0.1": "Apache-2.0",
"androidx.interpolator:interpolator:1.0.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-common-java8:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-common-jvm:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-common-jvm:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-livedata-core:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-livedata-core:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-livedata:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-livedata:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-process:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-runtime-android:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-runtime-android:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel-android:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel-android:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel:2.10.0": "Apache-2.0",
"androidx.lifecycle:lifecycle-viewmodel:2.11.0": "Apache-2.0",
"androidx.loader:loader:1.0.0": "Apache-2.0",
"androidx.navigation3:navigation3-runtime-android:1.2.0": "Apache-2.0",
"androidx.navigation3:navigation3-ui-android:1.2.0": "Apache-2.0",
"androidx.navigationevent:navigationevent-android:1.1.1": "Apache-2.0",
"androidx.navigationevent:navigationevent-compose-android:1.1.1": "Apache-2.0",
"androidx.profileinstaller:profileinstaller:1.4.0": "Apache-2.0",
"androidx.savedstate:savedstate-android:1.4.0": "Apache-2.0",
"androidx.savedstate:savedstate-compose-android:1.4.0": "Apache-2.0",
"androidx.savedstate:savedstate-ktx:1.4.0": "Apache-2.0",
"androidx.startup:startup-runtime:1.1.1": "Apache-2.0",
"androidx.test.espresso:espresso-core:3.7.0": "Apache-2.0",
"androidx.test.espresso:espresso-idling-resource:3.7.0": "Apache-2.0",
"androidx.test.ext:junit:1.1.5": "Apache-2.0",
"androidx.test.services:storage:1.6.0": "Apache-2.0",
"androidx.test:core:1.7.0": "Apache-2.0",
"androidx.test:monitor:1.8.0": "Apache-2.0",
"androidx.test:runner:1.7.0": "Apache-2.0",
"androidx.tracing:tracing-android:2.0.0": "Apache-2.0",
"androidx.tracing:tracing:1.1.0": "Apache-2.0",
"androidx.tracing:tracing:1.2.0": "Apache-2.0",
"androidx.vectordrawable:vectordrawable-animated:1.1.0": "Apache-2.0",
"androidx.vectordrawable:vectordrawable:1.1.0": "Apache-2.0",
"androidx.versionedparcelable:versionedparcelable:1.1.1": "Apache-2.0",
"androidx.viewpager:viewpager:1.0.0": "Apache-2.0",
"androidx.window:window-core-android:1.5.0": "Apache-2.0",
"androidx.window:window:1.5.0": "Apache-2.0",
"com.google.code.findbugs:jsr305:3.0.2": "Apache-2.0",
"com.google.dagger:dagger-lint-aar:2.60.1": "Apache-2.0",
"com.google.dagger:dagger:2.60.1": "Apache-2.0",
"com.google.dagger:hilt-android:2.60.1": "Apache-2.0",
"com.google.dagger:hilt-core:2.60.1": "Apache-2.0",
"com.google.errorprone:error_prone_annotations:2.30.0": "Apache-2.0",
"com.google.guava:listenablefuture:1.0": "Apache-2.0",
"jakarta.inject:jakarta.inject-api:2.0.1": "Apache-2.0",
"javax.inject:javax.inject:1": "Apache-2.0",
"junit:junit:4.13.2": "EPL-1.0",
"net.java.dev.jna:jna:5.19.1": "Apache-2.0 OR LGPL-2.1-or-later",
"org.hamcrest:hamcrest-core:1.3": "BSD-3-Clause",
"org.hamcrest:hamcrest-library:1.3": "BSD-3-Clause",
"org.jetbrains.kotlin:kotlin-stdlib:2.4.21": "Apache-2.0",
"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0": "Apache-2.0",
"org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0": "Apache-2.0",
"org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0": "Apache-2.0",
"org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3": "Apache-2.0",
"org.jetbrains:annotations:13.0": "Apache-2.0",
"org.jetbrains:annotations:23.0.0": "Apache-2.0",
"org.jspecify:jspecify:1.0.0": "Apache-2.0"
},
"rust_licenses": [
"(Apache-2.0 OR MIT) AND BSD-3-Clause",
"(MIT OR Apache-2.0) AND ISC",
"(MIT OR Apache-2.0) AND Unicode-3.0",
"0BSD OR MIT OR Apache-2.0",
"Apache-2.0",
"Apache-2.0 / MIT",
"Apache-2.0 OR ISC OR MIT",
"Apache-2.0 OR MIT",
"Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT",
"BSD-2-Clause",
"BSD-2-Clause OR Apache-2.0 OR MIT",
"BSD-3-Clause",
"BSD-3-Clause OR Apache-2.0",
"BSD-3-Clause OR MIT OR Apache-2.0",
"CC0-1.0 OR MIT-0 OR Apache-2.0",
"ISC",
"ISC AND (Apache-2.0 OR ISC)",
"ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0)",
"MIT",
"MIT OR Apache-2.0",
"MIT OR Apache-2.0 OR Zlib",
"MIT OR Zlib OR Apache-2.0",
"MIT/Apache-2.0",
"MPL-2.0",
"Unicode-3.0",
"Unlicense OR MIT",
"Unlicense/MIT",
"Zlib",
"Zlib OR Apache-2.0 OR MIT"
]
}
+4 -1
View File
@@ -28,7 +28,10 @@ android {
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
-1
View File
@@ -31,7 +31,6 @@ compose-ui = { module = "androidx.compose.ui:ui" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+16
View File
@@ -3946,11 +3946,17 @@
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.10.2">
<artifact name="junit-bom-5.10.2.module">
<sha256 value="de23b114b3e4119a8fe6eb17bed5a3852816698bace67071579d6d927ebb080a" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
</artifact>
<artifact name="junit-bom-5.10.2.pom">
<sha256 value="169dd904a4b0f6520cffe658cc62292bfe9f3c14a989fa92120724cde43a9968" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.11.0-M2">
<artifact name="junit-bom-5.11.0-M2.module">
<sha256 value="86477abcf490d6ca059aa9973cb108d22a506f49d1a5569bb32cc6cbf43c2cce" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
</artifact>
<artifact name="junit-bom-5.11.0-M2.pom">
<sha256 value="4a3ffc4a4edcfec2cb5d619922706a9407161798571939f854dfda73e4e17cc8" origin="Generated by Gradle"/>
</artifact>
@@ -4108,5 +4114,15 @@
<sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.guava" name="guava-parent" version="33.4.0-jre">
<artifact name="guava-parent-33.4.0-jre.pom">
<sha256 value="3a499ed34a0d9ee0f1bcc39230021a1cd4e2f7dd0426ab6844f585465d41dcd7" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.0">
<artifact name="kotlinx-coroutines-bom-1.8.0.pom">
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
</artifact>
</component>
</components>
</verification-metadata>
+4 -1
View File
@@ -32,7 +32,10 @@ android {
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
+17 -7
View File
@@ -12,7 +12,7 @@ android {
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
@@ -22,12 +22,17 @@ android {
lint {
abortOnError = true
warningsAsErrors = true
// The user scoped this milestone to ARM Android; x86 ChromeOS is excluded.
disable += "ChromeOsAbiSupport"
}
}
kotlin {
jvmToolchain(25)
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
dependencies {
@@ -73,6 +78,12 @@ androidComponents.onVariants { variant ->
val nativeOutput = layout.buildDirectory.dir("generated/native/$name/jniLibs")
val rawBindings = layout.buildDirectory.dir("generated/bindings-raw/$name")
val bindings = layout.buildDirectory.dir("generated/bindings/$name")
val rustFlags = listOf(
"-D warnings",
"-C link-arg=-Wl,-z,max-page-size=16384",
"-C link-arg=-Wl,-z,common-page-size=16384",
"-C link-arg=-Wl,--build-id=sha1"
).joinToString(" ")
val native = tasks.register<Exec>("build${title}Rust") {
dependsOn(checkRustToolchain)
workingDir = workspace
@@ -81,22 +92,21 @@ androidComponents.onVariants { variant ->
inputs.property("cargoNdk", libs.versions.cargoNdk.get())
inputs.property("ndkVersion", libs.versions.ndk.get())
inputs.property("minSdk", libs.versions.minSdk.get())
inputs.property("rustFlags", rustFlags)
inputs.property("abis", listOf("arm64-v8a", "armeabi-v7a"))
inputs.property("releaseDebug", "2")
outputs.dir(rawNative)
environment("RUSTUP_TOOLCHAIN", rustVersion)
environment("RUSTC", rustcExecutable.get())
environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.absolutePath)
environment("ANDROID_HOME", sdkDirectory.get().asFile.absolutePath)
environment(
"RUSTFLAGS",
"-D warnings -C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=16384"
)
environment("RUSTFLAGS", rustFlags)
environment("CARGO_PROFILE_RELEASE_DEBUG", "2")
commandLine(
listOf(
cargoExecutable.get(), "+$rustVersion", "ndk",
"--target", "arm64-v8a",
"--target", "armeabi-v7a",
"--target", "x86_64",
"--platform", libs.versions.minSdk.get(),
"--output-dir", rawNative.get().asFile.absolutePath,
"build", "--locked", "--package", "ironstorage-android", "--lib"
@@ -0,0 +1,26 @@
package de.rfc1437.ironstorage.bridge
import android.os.Bundle
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileShellState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeLoadPerformanceTest {
@Test
fun reportsNativeLoadAndFirstRustFixtureWithoutSecretData() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val runtime = NativeRuntime(instrumentation.targetContext)
val started = SystemClock.elapsedRealtimeNanos()
val shell = runtime.fixture(MobileShellState.EMPTY)
val elapsed = SystemClock.elapsedRealtimeNanos() - started
assertTrue(elapsed > 0)
assertEquals(5, shell.pages.size)
instrumentation.sendStatus(
0,
Bundle().apply { putLong("ironstorage_native_load_ns", elapsed) }
)
}
}
@@ -14,6 +14,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileTab
import de.rfc1437.ironstorage.bridge.generated.UniffiRustCallStatus
import de.rfc1437.ironstorage.bridge.generated.uniffiTraitInterfaceCall
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -102,12 +103,17 @@ class NativeRuntimeTest {
@Test
fun missingLibraryAndUnsupportedAbiFailBeforeEnteringRust() {
val abiFailure = assertThrows(NativeBridgeException::class.java) {
NativeRuntime.supportedAbi(listOf("unsupported-fixture-abi"))
NativeRuntime.supportedAbi(listOf("x86_64", "unsupported-fixture-abi"))
}
assertEquals(NativeFailureKind.UnsupportedAbi, abiFailure.kind)
assertEquals("arm64-v8a", NativeRuntime.supportedAbi(listOf("x86_64", "arm64-v8a")))
val packageFile = File.createTempFile("native-empty-package", ".apk", context.cacheDir)
try {
ZipOutputStream(packageFile.outputStream()).close()
ZipOutputStream(packageFile.outputStream()).use { archive ->
// API 26 rejects a ZIP with no entries; keep this valid package free of native code.
archive.putNextEntry(ZipEntry("non-native-fixture"))
archive.closeEntry()
}
val info = ApplicationInfo(context.applicationInfo).apply {
sourceDir = packageFile.absolutePath
splitSourceDirs = null
@@ -85,7 +85,7 @@ class NativeRuntime(context: Context) {
private const val LIBRARY_FILE = "libironstorage_android.so"
internal fun supportedAbi(deviceAbis: List<String>): String =
deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a", "x86_64") }
deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a") }
?: throw NativeBridgeException(NativeFailureKind.UnsupportedAbi)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.97.1"
profile = "minimal"
targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
targets = ["aarch64-linux-android", "armv7-linux-androideabi"]
+6 -5
View File
@@ -66,11 +66,12 @@ policy. The installed android-37.0 SDK reports an empty codename and
PreviewSdkInt=0 (revision 2); verify the same package metadata in CI before
pinning the build, rather than relying on preview wording in older instructions.
Release ABIs: **arm64-v8a**, **armeabi-v7a**, **x86_64**, corresponding to
**aarch64-linux-android**, **armv7-linux-androideabi**,
**x86_64-linux-android**. An ABI may be removed only with a dependency audit and
an amendment to this decision; emulator success alone proves no arm64 hardware
coverage. All shipped native dependencies, including JNA, must pass ELF LOAD
Release ABIs: **arm64-v8a** and **armeabi-v7a**, corresponding to
**aarch64-linux-android** and **armv7-linux-androideabi**. On 2026-10-08 the user
removed x86_64 Android from the support and CI requirements and specified
emulator-only testing for every issue. This supersedes the original three-ABI
decision in #88; historical test evidence remains historical.
All shipped native dependencies, including JNA, must pass ELF LOAD
alignment, APK ZIP alignment and actual 16 KB runtime checks. Do not depend on
compatibility mode. [Android's page-size guidance](https://developer.android.com/guide/practices/page-sizes)
requires auditing dependencies as well as the app library.
+189
View File
@@ -0,0 +1,189 @@
# Android verification and reproducible ARM builds
Issue #94 uses the existing Gitea `linux-arm64` runner. No macOS runner, physical
Android device, or x86 Android target is required. The release set is
`arm64-v8a` and `armeabi-v7a`; the minimum SDK is 26 and compile/target SDK is 37.
## CI jobs and local commands
`.gitea/workflows/android.yml` runs for main, Codex branches and pull requests.
The Rust job runs the repository formatting, warnings-as-errors workspace
check, Clippy and workspace tests with Rust 1.97.1. System build libraries are
installed inside the job container. The Android job uses native ARM64 Java 25,
Rust 1.97.1, cargo-ndk 4.1.2, locked Gradle dependencies and strict SHA-256
verification. It builds both debug/release Rust ABIs, compiles Kotlin with
warnings as errors, runs JVM tests, KtLint and Android Lint, compiles Compose
instrumentation, and builds a debug APK, unsigned release APK and unsigned AAB.
There are no Play credentials or upload signing keys in this workflow. #112
owns protected distribution signing; Gitea's `environment` key alone is not a
protection mechanism.
From the repository root, with `JAVA_HOME` and `ANDROID_HOME` set:
```sh
cargo fmt --all -- --check
RUSTFLAGS="-D warnings" cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
python3 .gitea/scripts/android_ci.py test
python3 .gitea/scripts/android_ci.py build
python3 .gitea/scripts/android_ci.py emulator emulator-5554
```
`CARGO` can specify the absolute rustup Cargo executable. `androidVerification`
is also directly available through `android/gradlew -p android`. The dependency
inventory task resolves each module under its own Gradle project lock and
requires configuration caching to be disabled. Ordinary build tasks retain
the existing incremental/configuration-cache behavior. Linker flags, selected
ABIs, SDK/NDK versions and release debug settings are explicit native task inputs.
## Native Linux ARM64 host tools
Google's [NDK r30 Linux download](https://developer.android.com/ndk/downloads)
contains x86_64 host binaries, independently of the target Android ABI. CI uses
an explicitly pinned native ARM64 community rebuild of that NDK, following the
approach described by [zongou/android-build](https://github.com/zongou/android-build).
`android/ci-toolchains.json` records exact URLs and reviewed SHA-256 hashes for:
- [HomuHomu833 NDK r30, aarch64-linux-gnu](https://github.com/HomuHomu833/android-ndk-custom/releases/tag/r30):
revision 30.0.16248370, Clang/LLD 21, Android compiler base r574158c. The inspected
compiler identifies its rebuild source as `a6285403ecfb895b7515518d40da02422ffffb20`.
- [lzhiyong native SDK tools 35.0.2](https://github.com/lzhiyong/android-sdk-tools/releases/tag/35.0.2):
statically linked ARM64 AAPT2 selected through Gradle's documented
`android.aapt2FromMavenOverride`. This host tool is separately pinned; it is
not represented as Google's build-tools 36 binary. A small argument adapter
splits AGP's `--resource-path-shortening-map=path` into the option and path
required by this AAPT2. Optimization remains enabled and exit codes propagate.
- Google's Java-based command-line tools 20.0, build 14742923. The newer 23.0
SDK manager delegates to a Linux x86_64 executable; the pinned Java manager
provisions the exact `platforms;android-37.0` and `build-tools;36.0.0` packages
without CPU emulation. APK Analyzer is also Java-based.
`prepare-linux` verifies every downloaded archive before extraction and runs
native compiler/linker/AAPT2 version probes. cargo-ndk 4.1.2 hardcodes the
`linux-x86_64` directory name on Linux, so a compatibility directory symlink
points at the native `linux-arm64` tools. It does not change executable
architecture. No binfmt registration, privileged container or emulator host
patch is used. Mac development retains its official installed SDK/NDK.
The community toolchain is an additional supply-chain input. Changing an asset,
compiler base, hash or host tool requires review and a clean build plus ARM
emulator validation. A changed release asset fails its committed checksum.
Retain the NDK's NOTICE files; toolchains are build inputs, not app runtime code.
## Artifact gates and reports
The Rust build tool checks module/source boundaries, subprocess references,
matching UniFFI metadata across ABIs and every packaged Rust/JNA ELF. APK native
libraries must be stored and aligned to 16 KB; ELF LOAD segments must also be
16 KB compatible. Generated bindings preserve contract/function checksums and
fail closed on a changed generator hardening template. Only the upstream eager
object-initialization function suppresses Kotlin's `UNUSED_EXPRESSION` false
positive; application warnings remain errors.
The packaged APK manifest is decoded and audited for the exact application ID,
backup and cleartext flags, reviewed permissions and exported launcher. The reviewed
AndroidX profile receiver must retain the privileged `DUMP` permission and exact
profile actions. Other components must explicitly remain unexported. The unused
Compose test-host manifest dependency is removed so it cannot export another
Activity in debug APKs. Backup rules must exclude
every credential/device-protected domain from cloud backup and device transfer;
instrumentation verifies the actual installed XML resources as well.
The ChromeOS x86 ABI recommendation is disabled because it conflicts with the
user's explicit ARM-only Android scope. No general Lint baseline is used.
Release Rust libraries must have no debug/symbol-table sections, and each FULL
`.so.dbg` file must contain debug information and the identical ELF build ID.
JNA's supplied symbol table must also match its stripped library's build ID.
The only packaged native libraries allowed are Rust, JNA and AndroidX graphics;
unreviewed names or duplicate entries fail.
AGP can include symbols for JNA's unselected ABIs; the published symbol ZIP
selects only both supported ARM directories, then the Rust gate verifies it
against the actual release APK. The artifact checksum manifest covers the
packages, symbol ZIP, R8 mapping, decoded manifests, test summaries and SBOM.
The CycloneDX inventory includes actual resolved Android application/test
artifacts, their configurations and SHA-256 values, plus the Rust normal feature
graph for both ARM targets with Cargo.lock registry hashes. It is a dependency
inventory, not a claim that every graph crate survives linker elimination.
Exact Android component versions and Rust license expressions require review
in `dependency-policy.json`. Runtime/test JGit, Bouncy Castle, Room, Retrofit,
OkHttp, PGPainless and ML Kit dependencies fail even transitively. Lint's own
build-tool dependencies are separate from the app's domain boundary. Gradle
strict verification additionally covers build/plugin/POM/module artifacts;
RustSec runs in the existing security-audit workflow.
Published JVM reports retain only hashed identifiers and pass/fail/error/skip
status. Assertion messages, parameter values, stack traces, properties, test
stdout/stderr and XML names are omitted. Failures remain failures and make the
gate fail; empty/missing/invalid reports are not accepted. Raw instrumentation
failures remain in `android/build/local-validation` with private permissions,
never in `ci-artifacts`. Do not upload emulator snapshots, app containers,
credential files, screenshot dumps or raw logs containing application data.
## Emulator matrix and deterministic fixtures
The existing Gitea runner has no supported native ARM Android Emulator package.
Google's current Linux emulator archives are x86_64 host binaries. Instrumented
validation therefore runs locally on the available ARM emulators; this limitation
is explicit rather than a silently skipped/passing CI emulator job. No additional
runner or physical device is provisioned.
Required local matrix for this issue:
| ARM emulator | Checks |
| --- | --- |
| API 26, 4 KB | Minimum-SDK native/JNI and Compose tests; installed backup resources; cold starts and native load |
| API 37, 4 KB | Current-SDK same tests; update over existing app data |
| API 37, strict 16 KB | Same tests with compatibility mode disabled; ELF/ZIP gate is not a substitute |
ARMv7 binaries are compiled and audited; an ARM64-only system image cannot run
an ARMv7-only APK. Record that runtime limit without requiring an x86 or physical
device. API 26 lacks `getconf`; the controller reads its kernel page-size facts
from `/proc/self/smaps`. ADB validates emulator identity and rejects physical
and unsupported-ABI targets. Installs use `-r`; force-stop/relaunch changes only
process state. Never uninstall or wipe existing test state.
Rust's shell fixtures cover all 25 tab/state combinations. Existing fake clocks
exercise lease expiry, background/foreground boundaries and activity refresh in
workspace tests. Rust secret-backend fixtures cover every typed protection/error
result and late completion. Kotlin's controlled coroutine fixture verifies that
cancelled work cannot replace newer state. Generated JNI tests cover secret
redaction, checksum/contract rejection, callback ownership/cancellation and
Keystore lockout. The private-runtime fixture creates a public-only password
store through Rust and reopens it, checking durable layout/tab state. Platform
Keystore tests from #93 remain available separately for real emulator biometrics;
they require synthetic enrollment and active-prompt ADB control, not a real
user's passphrase.
## Performance baseline
The controller records five process-cold Activity `TotalTime` samples and five
fresh-process native-load/first-Rust-fixture samples using monotonic Android
nanoseconds. Reports record SDK, ABI and actual page size. These are observations,
not flaky latency pass/fail thresholds. Never compare a debug emulator result to
a signed physical-device release measurement.
Initial #94 local debug baseline (five samples, medians):
| ARM64 image | Activity cold start | Native load and first Rust fixture |
| --- | ---: | ---: |
| API 26, 4 KB | 161 ms | 25.4 ms |
| API 37, 4 KB | 896 ms | 42.7 ms |
| API 37, strict 16 KB | 730 ms | 62.0 ms |
All three images passed ten JNI tests and two app/Compose tests covering all
25 Rust shell states and installed backup resources. The community-toolchain
Linux debug APK and both test APKs additionally passed those same suites on the
strict 16 KB image after re-signing with the existing local debug identity;
installation preserved the app data. Original Linux release packages remain
unsigned. Emulators ran concurrently with build work, so these observations
are a reproducible measurement method, not a calibrated performance budget.
#105 will add Macrobenchmark/Baseline Profiles once representative Android
screens exist. Its case definitions are: first page and scroll in 10,000 synthetic
entries; search update after each query edit; warm cached TOTP page with 100
synthetic seeds and an injected clock; editor open, field edits, reorder and save.
Use generated public fixtures, fixed queries/clocks, ten warm-up iterations and
30 measured iterations; record median/p95, allocations and device/API/toolchain.
Do not substitute the current shell for measurements of unimplemented features.
+1 -1
View File
@@ -64,7 +64,7 @@ milestone. This list gives ownership, not permission to skip acceptance criteria
| [#89 Gradle scaffold/module boundaries](https://git.rfc1437.de/hugo/IronStorage/issues/89) | Android build/app/core/platform/bridge modules | Kotlin DSL/catalog/wrapper, Compose/Hilt/KSP, source/dependency gates, all shell fixtures; clean debug APK |
| [#90 Private paths/config/cache/backup](https://git.rfc1437.de/hugo/IronStorage/issues/90) | crates/storage plus Android path adapter | Context roots/capabilities, atomic layout/migrations, backup rules; Rust and sandbox/update tests |
| [#91 Platform-neutral mobile contract](https://git.rfc1437.de/hugo/IronStorage/issues/91) | crates/storage and shared UniFFI bridge | Typed semantic actions/icons, opt-in Watch data, platform callbacks, shared Swift/Kotlin inventory tests |
| [#92 Native Rust/UniFFI packaging](https://git.rfc1437.de/hugo/IronStorage/issues/92) | Shared/Android Rust bridge and Gradle rust-bridge | cargo-ndk, generated immutable Kotlin/JNA Android, three ABIs, checksums/16 KB/loading/debug symbols |
| [#92 Native Rust/UniFFI packaging](https://git.rfc1437.de/hugo/IronStorage/issues/92) | Shared/Android Rust bridge and Gradle rust-bridge | cargo-ndk, generated immutable Kotlin/JNA Android, both ARM ABIs, checksums/16 KB/loading/debug symbols |
| [#93 Keystore adapter](https://git.rfc1437.de/hugo/IronStorage/issues/93) | Delivered: Rust sealed-record orchestration and Android OS adapter | [Keystore policy and recovery](keystore.md); native AES-GCM/CryptoObject and Rust lifecycle tests |
| [#94 CI/static analysis/reproducibility](https://git.rfc1437.de/hugo/IronStorage/issues/94) | Build/CI/test infrastructure | Cargo gates, Gradle build/Lint/ktlint, JVM/Compose/instrumented tests, verification/locks/SBOM/ABI checks |
| [#95 Local-first/HTTPS onboarding](https://git.rfc1437.de/hugo/IronStorage/issues/95) | storage mobile_onboarding plus Compose features | Existing Rust initialization/discovery/clone/credentials; no Kotlin transport |
+6
View File
@@ -18,6 +18,12 @@ camino = "1.2"
object = { version = "0.36.7", default-features = false, features = ["read", "elf"] }
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
roxmltree = "0.20.0"
toml.workspace = true
[[bin]]
name = "ironstorage-uniffi-bindgen"
path = "src/bindgen.rs"
+17 -1
View File
@@ -59,6 +59,17 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
if !source.contains(internal) || source.matches(callback).count() != 2 {
return Err("UniFFI unexpected-error template changed; review secret redaction".into());
}
let initializer = "public fun uniffiEnsureInitialized() {";
if source.matches(initializer).count() != 1 {
return Err(
"UniFFI initialization template changed; review eager contract initialization".into(),
);
}
// Object references deliberately trigger Kotlin initialization and integrity guards.
let source = source.replace(
initializer,
"@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized() {",
);
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
// Callback results are operation-owned; erase plaintext after copying into the wire buffer.
@@ -121,11 +132,16 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
mod tests {
use super::*;
const SOURCE: &str = "data class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
const SOURCE: &str = "public fun uniffiEnsureInitialized() {\n IntegrityCheckingUniffiLib\n UniffiLib\n}\ndata class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
#[test]
fn redacts_record_and_error_diagnostics_without_changing_fields_or_checksums() {
let hardened = harden_source(SOURCE).unwrap();
assert!(
hardened
.contains("@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized()")
);
assert!(hardened.contains(" IntegrityCheckingUniffiLib\n"));
assert!(hardened.contains("val value: String"));
assert!(hardened.contains("Secret([REDACTED])"));
assert!(!hardened.contains("get() = \"value="));
+579
View File
@@ -0,0 +1,579 @@
//! Build artifact gates. Inputs and diagnostics never contain application data.
use roxmltree::{Document, Node};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
error::Error,
fs,
path::Path,
};
const ANDROID: &str = "http://schemas.android.com/apk/res/android";
const DOMAINS: [&str; 9] = [
"root",
"file",
"database",
"sharedpref",
"external",
"device_root",
"device_file",
"device_database",
"device_sharedpref",
];
pub fn manifest(manifest: &Path, backup: &Path, extraction: &Path) -> Result<(), Box<dyn Error>> {
manifest_text(&fs::read_to_string(manifest)?)?;
backup_text(&fs::read_to_string(backup)?, false)?;
backup_text(&fs::read_to_string(extraction)?, true)?;
println!("Verified packaged manifest and complete backup/transfer exclusions");
Ok(())
}
fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
let document = Document::parse(source)?;
let root = document.root_element();
if root.tag_name().name() != "manifest"
|| root.attribute("package") != Some("de.rfc1437.ironstorage")
{
return Err("unexpected manifest identity".into());
}
let application = root
.children()
.find(|node| node.has_tag_name("application"))
.ok_or("application missing")?;
for (name, expected) in [("allowBackup", "false"), ("usesCleartextTraffic", "false")] {
if application.attribute((ANDROID, name)) != Some(expected) {
return Err(format!("unsafe application flag: {name}").into());
}
}
for name in ["fullBackupContent", "dataExtractionRules"] {
if !application
.attribute((ANDROID, name))
.is_some_and(|value| value.starts_with('@'))
{
return Err("backup rules missing from packaged manifest".into());
}
}
let allowed = [
"android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT",
"de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
];
for node in root.children().filter(|node| node.is_element()) {
if node.tag_name().name().starts_with("uses-permission")
&& !allowed.contains(&node.attribute((ANDROID, "name")).unwrap_or(""))
{
return Err("unreviewed packaged permission".into());
}
if node.has_tag_name("permission")
&& (node.attribute((ANDROID, "name")) != Some(allowed[2])
|| !matches!(
node.attribute((ANDROID, "protectionLevel")),
Some("signature" | "0x2")
))
{
return Err("unreviewed custom permission".into());
}
}
let mut launchers = 0;
for node in application.children().filter(|node| {
[
"activity",
"activity-alias",
"service",
"receiver",
"provider",
]
.contains(&node.tag_name().name())
}) {
let launcher =
node.attribute((ANDROID, "name")) == Some("de.rfc1437.ironstorage.MainActivity");
if launcher {
launchers += 1;
if node.attribute((ANDROID, "exported")) != Some("true") {
return Err("launcher must be exported".into());
}
let actions: BTreeSet<_> = node
.descendants()
.filter(|n| n.has_tag_name("action") || n.has_tag_name("category"))
.filter_map(|n| n.attribute((ANDROID, "name")))
.collect();
if actions
!= BTreeSet::from([
"android.intent.action.MAIN",
"android.intent.category.LAUNCHER",
])
{
return Err("unreviewed launcher intent".into());
}
} else if node.has_tag_name("receiver")
&& node.attribute((ANDROID, "name"))
== Some("androidx.profileinstaller.ProfileInstallReceiver")
{
let actions: BTreeSet<_> = node
.descendants()
.filter(|n| n.has_tag_name("action"))
.filter_map(|n| n.attribute((ANDROID, "name")))
.collect();
if node.attribute((ANDROID, "permission")) != Some("android.permission.DUMP")
|| node.attribute((ANDROID, "exported")) != Some("true")
|| actions
!= BTreeSet::from([
"androidx.profileinstaller.action.INSTALL_PROFILE",
"androidx.profileinstaller.action.SKIP_FILE",
"androidx.profileinstaller.action.SAVE_PROFILE",
"androidx.profileinstaller.action.BENCHMARK_OPERATION",
])
{
return Err(
"profile receiver must retain its privileged permission and reviewed actions"
.into(),
);
}
} else if node.attribute((ANDROID, "exported")) != Some("false") {
return Err("unreviewed exported component".into());
}
}
if launchers != 1 {
return Err("exactly one launcher required".into());
}
Ok(())
}
fn backup_text(source: &str, extraction: bool) -> Result<(), Box<dyn Error>> {
let document = Document::parse(source)?;
let root = document.root_element();
let sections = if extraction {
if !root.has_tag_name("data-extraction-rules") {
return Err("wrong extraction root".into());
}
let nodes: Vec<_> = root.children().filter(Node::is_element).collect();
if nodes.len() != 2
|| nodes
.iter()
.map(|n| n.tag_name().name())
.collect::<BTreeSet<_>>()
!= BTreeSet::from(["cloud-backup", "device-transfer"])
{
return Err("backup/transfer section missing".into());
}
nodes
} else {
if !root.has_tag_name("full-backup-content") {
return Err("wrong backup root".into());
}
vec![root]
};
for section in sections {
let mut domains = BTreeSet::new();
for node in section.children().filter(Node::is_element) {
if !node.has_tag_name("exclude")
|| node.attribute("path") != Some(".")
|| !domains.insert(node.attribute("domain").ok_or("missing domain")?)
{
return Err("backup rules must exclude each entire domain once".into());
}
}
if domains != BTreeSet::from(DOMAINS) {
return Err("backup excludes an incomplete domain set".into());
}
}
Ok(())
}
fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
#[derive(Serialize)]
struct TestResult {
id: String,
status: &'static str,
}
fn report(source: &str) -> Result<Vec<TestResult>, Box<dyn Error>> {
let document = Document::parse(source)?;
let mut results = Vec::new();
for node in document
.descendants()
.filter(|n| n.has_tag_name("testsuite"))
{
for attribute in ["errors", "failures"] {
let count: usize = node.attribute(attribute).unwrap_or("0").parse()?;
let actual = node
.children()
.filter(|n| {
n.has_tag_name("testcase")
&& n.children().any(|child| {
child.has_tag_name(if attribute == "errors" {
"error"
} else {
"failure"
})
})
})
.count();
if count > actual {
results.push(TestResult {
id: hash(
format!("{}:{attribute}", node.attribute("name").unwrap_or("")).as_bytes(),
),
status: "error",
});
}
}
}
for node in document
.descendants()
.filter(|n| n.has_tag_name("testcase"))
{
let status = if node.children().any(|n| n.has_tag_name("failure")) {
"failure"
} else if node.children().any(|n| n.has_tag_name("error")) {
"error"
} else if node.children().any(|n| n.has_tag_name("skipped")) {
"skipped"
} else {
"passed"
};
let identity = format!(
"{}:{}",
node.attribute("classname").unwrap_or(""),
node.attribute("name").unwrap_or("")
);
results.push(TestResult {
id: hash(identity.as_bytes()),
status,
});
}
if results.is_empty() {
return Err("JUnit report has no results".into());
}
Ok(results)
}
pub fn reports(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
let input = input.canonicalize()?;
let destination = if output.exists() {
output.canonicalize()?
} else {
output
.parent()
.ok_or("safe report directory missing")?
.canonicalize()?
.join(output.file_name().ok_or("safe report filename missing")?)
};
if destination.starts_with(&input) {
return Err("safe reports must be outside raw report directory".into());
}
let mut results = Vec::new();
collect_reports(&input, &mut results)?;
if results.is_empty() {
return Err("JUnit reports missing".into());
}
let failed = results
.iter()
.any(|r| matches!(r.status, "error" | "failure"));
fs::write(output, serde_json::to_vec_pretty(&results)?)?;
if failed {
return Err("test failures retained in sanitized report".into());
}
println!(
"Sanitized {} test results; only hashed identifiers and status retained",
results.len()
);
Ok(())
}
fn collect_reports(input: &Path, output: &mut Vec<TestResult>) -> Result<(), Box<dyn Error>> {
let mut entries: Vec<_> = fs::read_dir(input)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let kind = entry.file_type()?;
if kind.is_symlink() {
return Err("report symlink rejected".into());
}
if kind.is_dir() {
collect_reports(&entry.path(), output)?;
} else if entry.file_name().to_string_lossy().starts_with("TEST-")
&& entry.path().extension().is_some_and(|ext| ext == "xml")
{
if entry.metadata()?.len() > 16 * 1024 * 1024 {
return Err("report exceeds bound".into());
}
output.extend(report(&fs::read_to_string(entry.path())?)?);
}
}
Ok(())
}
#[derive(Deserialize)]
struct Policy {
android: BTreeMap<String, String>,
rust_licenses: BTreeSet<String>,
}
#[derive(Deserialize)]
struct AndroidDependency {
group: String,
name: String,
version: String,
sha256: String,
scope: String,
}
pub fn sbom(
android: &Path,
rust: &Path,
lock: &Path,
policy: &Path,
output: &Path,
) -> Result<(), Box<dyn Error>> {
let policy: Policy = serde_json::from_slice(&fs::read(policy)?)?;
let dependencies: Vec<AndroidDependency> = serde_json::from_slice(&fs::read(android)?)?;
if dependencies.is_empty() {
return Err("Android dependency inventory empty".into());
}
let lock: toml::Value = toml::from_str(&fs::read_to_string(lock)?)?;
let packages = lock
.get("package")
.and_then(toml::Value::as_array)
.ok_or("Cargo lock packages missing")?;
let mut components = BTreeMap::new();
for dependency in dependencies {
let license = android_license(
&dependency.group,
&dependency.name,
&dependency.version,
&policy,
)?;
if dependency.version.is_empty()
|| dependency.version.contains(['+', '[', ']', '(', ')', ','])
|| dependency.version.contains("SNAPSHOT")
|| dependency.sha256.len() != 64
|| !dependency.sha256.bytes().all(|b| b.is_ascii_hexdigit())
{
return Err("dependency must have a fixed version and SHA-256".into());
}
let purl = format!(
"pkg:maven/{}/{}@{}",
dependency.group, dependency.name, dependency.version
);
let key = format!("{purl}#{}#{}", dependency.sha256, dependency.scope);
components.insert(key.clone(), serde_json::json!({"type":"library", "bom-ref":key, "group":dependency.group, "name":dependency.name, "version":dependency.version, "purl":purl, "hashes":[{"alg":"SHA-256","content":dependency.sha256}], "licenses":[{"expression":license}], "properties":[{"name":"ironstorage:configuration","value":dependency.scope}]}));
}
for line in fs::read_to_string(rust)?
.lines()
.filter(|line| !line.is_empty())
{
let fields: Vec<_> = line.split('|').collect();
if fields.len() != 3 || !policy.rust_licenses.contains(fields[1]) {
return Err("Rust dependency license needs review".into());
}
let (name, remainder) = fields[0]
.split_once(" v")
.ok_or("invalid Rust dependency")?;
let version = remainder
.split_whitespace()
.next()
.ok_or("missing Rust version")?;
let purl = format!("pkg:cargo/{name}@{version}");
let package = packages
.iter()
.find(|p| {
p.get("name").and_then(toml::Value::as_str) == Some(name)
&& p.get("version").and_then(toml::Value::as_str) == Some(version)
})
.ok_or("Rust inventory differs from Cargo.lock")?;
let mut component = serde_json::json!({"type":"library", "bom-ref":purl, "name":name,"version":version,"purl":purl,"licenses":[{"expression":fields[1].replace(" / ", " OR ").replace('/', " OR ")}],"properties":[{"name":"ironstorage:configuration","value":"Android ARM normal dependencies (Cargo feature graph)"}]});
if let Some(checksum) = package.get("checksum").and_then(toml::Value::as_str) {
component["hashes"] = serde_json::json!([{"alg":"SHA-256", "content":checksum}]);
} else if !name.starts_with("ironstorage") {
return Err("Rust dependency lacks a registry checksum".into());
}
components.insert(purl.clone(), component);
}
if !components
.keys()
.any(|k| k.starts_with("pkg:cargo/ironstorage-android@"))
{
return Err("Android Rust root missing".into());
}
let value = serde_json::json!({"bomFormat":"CycloneDX", "specVersion":"1.6", "version":1, "components":components.into_values().collect::<Vec<_>>()});
fs::write(output, serde_json::to_vec_pretty(&value)?)?;
println!("Dependency/license policy passed; deterministic ARM CycloneDX inventory written");
Ok(())
}
fn android_license<'a>(
group: &str,
name: &str,
version: &str,
policy: &'a Policy,
) -> Result<&'a str, Box<dyn Error>> {
for forbidden in [
"org.eclipse.jgit",
"org.bouncycastle",
"androidx.room",
"com.squareup.retrofit2",
"com.squareup.okhttp3",
"org.pgpainless",
"com.google.mlkit",
] {
if group == forbidden || group.starts_with(&format!("{forbidden}.")) {
return Err("Android domain dependency forbidden".into());
}
}
policy
.android
.get(&format!("{group}:{name}:{version}"))
.map(String::as_str)
.ok_or_else(|| "Android dependency license needs review".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_rejects_backup_permissions_and_exports() {
let source = format!(
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
);
assert!(manifest_text(&source).is_ok());
for bad in [
source.replace("allowBackup=\"false\"", "allowBackup=\"true\""),
source.replace(
"provider android:name=\"fixture\" android:exported=\"false\"",
"provider android:name=\"fixture\" android:exported=\"true\"",
),
source.replace(
"<application",
"<uses-permission android:name=\"android.permission.INTERNET\"/><application",
),
] {
assert!(manifest_text(&bad).is_err());
}
}
#[test]
fn packaged_signature_and_profile_receiver_cannot_be_weakened() {
let launcher = format!(
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><permission android:name="de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" android:protectionLevel="0x2"/><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><receiver android:name="androidx.profileinstaller.ProfileInstallReceiver" android:permission="android.permission.DUMP" android:exported="true"><intent-filter><action android:name="androidx.profileinstaller.action.INSTALL_PROFILE"/><action android:name="androidx.profileinstaller.action.SKIP_FILE"/><action android:name="androidx.profileinstaller.action.SAVE_PROFILE"/><action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION"/></intent-filter></receiver></application></manifest>"#
);
assert!(manifest_text(&launcher).is_ok());
assert!(manifest_text(&launcher.replace("0x2", "0x0")).is_err());
assert!(
manifest_text(
&launcher.replace("android.permission.DUMP", "android.permission.INTERNET")
)
.is_err()
);
assert!(manifest_text(&launcher.replace("INSTALL_PROFILE", "UNREVIEWED")).is_err());
}
#[test]
fn backup_requires_both_sections_all_domains_and_no_include() {
let exclusions = DOMAINS
.map(|d| format!(r#"<exclude domain="{d}" path="."/>"#))
.join("");
let source = format!(
"<data-extraction-rules><cloud-backup>{exclusions}</cloud-backup><device-transfer>{exclusions}</device-transfer></data-extraction-rules>"
);
assert!(backup_text(&source, true).is_ok());
assert!(backup_text(&source.replace("device_sharedpref", "unknown"), true).is_err());
assert!(backup_text(&source.replace("<exclude", "<include"), true).is_err());
assert!(backup_text(&source.replace("<device-transfer>", "<unknown>"), true).is_err());
}
#[test]
fn reports_preserve_failures_without_names_messages_or_output() {
let secret = "otpauth://totp/secret?secret=FIXTURE";
let source = format!(
r#"<testsuite name="{secret}" errors="1"><testcase classname="{secret}" name="{secret}"><failure message="{secret}">{secret}</failure></testcase><testcase name="ok"/><system-out>{secret}</system-out></testsuite>"#
);
let results = report(&source).unwrap();
assert_eq!(
results.iter().map(|r| r.status).collect::<Vec<_>>(),
["error", "failure", "passed"]
);
let encoded = serde_json::to_string(&results).unwrap();
assert!(!encoded.contains("otpauth"));
assert!(!encoded.contains("FIXTURE"));
assert!(report("<testsuite/>").is_err());
}
#[test]
fn dependency_policy_rejects_transitive_domain_libraries_and_unknown_groups() {
let policy = Policy {
android: BTreeMap::from([
("androidx.core:core:1".into(), "Apache-2.0".into()),
("org.bouncycastle:fixture:1".into(), "MIT".into()),
]),
rust_licenses: BTreeSet::new(),
};
assert_eq!(
android_license("androidx.core", "core", "1", &policy).unwrap(),
"Apache-2.0"
);
assert!(android_license("org.bouncycastle", "fixture", "1", &policy).is_err());
assert!(android_license("org.bouncycastle.extra", "fixture", "1", &policy).is_err());
assert!(android_license("unknown", "fixture", "1", &policy).is_err());
assert!(android_license("androidx.core", "core", "2", &policy).is_err());
}
#[test]
fn filesystem_gates_reject_missing_hashes_lock_drift_and_raw_report_destination() {
let directory = std::env::temp_dir().join(format!(
"ironstorage-artifact-gates-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&directory).unwrap();
let android = directory.join("android.json");
let rust = directory.join("rust.txt");
let lock = directory.join("Cargo.lock");
let policy = directory.join("policy.json");
let output = directory.join("sbom.json");
let inventory = serde_json::json!([{"group":"androidx.core", "name":"core", "version":"1", "sha256":"a".repeat(64), "scope":"debug"}]);
fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
fs::write(
&rust,
"ironstorage-android v1.0.1|MIT|\nfixture v1.0.0|MIT|\n",
)
.unwrap();
fs::write(&lock, format!("[[package]]\nname='ironstorage-android'\nversion='1.0.1'\n[[package]]\nname='fixture'\nversion='1.0.0'\nchecksum='{}'\n", "b".repeat(64))).unwrap();
fs::write(
&policy,
r#"{"android":{"androidx.core:core:1":"Apache-2.0"},"rust_licenses":["MIT"]}"#,
)
.unwrap();
sbom(&android, &rust, &lock, &policy, &output).unwrap();
let encoded: serde_json::Value =
serde_json::from_slice(&fs::read(&output).unwrap()).unwrap();
assert_eq!(encoded["components"].as_array().unwrap().len(), 3);
let mut invalid = inventory.clone();
invalid[0]["sha256"] = "missing".into();
fs::write(&android, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
fs::write(
&rust,
"ironstorage-android v1.0.1|MIT|\nfixture v2.0.0|MIT|\n",
)
.unwrap();
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
fs::write(&rust, "fixture v1.0.0|MIT|\n").unwrap();
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
let raw = directory.join("raw");
fs::create_dir(&raw).unwrap();
fs::write(
raw.join("TEST-fixture.xml"),
"<testsuite><testcase name='public'/></testsuite>",
)
.unwrap();
assert!(reports(&raw, &raw.join("safe.json")).is_err());
assert!(!raw.join("safe.json").exists());
reports(&raw, &directory.join("safe.json")).unwrap();
fs::remove_dir_all(directory).unwrap();
}
}
+24 -1
View File
@@ -5,6 +5,7 @@ use std::{env, error::Error, fs, path::Path};
mod bindings;
mod contract;
mod gates;
mod native;
mod toolchain;
@@ -51,6 +52,28 @@ fn main() -> Result<(), Box<dyn Error>> {
[_, command, directory] if command == "audit-native" => {
native::audit_directory(Path::new(directory))?
}
[_, command, manifest, backup, extraction] if command == "audit-manifest" => {
gates::manifest(
Path::new(manifest),
Path::new(backup),
Path::new(extraction),
)?;
}
[_, command, package, symbols] if command == "audit-release" => {
native::audit_release(Path::new(package), Path::new(symbols))?;
}
[_, command, input, output] if command == "safe-reports" => {
gates::reports(Path::new(input), Path::new(output))?;
}
[_, command, android, rust, lock, policy, output] if command == "sbom" => {
gates::sbom(
Path::new(android),
Path::new(rust),
Path::new(lock),
Path::new(policy),
Path::new(output),
)?;
}
[_, command, cargo, rust, ndk, ndk_version, cargo_ndk] if command == "check-toolchain" => {
toolchain::check(
Path::new(cargo),
@@ -62,7 +85,7 @@ fn main() -> Result<(), Box<dyn Error>> {
}
_ => {
return Err(
"usage: ironstorage-android-build <fixtures output.kt | check android-dir | prepare-bindings input output | package-native input output | audit-elf path abi | audit-native jniLibs | audit-apk-split apk abi | audit-package apk-or-aab | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
"usage: ironstorage-android-build <fixtures output.kt | check android-dir | prepare-bindings input output | package-native input output | audit-elf path abi | audit-native jniLibs | audit-apk-split apk abi | audit-package apk-or-aab | audit-release apk symbols | audit-manifest manifest.xml backup.xml extraction.xml | sbom android.json rust.txt Cargo.lock policy.json output.json | safe-reports input-dir output.json | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
);
}
}
+79 -3
View File
@@ -5,7 +5,7 @@ use std::{collections::BTreeMap, error::Error, fs::File, io::Read, path::Path};
use object::{Architecture, BinaryFormat, Object, ObjectSection, ObjectSegment, ObjectSymbol};
const PAGE_SIZE: u64 = 16_384;
const ABIS: [&str; 3] = ["arm64-v8a", "armeabi-v7a", "x86_64"];
const ABIS: [&str; 2] = ["arm64-v8a", "armeabi-v7a"];
pub fn package_directory(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
audit_directory(input)?;
@@ -71,7 +71,7 @@ pub fn audit_directory(directory: &Path) -> Result<(), Box<dyn Error>> {
expected = Some(metadata);
}
}
println!("Verified three native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
println!("Verified both ARM native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
Ok(())
}
@@ -79,7 +79,6 @@ pub fn audit_elf(bytes: &[u8], abi: &str) -> Result<(), Box<dyn Error>> {
let expected = match abi {
"arm64-v8a" => Architecture::Aarch64,
"armeabi-v7a" => Architecture::Arm,
"x86_64" => Architecture::X86_64,
_ => return Err("unapproved native ABI".into()),
};
let library = object::File::parse(bytes)?;
@@ -111,6 +110,61 @@ pub fn audit_split(path: &Path, abi: &str) -> Result<(), Box<dyn Error>> {
audit_package_abis(path, &[abi])
}
pub fn audit_release(package: &Path, symbols: &Path) -> Result<(), Box<dyn Error>> {
audit_package(package)?;
let mut package = zip::ZipArchive::new(File::open(package)?)?;
let mut symbols = zip::ZipArchive::new(File::open(symbols)?)?;
for abi in ABIS {
for library in ["libironstorage_android.so", "libjnidispatch.so"] {
let mut bytes = Vec::new();
package
.by_name(&format!("lib/{abi}/{library}"))?
.read_to_end(&mut bytes)?;
let mut debug = Vec::new();
let mut entry = symbols.by_name(&format!("{abi}/{library}.dbg"))?;
if entry.size() > 512 * 1024 * 1024 {
return Err("native debug symbols exceed the audit bound".into());
}
entry.read_to_end(&mut debug)?;
audit_symbol_pair(&bytes, &debug, abi, library == "libironstorage_android.so")?;
}
}
println!("Release libraries are stripped; FULL symbols match both ARM build IDs");
Ok(())
}
fn audit_symbol_pair(
bytes: &[u8],
debug: &[u8],
abi: &str,
require_debug_info: bool,
) -> Result<(), Box<dyn Error>> {
audit_elf(bytes, abi)?;
let release = object::File::parse(bytes)?;
let symbols = object::File::parse(debug)?;
if symbols.format() != BinaryFormat::Elf || symbols.architecture() != release.architecture() {
return Err("debug archive architecture does not match release".into());
}
if release.sections().any(|s| {
s.name()
.is_ok_and(|n| n.starts_with(".debug_") || n == ".symtab")
}) {
return Err("release library contains unstripped debug symbols".into());
}
if (require_debug_info && symbols.section_by_name(".debug_info").is_none())
|| symbols.symbols().next().is_none()
{
return Err("native symbol archive lacks required symbols/debug information".into());
}
let id = release
.build_id()?
.ok_or("release library lacks a build ID")?;
if id.is_empty() || Some(id) != symbols.build_id()? {
return Err("native symbol archive does not match release build ID".into());
}
Ok(())
}
fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box<dyn Error>> {
let apk = path.extension().is_some_and(|ext| ext == "apk");
if !apk && !path.extension().is_some_and(|ext| ext == "aab") {
@@ -134,6 +188,18 @@ fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box<dyn Error>>
if !abis.contains(&abi) || library.contains('/') {
return Err("package contains an unapproved native library path".into());
}
if ![
"libironstorage_android.so",
"libjnidispatch.so",
"libandroidx.graphics.path.so",
]
.contains(&library)
|| libraries
.get(abi)
.is_some_and(|names| names.iter().any(|name| name == library))
{
return Err("package contains an unreviewed or duplicate native library".into());
}
if apk
&& (file.compression() != zip::CompressionMethod::Stored
|| file.data_start() % PAGE_SIZE != 0)
@@ -205,4 +271,14 @@ mod tests {
assert!(audit_elf(b"\x7fELF", "x86_64").is_err());
assert!(audit_elf(&[], "mips").is_err());
}
#[test]
fn symbol_gate_rejects_missing_full_symbols_and_build_identity() {
assert!(
audit_symbol_pair(&elf(PAGE_SIZE, 0), &elf(PAGE_SIZE, 0), "arm64-v8a", true).is_err()
);
assert!(
audit_symbol_pair(&elf(PAGE_SIZE, 0), b"wrong archive", "arm64-v8a", false).is_err()
);
}
}