Scaffold native Android build and enforce Rust boundaries (#89)
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-08 10:16:51 +02:00
1 parent 30d9119302
commit e079b82b15
62 files changed
+5822

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
#![forbid(unsafe_code)]
use ironstorage::mobile::{MobileShell, MobileShellState};
use std::{env, error::Error, fs, path::Path};
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))?,
_ => {
return Err(
"usage: ironstorage-android-build <fixtures output.kt | check android-dir>".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.{:?}, {}, {}, {}),\n",
page.tab(),
kotlin_string(page.title()),
kotlin_string(page.state_title()),
kotlin_string(page.state_detail()),
));
}
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\\\\é\"");
}
}