From 3436d55cdc3d7aa42caedb4faa2e26d40e852262 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 8 Oct 2026 17:47:21 +0200 Subject: [PATCH] Establish native Linux ARM64 Android CI and verification gates (#94) --- .gitea/scripts/android_ci.py | 392 ++++++++++++ .gitea/scripts/native-arm64/aapt2 | 21 + .gitea/workflows/android.yml | 124 ++++ Cargo.lock | 15 +- android/README.md | 35 +- android/app/build.gradle.kts | 10 +- android/app/gradle.lockfile | 1 - android/build.gradle.kts | 60 +- android/ci-toolchains.json | 14 + android/core/build.gradle.kts | 5 +- android/dependency-policy.json | 165 +++++ android/features/build.gradle.kts | 5 +- android/gradle/libs.versions.toml | 1 - android/gradle/verification-metadata.xml | 16 + .../gradle/wrapper/gradle-wrapper.properties | 6 +- android/platform/build.gradle.kts | 5 +- android/rust-bridge/build.gradle.kts | 36 +- .../bridge/NativeLoadPerformanceTest.kt | 26 + .../ironstorage/bridge/NativeRuntimeTest.kt | 10 +- .../ironstorage/bridge/NativeRuntime.kt | 2 +- android/rust-toolchain.toml | 2 +- docs/android/architecture.md | 11 +- docs/android/ci.md | 219 +++++++ docs/android/parity.md | 2 +- tools/android-build/Cargo.toml | 6 + tools/android-build/src/bindings.rs | 18 +- tools/android-build/src/gates.rs | 579 ++++++++++++++++++ tools/android-build/src/main.rs | 25 +- tools/android-build/src/native.rs | 82 ++- 29 files changed, 1837 insertions(+), 56 deletions(-) create mode 100644 .gitea/scripts/android_ci.py create mode 100755 .gitea/scripts/native-arm64/aapt2 create mode 100644 .gitea/workflows/android.yml create mode 100644 android/ci-toolchains.json create mode 100644 android/dependency-policy.json create mode 100644 android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeLoadPerformanceTest.kt create mode 100644 docs/android/ci.md create mode 100644 tools/android-build/src/gates.rs diff --git a/.gitea/scripts/android_ci.py b/.gitea/scripts/android_ci.py new file mode 100644 index 0000000..d63a6ba --- /dev/null +++ b/.gitea/scripts/android_ci.py @@ -0,0 +1,392 @@ +#!/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 re +import runpy +import shutil +import tarfile +import urllib.request +import zipfile +import xml.etree.ElementTree as ET +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 backup_references(manifest, resource_table): + application = ET.fromstring(manifest).find("application") + if application is None: + raise RuntimeError("Packaged application missing") + ids = [] + for attribute, name in (("fullBackupContent", "backup_rules"), ("dataExtractionRules", "data_extraction_rules")): + values = re.findall(r"(?m)^\s*resource (0x[0-9a-f]{8}) xml/" + name + r"(?:\s|$)", resource_table) + if len(values) != 1: + raise RuntimeError("Backup resource symbol missing or ambiguous") + if application.get("{http://schemas.android.com/apk/res/android}" + attribute) != "@ref/" + values[0]: + raise RuntimeError("Packaged manifest selects an unreviewed backup resource") + ids.extend(values) + if len(set(ids)) != 2: + raise RuntimeError("Backup resource symbols overlap") + + +def clear_native_intermediates(target): + paths = [target / triple for triple in ("aarch64-linux-android", "armv7-linux-androideabi")] + if target.resolve() != target.absolute() or any(path.is_symlink() or (path.exists() and not path.is_dir()) for path in paths): + raise RuntimeError("Native scratch path must not contain symlinks or unexpected files") + for path in paths: + if path.exists(): + shutil.rmtree(path) + + +def clean_ci(root=ROOT): + if os.environ.get("CI") != "true": + raise RuntimeError("Build cleanup is restricted to CI checkouts") + paths = [root / "target", root / "android/build"] + paths.extend(root / "android" / module / "build" for module in ("app", "core", "features", "platform", "rust-bridge")) + if any(path.resolve() != path or (path.exists() and not path.is_dir()) for path in paths): + raise RuntimeError("CI build directories must not contain symlinks or unexpected files") + for path in paths: + if path.exists(): + shutil.rmtree(path) + + +def build(): + ci = os.environ.get("CI") == "true" + scratch = ROOT / "android/build/native-cargo" + if ci: + if scratch.resolve() != scratch: + raise RuntimeError("CI Cargo scratch directory must not contain symlinks") + os.environ["CARGO_TARGET_DIR"] = str(scratch) + # Resolve/check the distribution before spending time compiling the host tool. + gradle("--version") + # UniFFI generation is memory-heavy; compile host tools before Gradle build JVMs. + run(CARGO, "+1.97.1", "build", "--locked", "-p", "ironstorage-android-build", cwd=ROOT) + if ci: + for variant in ("Debug", "Release"): + gradle(f":rust-bridge:prepare{variant}RustBindings") + clear_native_intermediates(scratch) + 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" + aapt2 = ROOT / ".gitea/scripts/native-arm64/aapt2" if platform.system() == "Linux" else SDK / "build-tools/36.0.0/aapt2" + 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) + backup_references(manifest.read_text(), run(aapt2, "dump", "resources", path, capture=True).stdout) + rules = [] + for name in ("backup_rules", "data_extraction_rules"): + resource = run(analyzer, "resources", "value", "--type", "xml", "--config", "default", "--name", name, path, capture=True).stdout.strip() + decoded = output / f"{variant}-{name}.xml" + decoded.write_text(run(analyzer, "resources", "xml", "--file", resource, path, capture=True).stdout) + rules.append(decoded) + tool("audit-manifest", manifest, *rules) + 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 module in ("app", "core") or 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_ci_cleanup_refuses_local_or_redirected_build_directories(self): + previous = os.environ.get("CI") + try: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + (root / "target").mkdir() + (root / "target/public-fixture").write_text("artifact") + os.environ.pop("CI", None) + with self.assertRaisesRegex(RuntimeError, "restricted to CI"): + clean_ci(root) + self.assertTrue((root / "target/public-fixture").exists()) + os.environ["CI"] = "true" + (root / "android/app").mkdir(parents=True) + (root / "android/app/build").symlink_to(root / "target", target_is_directory=True) + with self.assertRaisesRegex(RuntimeError, "symlinks"): + clean_ci(root) + self.assertTrue((root / "target/public-fixture").exists()) + (root / "android/app/build").unlink() + (root / "android/app/build").mkdir() + (root / "android/app/src").mkdir() + clean_ci(root) + self.assertFalse((root / "target").exists()) + self.assertFalse((root / "android/app/build").exists()) + self.assertTrue((root / "android/app/src").is_dir()) + finally: + if previous is None: + os.environ.pop("CI", None) + else: + os.environ["CI"] = previous + + def test_native_scratch_cleanup_keeps_host_tools_and_rejects_symlinks(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory).resolve() + triples = ("aarch64-linux-android", "armv7-linux-androideabi") + for name in (*triples, "debug"): + (target / name).mkdir() + (target / name / "public-fixture").write_text("artifact") + clear_native_intermediates(target) + self.assertTrue((target / "debug/public-fixture").is_file()) + self.assertTrue(all(not (target / name).exists() for name in triples)) + (target / triples[0]).symlink_to(target / "debug", target_is_directory=True) + with self.assertRaisesRegex(RuntimeError, "symlinks"): + clear_native_intermediates(target) + self.assertTrue((target / "debug/public-fixture").is_file()) + + def test_manifest_must_select_the_audited_backup_resources(self): + manifest = '' + symbols = " resource 0x7f110000 xml/backup_rules\n resource 0x7f110001 xml/data_extraction_rules\n" + backup_references(manifest, symbols) + for wrong in (manifest.replace("0x7f110000", "0x7f110002"), manifest.replace("@ref/0x7f110001", "false")): + with self.assertRaisesRegex(RuntimeError, "unreviewed backup resource"): + backup_references(wrong, symbols) + for wrong in (symbols.replace("backup_rules", "other_rules"), symbols + symbols, symbols.replace("0x7f110001", "0x7f110000")): + with self.assertRaises(RuntimeError): + backup_references(manifest, wrong) + with self.assertRaisesRegex(RuntimeError, "symbols overlap"): + backup_references(manifest.replace("0x7f110001", "0x7f110000"), symbols.replace("0x7f110001", "0x7f110000")) + + 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", "clean-ci"]) + parser.add_argument("serial", nargs="?") + args = parser.parse_args() + if args.command not in ("test", "clean-ci") 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 == "clean-ci": + clean_ci() + 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") diff --git a/.gitea/scripts/native-arm64/aapt2 b/.gitea/scripts/native-arm64/aapt2 new file mode 100755 index 0000000..05a3693 --- /dev/null +++ b/.gitea/scripts/native-arm64/aapt2 @@ -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:])]) diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml new file mode 100644 index 0000000..94370c9 --- /dev/null +++ b/.gitea/workflows/android.yml @@ -0,0 +1,124 @@ +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: '1' + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=lld + +jobs: + rust: + runs-on: linux-arm64 + timeout-minutes: 90 + env: + CI: 'true' + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_TEST_DEBUG: line-tables-only + steps: + - uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install workspace system libraries + run: | + df -h . + df -i . + exit 1 # Temporary artifact transport probe; restore final workflow afterward. + available=$(df --output=avail -B1 . | tail -n 1) + if [ "$available" -lt 17179869184 ]; then + echo 'Rust CI needs at least 16 GiB free in its checkout filesystem.' >&2 + exit 1 + fi + apt-get update + apt-get install -y --no-install-recommends python3 lld 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 + du -sh target + - name: Reclaim this checkout's generated build directories + if: always() + run: python3 .gitea/scripts/android_ci.py clean-ci + + - name: Create public artifact transport fixture + if: always() + run: | + mkdir -p /tmp/ironstorage94-artifact-probe + echo '{"purpose":"public artifact transport verification","secret_data":false}' > /tmp/ironstorage94-artifact-probe/probe.json + - name: Verify Gitea artifact transport + if: always() + uses: https://github.com/actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: issue94-artifact-transport-probe + path: /tmp/ironstorage94-artifact-probe/ + if-no-files-found: error + retention-days: 1 + + android: + runs-on: linux-arm64 + timeout-minutes: 150 + env: + ANDROID_HOME: /opt/ironstorage-android-sdk + JAVA_HOME: /usr/lib/jvm/java-25-openjdk-arm64 + CI: 'true' + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_DEV_DEBUG: '0' + GRADLE_OPTS: -Dorg.gradle.workers.max=1 -Dorg.gradle.jvmargs=-Xmx1g -Dkotlin.daemon.jvm.options=-Xmx512m + steps: + - uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install native ARM64 Java and build prerequisites + run: | + df -h . + df -i . + available=$(df --output=avail -B1 . | tail -n 1) + if [ "$available" -lt 25769803776 ]; then + echo 'Android CI needs at least 24 GiB free in its checkout filesystem.' >&2 + exit 1 + fi + apt-get update + apt-get install -y --no-install-recommends openjdk-25-jdk python3 ca-certificates curl unzip xz-utils gcc lld 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 + - name: Reclaim this checkout's generated build directories after upload + if: always() + run: python3 .gitea/scripts/android_ci.py clean-ci diff --git a/Cargo.lock b/Cargo.lock index 794b748..de4cab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/android/README.md b/android/README.md index f32026e..3e41f2b 100644 --- a/android/README.md +++ b/android/README.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 165b347..a46ad45 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index b957dbe..7ba2c08 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -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 diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 18c4a75..9fab16f 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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("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") +} diff --git a/android/ci-toolchains.json b/android/ci-toolchains.json new file mode 100644 index 0000000..0d3939b --- /dev/null +++ b/android/ci-toolchains.json @@ -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" + } +} diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts index e14e483..7db1351 100644 --- a/android/core/build.gradle.kts +++ b/android/core/build.gradle.kts @@ -25,7 +25,10 @@ android { kotlin { jvmToolchain(25) - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } } dependencies { diff --git a/android/dependency-policy.json b/android/dependency-policy.json new file mode 100644 index 0000000..e4db954 --- /dev/null +++ b/android/dependency-policy.json @@ -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" + ] +} diff --git a/android/features/build.gradle.kts b/android/features/build.gradle.kts index db18bab..4bea6c6 100644 --- a/android/features/build.gradle.kts +++ b/android/features/build.gradle.kts @@ -28,7 +28,10 @@ android { kotlin { jvmToolchain(25) - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } } dependencies { diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a4332e8..a24747f 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index df050a9..7d9d06d 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -3946,11 +3946,17 @@ + + + + + + @@ -4108,5 +4114,15 @@ + + + + + + + + + + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index a78c88e..471f9b3 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,9 +2,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip -networkTimeout=10000 -retries=0 -retryBackOffMs=500 +networkTimeout=60000 +retries=3 +retryBackOffMs=2000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/platform/build.gradle.kts b/android/platform/build.gradle.kts index da0f1f1..61d9646 100644 --- a/android/platform/build.gradle.kts +++ b/android/platform/build.gradle.kts @@ -32,7 +32,10 @@ android { kotlin { jvmToolchain(25) - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } } dependencies { diff --git a/android/rust-bridge/build.gradle.kts b/android/rust-bridge/build.gradle.kts index 6d8bece..86b03a7 100644 --- a/android/rust-bridge/build.gradle.kts +++ b/android/rust-bridge/build.gradle.kts @@ -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("build${title}Rust") { dependsOn(checkRustToolchain) workingDir = workspace @@ -81,22 +92,33 @@ 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") + inputs.property( + "devDebug", + providers.environmentVariable("CARGO_PROFILE_DEV_DEBUG").orElse("cargo-default") + ) + inputs.property( + "incremental", + providers.environmentVariable("CARGO_INCREMENTAL").orElse("cargo-default") + ) + inputs.property( + "cargoTargetDirectory", + providers.environmentVariable("CARGO_TARGET_DIR").orElse("workspace-default") + ) 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" diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeLoadPerformanceTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeLoadPerformanceTest.kt new file mode 100644 index 0000000..948186a --- /dev/null +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeLoadPerformanceTest.kt @@ -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) } + ) + } +} diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt index 4dbb720..8dadd80 100644 --- a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt @@ -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 diff --git a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt index ce32ca6..bf4dc2b 100644 --- a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt +++ b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt @@ -85,7 +85,7 @@ class NativeRuntime(context: Context) { private const val LIBRARY_FILE = "libironstorage_android.so" internal fun supportedAbi(deviceAbis: List): 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) } } diff --git a/android/rust-toolchain.toml b/android/rust-toolchain.toml index 739e30f..f72e1f1 100644 --- a/android/rust-toolchain.toml +++ b/android/rust-toolchain.toml @@ -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"] diff --git a/docs/android/architecture.md b/docs/android/architecture.md index 2e55a0d..8ad8f34 100644 --- a/docs/android/architecture.md +++ b/docs/android/architecture.md @@ -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. diff --git a/docs/android/ci.md b/docs/android/ci.md new file mode 100644 index 0000000..4fb466d --- /dev/null +++ b/docs/android/ci.md @@ -0,0 +1,219 @@ +# 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. Native Linux LLD and one build worker bound +workspace test-linking memory. The CI host test profile uses `line-tables-only` +debug information for backtrace file/line diagnostics and disables incremental +caches to bound disk use; every test remains enabled. This does not change the +Android release profile's required FULL debug-symbol archive. +Jobs check available disk space before installing tools: at least 16 GiB for +the host Rust gates and 24 GiB for Android, including SDK/dependencies, both +native variants and package assembly. Insufficient capacity fails immediately +with an explicit diagnostic. Each job schedules an `always()` cleanup step for +only this checkout's generated Cargo/Android build directories after its gates/artifact upload; +cleanup rejects symlinks and refuses to run outside CI. It does not prune +shared runner caches, SDK installations, other repositories or emulator data. +Hard runner termination can prevent cleanup; retained job data then needs +runner-host administration. +The Android job compiles the UniFFI host tool before starting Gradle. Host/debug +builds use line-table debug information and no incremental caches. +Release Rust libraries retain their explicit FULL debug information. These +profile/environment inputs participate in Gradle's native-task cache keys. +With `CI=true`, Cargo uses the owned `android/build/native-cargo` scratch +directory. Each native variant is compiled, audited and converted to bindings +before its Android-target compiler intermediates are removed. Copied libraries, +generated bindings and the host audit tool remain for the full Gradle verification +and assembly phase. This bounds peak disk use without removing tests or release +symbols; ordinary local development retains its Cargo intermediates. +The Gradle wrapper retries transient distribution-download failures three times +with bounded exponential backoff; its pinned distribution checksum remains required. +The Android job has a 150-minute timeout for the measured single-runner build +and uses one worker, a 1 GB Gradle heap and a 512 MB Kotlin compiler heap. It 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. The packaged manifest's backup resource IDs must match +the APK's compiled resource table for the reviewed rules. APK Analyzer resolves their actual +paths, including resource-shortened release paths, and Rust audits those decoded +APK XML files. 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. diff --git a/docs/android/parity.md b/docs/android/parity.md index 272f513..1c842e7 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -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 | diff --git a/tools/android-build/Cargo.toml b/tools/android-build/Cargo.toml index 882278d..52d25a4 100644 --- a/tools/android-build/Cargo.toml +++ b/tools/android-build/Cargo.toml @@ -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" diff --git a/tools/android-build/src/bindings.rs b/tools/android-build/src/bindings.rs index 28b7aaa..51c5b00 100644 --- a/tools/android-build/src/bindings.rs +++ b/tools/android-build/src/bindings.rs @@ -59,6 +59,17 @@ fn harden_source(source: &str) -> Result> { 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> { 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=")); diff --git a/tools/android-build/src/gates.rs b/tools/android-build/src/gates.rs new file mode 100644 index 0000000..a79894a --- /dev/null +++ b/tools/android-build/src/gates.rs @@ -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> { + 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> { + 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> { + 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::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, Box> { + 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> { + 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) -> Result<(), Box> { + let mut entries: Vec<_> = fs::read_dir(input)?.collect::>()?; + 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, + rust_licenses: BTreeSet, +} +#[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> { + let policy: Policy = serde_json::from_slice(&fs::read(policy)?)?; + let dependencies: Vec = 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::>()}); + 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> { + 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#""# + ); + 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( + ""# + ); + 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#""#)) + .join(""); + let source = format!( + "{exclusions}{exclusions}" + ); + assert!(backup_text(&source, true).is_ok()); + assert!(backup_text(&source.replace("device_sharedpref", "unknown"), true).is_err()); + assert!(backup_text(&source.replace("", ""), true).is_err()); + } + #[test] + fn reports_preserve_failures_without_names_messages_or_output() { + let secret = "otpauth://totp/secret?secret=FIXTURE"; + let source = format!( + r#"{secret}{secret}"# + ); + let results = report(&source).unwrap(); + assert_eq!( + results.iter().map(|r| r.status).collect::>(), + ["error", "failure", "passed"] + ); + let encoded = serde_json::to_string(&results).unwrap(); + assert!(!encoded.contains("otpauth")); + assert!(!encoded.contains("FIXTURE")); + assert!(report("").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"), + "", + ) + .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(); + } +} diff --git a/tools/android-build/src/main.rs b/tools/android-build/src/main.rs index 8ce3436..5c2e3e8 100644 --- a/tools/android-build/src/main.rs +++ b/tools/android-build/src/main.rs @@ -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> { [_, 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> { } _ => { return Err( - "usage: ironstorage-android-build ".into(), + "usage: ironstorage-android-build ".into(), ); } } diff --git a/tools/android-build/src/native.rs b/tools/android-build/src/native.rs index 18edd99..bbb10a1 100644 --- a/tools/android-build/src/native.rs +++ b/tools/android-build/src/native.rs @@ -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> { audit_directory(input)?; @@ -71,7 +71,7 @@ pub fn audit_directory(directory: &Path) -> Result<(), Box> { 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> { 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> { audit_package_abis(path, &[abi]) } +pub fn audit_release(package: &Path, symbols: &Path) -> Result<(), Box> { + 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> { + 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> { 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> 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() + ); + } }