Establish native Linux ARM64 Android CI and verification gates (#94)
This commit is contained in:
1 parent
1abd75282e
commit
dbc0210c55
29 files changed
+1752
-56
No files matched your search
@@ -0,0 +1,348 @@
|
||||
#!/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 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_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 = '<manifest xmlns:android="http://schemas.android.com/apk/res/android"><application android:fullBackupContent="@ref/0x7f110000" android:dataExtractionRules="@ref/0x7f110001"/></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"])
|
||||
parser.add_argument("serial", nargs="?")
|
||||
args = parser.parse_args()
|
||||
if args.command != "test" and not (os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")):
|
||||
parser.error("ANDROID_HOME or ANDROID_SDK_ROOT is required")
|
||||
if args.command == "test":
|
||||
unittest.main(argv=[__file__])
|
||||
elif args.command == "prepare-linux":
|
||||
prepare_linux()
|
||||
elif args.command == "build":
|
||||
build()
|
||||
elif args.serial:
|
||||
emulator(args.serial)
|
||||
else:
|
||||
parser.error("emulator requires an ADB serial")
|
||||
Executable
+21
@@ -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:])])
|
||||
@@ -0,0 +1,92 @@
|
||||
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:
|
||||
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 .
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends 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
|
||||
|
||||
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: line-tables-only
|
||||
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 .
|
||||
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
|
||||
Generated
+10
-5
@@ -5450,6 +5450,11 @@ dependencies = [
|
||||
"ironstorage",
|
||||
"object",
|
||||
"regex",
|
||||
"roxmltree",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"uniffi",
|
||||
"uniffi_bindgen",
|
||||
"zip",
|
||||
@@ -8748,9 +8753,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -8834,9 +8839,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -9879,7 +9884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+18
-17
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import groovy.json.JsonOutput
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||
import org.gradle.api.artifacts.ExternalModuleDependency
|
||||
import org.gradle.api.artifacts.ProjectDependency
|
||||
|
||||
@@ -39,8 +42,15 @@ subprojects {
|
||||
listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3").none { group.startsWith(it) }
|
||||
}) { "Domain libraries must stay in crates/storage: $group:$name" }
|
||||
}
|
||||
val enforceDomain = name.endsWith("RuntimeClasspath") || name.endsWith("CompileClasspath")
|
||||
resolutionStrategy.eachDependency {
|
||||
require(!requested.version.orEmpty().contains('+')) { "Dynamic versions are forbidden" }
|
||||
require(requested.version.orEmpty().let { version ->
|
||||
!version.contains('+') && !version.contains("SNAPSHOT") &&
|
||||
!version.startsWith("latest.") && version.none { it in "[]()," }
|
||||
}) { "Dynamic versions are forbidden" }
|
||||
require(!enforceDomain || listOf("org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3", "org.pgpainless", "com.google.mlkit").none {
|
||||
requested.group == it || requested.group.startsWith("$it.")
|
||||
}) { "Transitive domain libraries must stay in crates/storage" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,3 +89,51 @@ tasks.register<Exec>("auditNativePackages") {
|
||||
) + packages.map { it.asFile.absolutePath }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Each project resolves its own configurations under Gradle's project lock.
|
||||
subprojects {
|
||||
val owner = project
|
||||
tasks.register("dependencyInventory") {
|
||||
val output = layout.buildDirectory.file("reports/android-dependencies.json")
|
||||
outputs.file(output)
|
||||
outputs.upToDateWhen { false }
|
||||
doLast {
|
||||
val rows = listOf("debugRuntimeClasspath", "releaseRuntimeClasspath", "debugAndroidTestRuntimeClasspath", "debugUnitTestRuntimeClasspath").flatMap { name ->
|
||||
val configuration = owner.configurations.findByName(name)
|
||||
if (configuration == null || !configuration.isCanBeResolved) emptyList() else {
|
||||
configuration.incoming.artifactView {
|
||||
componentFilter { it is ModuleComponentIdentifier }
|
||||
}.artifacts.artifacts.mapNotNull { artifact ->
|
||||
val id = artifact.id.componentIdentifier as? ModuleComponentIdentifier
|
||||
id?.let {
|
||||
mapOf(
|
||||
"group" to it.group, "name" to it.module, "version" to it.version,
|
||||
"sha256" to MessageDigest.getInstance("SHA-256").digest(artifact.file.readBytes())
|
||||
.joinToString("") { byte -> "%02x".format(byte) },
|
||||
"scope" to "${owner.path}:$name"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.sortedWith(compareBy({ it.getValue("scope") }, { it.getValue("group") }, { it.getValue("name") }, { it.getValue("version") }, { it.getValue("sha256") }))
|
||||
output.get().asFile.apply {
|
||||
parentFile.mkdirs()
|
||||
writeText(JsonOutput.prettyPrint(JsonOutput.toJson(rows)) + "\n")
|
||||
}
|
||||
}
|
||||
notCompatibleWithConfigurationCache("Resolves reviewed artifact graphs")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("dependencyInventory") {
|
||||
dependsOn(subprojects.map { "${it.path}:dependencyInventory" })
|
||||
}
|
||||
|
||||
tasks.register("androidVerification") {
|
||||
dependsOn("checkArchitecture", "dependencyInventory", "auditNativePackages")
|
||||
dependsOn(subprojects.map { "${it.path}:ktlintCheck" })
|
||||
dependsOn(subprojects.map { "${it.path}:lintDebug" })
|
||||
dependsOn(subprojects.map { "${it.path}:testDebugUnitTest" })
|
||||
dependsOn(":app:assembleDebugAndroidTest", ":rust-bridge:assembleDebugAndroidTest", ":platform:assembleDebugAndroidTest")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ android {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(25)
|
||||
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -28,7 +28,10 @@ android {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(25)
|
||||
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -3946,11 +3946,17 @@
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit" name="junit-bom" version="5.10.2">
|
||||
<artifact name="junit-bom-5.10.2.module">
|
||||
<sha256 value="de23b114b3e4119a8fe6eb17bed5a3852816698bace67071579d6d927ebb080a" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
</artifact>
|
||||
<artifact name="junit-bom-5.10.2.pom">
|
||||
<sha256 value="169dd904a4b0f6520cffe658cc62292bfe9f3c14a989fa92120724cde43a9968" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.junit" name="junit-bom" version="5.11.0-M2">
|
||||
<artifact name="junit-bom-5.11.0-M2.module">
|
||||
<sha256 value="86477abcf490d6ca059aa9973cb108d22a506f49d1a5569bb32cc6cbf43c2cce" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
</artifact>
|
||||
<artifact name="junit-bom-5.11.0-M2.pom">
|
||||
<sha256 value="4a3ffc4a4edcfec2cb5d619922706a9407161798571939f854dfda73e4e17cc8" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
@@ -4108,5 +4114,15 @@
|
||||
<sha256 value="0fe31326e83b7622cbcd9c75d467b291f01ffe3cc6e3e76651ac05a1c1c7a360" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.guava" name="guava-parent" version="33.4.0-jre">
|
||||
<artifact name="guava-parent-33.4.0-jre.pom">
|
||||
<sha256 value="3a499ed34a0d9ee0f1bcc39230021a1cd4e2f7dd0426ab6844f585465d41dcd7" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.0">
|
||||
<artifact name="kotlinx-coroutines-bom-1.8.0.pom">
|
||||
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Reviewed against Maven Central for Linux ARM64 CI"/>
|
||||
</artifact>
|
||||
</component>
|
||||
</components>
|
||||
</verification-metadata>
|
||||
+3
-3
@@ -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
|
||||
@@ -32,7 +32,10 @@ android {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(25)
|
||||
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -12,7 +12,7 @@ android {
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
@@ -22,12 +22,17 @@ android {
|
||||
lint {
|
||||
abortOnError = true
|
||||
warningsAsErrors = true
|
||||
// The user scoped this milestone to ARM Android; x86 ChromeOS is excluded.
|
||||
disable += "ChromeOsAbiSupport"
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(25)
|
||||
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -73,6 +78,12 @@ androidComponents.onVariants { variant ->
|
||||
val nativeOutput = layout.buildDirectory.dir("generated/native/$name/jniLibs")
|
||||
val rawBindings = layout.buildDirectory.dir("generated/bindings-raw/$name")
|
||||
val bindings = layout.buildDirectory.dir("generated/bindings/$name")
|
||||
val rustFlags = listOf(
|
||||
"-D warnings",
|
||||
"-C link-arg=-Wl,-z,max-page-size=16384",
|
||||
"-C link-arg=-Wl,-z,common-page-size=16384",
|
||||
"-C link-arg=-Wl,--build-id=sha1"
|
||||
).joinToString(" ")
|
||||
val native = tasks.register<Exec>("build${title}Rust") {
|
||||
dependsOn(checkRustToolchain)
|
||||
workingDir = workspace
|
||||
@@ -81,22 +92,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"
|
||||
|
||||
+26
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -14,6 +14,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileTab
|
||||
import de.rfc1437.ironstorage.bridge.generated.UniffiRustCallStatus
|
||||
import de.rfc1437.ironstorage.bridge.generated.uniffiTraitInterfaceCall
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -102,12 +103,17 @@ class NativeRuntimeTest {
|
||||
@Test
|
||||
fun missingLibraryAndUnsupportedAbiFailBeforeEnteringRust() {
|
||||
val abiFailure = assertThrows(NativeBridgeException::class.java) {
|
||||
NativeRuntime.supportedAbi(listOf("unsupported-fixture-abi"))
|
||||
NativeRuntime.supportedAbi(listOf("x86_64", "unsupported-fixture-abi"))
|
||||
}
|
||||
assertEquals(NativeFailureKind.UnsupportedAbi, abiFailure.kind)
|
||||
assertEquals("arm64-v8a", NativeRuntime.supportedAbi(listOf("x86_64", "arm64-v8a")))
|
||||
val packageFile = File.createTempFile("native-empty-package", ".apk", context.cacheDir)
|
||||
try {
|
||||
ZipOutputStream(packageFile.outputStream()).close()
|
||||
ZipOutputStream(packageFile.outputStream()).use { archive ->
|
||||
// API 26 rejects a ZIP with no entries; keep this valid package free of native code.
|
||||
archive.putNextEntry(ZipEntry("non-native-fixture"))
|
||||
archive.closeEntry()
|
||||
}
|
||||
val info = ApplicationInfo(context.applicationInfo).apply {
|
||||
sourceDir = packageFile.absolutePath
|
||||
splitSourceDirs = null
|
||||
|
||||
@@ -85,7 +85,7 @@ class NativeRuntime(context: Context) {
|
||||
private const val LIBRARY_FILE = "libironstorage_android.so"
|
||||
|
||||
internal fun supportedAbi(deviceAbis: List<String>): String =
|
||||
deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a") }
|
||||
?: throw NativeBridgeException(NativeFailureKind.UnsupportedAbi)
|
||||
}
|
||||
}
|
||||
@@ -1,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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# 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.
|
||||
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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
@@ -59,6 +59,17 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
|
||||
if !source.contains(internal) || source.matches(callback).count() != 2 {
|
||||
return Err("UniFFI unexpected-error template changed; review secret redaction".into());
|
||||
}
|
||||
let initializer = "public fun uniffiEnsureInitialized() {";
|
||||
if source.matches(initializer).count() != 1 {
|
||||
return Err(
|
||||
"UniFFI initialization template changed; review eager contract initialization".into(),
|
||||
);
|
||||
}
|
||||
// Object references deliberately trigger Kotlin initialization and integrity guards.
|
||||
let source = source.replace(
|
||||
initializer,
|
||||
"@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized() {",
|
||||
);
|
||||
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
|
||||
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
|
||||
// Callback results are operation-owned; erase plaintext after copying into the wire buffer.
|
||||
@@ -121,11 +132,16 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SOURCE: &str = "data class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
|
||||
const SOURCE: &str = "public fun uniffiEnsureInitialized() {\n IntegrityCheckingUniffiLib\n UniffiLib\n}\ndata class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
|
||||
|
||||
#[test]
|
||||
fn redacts_record_and_error_diagnostics_without_changing_fields_or_checksums() {
|
||||
let hardened = harden_source(SOURCE).unwrap();
|
||||
assert!(
|
||||
hardened
|
||||
.contains("@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized()")
|
||||
);
|
||||
assert!(hardened.contains(" IntegrityCheckingUniffiLib\n"));
|
||||
assert!(hardened.contains("val value: String"));
|
||||
assert!(hardened.contains("Secret([REDACTED])"));
|
||||
assert!(!hardened.contains("get() = \"value="));
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
//! Build artifact gates. Inputs and diagnostics never contain application data.
|
||||
use roxmltree::{Document, Node};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
error::Error,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
const ANDROID: &str = "http://schemas.android.com/apk/res/android";
|
||||
const DOMAINS: [&str; 9] = [
|
||||
"root",
|
||||
"file",
|
||||
"database",
|
||||
"sharedpref",
|
||||
"external",
|
||||
"device_root",
|
||||
"device_file",
|
||||
"device_database",
|
||||
"device_sharedpref",
|
||||
];
|
||||
|
||||
pub fn manifest(manifest: &Path, backup: &Path, extraction: &Path) -> Result<(), Box<dyn Error>> {
|
||||
manifest_text(&fs::read_to_string(manifest)?)?;
|
||||
backup_text(&fs::read_to_string(backup)?, false)?;
|
||||
backup_text(&fs::read_to_string(extraction)?, true)?;
|
||||
println!("Verified packaged manifest and complete backup/transfer exclusions");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
|
||||
let document = Document::parse(source)?;
|
||||
let root = document.root_element();
|
||||
if root.tag_name().name() != "manifest"
|
||||
|| root.attribute("package") != Some("de.rfc1437.ironstorage")
|
||||
{
|
||||
return Err("unexpected manifest identity".into());
|
||||
}
|
||||
let application = root
|
||||
.children()
|
||||
.find(|node| node.has_tag_name("application"))
|
||||
.ok_or("application missing")?;
|
||||
for (name, expected) in [("allowBackup", "false"), ("usesCleartextTraffic", "false")] {
|
||||
if application.attribute((ANDROID, name)) != Some(expected) {
|
||||
return Err(format!("unsafe application flag: {name}").into());
|
||||
}
|
||||
}
|
||||
for name in ["fullBackupContent", "dataExtractionRules"] {
|
||||
if !application
|
||||
.attribute((ANDROID, name))
|
||||
.is_some_and(|value| value.starts_with('@'))
|
||||
{
|
||||
return Err("backup rules missing from packaged manifest".into());
|
||||
}
|
||||
}
|
||||
let allowed = [
|
||||
"android.permission.USE_BIOMETRIC",
|
||||
"android.permission.USE_FINGERPRINT",
|
||||
"de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
|
||||
];
|
||||
for node in root.children().filter(|node| node.is_element()) {
|
||||
if node.tag_name().name().starts_with("uses-permission")
|
||||
&& !allowed.contains(&node.attribute((ANDROID, "name")).unwrap_or(""))
|
||||
{
|
||||
return Err("unreviewed packaged permission".into());
|
||||
}
|
||||
if node.has_tag_name("permission")
|
||||
&& (node.attribute((ANDROID, "name")) != Some(allowed[2])
|
||||
|| !matches!(
|
||||
node.attribute((ANDROID, "protectionLevel")),
|
||||
Some("signature" | "0x2")
|
||||
))
|
||||
{
|
||||
return Err("unreviewed custom permission".into());
|
||||
}
|
||||
}
|
||||
let mut launchers = 0;
|
||||
for node in application.children().filter(|node| {
|
||||
[
|
||||
"activity",
|
||||
"activity-alias",
|
||||
"service",
|
||||
"receiver",
|
||||
"provider",
|
||||
]
|
||||
.contains(&node.tag_name().name())
|
||||
}) {
|
||||
let launcher =
|
||||
node.attribute((ANDROID, "name")) == Some("de.rfc1437.ironstorage.MainActivity");
|
||||
if launcher {
|
||||
launchers += 1;
|
||||
if node.attribute((ANDROID, "exported")) != Some("true") {
|
||||
return Err("launcher must be exported".into());
|
||||
}
|
||||
let actions: BTreeSet<_> = node
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("action") || n.has_tag_name("category"))
|
||||
.filter_map(|n| n.attribute((ANDROID, "name")))
|
||||
.collect();
|
||||
if actions
|
||||
!= BTreeSet::from([
|
||||
"android.intent.action.MAIN",
|
||||
"android.intent.category.LAUNCHER",
|
||||
])
|
||||
{
|
||||
return Err("unreviewed launcher intent".into());
|
||||
}
|
||||
} else if node.has_tag_name("receiver")
|
||||
&& node.attribute((ANDROID, "name"))
|
||||
== Some("androidx.profileinstaller.ProfileInstallReceiver")
|
||||
{
|
||||
let actions: BTreeSet<_> = node
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("action"))
|
||||
.filter_map(|n| n.attribute((ANDROID, "name")))
|
||||
.collect();
|
||||
if node.attribute((ANDROID, "permission")) != Some("android.permission.DUMP")
|
||||
|| node.attribute((ANDROID, "exported")) != Some("true")
|
||||
|| actions
|
||||
!= BTreeSet::from([
|
||||
"androidx.profileinstaller.action.INSTALL_PROFILE",
|
||||
"androidx.profileinstaller.action.SKIP_FILE",
|
||||
"androidx.profileinstaller.action.SAVE_PROFILE",
|
||||
"androidx.profileinstaller.action.BENCHMARK_OPERATION",
|
||||
])
|
||||
{
|
||||
return Err(
|
||||
"profile receiver must retain its privileged permission and reviewed actions"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
} else if node.attribute((ANDROID, "exported")) != Some("false") {
|
||||
return Err("unreviewed exported component".into());
|
||||
}
|
||||
}
|
||||
if launchers != 1 {
|
||||
return Err("exactly one launcher required".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backup_text(source: &str, extraction: bool) -> Result<(), Box<dyn Error>> {
|
||||
let document = Document::parse(source)?;
|
||||
let root = document.root_element();
|
||||
let sections = if extraction {
|
||||
if !root.has_tag_name("data-extraction-rules") {
|
||||
return Err("wrong extraction root".into());
|
||||
}
|
||||
let nodes: Vec<_> = root.children().filter(Node::is_element).collect();
|
||||
if nodes.len() != 2
|
||||
|| nodes
|
||||
.iter()
|
||||
.map(|n| n.tag_name().name())
|
||||
.collect::<BTreeSet<_>>()
|
||||
!= BTreeSet::from(["cloud-backup", "device-transfer"])
|
||||
{
|
||||
return Err("backup/transfer section missing".into());
|
||||
}
|
||||
nodes
|
||||
} else {
|
||||
if !root.has_tag_name("full-backup-content") {
|
||||
return Err("wrong backup root".into());
|
||||
}
|
||||
vec![root]
|
||||
};
|
||||
for section in sections {
|
||||
let mut domains = BTreeSet::new();
|
||||
for node in section.children().filter(Node::is_element) {
|
||||
if !node.has_tag_name("exclude")
|
||||
|| node.attribute("path") != Some(".")
|
||||
|| !domains.insert(node.attribute("domain").ok_or("missing domain")?)
|
||||
{
|
||||
return Err("backup rules must exclude each entire domain once".into());
|
||||
}
|
||||
}
|
||||
if domains != BTreeSet::from(DOMAINS) {
|
||||
return Err("backup excludes an incomplete domain set".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestResult {
|
||||
id: String,
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
fn report(source: &str) -> Result<Vec<TestResult>, Box<dyn Error>> {
|
||||
let document = Document::parse(source)?;
|
||||
let mut results = Vec::new();
|
||||
for node in document
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("testsuite"))
|
||||
{
|
||||
for attribute in ["errors", "failures"] {
|
||||
let count: usize = node.attribute(attribute).unwrap_or("0").parse()?;
|
||||
let actual = node
|
||||
.children()
|
||||
.filter(|n| {
|
||||
n.has_tag_name("testcase")
|
||||
&& n.children().any(|child| {
|
||||
child.has_tag_name(if attribute == "errors" {
|
||||
"error"
|
||||
} else {
|
||||
"failure"
|
||||
})
|
||||
})
|
||||
})
|
||||
.count();
|
||||
if count > actual {
|
||||
results.push(TestResult {
|
||||
id: hash(
|
||||
format!("{}:{attribute}", node.attribute("name").unwrap_or("")).as_bytes(),
|
||||
),
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for node in document
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("testcase"))
|
||||
{
|
||||
let status = if node.children().any(|n| n.has_tag_name("failure")) {
|
||||
"failure"
|
||||
} else if node.children().any(|n| n.has_tag_name("error")) {
|
||||
"error"
|
||||
} else if node.children().any(|n| n.has_tag_name("skipped")) {
|
||||
"skipped"
|
||||
} else {
|
||||
"passed"
|
||||
};
|
||||
let identity = format!(
|
||||
"{}:{}",
|
||||
node.attribute("classname").unwrap_or(""),
|
||||
node.attribute("name").unwrap_or("")
|
||||
);
|
||||
results.push(TestResult {
|
||||
id: hash(identity.as_bytes()),
|
||||
status,
|
||||
});
|
||||
}
|
||||
if results.is_empty() {
|
||||
return Err("JUnit report has no results".into());
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn reports(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let input = input.canonicalize()?;
|
||||
let destination = if output.exists() {
|
||||
output.canonicalize()?
|
||||
} else {
|
||||
output
|
||||
.parent()
|
||||
.ok_or("safe report directory missing")?
|
||||
.canonicalize()?
|
||||
.join(output.file_name().ok_or("safe report filename missing")?)
|
||||
};
|
||||
if destination.starts_with(&input) {
|
||||
return Err("safe reports must be outside raw report directory".into());
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
collect_reports(&input, &mut results)?;
|
||||
if results.is_empty() {
|
||||
return Err("JUnit reports missing".into());
|
||||
}
|
||||
let failed = results
|
||||
.iter()
|
||||
.any(|r| matches!(r.status, "error" | "failure"));
|
||||
fs::write(output, serde_json::to_vec_pretty(&results)?)?;
|
||||
if failed {
|
||||
return Err("test failures retained in sanitized report".into());
|
||||
}
|
||||
println!(
|
||||
"Sanitized {} test results; only hashed identifiers and status retained",
|
||||
results.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_reports(input: &Path, output: &mut Vec<TestResult>) -> Result<(), Box<dyn Error>> {
|
||||
let mut entries: Vec<_> = fs::read_dir(input)?.collect::<Result<_, _>>()?;
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_symlink() {
|
||||
return Err("report symlink rejected".into());
|
||||
}
|
||||
if kind.is_dir() {
|
||||
collect_reports(&entry.path(), output)?;
|
||||
} else if entry.file_name().to_string_lossy().starts_with("TEST-")
|
||||
&& entry.path().extension().is_some_and(|ext| ext == "xml")
|
||||
{
|
||||
if entry.metadata()?.len() > 16 * 1024 * 1024 {
|
||||
return Err("report exceeds bound".into());
|
||||
}
|
||||
output.extend(report(&fs::read_to_string(entry.path())?)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Policy {
|
||||
android: BTreeMap<String, String>,
|
||||
rust_licenses: BTreeSet<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct AndroidDependency {
|
||||
group: String,
|
||||
name: String,
|
||||
version: String,
|
||||
sha256: String,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
pub fn sbom(
|
||||
android: &Path,
|
||||
rust: &Path,
|
||||
lock: &Path,
|
||||
policy: &Path,
|
||||
output: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let policy: Policy = serde_json::from_slice(&fs::read(policy)?)?;
|
||||
let dependencies: Vec<AndroidDependency> = serde_json::from_slice(&fs::read(android)?)?;
|
||||
if dependencies.is_empty() {
|
||||
return Err("Android dependency inventory empty".into());
|
||||
}
|
||||
let lock: toml::Value = toml::from_str(&fs::read_to_string(lock)?)?;
|
||||
let packages = lock
|
||||
.get("package")
|
||||
.and_then(toml::Value::as_array)
|
||||
.ok_or("Cargo lock packages missing")?;
|
||||
let mut components = BTreeMap::new();
|
||||
for dependency in dependencies {
|
||||
let license = android_license(
|
||||
&dependency.group,
|
||||
&dependency.name,
|
||||
&dependency.version,
|
||||
&policy,
|
||||
)?;
|
||||
if dependency.version.is_empty()
|
||||
|| dependency.version.contains(['+', '[', ']', '(', ')', ','])
|
||||
|| dependency.version.contains("SNAPSHOT")
|
||||
|| dependency.sha256.len() != 64
|
||||
|| !dependency.sha256.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("dependency must have a fixed version and SHA-256".into());
|
||||
}
|
||||
let purl = format!(
|
||||
"pkg:maven/{}/{}@{}",
|
||||
dependency.group, dependency.name, dependency.version
|
||||
);
|
||||
let key = format!("{purl}#{}#{}", dependency.sha256, dependency.scope);
|
||||
components.insert(key.clone(), serde_json::json!({"type":"library", "bom-ref":key, "group":dependency.group, "name":dependency.name, "version":dependency.version, "purl":purl, "hashes":[{"alg":"SHA-256","content":dependency.sha256}], "licenses":[{"expression":license}], "properties":[{"name":"ironstorage:configuration","value":dependency.scope}]}));
|
||||
}
|
||||
for line in fs::read_to_string(rust)?
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
let fields: Vec<_> = line.split('|').collect();
|
||||
if fields.len() != 3 || !policy.rust_licenses.contains(fields[1]) {
|
||||
return Err("Rust dependency license needs review".into());
|
||||
}
|
||||
let (name, remainder) = fields[0]
|
||||
.split_once(" v")
|
||||
.ok_or("invalid Rust dependency")?;
|
||||
let version = remainder
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or("missing Rust version")?;
|
||||
let purl = format!("pkg:cargo/{name}@{version}");
|
||||
let package = packages
|
||||
.iter()
|
||||
.find(|p| {
|
||||
p.get("name").and_then(toml::Value::as_str) == Some(name)
|
||||
&& p.get("version").and_then(toml::Value::as_str) == Some(version)
|
||||
})
|
||||
.ok_or("Rust inventory differs from Cargo.lock")?;
|
||||
let mut component = serde_json::json!({"type":"library", "bom-ref":purl, "name":name,"version":version,"purl":purl,"licenses":[{"expression":fields[1].replace(" / ", " OR ").replace('/', " OR ")}],"properties":[{"name":"ironstorage:configuration","value":"Android ARM normal dependencies (Cargo feature graph)"}]});
|
||||
if let Some(checksum) = package.get("checksum").and_then(toml::Value::as_str) {
|
||||
component["hashes"] = serde_json::json!([{"alg":"SHA-256", "content":checksum}]);
|
||||
} else if !name.starts_with("ironstorage") {
|
||||
return Err("Rust dependency lacks a registry checksum".into());
|
||||
}
|
||||
components.insert(purl.clone(), component);
|
||||
}
|
||||
if !components
|
||||
.keys()
|
||||
.any(|k| k.starts_with("pkg:cargo/ironstorage-android@"))
|
||||
{
|
||||
return Err("Android Rust root missing".into());
|
||||
}
|
||||
let value = serde_json::json!({"bomFormat":"CycloneDX", "specVersion":"1.6", "version":1, "components":components.into_values().collect::<Vec<_>>()});
|
||||
fs::write(output, serde_json::to_vec_pretty(&value)?)?;
|
||||
println!("Dependency/license policy passed; deterministic ARM CycloneDX inventory written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn android_license<'a>(
|
||||
group: &str,
|
||||
name: &str,
|
||||
version: &str,
|
||||
policy: &'a Policy,
|
||||
) -> Result<&'a str, Box<dyn Error>> {
|
||||
for forbidden in [
|
||||
"org.eclipse.jgit",
|
||||
"org.bouncycastle",
|
||||
"androidx.room",
|
||||
"com.squareup.retrofit2",
|
||||
"com.squareup.okhttp3",
|
||||
"org.pgpainless",
|
||||
"com.google.mlkit",
|
||||
] {
|
||||
if group == forbidden || group.starts_with(&format!("{forbidden}.")) {
|
||||
return Err("Android domain dependency forbidden".into());
|
||||
}
|
||||
}
|
||||
policy
|
||||
.android
|
||||
.get(&format!("{group}:{name}:{version}"))
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| "Android dependency license needs review".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn manifest_rejects_backup_permissions_and_exports() {
|
||||
let source = format!(
|
||||
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
|
||||
);
|
||||
assert!(manifest_text(&source).is_ok());
|
||||
for bad in [
|
||||
source.replace("allowBackup=\"false\"", "allowBackup=\"true\""),
|
||||
source.replace(
|
||||
"provider android:name=\"fixture\" android:exported=\"false\"",
|
||||
"provider android:name=\"fixture\" android:exported=\"true\"",
|
||||
),
|
||||
source.replace(
|
||||
"<application",
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/><application",
|
||||
),
|
||||
] {
|
||||
assert!(manifest_text(&bad).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn packaged_signature_and_profile_receiver_cannot_be_weakened() {
|
||||
let launcher = format!(
|
||||
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><permission android:name="de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" android:protectionLevel="0x2"/><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><receiver android:name="androidx.profileinstaller.ProfileInstallReceiver" android:permission="android.permission.DUMP" android:exported="true"><intent-filter><action android:name="androidx.profileinstaller.action.INSTALL_PROFILE"/><action android:name="androidx.profileinstaller.action.SKIP_FILE"/><action android:name="androidx.profileinstaller.action.SAVE_PROFILE"/><action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION"/></intent-filter></receiver></application></manifest>"#
|
||||
);
|
||||
assert!(manifest_text(&launcher).is_ok());
|
||||
assert!(manifest_text(&launcher.replace("0x2", "0x0")).is_err());
|
||||
assert!(
|
||||
manifest_text(
|
||||
&launcher.replace("android.permission.DUMP", "android.permission.INTERNET")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(manifest_text(&launcher.replace("INSTALL_PROFILE", "UNREVIEWED")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_requires_both_sections_all_domains_and_no_include() {
|
||||
let exclusions = DOMAINS
|
||||
.map(|d| format!(r#"<exclude domain="{d}" path="."/>"#))
|
||||
.join("");
|
||||
let source = format!(
|
||||
"<data-extraction-rules><cloud-backup>{exclusions}</cloud-backup><device-transfer>{exclusions}</device-transfer></data-extraction-rules>"
|
||||
);
|
||||
assert!(backup_text(&source, true).is_ok());
|
||||
assert!(backup_text(&source.replace("device_sharedpref", "unknown"), true).is_err());
|
||||
assert!(backup_text(&source.replace("<exclude", "<include"), true).is_err());
|
||||
assert!(backup_text(&source.replace("<device-transfer>", "<unknown>"), true).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reports_preserve_failures_without_names_messages_or_output() {
|
||||
let secret = "otpauth://totp/secret?secret=FIXTURE";
|
||||
let source = format!(
|
||||
r#"<testsuite name="{secret}" errors="1"><testcase classname="{secret}" name="{secret}"><failure message="{secret}">{secret}</failure></testcase><testcase name="ok"/><system-out>{secret}</system-out></testsuite>"#
|
||||
);
|
||||
let results = report(&source).unwrap();
|
||||
assert_eq!(
|
||||
results.iter().map(|r| r.status).collect::<Vec<_>>(),
|
||||
["error", "failure", "passed"]
|
||||
);
|
||||
let encoded = serde_json::to_string(&results).unwrap();
|
||||
assert!(!encoded.contains("otpauth"));
|
||||
assert!(!encoded.contains("FIXTURE"));
|
||||
assert!(report("<testsuite/>").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn dependency_policy_rejects_transitive_domain_libraries_and_unknown_groups() {
|
||||
let policy = Policy {
|
||||
android: BTreeMap::from([
|
||||
("androidx.core:core:1".into(), "Apache-2.0".into()),
|
||||
("org.bouncycastle:fixture:1".into(), "MIT".into()),
|
||||
]),
|
||||
rust_licenses: BTreeSet::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
android_license("androidx.core", "core", "1", &policy).unwrap(),
|
||||
"Apache-2.0"
|
||||
);
|
||||
assert!(android_license("org.bouncycastle", "fixture", "1", &policy).is_err());
|
||||
assert!(android_license("org.bouncycastle.extra", "fixture", "1", &policy).is_err());
|
||||
assert!(android_license("unknown", "fixture", "1", &policy).is_err());
|
||||
assert!(android_license("androidx.core", "core", "2", &policy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filesystem_gates_reject_missing_hashes_lock_drift_and_raw_report_destination() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"ironstorage-artifact-gates-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir(&directory).unwrap();
|
||||
let android = directory.join("android.json");
|
||||
let rust = directory.join("rust.txt");
|
||||
let lock = directory.join("Cargo.lock");
|
||||
let policy = directory.join("policy.json");
|
||||
let output = directory.join("sbom.json");
|
||||
let inventory = serde_json::json!([{"group":"androidx.core", "name":"core", "version":"1", "sha256":"a".repeat(64), "scope":"debug"}]);
|
||||
fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
|
||||
fs::write(
|
||||
&rust,
|
||||
"ironstorage-android v1.0.1|MIT|\nfixture v1.0.0|MIT|\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&lock, format!("[[package]]\nname='ironstorage-android'\nversion='1.0.1'\n[[package]]\nname='fixture'\nversion='1.0.0'\nchecksum='{}'\n", "b".repeat(64))).unwrap();
|
||||
fs::write(
|
||||
&policy,
|
||||
r#"{"android":{"androidx.core:core:1":"Apache-2.0"},"rust_licenses":["MIT"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
sbom(&android, &rust, &lock, &policy, &output).unwrap();
|
||||
let encoded: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&output).unwrap()).unwrap();
|
||||
assert_eq!(encoded["components"].as_array().unwrap().len(), 3);
|
||||
let mut invalid = inventory.clone();
|
||||
invalid[0]["sha256"] = "missing".into();
|
||||
fs::write(&android, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
|
||||
fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
|
||||
fs::write(
|
||||
&rust,
|
||||
"ironstorage-android v1.0.1|MIT|\nfixture v2.0.0|MIT|\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
|
||||
fs::write(&rust, "fixture v1.0.0|MIT|\n").unwrap();
|
||||
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
|
||||
let raw = directory.join("raw");
|
||||
fs::create_dir(&raw).unwrap();
|
||||
fs::write(
|
||||
raw.join("TEST-fixture.xml"),
|
||||
"<testsuite><testcase name='public'/></testsuite>",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reports(&raw, &raw.join("safe.json")).is_err());
|
||||
assert!(!raw.join("safe.json").exists());
|
||||
reports(&raw, &directory.join("safe.json")).unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::{env, error::Error, fs, path::Path};
|
||||
|
||||
mod bindings;
|
||||
mod contract;
|
||||
mod gates;
|
||||
mod native;
|
||||
mod toolchain;
|
||||
|
||||
@@ -51,6 +52,28 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
[_, command, directory] if command == "audit-native" => {
|
||||
native::audit_directory(Path::new(directory))?
|
||||
}
|
||||
[_, command, manifest, backup, extraction] if command == "audit-manifest" => {
|
||||
gates::manifest(
|
||||
Path::new(manifest),
|
||||
Path::new(backup),
|
||||
Path::new(extraction),
|
||||
)?;
|
||||
}
|
||||
[_, command, package, symbols] if command == "audit-release" => {
|
||||
native::audit_release(Path::new(package), Path::new(symbols))?;
|
||||
}
|
||||
[_, command, input, output] if command == "safe-reports" => {
|
||||
gates::reports(Path::new(input), Path::new(output))?;
|
||||
}
|
||||
[_, command, android, rust, lock, policy, output] if command == "sbom" => {
|
||||
gates::sbom(
|
||||
Path::new(android),
|
||||
Path::new(rust),
|
||||
Path::new(lock),
|
||||
Path::new(policy),
|
||||
Path::new(output),
|
||||
)?;
|
||||
}
|
||||
[_, command, cargo, rust, ndk, ndk_version, cargo_ndk] if command == "check-toolchain" => {
|
||||
toolchain::check(
|
||||
Path::new(cargo),
|
||||
@@ -62,7 +85,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"usage: ironstorage-android-build <fixtures output.kt | check android-dir | prepare-bindings input output | package-native input output | audit-elf path abi | audit-native jniLibs | audit-apk-split apk abi | audit-package apk-or-aab | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
|
||||
"usage: ironstorage-android-build <fixtures output.kt | check android-dir | prepare-bindings input output | package-native input output | audit-elf path abi | audit-native jniLibs | audit-apk-split apk abi | audit-package apk-or-aab | audit-release apk symbols | audit-manifest manifest.xml backup.xml extraction.xml | sbom android.json rust.txt Cargo.lock policy.json output.json | safe-reports input-dir output.json | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{collections::BTreeMap, error::Error, fs::File, io::Read, path::Path};
|
||||
use object::{Architecture, BinaryFormat, Object, ObjectSection, ObjectSegment, ObjectSymbol};
|
||||
|
||||
const PAGE_SIZE: u64 = 16_384;
|
||||
const ABIS: [&str; 3] = ["arm64-v8a", "armeabi-v7a", "x86_64"];
|
||||
const ABIS: [&str; 2] = ["arm64-v8a", "armeabi-v7a"];
|
||||
|
||||
pub fn package_directory(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
audit_directory(input)?;
|
||||
@@ -71,7 +71,7 @@ pub fn audit_directory(directory: &Path) -> Result<(), Box<dyn Error>> {
|
||||
expected = Some(metadata);
|
||||
}
|
||||
}
|
||||
println!("Verified three native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
|
||||
println!("Verified both ARM native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ pub fn audit_elf(bytes: &[u8], abi: &str) -> Result<(), Box<dyn Error>> {
|
||||
let expected = match abi {
|
||||
"arm64-v8a" => Architecture::Aarch64,
|
||||
"armeabi-v7a" => Architecture::Arm,
|
||||
"x86_64" => Architecture::X86_64,
|
||||
_ => return Err("unapproved native ABI".into()),
|
||||
};
|
||||
let library = object::File::parse(bytes)?;
|
||||
@@ -111,6 +110,61 @@ pub fn audit_split(path: &Path, abi: &str) -> Result<(), Box<dyn Error>> {
|
||||
audit_package_abis(path, &[abi])
|
||||
}
|
||||
|
||||
pub fn audit_release(package: &Path, symbols: &Path) -> Result<(), Box<dyn Error>> {
|
||||
audit_package(package)?;
|
||||
let mut package = zip::ZipArchive::new(File::open(package)?)?;
|
||||
let mut symbols = zip::ZipArchive::new(File::open(symbols)?)?;
|
||||
for abi in ABIS {
|
||||
for library in ["libironstorage_android.so", "libjnidispatch.so"] {
|
||||
let mut bytes = Vec::new();
|
||||
package
|
||||
.by_name(&format!("lib/{abi}/{library}"))?
|
||||
.read_to_end(&mut bytes)?;
|
||||
let mut debug = Vec::new();
|
||||
let mut entry = symbols.by_name(&format!("{abi}/{library}.dbg"))?;
|
||||
if entry.size() > 512 * 1024 * 1024 {
|
||||
return Err("native debug symbols exceed the audit bound".into());
|
||||
}
|
||||
entry.read_to_end(&mut debug)?;
|
||||
audit_symbol_pair(&bytes, &debug, abi, library == "libironstorage_android.so")?;
|
||||
}
|
||||
}
|
||||
println!("Release libraries are stripped; FULL symbols match both ARM build IDs");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_symbol_pair(
|
||||
bytes: &[u8],
|
||||
debug: &[u8],
|
||||
abi: &str,
|
||||
require_debug_info: bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
audit_elf(bytes, abi)?;
|
||||
let release = object::File::parse(bytes)?;
|
||||
let symbols = object::File::parse(debug)?;
|
||||
if symbols.format() != BinaryFormat::Elf || symbols.architecture() != release.architecture() {
|
||||
return Err("debug archive architecture does not match release".into());
|
||||
}
|
||||
if release.sections().any(|s| {
|
||||
s.name()
|
||||
.is_ok_and(|n| n.starts_with(".debug_") || n == ".symtab")
|
||||
}) {
|
||||
return Err("release library contains unstripped debug symbols".into());
|
||||
}
|
||||
if (require_debug_info && symbols.section_by_name(".debug_info").is_none())
|
||||
|| symbols.symbols().next().is_none()
|
||||
{
|
||||
return Err("native symbol archive lacks required symbols/debug information".into());
|
||||
}
|
||||
let id = release
|
||||
.build_id()?
|
||||
.ok_or("release library lacks a build ID")?;
|
||||
if id.is_empty() || Some(id) != symbols.build_id()? {
|
||||
return Err("native symbol archive does not match release build ID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box<dyn Error>> {
|
||||
let apk = path.extension().is_some_and(|ext| ext == "apk");
|
||||
if !apk && !path.extension().is_some_and(|ext| ext == "aab") {
|
||||
@@ -134,6 +188,18 @@ fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box<dyn Error>>
|
||||
if !abis.contains(&abi) || library.contains('/') {
|
||||
return Err("package contains an unapproved native library path".into());
|
||||
}
|
||||
if ![
|
||||
"libironstorage_android.so",
|
||||
"libjnidispatch.so",
|
||||
"libandroidx.graphics.path.so",
|
||||
]
|
||||
.contains(&library)
|
||||
|| libraries
|
||||
.get(abi)
|
||||
.is_some_and(|names| names.iter().any(|name| name == library))
|
||||
{
|
||||
return Err("package contains an unreviewed or duplicate native library".into());
|
||||
}
|
||||
if apk
|
||||
&& (file.compression() != zip::CompressionMethod::Stored
|
||||
|| file.data_start() % PAGE_SIZE != 0)
|
||||
@@ -205,4 +271,14 @@ mod tests {
|
||||
assert!(audit_elf(b"\x7fELF", "x86_64").is_err());
|
||||
assert!(audit_elf(&[], "mips").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_gate_rejects_missing_full_symbols_and_build_identity() {
|
||||
assert!(
|
||||
audit_symbol_pair(&elf(PAGE_SIZE, 0), &elf(PAGE_SIZE, 0), "arm64-v8a", true).is_err()
|
||||
);
|
||||
assert!(
|
||||
audit_symbol_pair(&elf(PAGE_SIZE, 0), b"wrong archive", "arm64-v8a", false).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user