rust-apk 1.0.0

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

/// The longest name a classic ZIP entry can have.
pub const MAX_ARCHIVE_PATH_LEN: usize = u16::MAX as usize;

/// Why an [`ArchivePath`] was rejected.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ArchivePathError {
    #[error("archive path is empty")]
    Empty,
    #[error("archive path is {len} bytes; ZIP entry names are limited to {max}", max = MAX_ARCHIVE_PATH_LEN)]
    TooLong { len: usize },
    #[error("archive path contains forbidden character {character:?}")]
    ForbiddenCharacter { character: char },
    #[error("archive path component {component:?} is empty or a `.`/`..` traversal")]
    InvalidComponent { component: String },
    #[error("archive path begins with drive-like component {component:?}")]
    DriveLikeComponent { component: String },
}

fn validate_archive_path(value: &str) -> Result<(), ArchivePathError> {
    if value.is_empty() {
        return Err(ArchivePathError::Empty);
    }
    if value.len() > MAX_ARCHIVE_PATH_LEN {
        return Err(ArchivePathError::TooLong { len: value.len() });
    }
    if let Some(character) = value.chars().find(|&c| c.is_control() || c == '\\') {
        return Err(ArchivePathError::ForbiddenCharacter { character });
    }
    if let Some(component) = value.split('/').find(|c| c.is_empty() || *c == "." || *c == "..") {
        return Err(ArchivePathError::InvalidComponent { component: component.to_owned() });
    }
    match value.split('/').next() {
        Some(first) if first.contains(':') => Err(ArchivePathError::DriveLikeComponent { component: first.to_owned() }),
        _ => Ok(()),
    }
}

/// A safe, relative ZIP entry path such as `assets/levels/1.bin`.
///
/// Paths use `/` separators and cannot be absolute or contain empty, `.`, or
/// `..` components, backslashes, control characters, or a drive-like first
/// component, so extracting an archive cannot write outside its directory.
#[nutype(
    validate(with = validate_archive_path, error = ArchivePathError),
    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
)]
pub struct ArchivePath(String);

impl ArchivePath {
    /// `AndroidManifest.xml`.
    pub fn manifest() -> Self {
        Self::known("AndroidManifest.xml")
    }

    /// `resources.arsc`.
    pub fn resource_table() -> Self {
        Self::known("resources.arsc")
    }

    pub(crate) fn known(path: &str) -> Self {
        Self::try_new(path).expect("built-in archive path is valid")
    }

    /// Joins a relative path below this one.
    pub fn join(&self, relative: &str) -> Result<Self, ArchivePathError> {
        Self::try_new(format!("{self}/{relative}"))
    }

    /// The final path component.
    pub fn file_name(&self) -> &str {
        self.rsplit('/').next().unwrap_or_default()
    }

    /// The extension of the file name, without the dot.
    pub fn extension(&self) -> Option<&str> {
        let name = self.file_name();
        name.rfind('.').filter(|&dot| dot > 0).map(|dot| &name[dot + 1..])
    }

    /// Whether this path lies inside directory `dir` (ASCII case-insensitive).
    pub fn is_in(&self, dir: &str) -> bool {
        let path = self.as_str();
        path.len() > dir.len() && path.as_bytes()[dir.len()] == b'/' && path[..dir.len()].eq_ignore_ascii_case(dir)
    }

    pub fn as_str(&self) -> &str {
        self.as_ref()
    }
}

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

    #[test]
    fn rejects_traversal_and_platform_specific_paths() {
        use ArchivePathError::*;
        let cases = [
            ("", Empty),
            ("/abs", InvalidComponent { component: "".into() }),
            ("a//b", InvalidComponent { component: "".into() }),
            ("a/./b", InvalidComponent { component: ".".into() }),
            ("../x", InvalidComponent { component: "..".into() }),
            ("dir/", InvalidComponent { component: "".into() }),
            ("a\\b", ForbiddenCharacter { character: '\\' }),
            ("a\nb", ForbiddenCharacter { character: '\n' }),
            ("C:/x", DriveLikeComponent { component: "C:".into() }),
        ];
        for (path, error) in cases {
            assert_eq!(ArchivePath::try_new(path), Err(error), "{path:?}");
        }
        assert_eq!(ArchivePath::try_new("a".repeat(65_536)), Err(TooLong { len: 65_536 }));
    }

    #[test]
    fn accepts_and_inspects_regular_paths() {
        let path = ArchivePath::try_new("assets/a:b/level.tar.gz").unwrap();
        assert_eq!(path.file_name(), "level.tar.gz");
        assert_eq!(path.extension(), Some("gz"));
        assert!(path.is_in("assets"));
        assert!(!path.is_in("asset"));
        assert!(ArchivePath::try_new("META-INF/CERT.SF").unwrap().is_in("meta-inf"));
        assert_eq!(ArchivePath::try_new(".hidden").unwrap().extension(), None);
    }
}