Prepare Android release artifacts and listing (#112)
Add Rust distribution auditing, pinned post-build AAB signing, complete offline third-party notices, an Android privacy policy and reviewable Play listing assets. Preserve the application ID and audit both ARM ABIs, native symbols, baseline profiles and R8 mapping. Fix dialog focus ownership, navigation-triggered cleanup loops and stale authentication errors after first-run setup, with native and real-activity regressions. Google Play publication is deferred by the user. Issue #112 remains open for production signing, Console declarations and actual Play install/update verification.
This commit is contained in:
1 parent
b54636b7bc
commit
bf83a0ba41
51 files changed
+1493
-41
No files matched your search
@@ -57,7 +57,7 @@ fn release_manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
|
||||
pub(super) 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"
|
||||
@@ -111,7 +111,7 @@ fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
|
||||
&& (node.attribute((ANDROID, "name")) != Some(allowed[2])
|
||||
|| !matches!(
|
||||
node.attribute((ANDROID, "protectionLevel")),
|
||||
Some("signature" | "0x2")
|
||||
Some("signature" | "0x2" | "0x00000002")
|
||||
))
|
||||
{
|
||||
return Err("unreviewed custom permission".into());
|
||||
@@ -550,6 +550,21 @@ mod tests {
|
||||
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 (level, valid) in [
|
||||
("signature", true),
|
||||
("0x2", true),
|
||||
("0x00000002", true),
|
||||
("normal", false),
|
||||
("0x00000012", false),
|
||||
] {
|
||||
let permission = format!(
|
||||
r#"<permission android:name="de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" android:protectionLevel="{level}"/><application"#
|
||||
);
|
||||
assert_eq!(
|
||||
manifest_text(&source.replace("<application", &permission)).is_ok(),
|
||||
valid
|
||||
);
|
||||
}
|
||||
assert!(manifest_text(&source.replace(
|
||||
"<application",
|
||||
"<uses-permission android:name=\"android.permission.QUERY_ALL_PACKAGES\"/><application",
|
||||
|
||||
@@ -7,6 +7,7 @@ mod bindings;
|
||||
mod contract;
|
||||
mod gates;
|
||||
mod native;
|
||||
mod release;
|
||||
mod toolchain;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
@@ -65,6 +66,27 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
[_, command, package, symbols] if command == "audit-release" => {
|
||||
native::audit_release(Path::new(package), Path::new(symbols))?;
|
||||
}
|
||||
[
|
||||
_,
|
||||
command,
|
||||
android,
|
||||
manifest,
|
||||
bundle,
|
||||
mapping,
|
||||
symbols,
|
||||
notices,
|
||||
output,
|
||||
] if command == "audit-distribution" => {
|
||||
release::audit(
|
||||
Path::new(android),
|
||||
Path::new(manifest),
|
||||
Path::new(bundle),
|
||||
Path::new(mapping),
|
||||
Path::new(symbols),
|
||||
Path::new(notices),
|
||||
Path::new(output),
|
||||
)?;
|
||||
}
|
||||
[_, command, input, output] if command == "safe-reports" => {
|
||||
gates::reports(Path::new(input), Path::new(output))?;
|
||||
}
|
||||
@@ -88,7 +110,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 | audit-release apk symbols | audit-release-manifest manifest.xml | 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(),
|
||||
"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-or-aab symbols | audit-distribution android-dir manifest.xml app.aab mapping.txt symbols.zip notices.txt output.json | audit-release-manifest manifest.xml | 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +112,18 @@ pub fn audit_split(path: &Path, abi: &str) -> Result<(), Box<dyn Error>> {
|
||||
|
||||
pub fn audit_release(package: &Path, symbols: &Path) -> Result<(), Box<dyn Error>> {
|
||||
audit_package(package)?;
|
||||
let prefix = if package.extension().is_some_and(|ext| ext == "aab") {
|
||||
"base/lib"
|
||||
} else {
|
||||
"lib"
|
||||
};
|
||||
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}"))?
|
||||
.by_name(&format!("{prefix}/{abi}/{library}"))?
|
||||
.read_to_end(&mut bytes)?;
|
||||
let mut debug = Vec::new();
|
||||
let mut entry = symbols.by_name(&format!("{abi}/{library}.dbg"))?;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Distribution provenance supplements the existing manifest and native gates.
|
||||
use std::{collections::BTreeMap, error::Error, fs, io::Read, path::Path};
|
||||
|
||||
use roxmltree::Document;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const ANDROID: &str = "http://schemas.android.com/apk/res/android";
|
||||
|
||||
pub fn audit(
|
||||
android: &Path,
|
||||
manifest: &Path,
|
||||
bundle: &Path,
|
||||
mapping: &Path,
|
||||
symbols: &Path,
|
||||
notices: &Path,
|
||||
output: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
super::gates::release_manifest(manifest)?;
|
||||
let version_source = fs::read_to_string(android.join("release.properties"))?;
|
||||
let version = version(&version_source)?;
|
||||
let catalog: toml::Value = toml::from_str(&fs::read_to_string(
|
||||
android.join("gradle/libs.versions.toml"),
|
||||
)?)?;
|
||||
let manifest_source = fs::read_to_string(manifest)?;
|
||||
super::gates::manifest_text(&manifest_source)?;
|
||||
identity(&manifest_source, &version, &catalog)?;
|
||||
super::native::audit_release(bundle, symbols)?;
|
||||
let mut archive = zip::ZipArchive::new(fs::File::open(bundle)?)?;
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
for index in 0..archive.len() {
|
||||
let entry = archive.by_index(index)?;
|
||||
let name = entry.name();
|
||||
if entry.enclosed_name().is_none()
|
||||
|| !names.insert(name.to_owned())
|
||||
|| (name.contains("/lib/") && !name.starts_with("base/lib/"))
|
||||
{
|
||||
return Err("unreviewed or duplicate bundle path".into());
|
||||
}
|
||||
}
|
||||
for name in [
|
||||
"BUNDLE-METADATA/com.android.tools.build.profiles/baseline.prof",
|
||||
"BUNDLE-METADATA/com.android.tools.build.profiles/baseline.profm",
|
||||
] {
|
||||
if archive.by_name(name)?.size() == 0 {
|
||||
return Err("release baseline profile is empty".into());
|
||||
}
|
||||
}
|
||||
let mut bundled_notices = Vec::new();
|
||||
let mut entry = archive.by_name("base/assets/third-party-notices.txt")?;
|
||||
if entry.size() == 0 || entry.size() > 16 * 1024 * 1024 {
|
||||
return Err("release third-party notices missing or oversized".into());
|
||||
}
|
||||
entry.read_to_end(&mut bundled_notices)?;
|
||||
drop(entry);
|
||||
if bundled_notices != fs::read(notices)? {
|
||||
return Err("retained third-party notices do not match the bundle".into());
|
||||
}
|
||||
let mut bundled_mapping = Vec::new();
|
||||
let mut entry =
|
||||
archive.by_name("BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map")?;
|
||||
// Compose and generated UniFFI/JNA mappings currently occupy about 54 MiB.
|
||||
if entry.size() > 128 * 1024 * 1024 {
|
||||
return Err("R8 mapping exceeds the audit bound".into());
|
||||
}
|
||||
entry.read_to_end(&mut bundled_mapping)?;
|
||||
if bundled_mapping.is_empty() || bundled_mapping != fs::read(mapping)? {
|
||||
return Err("retained R8 mapping does not match the bundle".into());
|
||||
}
|
||||
let artifacts: BTreeMap<_, _> = [manifest, bundle, mapping, symbols, notices]
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
Ok((
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or("artifact name missing")?
|
||||
.to_owned(),
|
||||
format!("{:x}", Sha256::digest(fs::read(path)?)),
|
||||
))
|
||||
})
|
||||
.collect::<Result<_, Box<dyn Error>>>()?;
|
||||
if artifacts.len() != 5 {
|
||||
return Err("distribution artifact names must be distinct".into());
|
||||
}
|
||||
fs::write(
|
||||
output,
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"applicationId": "de.rfc1437.ironstorage", "version": version,
|
||||
"abis": ["arm64-v8a", "armeabi-v7a"], "sha256": artifacts,
|
||||
"playAcceptance": "pending: upload and installed signing lineage require Play Console"
|
||||
}))?,
|
||||
)?;
|
||||
println!("Distribution identity, SDK, native symbols, baseline profile and R8 mapping passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn version(source: &str) -> Result<BTreeMap<&str, &str>, Box<dyn Error>> {
|
||||
let mut result = BTreeMap::new();
|
||||
for line in source.lines() {
|
||||
let (key, value) = line
|
||||
.split_once('=')
|
||||
.ok_or("invalid release version property")?;
|
||||
if !["versionCode", "versionName"].contains(&key) || result.insert(key, value).is_some() {
|
||||
return Err("unreviewed or duplicate release version property".into());
|
||||
}
|
||||
}
|
||||
let code: u32 = result
|
||||
.get("versionCode")
|
||||
.ok_or("versionCode missing")?
|
||||
.parse()?;
|
||||
let name = result.get("versionName").ok_or("versionName missing")?;
|
||||
if code == 0
|
||||
|| code > 2_100_000_000
|
||||
|| name.is_empty()
|
||||
|| name.len() > 50
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".-+".contains(&b))
|
||||
{
|
||||
return Err("invalid Play release version".into());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn identity(
|
||||
source: &str,
|
||||
version: &BTreeMap<&str, &str>,
|
||||
catalog: &toml::Value,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let document = Document::parse(source)?;
|
||||
let root = document.root_element();
|
||||
if !root.has_tag_name("manifest") || root.attribute("package") != Some("de.rfc1437.ironstorage")
|
||||
{
|
||||
return Err("unexpected distribution application ID".into());
|
||||
}
|
||||
for (name, expected) in version {
|
||||
if root.attribute((ANDROID, *name)) != Some(*expected) {
|
||||
return Err(format!("distribution {name} differs from release.properties").into());
|
||||
}
|
||||
}
|
||||
let sdk = root
|
||||
.children()
|
||||
.find(|node| node.has_tag_name("uses-sdk"))
|
||||
.ok_or("uses-sdk missing")?;
|
||||
for (attribute, key) in [
|
||||
("minSdkVersion", "minSdk"),
|
||||
("targetSdkVersion", "compileSdk"),
|
||||
] {
|
||||
let expected = catalog
|
||||
.get("versions")
|
||||
.and_then(|v| v.get(key))
|
||||
.and_then(toml::Value::as_str)
|
||||
.ok_or("SDK catalog version missing")?;
|
||||
if sdk.attribute((ANDROID, attribute)) != Some(expected) {
|
||||
return Err(format!("distribution {attribute} differs from the pinned SDK").into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_ambiguous_invalid_or_exhausted_play_versions() {
|
||||
for source in [
|
||||
"versionCode=0\nversionName=1",
|
||||
"versionCode=2100000001\nversionName=1",
|
||||
"versionCode=1\nversionCode=2\nversionName=1",
|
||||
"versionCode=1",
|
||||
"versionCode=1\nversionName=",
|
||||
"versionCode=1\nversionName=1\nother=value",
|
||||
] {
|
||||
assert!(version(source).is_err(), "accepted {source}");
|
||||
}
|
||||
assert!(version("versionCode=2\nversionName=1.0.1\n").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_package_version_or_sdk_in_the_actual_manifest() {
|
||||
let versions = version("versionCode=2\nversionName=1.0.1").unwrap();
|
||||
let catalog = toml::from_str("[versions]\nminSdk='26'\ncompileSdk='37'\n").unwrap();
|
||||
let manifest = r#"<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.rfc1437.ironstorage" android:versionCode="2" android:versionName="1.0.1"><uses-sdk android:minSdkVersion="26" android:targetSdkVersion="37"/></manifest>"#;
|
||||
assert!(identity(manifest, &versions, &catalog).is_ok());
|
||||
for (from, to) in [
|
||||
("ironstorage", "fixture"),
|
||||
("Code=\"2\"", "Code=\"1\""),
|
||||
("Name=\"1.0.1\"", "Name=\"0.1\""),
|
||||
("Version=\"26\"", "Version=\"25\""),
|
||||
("Version=\"37\"", "Version=\"35\""),
|
||||
] {
|
||||
assert!(identity(&manifest.replace(from, to), &versions, &catalog).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user