rust-apk 1.0.0

Build, align, sign, verify, and inspect Android APKs in pure Rust
Documentation
//! Building complete APKs and checking them with Android's own tools.

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};

/// A NativeActivity app using most builder features.
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()));
}

/// A builder change that must make `write` fail with a specific error.
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");
}