262 lines
9.7 KiB
Rust
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\\\\é\"");
|
|
}
|
|
}
|