#![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> { 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 ".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> = 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> { 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> { 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\\\\é\""); } }