Establish native Linux ARM64 Android CI and verification gates (#94)
Android ARM verification / rust (push) Failing after 13s
Android ARM verification / android (push) Failing after 7s

This commit is contained in:
hugo committed 2026-10-09 00:20:58 +02:00
1 parent 1abd75282e
commit 8fa187ab8e
29 files changed
+1862 -56

No files matched your search

+6
View File
@@ -18,6 +18,12 @@ camino = "1.2"
object = { version = "0.36.7", default-features = false, features = ["read", "elf"] }
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
roxmltree = "0.20.0"
toml.workspace = true
[[bin]]
name = "ironstorage-uniffi-bindgen"
path = "src/bindgen.rs"
+17 -1
View File
@@ -59,6 +59,17 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
if !source.contains(internal) || source.matches(callback).count() != 2 {
return Err("UniFFI unexpected-error template changed; review secret redaction".into());
}
let initializer = "public fun uniffiEnsureInitialized() {";
if source.matches(initializer).count() != 1 {
return Err(
"UniFFI initialization template changed; review eager contract initialization".into(),
);
}
// Object references deliberately trigger Kotlin initialization and integrity guards.
let source = source.replace(
initializer,
"@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized() {",
);
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
// Callback results are operation-owned; erase plaintext after copying into the wire buffer.
@@ -121,11 +132,16 @@ fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
mod tests {
use super::*;
const SOURCE: &str = "data class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
const SOURCE: &str = "public fun uniffiEnsureInitialized() {\n IntegrityCheckingUniffiLib\n UniffiLib\n}\ndata class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
#[test]
fn redacts_record_and_error_diagnostics_without_changing_fields_or_checksums() {
let hardened = harden_source(SOURCE).unwrap();
assert!(
hardened
.contains("@Suppress(\"UNUSED_EXPRESSION\")\npublic fun uniffiEnsureInitialized()")
);
assert!(hardened.contains(" IntegrityCheckingUniffiLib\n"));
assert!(hardened.contains("val value: String"));
assert!(hardened.contains("Secret([REDACTED])"));
assert!(!hardened.contains("get() = \"value="));
+579
View File
@@ -0,0 +1,579 @@
//! 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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<_>>()
!= 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<Vec<TestResult>, Box<dyn Error>> {
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<dyn Error>> {
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<TestResult>) -> Result<(), Box<dyn Error>> {
let mut entries: Vec<_> = fs::read_dir(input)?.collect::<Result<_, _>>()?;
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<String, String>,
rust_licenses: BTreeSet<String>,
}
#[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<dyn Error>> {
let policy: Policy = serde_json::from_slice(&fs::read(policy)?)?;
let dependencies: Vec<AndroidDependency> = 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::<Vec<_>>()});
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<dyn Error>> {
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#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
);
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(
"<application",
"<uses-permission android:name=\"android.permission.INTERNET\"/><application",
),
] {
assert!(manifest_text(&bad).is_err());
}
}
#[test]
fn packaged_signature_and_profile_receiver_cannot_be_weakened() {
let launcher = format!(
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><permission android:name="de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" android:protectionLevel="0x2"/><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><receiver android:name="androidx.profileinstaller.ProfileInstallReceiver" android:permission="android.permission.DUMP" android:exported="true"><intent-filter><action android:name="androidx.profileinstaller.action.INSTALL_PROFILE"/><action android:name="androidx.profileinstaller.action.SKIP_FILE"/><action android:name="androidx.profileinstaller.action.SAVE_PROFILE"/><action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION"/></intent-filter></receiver></application></manifest>"#
);
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#"<exclude domain="{d}" path="."/>"#))
.join("");
let source = format!(
"<data-extraction-rules><cloud-backup>{exclusions}</cloud-backup><device-transfer>{exclusions}</device-transfer></data-extraction-rules>"
);
assert!(backup_text(&source, true).is_ok());
assert!(backup_text(&source.replace("device_sharedpref", "unknown"), true).is_err());
assert!(backup_text(&source.replace("<exclude", "<include"), true).is_err());
assert!(backup_text(&source.replace("<device-transfer>", "<unknown>"), true).is_err());
}
#[test]
fn reports_preserve_failures_without_names_messages_or_output() {
let secret = "otpauth://totp/secret?secret=FIXTURE";
let source = format!(
r#"<testsuite name="{secret}" errors="1"><testcase classname="{secret}" name="{secret}"><failure message="{secret}">{secret}</failure></testcase><testcase name="ok"/><system-out>{secret}</system-out></testsuite>"#
);
let results = report(&source).unwrap();
assert_eq!(
results.iter().map(|r| r.status).collect::<Vec<_>>(),
["error", "failure", "passed"]
);
let encoded = serde_json::to_string(&results).unwrap();
assert!(!encoded.contains("otpauth"));
assert!(!encoded.contains("FIXTURE"));
assert!(report("<testsuite/>").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"),
"<testsuite><testcase name='public'/></testsuite>",
)
.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();
}
}
+24 -1
View File
@@ -5,6 +5,7 @@ use std::{env, error::Error, fs, path::Path};
mod bindings;
mod contract;
mod gates;
mod native;
mod toolchain;
@@ -51,6 +52,28 @@ fn main() -> Result<(), Box<dyn Error>> {
[_, 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),
@@ -62,7 +85,7 @@ fn main() -> Result<(), Box<dyn Error>> {
}
_ => {
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 | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
"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(),
);
}
}
+79 -3
View File
@@ -5,7 +5,7 @@ use std::{collections::BTreeMap, error::Error, fs::File, io::Read, path::Path};
use object::{Architecture, BinaryFormat, Object, ObjectSection, ObjectSegment, ObjectSymbol};
const PAGE_SIZE: u64 = 16_384;
const ABIS: [&str; 3] = ["arm64-v8a", "armeabi-v7a", "x86_64"];
const ABIS: [&str; 2] = ["arm64-v8a", "armeabi-v7a"];
pub fn package_directory(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
audit_directory(input)?;
@@ -71,7 +71,7 @@ pub fn audit_directory(directory: &Path) -> Result<(), Box<dyn Error>> {
expected = Some(metadata);
}
}
println!("Verified three native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
println!("Verified both ARM native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
Ok(())
}
@@ -79,7 +79,6 @@ pub fn audit_elf(bytes: &[u8], abi: &str) -> Result<(), Box<dyn Error>> {
let expected = match abi {
"arm64-v8a" => Architecture::Aarch64,
"armeabi-v7a" => Architecture::Arm,
"x86_64" => Architecture::X86_64,
_ => return Err("unapproved native ABI".into()),
};
let library = object::File::parse(bytes)?;
@@ -111,6 +110,61 @@ pub fn audit_split(path: &Path, abi: &str) -> Result<(), Box<dyn Error>> {
audit_package_abis(path, &[abi])
}
pub fn audit_release(package: &Path, symbols: &Path) -> Result<(), Box<dyn Error>> {
audit_package(package)?;
let mut package = zip::ZipArchive::new(File::open(package)?)?;
let mut symbols = zip::ZipArchive::new(File::open(symbols)?)?;
for abi in ABIS {
for library in ["libironstorage_android.so", "libjnidispatch.so"] {
let mut bytes = Vec::new();
package
.by_name(&format!("lib/{abi}/{library}"))?
.read_to_end(&mut bytes)?;
let mut debug = Vec::new();
let mut entry = symbols.by_name(&format!("{abi}/{library}.dbg"))?;
if entry.size() > 512 * 1024 * 1024 {
return Err("native debug symbols exceed the audit bound".into());
}
entry.read_to_end(&mut debug)?;
audit_symbol_pair(&bytes, &debug, abi, library == "libironstorage_android.so")?;
}
}
println!("Release libraries are stripped; FULL symbols match both ARM build IDs");
Ok(())
}
fn audit_symbol_pair(
bytes: &[u8],
debug: &[u8],
abi: &str,
require_debug_info: bool,
) -> Result<(), Box<dyn Error>> {
audit_elf(bytes, abi)?;
let release = object::File::parse(bytes)?;
let symbols = object::File::parse(debug)?;
if symbols.format() != BinaryFormat::Elf || symbols.architecture() != release.architecture() {
return Err("debug archive architecture does not match release".into());
}
if release.sections().any(|s| {
s.name()
.is_ok_and(|n| n.starts_with(".debug_") || n == ".symtab")
}) {
return Err("release library contains unstripped debug symbols".into());
}
if (require_debug_info && symbols.section_by_name(".debug_info").is_none())
|| symbols.symbols().next().is_none()
{
return Err("native symbol archive lacks required symbols/debug information".into());
}
let id = release
.build_id()?
.ok_or("release library lacks a build ID")?;
if id.is_empty() || Some(id) != symbols.build_id()? {
return Err("native symbol archive does not match release build ID".into());
}
Ok(())
}
fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box<dyn Error>> {
let apk = path.extension().is_some_and(|ext| ext == "apk");
if !apk && !path.extension().is_some_and(|ext| ext == "aab") {
@@ -134,6 +188,18 @@ fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box<dyn Error>>
if !abis.contains(&abi) || library.contains('/') {
return Err("package contains an unapproved native library path".into());
}
if ![
"libironstorage_android.so",
"libjnidispatch.so",
"libandroidx.graphics.path.so",
]
.contains(&library)
|| libraries
.get(abi)
.is_some_and(|names| names.iter().any(|name| name == library))
{
return Err("package contains an unreviewed or duplicate native library".into());
}
if apk
&& (file.compression() != zip::CompressionMethod::Stored
|| file.data_start() % PAGE_SIZE != 0)
@@ -205,4 +271,14 @@ mod tests {
assert!(audit_elf(b"\x7fELF", "x86_64").is_err());
assert!(audit_elf(&[], "mips").is_err());
}
#[test]
fn symbol_gate_rejects_missing_full_symbols_and_build_identity() {
assert!(
audit_symbol_pair(&elf(PAGE_SIZE, 0), &elf(PAGE_SIZE, 0), "arm64-v8a", true).is_err()
);
assert!(
audit_symbol_pair(&elf(PAGE_SIZE, 0), b"wrong archive", "arm64-v8a", false).is_err()
);
}
}