//! Build artifact gates. Inputs and diagnostics never contain application data. use roxmltree::{Document, Node}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet}, error::Error, fs, path::Path, }; const ANDROID: &str = "http://schemas.android.com/apk/res/android"; const DOMAINS: [&str; 9] = [ "root", "file", "database", "sharedpref", "external", "device_root", "device_file", "device_database", "device_sharedpref", ]; pub fn manifest(manifest: &Path, backup: &Path, extraction: &Path) -> Result<(), Box> { manifest_text(&fs::read_to_string(manifest)?)?; backup_text(&fs::read_to_string(backup)?, false)?; backup_text(&fs::read_to_string(extraction)?, true)?; println!("Verified packaged manifest and complete backup/transfer exclusions"); Ok(()) } fn manifest_text(source: &str) -> Result<(), Box> { let document = Document::parse(source)?; let root = document.root_element(); if root.tag_name().name() != "manifest" || root.attribute("package") != Some("de.rfc1437.ironstorage") { return Err("unexpected manifest identity".into()); } let application = root .children() .find(|node| node.has_tag_name("application")) .ok_or("application missing")?; for (name, expected) in [("allowBackup", "false"), ("usesCleartextTraffic", "false")] { if application.attribute((ANDROID, name)) != Some(expected) { return Err(format!("unsafe application flag: {name}").into()); } } for name in ["fullBackupContent", "dataExtractionRules"] { if !application .attribute((ANDROID, name)) .is_some_and(|value| value.starts_with('@')) { return Err("backup rules missing from packaged manifest".into()); } } let allowed = [ "android.permission.USE_BIOMETRIC", "android.permission.USE_FINGERPRINT", "de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", ]; for node in root.children().filter(|node| node.is_element()) { if node.tag_name().name().starts_with("uses-permission") && !allowed.contains(&node.attribute((ANDROID, "name")).unwrap_or("")) { return Err("unreviewed packaged permission".into()); } if node.has_tag_name("permission") && (node.attribute((ANDROID, "name")) != Some(allowed[2]) || !matches!( node.attribute((ANDROID, "protectionLevel")), Some("signature" | "0x2") )) { return Err("unreviewed custom permission".into()); } } let mut launchers = 0; for node in application.children().filter(|node| { [ "activity", "activity-alias", "service", "receiver", "provider", ] .contains(&node.tag_name().name()) }) { let launcher = node.attribute((ANDROID, "name")) == Some("de.rfc1437.ironstorage.MainActivity"); if launcher { launchers += 1; if node.attribute((ANDROID, "exported")) != Some("true") { return Err("launcher must be exported".into()); } let actions: BTreeSet<_> = node .descendants() .filter(|n| n.has_tag_name("action") || n.has_tag_name("category")) .filter_map(|n| n.attribute((ANDROID, "name"))) .collect(); if actions != BTreeSet::from([ "android.intent.action.MAIN", "android.intent.category.LAUNCHER", ]) { return Err("unreviewed launcher intent".into()); } } else if node.has_tag_name("receiver") && node.attribute((ANDROID, "name")) == Some("androidx.profileinstaller.ProfileInstallReceiver") { let actions: BTreeSet<_> = node .descendants() .filter(|n| n.has_tag_name("action")) .filter_map(|n| n.attribute((ANDROID, "name"))) .collect(); if node.attribute((ANDROID, "permission")) != Some("android.permission.DUMP") || node.attribute((ANDROID, "exported")) != Some("true") || actions != BTreeSet::from([ "androidx.profileinstaller.action.INSTALL_PROFILE", "androidx.profileinstaller.action.SKIP_FILE", "androidx.profileinstaller.action.SAVE_PROFILE", "androidx.profileinstaller.action.BENCHMARK_OPERATION", ]) { return Err( "profile receiver must retain its privileged permission and reviewed actions" .into(), ); } } else if node.attribute((ANDROID, "exported")) != Some("false") { return Err("unreviewed exported component".into()); } } if launchers != 1 { return Err("exactly one launcher required".into()); } Ok(()) } fn backup_text(source: &str, extraction: bool) -> Result<(), Box> { let document = Document::parse(source)?; let root = document.root_element(); let sections = if extraction { if !root.has_tag_name("data-extraction-rules") { return Err("wrong extraction root".into()); } let nodes: Vec<_> = root.children().filter(Node::is_element).collect(); if nodes.len() != 2 || nodes .iter() .map(|n| n.tag_name().name()) .collect::>() != BTreeSet::from(["cloud-backup", "device-transfer"]) { return Err("backup/transfer section missing".into()); } nodes } else { if !root.has_tag_name("full-backup-content") { return Err("wrong backup root".into()); } vec![root] }; for section in sections { let mut domains = BTreeSet::new(); for node in section.children().filter(Node::is_element) { if !node.has_tag_name("exclude") || node.attribute("path") != Some(".") || !domains.insert(node.attribute("domain").ok_or("missing domain")?) { return Err("backup rules must exclude each entire domain once".into()); } } if domains != BTreeSet::from(DOMAINS) { return Err("backup excludes an incomplete domain set".into()); } } Ok(()) } fn hash(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } #[derive(Serialize)] struct TestResult { id: String, status: &'static str, } fn report(source: &str) -> Result, Box> { let document = Document::parse(source)?; let mut results = Vec::new(); for node in document .descendants() .filter(|n| n.has_tag_name("testsuite")) { for attribute in ["errors", "failures"] { let count: usize = node.attribute(attribute).unwrap_or("0").parse()?; let actual = node .children() .filter(|n| { n.has_tag_name("testcase") && n.children().any(|child| { child.has_tag_name(if attribute == "errors" { "error" } else { "failure" }) }) }) .count(); if count > actual { results.push(TestResult { id: hash( format!("{}:{attribute}", node.attribute("name").unwrap_or("")).as_bytes(), ), status: "error", }); } } } for node in document .descendants() .filter(|n| n.has_tag_name("testcase")) { let status = if node.children().any(|n| n.has_tag_name("failure")) { "failure" } else if node.children().any(|n| n.has_tag_name("error")) { "error" } else if node.children().any(|n| n.has_tag_name("skipped")) { "skipped" } else { "passed" }; let identity = format!( "{}:{}", node.attribute("classname").unwrap_or(""), node.attribute("name").unwrap_or("") ); results.push(TestResult { id: hash(identity.as_bytes()), status, }); } if results.is_empty() { return Err("JUnit report has no results".into()); } Ok(results) } pub fn reports(input: &Path, output: &Path) -> Result<(), Box> { let input = input.canonicalize()?; let destination = if output.exists() { output.canonicalize()? } else { output .parent() .ok_or("safe report directory missing")? .canonicalize()? .join(output.file_name().ok_or("safe report filename missing")?) }; if destination.starts_with(&input) { return Err("safe reports must be outside raw report directory".into()); } let mut results = Vec::new(); collect_reports(&input, &mut results)?; if results.is_empty() { return Err("JUnit reports missing".into()); } let failed = results .iter() .any(|r| matches!(r.status, "error" | "failure")); fs::write(output, serde_json::to_vec_pretty(&results)?)?; if failed { return Err("test failures retained in sanitized report".into()); } println!( "Sanitized {} test results; only hashed identifiers and status retained", results.len() ); Ok(()) } fn collect_reports(input: &Path, output: &mut Vec) -> Result<(), Box> { let mut entries: Vec<_> = fs::read_dir(input)?.collect::>()?; entries.sort_by_key(|e| e.file_name()); for entry in entries { let kind = entry.file_type()?; if kind.is_symlink() { return Err("report symlink rejected".into()); } if kind.is_dir() { collect_reports(&entry.path(), output)?; } else if entry.file_name().to_string_lossy().starts_with("TEST-") && entry.path().extension().is_some_and(|ext| ext == "xml") { if entry.metadata()?.len() > 16 * 1024 * 1024 { return Err("report exceeds bound".into()); } output.extend(report(&fs::read_to_string(entry.path())?)?); } } Ok(()) } #[derive(Deserialize)] struct Policy { android: BTreeMap, rust_licenses: BTreeSet, } #[derive(Deserialize)] struct AndroidDependency { group: String, name: String, version: String, sha256: String, scope: String, } pub fn sbom( android: &Path, rust: &Path, lock: &Path, policy: &Path, output: &Path, ) -> Result<(), Box> { let policy: Policy = serde_json::from_slice(&fs::read(policy)?)?; let dependencies: Vec = serde_json::from_slice(&fs::read(android)?)?; if dependencies.is_empty() { return Err("Android dependency inventory empty".into()); } let lock: toml::Value = toml::from_str(&fs::read_to_string(lock)?)?; let packages = lock .get("package") .and_then(toml::Value::as_array) .ok_or("Cargo lock packages missing")?; let mut components = BTreeMap::new(); for dependency in dependencies { let license = android_license( &dependency.group, &dependency.name, &dependency.version, &policy, )?; if dependency.version.is_empty() || dependency.version.contains(['+', '[', ']', '(', ')', ',']) || dependency.version.contains("SNAPSHOT") || dependency.sha256.len() != 64 || !dependency.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { return Err("dependency must have a fixed version and SHA-256".into()); } let purl = format!( "pkg:maven/{}/{}@{}", dependency.group, dependency.name, dependency.version ); let key = format!("{purl}#{}#{}", dependency.sha256, dependency.scope); components.insert(key.clone(), serde_json::json!({"type":"library", "bom-ref":key, "group":dependency.group, "name":dependency.name, "version":dependency.version, "purl":purl, "hashes":[{"alg":"SHA-256","content":dependency.sha256}], "licenses":[{"expression":license}], "properties":[{"name":"ironstorage:configuration","value":dependency.scope}]})); } for line in fs::read_to_string(rust)? .lines() .filter(|line| !line.is_empty()) { let fields: Vec<_> = line.split('|').collect(); if fields.len() != 3 || !policy.rust_licenses.contains(fields[1]) { return Err("Rust dependency license needs review".into()); } let (name, remainder) = fields[0] .split_once(" v") .ok_or("invalid Rust dependency")?; let version = remainder .split_whitespace() .next() .ok_or("missing Rust version")?; let purl = format!("pkg:cargo/{name}@{version}"); let package = packages .iter() .find(|p| { p.get("name").and_then(toml::Value::as_str) == Some(name) && p.get("version").and_then(toml::Value::as_str) == Some(version) }) .ok_or("Rust inventory differs from Cargo.lock")?; let mut component = serde_json::json!({"type":"library", "bom-ref":purl, "name":name,"version":version,"purl":purl,"licenses":[{"expression":fields[1].replace(" / ", " OR ").replace('/', " OR ")}],"properties":[{"name":"ironstorage:configuration","value":"Android ARM normal dependencies (Cargo feature graph)"}]}); if let Some(checksum) = package.get("checksum").and_then(toml::Value::as_str) { component["hashes"] = serde_json::json!([{"alg":"SHA-256", "content":checksum}]); } else if !name.starts_with("ironstorage") { return Err("Rust dependency lacks a registry checksum".into()); } components.insert(purl.clone(), component); } if !components .keys() .any(|k| k.starts_with("pkg:cargo/ironstorage-android@")) { return Err("Android Rust root missing".into()); } let value = serde_json::json!({"bomFormat":"CycloneDX", "specVersion":"1.6", "version":1, "components":components.into_values().collect::>()}); fs::write(output, serde_json::to_vec_pretty(&value)?)?; println!("Dependency/license policy passed; deterministic ARM CycloneDX inventory written"); Ok(()) } fn android_license<'a>( group: &str, name: &str, version: &str, policy: &'a Policy, ) -> Result<&'a str, Box> { for forbidden in [ "org.eclipse.jgit", "org.bouncycastle", "androidx.room", "com.squareup.retrofit2", "com.squareup.okhttp3", "org.pgpainless", "com.google.mlkit", ] { if group == forbidden || group.starts_with(&format!("{forbidden}.")) { return Err("Android domain dependency forbidden".into()); } } policy .android .get(&format!("{group}:{name}:{version}")) .map(String::as_str) .ok_or_else(|| "Android dependency license needs review".into()) } #[cfg(test)] mod tests { use super::*; #[test] fn manifest_rejects_backup_permissions_and_exports() { let source = format!( r#""# ); assert!(manifest_text(&source).is_ok()); for bad in [ source.replace("allowBackup=\"false\"", "allowBackup=\"true\""), source.replace( "provider android:name=\"fixture\" android:exported=\"false\"", "provider android:name=\"fixture\" android:exported=\"true\"", ), source.replace( ""# ); assert!(manifest_text(&launcher).is_ok()); assert!(manifest_text(&launcher.replace("0x2", "0x0")).is_err()); assert!( manifest_text( &launcher.replace("android.permission.DUMP", "android.permission.INTERNET") ) .is_err() ); assert!(manifest_text(&launcher.replace("INSTALL_PROFILE", "UNREVIEWED")).is_err()); } #[test] fn backup_requires_both_sections_all_domains_and_no_include() { let exclusions = DOMAINS .map(|d| format!(r#""#)) .join(""); let source = format!( "{exclusions}{exclusions}" ); assert!(backup_text(&source, true).is_ok()); assert!(backup_text(&source.replace("device_sharedpref", "unknown"), true).is_err()); assert!(backup_text(&source.replace("", ""), true).is_err()); } #[test] fn reports_preserve_failures_without_names_messages_or_output() { let secret = "otpauth://totp/secret?secret=FIXTURE"; let source = format!( r#"{secret}{secret}"# ); let results = report(&source).unwrap(); assert_eq!( results.iter().map(|r| r.status).collect::>(), ["error", "failure", "passed"] ); let encoded = serde_json::to_string(&results).unwrap(); assert!(!encoded.contains("otpauth")); assert!(!encoded.contains("FIXTURE")); assert!(report("").is_err()); } #[test] fn dependency_policy_rejects_transitive_domain_libraries_and_unknown_groups() { let policy = Policy { android: BTreeMap::from([ ("androidx.core:core:1".into(), "Apache-2.0".into()), ("org.bouncycastle:fixture:1".into(), "MIT".into()), ]), rust_licenses: BTreeSet::new(), }; assert_eq!( android_license("androidx.core", "core", "1", &policy).unwrap(), "Apache-2.0" ); assert!(android_license("org.bouncycastle", "fixture", "1", &policy).is_err()); assert!(android_license("org.bouncycastle.extra", "fixture", "1", &policy).is_err()); assert!(android_license("unknown", "fixture", "1", &policy).is_err()); assert!(android_license("androidx.core", "core", "2", &policy).is_err()); } #[test] fn filesystem_gates_reject_missing_hashes_lock_drift_and_raw_report_destination() { let directory = std::env::temp_dir().join(format!( "ironstorage-artifact-gates-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir(&directory).unwrap(); let android = directory.join("android.json"); let rust = directory.join("rust.txt"); let lock = directory.join("Cargo.lock"); let policy = directory.join("policy.json"); let output = directory.join("sbom.json"); let inventory = serde_json::json!([{"group":"androidx.core", "name":"core", "version":"1", "sha256":"a".repeat(64), "scope":"debug"}]); fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap(); fs::write( &rust, "ironstorage-android v1.0.1|MIT|\nfixture v1.0.0|MIT|\n", ) .unwrap(); fs::write(&lock, format!("[[package]]\nname='ironstorage-android'\nversion='1.0.1'\n[[package]]\nname='fixture'\nversion='1.0.0'\nchecksum='{}'\n", "b".repeat(64))).unwrap(); fs::write( &policy, r#"{"android":{"androidx.core:core:1":"Apache-2.0"},"rust_licenses":["MIT"]}"#, ) .unwrap(); sbom(&android, &rust, &lock, &policy, &output).unwrap(); let encoded: serde_json::Value = serde_json::from_slice(&fs::read(&output).unwrap()).unwrap(); assert_eq!(encoded["components"].as_array().unwrap().len(), 3); let mut invalid = inventory.clone(); invalid[0]["sha256"] = "missing".into(); fs::write(&android, serde_json::to_vec(&invalid).unwrap()).unwrap(); assert!(sbom(&android, &rust, &lock, &policy, &output).is_err()); fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap(); fs::write( &rust, "ironstorage-android v1.0.1|MIT|\nfixture v2.0.0|MIT|\n", ) .unwrap(); assert!(sbom(&android, &rust, &lock, &policy, &output).is_err()); fs::write(&rust, "fixture v1.0.0|MIT|\n").unwrap(); assert!(sbom(&android, &rust, &lock, &policy, &output).is_err()); let raw = directory.join("raw"); fs::create_dir(&raw).unwrap(); fs::write( raw.join("TEST-fixture.xml"), "", ) .unwrap(); assert!(reports(&raw, &raw.join("safe.json")).is_err()); assert!(!raw.join("safe.json").exists()); reports(&raw, &directory.join("safe.json")).unwrap(); fs::remove_dir_all(directory).unwrap(); } }