mod common;
use std::path::Path;
use apk::build::BuildError;
use apk::manifest::ManifestError;
use apk::native::{LibraryStorage, NativeError, NativePolicy};
use apk::prelude::*;
use apk::res::ResourceRef;
use apk::sign::{CoverageError, Scheme, Schemes, SignError, VerifyError};
use apk::xml::XmlError;
use common::{NATIVE_LIBRARY, PNG};
fn postcard() -> ApkBuilder {
let mut app = ApkBuilder::native_activity("org.example.postcard".parse().unwrap(), "postcard".parse().unwrap());
app.label("Postcard")
.version(VersionCode::try_new(10203).unwrap(), "1.2.3")
.min_sdk(ApiLevel::LOLLIPOP)
.permission("android.permission.INTERNET".parse().unwrap())
.permission("org.example.postcard.permission.SHARE".parse().unwrap());
app.localized_string("app_name", "fr".parse().unwrap(), "Carte postale")
.unwrap()
.localized_string("app_name", "zh-Hant-TW".parse().unwrap(), "明信片")
.unwrap()
.string("greeting", "Hello from Rust")
.unwrap()
.icon(Density::MDPI, PNG)
.unwrap()
.icon(Density::XXXHDPI, PNG)
.unwrap()
.asset("messages/hello.txt", b"hello")
.unwrap()
.native_library_bytes(Abi::Arm64V8a, "postcard".parse().unwrap(), NATIVE_LIBRARY)
.unwrap();
app
}
fn data_offset(apk: &Path, name: &str) -> (u64, zip::CompressionMethod) {
let mut archive = zip::ZipArchive::new(std::fs::File::open(apk).unwrap()).unwrap();
let entry = archive.by_name(name).unwrap();
(entry.data_start().unwrap(), entry.compression())
}
#[test]
fn native_activity_apk_is_accepted_by_aapt2_and_apksigner() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("postcard.apk");
let mut app = postcard();
app.sign_with(Signer::new(common::identity("debug")));
app.write(&path).unwrap();
let verification = verify(&path).unwrap();
assert_eq!(verification.schemes, Schemes::V1 | Schemes::V2 | Schemes::V3);
assert_eq!(verification.certificate(), common::identity("debug").certificate());
let mut apk = Apk::open(&path).unwrap();
let manifest = apk.manifest().unwrap();
assert_eq!(manifest.package(), Some("org.example.postcard"));
assert_eq!((manifest.version_code(), manifest.version_name()), (Some(10203), Some("1.2.3")));
assert_eq!(manifest.min_sdk().to_string(), "21");
let permissions: Vec<&str> = manifest.permissions().collect();
assert_eq!(permissions, ["android.permission.INTERNET", "org.example.postcard.permission.SHARE"]);
assert_eq!(manifest.launcher_activity(), Some("android.app.NativeActivity"));
assert_eq!(manifest.native_activity_library(), Some(Ok("postcard")));
assert_eq!((manifest.has_code(), manifest.extract_native_libs()), (false, Some(false)));
assert_eq!(apk.label().unwrap().as_deref(), Some("Postcard"));
assert_eq!(apk.native_libraries()[&Abi::Arm64V8a], ["postcard".parse::<LibraryName>().unwrap()]);
assert_eq!(apk.read("assets/messages/hello.txt").unwrap(), b"hello");
let (library, method) = data_offset(&path, "lib/arm64-v8a/libpostcard.so");
assert_eq!((library % 16384, method), (0, zip::CompressionMethod::Stored), "library must be mappable");
let (table, method) = data_offset(&path, "resources.arsc");
assert_eq!((table % 4, method), (0, zip::CompressionMethod::Stored), "Android 11+ requires this");
if let Some(badging) = common::aapt2(&["dump", "badging"], &path) {
let badging = badging.expect_success("aapt2 dump badging").stdout;
for expected in [
"package: name='org.example.postcard' versionCode='10203' versionName='1.2.3'",
"minSdkVersion:'21'",
"uses-permission: name='android.permission.INTERNET'",
"application-label-fr:'Carte postale'",
"application-label-zh-Hant-TW:'明信片'",
"launchable-activity: name='android.app.NativeActivity'",
"native-code: 'arm64-v8a'",
"application-icon-640:'res/mipmap-xxxhdpi-v4/ic_launcher.png'",
] {
assert!(badging.contains(expected), "aapt2 badging lacks {expected:?}:\n{badging}");
}
}
if let Some(run) = common::apksigner_verify(&path, 21) {
let run = run.expect_success("apksigner verify");
for scheme in [
"v1 scheme (JAR signing): true",
"v2 scheme (APK Signature Scheme v2): true",
"v3 scheme (APK Signature Scheme v3): true",
] {
assert!(run.stdout.contains(scheme), "{}", run.stdout);
}
}
}
#[test]
fn builds_are_byte_for_byte_reproducible() {
let dir = tempfile::tempdir().unwrap();
let build = |name: &str, key: &str| {
let path = dir.path().join(name);
let mut app = postcard();
app.sign_with(Signer::new(common::identity(key)));
app.write(&path).unwrap();
std::fs::read(path).unwrap()
};
assert_eq!(build("rsa-1.apk", "debug"), build("rsa-2.apk", "debug"));
assert_eq!(build("ec-1.apk", "ec-p256"), build("ec-2.apk", "ec-p256"));
}
#[test]
fn native_library_insertion_order_does_not_change_the_apk() {
let dir = tempfile::tempdir().unwrap();
let build = |file, names: [&str; 2]| {
let path = dir.path().join(file);
let mut app = postcard();
for name in names {
app.native_library_bytes(Abi::Arm64V8a, name.parse().unwrap(), NATIVE_LIBRARY).unwrap();
}
app.write(&path).unwrap();
path
};
let left = build("left.apk", ["alpha", "zeta"]);
let right = build("right.apk", ["zeta", "alpha"]);
assert_eq!(std::fs::read(&left).unwrap(), std::fs::read(&right).unwrap());
let libraries = Apk::open(left).unwrap().native_libraries();
assert_eq!(libraries[&Abi::Arm64V8a], ["alpha", "postcard", "zeta"].map(|s| s.parse::<LibraryName>().unwrap()));
}
struct Misconfiguration {
name: &'static str,
configure: fn(&mut ApkBuilder),
expected: fn(&apk::Error) -> bool,
}
#[test]
fn inconsistent_configurations_fail_before_anything_is_written() {
let cases = [
Misconfiguration {
name: "negative minimum SDK inserted through the XML tree",
configure: |app| {
app.manifest_mut().root_mut().element_mut("uses-sdk").unwrap().set_attr(attr::MIN_SDK_VERSION, -1);
},
expected: |e| {
matches!(e, apk::Error::Manifest(ManifestError::InvalidSdkVersion { attribute: "minSdkVersion", .. }))
},
},
Misconfiguration {
name: "min above target",
configure: |app| {
app.min_sdk(ApiLevel::BAKLAVA).target_sdk(ApiLevel::S);
},
expected: |e| matches!(e, apk::Error::Build(BuildError::MinSdkAboveTarget { .. })),
},
Misconfiguration {
name: "unexported activity with filters",
configure: |app| {
let activity = manifest::activity(&"org.example.postcard.Other".parse().unwrap())
.with_child(manifest::launcher_filter());
app.manifest_mut().application_mut().push_child(activity);
},
expected: |e| matches!(e, apk::Error::Build(BuildError::ExportedRequired { component }) if component.ends_with("Other")),
},
Misconfiguration {
name: "code declared without DEX",
configure: |app| {
app.manifest_mut().application_mut().set_attr(attr::HAS_CODE, true);
},
expected: |e| matches!(e, apk::Error::Build(BuildError::MissingCode)),
},
Misconfiguration {
name: "DEX with hasCode=false",
configure: |app| {
app.dex(b"dex\n035\0".as_slice());
},
expected: |e| matches!(e, apk::Error::Build(BuildError::CodeDeclaredAbsent)),
},
Misconfiguration {
name: "DEX with hasCode as text, which Android reads as false",
configure: |app| {
app.dex(b"dex\n035\0".as_slice());
app.manifest_mut().application_mut().set_attr(attr::HAS_CODE, "true");
},
expected: |e| matches!(e, apk::Error::Xml(XmlError::MistypedValue { attribute: "hasCode", .. })),
},
Misconfiguration {
name: "NativeActivity library named by a resource",
configure: |app| {
let activity = app.manifest_mut().application_mut().element_mut("activity").unwrap();
let library: ResourceRef = "@string/library".parse().unwrap();
activity.element_mut("meta-data").unwrap().set_attr(attr::VALUE, library);
},
expected: |e| matches!(e, apk::Error::Build(BuildError::NativeLibraryNotLiteral { .. })),
},
Misconfiguration {
name: "extractNativeLibs contradicts storage",
configure: |app| {
app.manifest_mut().application_mut().set_attr(attr::EXTRACT_NATIVE_LIBS, true);
},
expected: |e| matches!(e, apk::Error::Build(BuildError::ExtractNativeLibsConflict { manifest: true, .. })),
},
Misconfiguration {
name: "NativeActivity edited to load a library that is not packaged",
configure: |app| {
let activity = app.manifest_mut().application_mut().element_mut("activity").unwrap();
activity.element_mut("meta-data").unwrap().set_attr(attr::VALUE, "other");
},
expected: |e| matches!(e, apk::Error::Native(NativeError::MissingEntryLibrary { .. })),
},
Misconfiguration {
name: "library for the wrong ABI",
configure: |app| {
app.native_library_bytes(Abi::X86_64, "postcard".parse().unwrap(), NATIVE_LIBRARY).unwrap();
},
expected: |e| {
matches!(
e,
apk::Error::Native(NativeError::AbiMismatch { expected: Abi::X86_64, found: Abi::Arm64V8a, .. })
)
},
},
Misconfiguration {
name: "ABI without the entry library",
configure: |app| {
app.native_library_bytes(Abi::X86_64, "helper".parse().unwrap(), NATIVE_LIBRARY).unwrap();
},
expected: |e| matches!(e, apk::Error::Native(NativeError::MissingEntryLibrary { abi: Abi::X86_64, .. })),
},
Misconfiguration {
name: "JAR-only signing for a modern target",
configure: |app| {
app.sign_with(Signer::new(common::identity("debug")).schemes(Schemes::V1));
},
expected: |e| matches!(e, apk::Error::Sign(SignError::Coverage(CoverageError::TargetRequiresV2 { .. }))),
},
Misconfiguration {
name: "v2-only signing for API 21",
configure: |app| {
app.sign_with(Signer::new(common::identity("debug")).schemes(Schemes::V2));
},
expected: |e| {
matches!(
e,
apk::Error::Sign(SignError::Coverage(CoverageError::UnverifiableOnMinSdk {
needed: Scheme::V1,
..
}))
)
},
},
];
let dir = tempfile::tempdir().unwrap();
for Misconfiguration { name, configure, expected } in cases {
let mut app = postcard();
configure(&mut app);
let path = dir.path().join(format!("{name}.apk"));
let error = app.write(&path).expect_err(name);
assert!(expected(&error), "{name}: unexpected error {error:?}");
assert!(!path.exists(), "{name}: output written despite the error");
}
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0, "temporary files leaked");
}
#[test]
fn builder_owned_paths_cannot_be_added_as_plain_files() {
let mut app = postcard();
for path in ["AndroidManifest.xml", "resources.arsc", "classes2.dex", "lib/arm64-v8a/libx.so", "META-INF/CERT.SF"] {
let path = ArchivePath::try_new(path).unwrap();
let result = app.file(path.clone(), b"x".as_slice(), None);
assert!(matches!(result, Err(apk::Error::Build(BuildError::ReservedPath { .. }))), "{path} was accepted");
}
app.file(ArchivePath::try_new("META-INF/services/org.example.Plugin").unwrap(), b"x".as_slice(), None).unwrap();
}
#[test]
fn extracted_storage_compresses_libraries_and_says_so() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("extracted.apk");
let mut app = postcard();
app.native_policy(NativePolicy { storage: LibraryStorage::Extracted, ..NativePolicy::default() });
app.write(&path).unwrap();
assert_eq!(data_offset(&path, "lib/arm64-v8a/libpostcard.so").1, zip::CompressionMethod::Deflated);
assert_eq!(Apk::open(&path).unwrap().manifest().unwrap().extract_native_libs(), Some(true));
assert!(matches!(verify(&path), Err(apk::Error::Verify(VerifyError::Unsigned))));
}
#[test]
fn dex_files_enable_code() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("code.apk");
let mut app = ApkBuilder::new(Manifest::new(&"org.example.code".parse().unwrap()));
app.dex(b"dex\n035\0first".as_slice()).dex(b"dex\n035\0second".as_slice());
app.write(&path).unwrap();
let mut apk = Apk::open(&path).unwrap();
assert!(apk.manifest().unwrap().has_code());
assert_eq!(apk.read("classes2.dex").unwrap(), b"dex\n035\0second");
}