rust-apk 1.0.0

Build, align, sign, verify, and inspect Android APKs in pure Rust
Documentation
//! Resource identifiers, names, and symbolic references.

use std::fmt;
use std::str::FromStr;

use nutype::nutype;

/// A packed resource identifier: `0xPPTTEEEE` (package, type, entry).
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ResourceId(pub u32);

impl ResourceId {
    /// The package ID of application resources.
    pub const APP_PACKAGE: u8 = 0x7f;
    /// The package ID of framework (`android:`) resources.
    pub const ANDROID_PACKAGE: u8 = 0x01;

    pub const fn new(package: u8, type_id: u8, entry: u16) -> Self {
        Self(((package as u32) << 24) | ((type_id as u32) << 16) | entry as u32)
    }

    pub const fn package(self) -> u8 {
        (self.0 >> 24) as u8
    }

    /// The 1-based type index within the package.
    pub const fn type_id(self) -> u8 {
        (self.0 >> 16) as u8
    }

    pub const fn entry(self) -> u16 {
        self.0 as u16
    }
}

impl fmt::Display for ResourceId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "0x{:08x}", self.0)
    }
}

impl fmt::Debug for ResourceId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "ResourceId(0x{:08x})", self.0)
    }
}

macro_rules! resource_types {
    ($($variant:ident => $name:literal,)+) => {
        /// An Android resource type, the `type` in `@type/name`.
        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
        #[non_exhaustive]
        pub enum ResourceType {
            $($variant,)+
        }

        impl ResourceType {
            pub const ALL: &[Self] = &[$(Self::$variant,)+];

            pub const fn as_str(self) -> &'static str {
                match self {
                    $(Self::$variant => $name,)+
                }
            }
        }
    };
}

resource_types! {
    Anim => "anim",
    Animator => "animator",
    Array => "array",
    Attr => "attr",
    Bool => "bool",
    Color => "color",
    Dimen => "dimen",
    Drawable => "drawable",
    Font => "font",
    Fraction => "fraction",
    Id => "id",
    Integer => "integer",
    Interpolator => "interpolator",
    Layout => "layout",
    Menu => "menu",
    Mipmap => "mipmap",
    Navigation => "navigation",
    Plurals => "plurals",
    Raw => "raw",
    String => "string",
    Style => "style",
    Transition => "transition",
    Xml => "xml",
}

impl fmt::Display for ResourceType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A resource type name Android does not define.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error("unknown resource type {name:?}")]
pub struct UnknownResourceType {
    pub name: String,
}

impl FromStr for ResourceType {
    type Err = UnknownResourceType;
    fn from_str(name: &str) -> Result<Self, UnknownResourceType> {
        Self::ALL
            .iter()
            .copied()
            .find(|ty| ty.as_str() == name)
            .ok_or_else(|| UnknownResourceType { name: name.to_owned() })
    }
}

/// Why a [`ResourceName`] was rejected.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ResourceNameError {
    #[error("resource name is empty")]
    Empty,
    #[error("resource name {name:?} must start with an ASCII letter or `_`")]
    InvalidStart { name: String },
    #[error("resource name contains {character:?}; only ASCII letters, digits, `_`, and `.` are allowed")]
    InvalidCharacter { character: char },
    #[error("resource name {name:?} ends with `.`")]
    TrailingDot { name: String },
    #[error("resource name is {len} bytes; resource tables store at most {max}", max = MAX_RESOURCE_NAME_LEN)]
    TooLong { len: usize },
}

/// The longest resource name: the limit of a UTF-8 string pool entry.
pub const MAX_RESOURCE_NAME_LEN: usize = 0x7fff;

fn validate_resource_name(name: &str) -> Result<(), ResourceNameError> {
    if name.len() > MAX_RESOURCE_NAME_LEN {
        return Err(ResourceNameError::TooLong { len: name.len() });
    }
    let first = name.chars().next().ok_or(ResourceNameError::Empty)?;
    if !(first.is_ascii_alphabetic() || first == '_') {
        return Err(ResourceNameError::InvalidStart { name: name.to_owned() });
    }
    if let Some(character) = name.chars().find(|&c| !(c.is_ascii_alphanumeric() || c == '_' || c == '.')) {
        return Err(ResourceNameError::InvalidCharacter { character });
    }
    if name.ends_with('.') {
        return Err(ResourceNameError::TrailingDot { name: name.to_owned() });
    }
    Ok(())
}

/// A resource entry name such as `app_name` or `Theme.App`.
#[nutype(
    validate(with = validate_resource_name, error = ResourceNameError),
    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Borrow, Deref, FromStr, TryFrom, Into)
)]
pub struct ResourceName(String);

impl ResourceName {
    /// Whether this name is valid for a file resource (`res/<type>/<name>.<ext>`):
    /// lowercase letters, digits, and `_`.
    pub fn is_file_name(&self) -> bool {
        self.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
    }
}

/// Why a resource reference such as `@string/app_name` was rejected.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ResourceRefError {
    #[error("reference {text:?} must start with `@`")]
    MissingSigil { text: String },
    #[error("reference {text:?} has an invalid hexadecimal ID")]
    InvalidId { text: String },
    #[error("package {package:?} cannot be referenced by name; only `android` can")]
    UnsupportedPackage { package: String },
    #[error("reference {text:?} must have the form `type/name`")]
    MissingType { text: String },
    #[error(transparent)]
    Type(#[from] UnknownResourceType),
    #[error(transparent)]
    Name(#[from] ResourceNameError),
}

/// The package that owns a named resource reference.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RefPackage {
    /// The application being built (`@type/name`).
    App,
    /// The Android framework (`@android:type/name`).
    Android,
}

/// A reference to a resource, either by numeric ID or by name.
///
/// Parses and prints the XML notation: `@string/app_name`,
/// `@android:style/Theme.Material`, or `@0x7f010000`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ResourceRef {
    Id(ResourceId),
    Named { package: RefPackage, ty: ResourceType, name: ResourceName },
}

impl ResourceRef {
    /// `@<ty>/<name>` in the application package.
    pub fn app(ty: ResourceType, name: impl Into<String>) -> Result<Self, ResourceNameError> {
        Ok(Self::Named { package: RefPackage::App, ty, name: ResourceName::try_new(name)? })
    }

    /// `@android:<ty>/<name>`.
    pub fn android(ty: ResourceType, name: impl Into<String>) -> Result<Self, ResourceNameError> {
        Ok(Self::Named { package: RefPackage::Android, ty, name: ResourceName::try_new(name)? })
    }

    /// Parses the body of a reference after its `@` or `?` sigil. A body
    /// without `type/` uses `default_ty` (`?android:windowFullscreen` means `attr`).
    pub(crate) fn parse_body(body: &str, default_ty: Option<ResourceType>) -> Result<Self, ResourceRefError> {
        if let Some(hex) = body.strip_prefix("0x") {
            let id = u32::from_str_radix(hex, 16).map_err(|_| ResourceRefError::InvalidId { text: body.to_owned() })?;
            return Ok(Self::Id(ResourceId(id)));
        }
        let (package, rest) = match body.split_once(':') {
            Some(("android", rest)) => (RefPackage::Android, rest),
            Some((package, _)) => return Err(ResourceRefError::UnsupportedPackage { package: package.to_owned() }),
            None => (RefPackage::App, body),
        };
        let (ty, name) = match (rest.split_once('/'), default_ty) {
            (Some((ty, name)), _) => (ty.parse()?, name),
            (None, Some(ty)) => (ty, rest),
            (None, None) => return Err(ResourceRefError::MissingType { text: body.to_owned() }),
        };
        Ok(Self::Named { package, ty, name: ResourceName::try_new(name)? })
    }
}

impl From<ResourceId> for ResourceRef {
    fn from(id: ResourceId) -> Self {
        Self::Id(id)
    }
}

impl FromStr for ResourceRef {
    type Err = ResourceRefError;
    fn from_str(text: &str) -> Result<Self, ResourceRefError> {
        let body = text.strip_prefix('@').ok_or_else(|| ResourceRefError::MissingSigil { text: text.to_owned() })?;
        Self::parse_body(body, None)
    }
}

impl fmt::Display for ResourceRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Id(id) => write!(f, "@{id}"),
            Self::Named { package: RefPackage::App, ty, name } => write!(f, "@{ty}/{name}"),
            Self::Named { package: RefPackage::Android, ty, name } => write!(f, "@android:{ty}/{name}"),
        }
    }
}

/// Maps named application resources (`@type/name`) to their IDs.
///
/// Implemented by [`ResourceTable`](super::ResourceTable) and by
/// [`CompiledResources`](super::CompiledResources). `()` resolves nothing.
pub trait ResourceLookup {
    fn lookup(&self, ty: ResourceType, name: &ResourceName) -> Option<ResourceId>;
}

impl ResourceLookup for () {
    fn lookup(&self, _: ResourceType, _: &ResourceName) -> Option<ResourceId> {
        None
    }
}

impl<T: ResourceLookup + ?Sized> ResourceLookup for &T {
    fn lookup(&self, ty: ResourceType, name: &ResourceName) -> Option<ResourceId> {
        (**self).lookup(ty, name)
    }
}

/// Resolves `reference` against the framework and `app` resources.
pub fn resolve(reference: &ResourceRef, app: &impl ResourceLookup) -> Option<ResourceId> {
    match reference {
        ResourceRef::Id(id) => Some(*id),
        ResourceRef::Named { package: RefPackage::App, ty, name } => app.lookup(*ty, name),
        ResourceRef::Named { package: RefPackage::Android, .. } => crate::android::resolve(reference),
    }
}

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

    #[test]
    fn parses_and_prints_references() {
        for text in ["@string/app_name", "@android:style/Theme.Material.NoActionBar", "@0x7f020001"] {
            let r: ResourceRef = text.parse().unwrap();
            assert_eq!(r.to_string(), text);
        }
        assert_eq!(
            "string/app_name".parse::<ResourceRef>(),
            Err(ResourceRefError::MissingSigil { text: "string/app_name".into() })
        );
        assert_eq!(
            "@vendor:string/x".parse::<ResourceRef>(),
            Err(ResourceRefError::UnsupportedPackage { package: "vendor".into() })
        );
        assert_eq!(
            "@bogus/x".parse::<ResourceRef>(),
            Err(ResourceRefError::Type(UnknownResourceType { name: "bogus".into() }))
        );
        assert!(matches!("@string/1x".parse::<ResourceRef>(), Err(ResourceRefError::Name(_))));
    }

    #[test]
    fn resource_ids_unpack() {
        let id = ResourceId::new(0x7f, 0x02, 0x0003);
        assert_eq!((id.package(), id.type_id(), id.entry()), (0x7f, 2, 3));
        assert_eq!(id.to_string(), "0x7f020003");
    }
}