rust-apk 1.0.0

Build, align, sign, verify, and inspect Android APKs in pure Rust
Documentation
use nutype::nutype;

use super::is_java_identifier;

/// Why a [`PackageName`] was rejected.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PackageNameError {
    #[error("package name has {segments} segment(s); Android requires at least two")]
    TooFewSegments { segments: usize },
    #[error("package name segment {segment:?} is not an ASCII Java identifier without `$`")]
    InvalidSegment { segment: String },
}

fn validate_package_name(value: &str) -> Result<(), PackageNameError> {
    let segments = value.split('.').count();
    if segments < 2 {
        return Err(PackageNameError::TooFewSegments { segments });
    }
    match value.split('.').find(|s| !is_java_identifier(s) || s.contains('$')) {
        Some(segment) => Err(PackageNameError::InvalidSegment { segment: segment.to_owned() }),
        None => Ok(()),
    }
}

/// An Android application ID such as `org.example.game`.
#[nutype(
    validate(with = validate_package_name, error = PackageNameError),
    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
)]
pub struct PackageName(String);

/// Why a [`ClassName`] was rejected.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ClassNameError {
    #[error("class name is empty")]
    Empty,
    #[error("class name segment {segment:?} is not an ASCII Java identifier")]
    InvalidSegment { segment: String },
}

fn validate_class_name(value: &str) -> Result<(), ClassNameError> {
    let body = value.strip_prefix('.').unwrap_or(value);
    if body.is_empty() {
        return Err(ClassNameError::Empty);
    }
    match body.split('.').find(|s| !is_java_identifier(s)) {
        Some(segment) => Err(ClassNameError::InvalidSegment { segment: segment.to_owned() }),
        None => Ok(()),
    }
}

/// A Java class name such as `android.app.NativeActivity`.
///
/// A leading `.` (e.g. `.MainActivity`) is accepted; Android resolves it
/// relative to the package name.
#[nutype(
    validate(with = validate_class_name, error = ClassNameError),
    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
)]
pub struct ClassName(String);

impl ClassName {
    /// `android.app.NativeActivity`, the framework activity that hosts a native library.
    pub fn native_activity() -> Self {
        Self::try_new("android.app.NativeActivity").expect("valid class name")
    }
}

/// Why a [`PermissionName`] was rejected.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PermissionNameError {
    #[error("permission name has {segments} segment(s); at least two are required")]
    TooFewSegments { segments: usize },
    #[error("permission name segment {segment:?} is not an ASCII Java identifier")]
    InvalidSegment { segment: String },
}

fn validate_permission_name(value: &str) -> Result<(), PermissionNameError> {
    let segments = value.split('.').count();
    if segments < 2 {
        return Err(PermissionNameError::TooFewSegments { segments });
    }
    match value.split('.').find(|s| !is_java_identifier(s)) {
        Some(segment) => Err(PermissionNameError::InvalidSegment { segment: segment.to_owned() }),
        None => Ok(()),
    }
}

/// A permission name such as `android.permission.INTERNET`.
#[nutype(
    validate(with = validate_permission_name, error = PermissionNameError),
    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
)]
pub struct PermissionName(String);

impl PermissionName {
    /// `android.permission.<name>`.
    pub fn android(name: &str) -> Result<Self, PermissionNameError> {
        Self::try_new(format!("android.permission.{name}"))
    }
}

/// Why a [`LibraryName`] was rejected.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum LibraryNameError {
    #[error("library name is empty")]
    Empty,
    #[error("library name is {len} bytes long; the limit is {max}")]
    TooLong { len: usize, max: usize },
    #[error("library name {name:?} includes the `lib` prefix and `.so` suffix; pass only the stem")]
    IncludesFileAffixes { name: String },
    #[error("library name contains {character:?}; only ASCII letters, digits, `_`, `-`, `.`, and `+` are allowed")]
    InvalidCharacter { character: char },
    #[error("file name {file_name:?} does not have the form `lib<name>.so`")]
    NotALibraryFileName { file_name: String },
}

const MAX_LIBRARY_NAME: usize = 200;

fn validate_library_name(value: &str) -> Result<(), LibraryNameError> {
    if value.is_empty() {
        return Err(LibraryNameError::Empty);
    }
    if value.len() > MAX_LIBRARY_NAME {
        return Err(LibraryNameError::TooLong { len: value.len(), max: MAX_LIBRARY_NAME });
    }
    if value.starts_with("lib") && value.ends_with(".so") {
        return Err(LibraryNameError::IncludesFileAffixes { name: value.to_owned() });
    }
    match value.chars().find(|&c| !(c.is_ascii_alphanumeric() || "_-.+".contains(c))) {
        Some(character) => Err(LibraryNameError::InvalidCharacter { character }),
        None => Ok(()),
    }
}

/// A native library name without its `lib` prefix and `.so` suffix:
/// `c++_shared` names `libc++_shared.so`.
#[nutype(
    validate(with = validate_library_name, error = LibraryNameError),
    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
)]
pub struct LibraryName(String);

impl LibraryName {
    /// Extracts the name from a file name of the form `lib<name>.so`.
    pub fn from_file_name(file_name: &str) -> Result<Self, LibraryNameError> {
        let stem = file_name.strip_prefix("lib").and_then(|rest| rest.strip_suffix(".so"));
        let stem = stem.ok_or_else(|| LibraryNameError::NotALibraryFileName { file_name: file_name.to_owned() })?;
        Self::try_new(stem)
    }

    /// The file name stored in the APK: `lib<name>.so`.
    pub fn file_name(&self) -> String {
        format!("lib{}.so", self.as_ref())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn package_names() {
        for valid in ["org.example", "a.b.c", "com.Example_1.app_"] {
            assert!(PackageName::try_new(valid).is_ok(), "{valid}");
        }
        assert_eq!(PackageName::try_new("single"), Err(PackageNameError::TooFewSegments { segments: 1 }));
        assert_eq!(
            PackageName::try_new("org.1example"),
            Err(PackageNameError::InvalidSegment { segment: "1example".into() })
        );
        for invalid in ["org..example", ".org.example", "org.ex-ample", "org.ex$ample", "org.example."] {
            assert!(PackageName::try_new(invalid).is_err(), "{invalid}");
        }
    }

    #[test]
    fn class_names_accept_relative_names() {
        assert!(ClassName::try_new(".MainActivity").is_ok());
        assert!(ClassName::try_new("org.example.Main$Inner").is_ok());
        assert_eq!(ClassName::try_new("."), Err(ClassNameError::Empty));
        assert!(ClassName::try_new("org.example.").is_err());
    }

    #[test]
    fn library_names_cover_ndk_conventions() {
        assert_eq!(LibraryName::try_new("c++_shared").unwrap().file_name(), "libc++_shared.so");
        assert_eq!(LibraryName::from_file_name("libmain.so").unwrap().as_ref(), "main");
        assert_eq!(
            LibraryName::try_new("libmain.so"),
            Err(LibraryNameError::IncludesFileAffixes { name: "libmain.so".into() })
        );
        assert_eq!(LibraryName::try_new("a/b"), Err(LibraryNameError::InvalidCharacter { character: '/' }));
        assert!(matches!(LibraryName::from_file_name("main.so"), Err(LibraryNameError::NotALibraryFileName { .. })));
    }
}