Scaffold native Android build and enforce Rust boundaries (#89)
Dependency security audit / rustsec (push) Failing after 22s
Dependency security audit / rustsec (push) Failing after 22s
This commit is contained in:
1 parent
30d9119302
commit
e079b82b15
62 files changed
+5822
No files matched your search
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ironstorage-android-build"
|
||||
description = "Android fixture generation and architecture boundary checks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
ironstorage.workspace = true
|
||||
regex.workspace = true
|
||||
@@ -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\\\\é\"");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user