Files
IronStorage/tools/android-build/src/main.rs
T
hugo bf54b9d0a2
Android ARM verification / rust (push) Successful in 50m42s
Android ARM verification / android (push) Canceled after 1m40s
Establish native Linux ARM64 Android CI and verification gates (#94)
2026-10-08 19:36:26 +02:00

262 lines
9.7 KiB
Rust

#![forbid(unsafe_code)]
use ironstorage::mobile::{MobileShell, MobileShellState};
use std::{env, error::Error, fs, path::Path};
mod bindings;
mod contract;
mod gates;
mod native;
mod toolchain;
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<_> = env::args_os().collect();
match args.as_slice() {
[_, command, path] if command == "fixtures" => {
let path = Path::new(path);
fs::create_dir_all(path.parent().ok_or("fixture output needs a parent")?)?;
fs::write(path, fixtures())?;
}
[_, command, path] if command == "check" => check(Path::new(path))?,
[_, command, library, config, output, mode] if command == "contract-bindings" => {
contract::generate(
Path::new(library),
Path::new(config),
Path::new(output),
mode == "watch",
)?;
}
[_, command, phone, watch] if command == "compare-contract" => {
contract::compare(Path::new(phone), Path::new(watch))?;
}
[_, command, input, output] if command == "prepare-swift" => {
contract::prepare_swift(Path::new(input), Path::new(output))?;
}
[_, command, input, output] if command == "prepare-bindings" => {
bindings::prepare(Path::new(input), Path::new(output))?;
}
[_, command, input, output] if command == "package-native" => {
native::package_directory(Path::new(input), Path::new(output))?;
}
[_, command, path, abi] if command == "audit-elf" => {
native::audit_elf(&fs::read(path)?, abi.to_str().ok_or("invalid ABI")?)?;
}
[_, command, path, abi] if command == "audit-apk-split" => {
native::audit_split(Path::new(path), abi.to_str().ok_or("invalid ABI")?)?;
}
[_, command, paths @ ..] if command == "audit-package" && !paths.is_empty() => {
for path in paths {
native::audit_package(Path::new(path))?;
}
}
[_, 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),
rust.to_str().ok_or("invalid Rust version")?,
Path::new(ndk),
ndk_version.to_str().ok_or("invalid NDK version")?,
cargo_ndk.to_str().ok_or("invalid cargo-ndk version")?,
)?;
}
_ => {
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 | 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(),
);
}
}
Ok(())
}
fn fixtures() -> String {
let mut output = String::from(
"// Generated from Rust MobileShell fixtures. Do not edit.\npackage de.rfc1437.ironstorage.core\n\nobject ShellFixtures {\n val all: Map<ShellState, List<ShellPage>> = mapOf(\n",
);
for state in [
MobileShellState::Loading,
MobileShellState::Empty,
MobileShellState::Ready,
MobileShellState::Locked,
MobileShellState::Error,
] {
output.push_str(&format!(" ShellState.{state:?} to listOf(\n"));
for page in MobileShell::fixture(state).pages() {
output.push_str(&format!(
" ShellPage(ShellTab.{:?}, {}, {}, {}, ShellIcon.{:?}),\n",
page.tab(),
kotlin_string(page.title()),
kotlin_string(page.state_title()),
kotlin_string(page.state_detail()),
page.icon(),
));
}
output.push_str(" ),\n");
}
output.push_str(" )\n}\n");
output
}
fn kotlin_string(value: &str) -> String {
let mut output = String::from("\"");
for character in value.chars() {
match character {
'\\' => output.push_str("\\\\"),
'"' => output.push_str("\\\""),
'$' => output.push_str("\\$"),
character if character.is_control() => {
output.push_str(&format!("\\u{:04x}", u32::from(character)));
}
character => output.push(character),
}
}
output.push('"');
output
}
fn check(root: &Path) -> Result<(), Box<dyn Error>> {
let project = regex::Regex::new(r#"project\s*\(\s*"\:([^\"]+)"\s*\)"#)?;
for (module, allowed) in [
("app", &["features", "core", "platform"][..]),
("features", &["core"][..]),
("core", &["rust-bridge"][..]),
("platform", &["core"][..]),
("rust-bridge", &[][..]),
] {
let directory = root.join(module);
let build = fs::read_to_string(directory.join("build.gradle.kts"))?;
for dependency in project.captures_iter(&build) {
if !allowed.contains(&&dependency[1]) {
return Err(format!("{module} cannot depend on {}", &dependency[1]).into());
}
}
check_source_tree(&directory.join("src"), module)?;
}
println!("Android module and source boundaries passed");
Ok(())
}
fn check_source_tree(directory: &Path, module: &str) -> Result<(), Box<dyn Error>> {
if !directory.try_exists()? {
return Ok(());
}
for entry in fs::read_dir(directory)? {
let entry = entry?;
let kind = entry.file_type()?;
if kind.is_symlink() {
return Err(
format!("source symlink is not allowed: {}", entry.path().display()).into(),
);
}
if kind.is_dir() {
check_source_tree(&entry.path(), module)?;
} else if entry.path().extension().is_some_and(|ext| ext == "kt") {
let source = fs::read_to_string(entry.path())?;
check_source(&source, module)
.map_err(|error| format!("{}: {error}", entry.path().display()))?;
}
}
Ok(())
}
fn check_source(source: &str, module: &str) -> Result<(), String> {
for forbidden in ["ProcessBuilder", "Runtime.getRuntime", "java.lang.Runtime"] {
if source.contains(forbidden) {
return Err(format!("application subprocess path: {forbidden}"));
}
}
for forbidden in [
"org.eclipse.jgit",
"org.bouncycastle",
"androidx.room",
"retrofit2",
"okhttp3",
] {
if source.contains(forbidden) {
return Err(format!("competing domain dependency: {forbidden}"));
}
}
if module == "features" {
for forbidden in [
"java.io",
"java.nio.file",
"kotlin.io",
"javax.crypto",
"java.security",
"de.rfc1437.ironstorage.platform",
"de.rfc1437.ironstorage.bridge",
"uniffi",
"com.sun.jna",
] {
if source.contains(forbidden) {
return Err(format!("feature bypasses facade: {forbidden}"));
}
}
}
if module != "core" && module != "rust-bridge" {
for forbidden in ["uniffi", "com.sun.jna", "de.rfc1437.ironstorage.bridge"] {
if source.contains(forbidden) {
return Err(format!(
"only core may consume generated bindings: {forbidden}"
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_domain_and_native_access_but_allows_os_crypto_adapter() {
for source in [
"import java.io.File",
"import org.eclipse.jgit.api.Git",
"import uniffi.ironstorage.MobileShell",
"import javax.crypto.Cipher",
"val process = ProcessBuilder(\"git\")",
"val file = java.io.File(\"store\")",
] {
assert!(check_source(source, "features").is_err(), "{source}");
}
assert!(check_source("import javax.crypto.Cipher", "platform").is_ok());
assert!(check_source("import uniffi.ironstorage.MobileShell", "core").is_ok());
}
#[test]
fn fixtures_are_complete_non_secret_and_deterministic() {
let source = fixtures();
assert_eq!(source, fixtures());
assert_eq!(source.matches(" to listOf(").count(), 5);
assert_eq!(source.matches("ShellPage(").count(), 25);
assert!(!source.contains("Watch"));
assert_eq!(kotlin_string("a\"$b\nc"), "\"a\\\"\\$b\\u000ac\"");
assert_eq!(kotlin_string("\0\u{c}\\é"), "\"\\u0000\\u000c\\\\é\"");
}
}