rust-apk 1.0.0

Build, align, sign, verify, and inspect Android APKs in pure Rust
Documentation
//! The ZIP container, with the rules APKs add on top.
//!
//! Entry data is read and written by the [`zip`] crate. This module adds what
//! APK tooling needs: one strict view of an archive's entries,
//! deterministic and aligned writing, and the exact byte ranges that
//! signature schemes cover ([`ApkLayout`]).

mod layout;
mod source;
mod writer;

use std::io::{self, Read, Seek, SeekFrom};

use zip::ZipArchive;
use zip::read::{ArchiveOffset, Config};

pub use layout::ApkLayout;
pub(crate) use layout::SIGNING_BLOCK_MAGIC;
pub(crate) use source::ReadAt;
pub(crate) use writer::ApkWriter;
pub use writer::{Compression, EntryOptions};

use crate::types::ArchivePathError;

pub use crate::Content;
pub use crate::digest::DigestAlgorithm;

/// Whether an entry is covered by a JAR signature.
pub(crate) fn is_signed_entry(name: &str) -> bool {
    !name.ends_with('/') && !is_signature_file(name)
}

/// JAR signature files replaced when re-signing an archive.
pub(crate) fn is_signature_file(name: &str) -> bool {
    let Some(file) = name.strip_prefix("META-INF/").filter(|f| !f.contains('/')) else { return false };
    let file = file.to_ascii_lowercase();
    file == "manifest.mf"
        || file.starts_with("sig-")
        || [".sf", ".rsa", ".dsa", ".ec"].iter().any(|ext| file.ends_with(ext))
}

/// A classic ZIP limit that an archive would exceed.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ZipLimit {
    /// Archives and entries are limited to 4 GiB without ZIP64.
    Size,
    /// At most 65534 entries; `0xffff` is reserved for ZIP64.
    EntryCount,
}

/// A malformed, unsupported, or unrepresentable ZIP archive.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ZipError {
    #[error(transparent)]
    Io(#[from] io::Error),
    #[error(transparent)]
    Zip(#[from] zip::result::ZipError),
    #[error("no end of central directory record found")]
    MissingEndOfCentralDirectory,
    #[error("ZIP64 archives are not supported by APK signature schemes")]
    Zip64,
    #[error("multi-disk archives are not supported")]
    MultiDisk,
    #[error("central directory at {offset:#x} with size {size} does not end at the EOCD record at {eocd:#x}")]
    InconsistentCentralDirectory { offset: u64, size: u64, eocd: u64 },
    #[error("the central directory reads differently to different ZIP parsers")]
    AmbiguousCentralDirectory,
    #[error("the first entry starts at offset {offset:#x}, not at the start of the file")]
    DataBeforeEntries { offset: u64 },
    #[error("entry {index}'s local header is malformed")]
    BadLocalHeader { index: usize },
    #[error("entry {index}'s data runs past the end of the entries, into the signing block or central directory")]
    EntryOutOfBounds { index: usize },
    #[error("APK signing block size fields disagree ({leading} vs {trailing})")]
    SigningBlockSizeMismatch { leading: u64, trailing: u64 },
    #[error("APK signing block of {size} bytes does not fit before the central directory")]
    SigningBlockOutOfBounds { size: u64 },
    #[error("entry {index} has a name that is not valid UTF-8")]
    NonUtf8Name { index: usize },
    #[error("entry name {name:?} is unsafe: {source}")]
    UnsafeName { name: String, source: ArchivePathError },
    #[error("the archive has no entry {name:?}")]
    MissingEntry { name: String },
    #[error("entry {name:?} decompresses to more than {limit} bytes")]
    EntryTooLarge { name: String, limit: u64 },
    #[error("entry {name:?} appears more than once")]
    DuplicateEntry { name: String },
    #[error("the central directory lists {listed} entries but only {distinct} distinct names")]
    DuplicateNames { listed: u16, distinct: usize },
    #[error("entry {name:?} is encrypted")]
    EncryptedEntry { name: String },
    #[error("archive exceeds the classic ZIP {limit:?} limit")]
    LimitExceeded { limit: ZipLimit },
    #[error("input {name:?} changed size while it was archived (expected {expected} bytes, read {actual})")]
    SourceChanged { name: String, expected: u64, actual: u64 },
}

/// Opens a classic APK ZIP and checks header and layout consistency.
///
/// The first entry must start the file, and every entry's data must end
/// before the signing block, so signatures cover all of it. Names must be
/// unique, local headers must agree with the central directory, entries must
/// not overlap, and the `zip` crate's view (which honors Unicode path and
/// ZIP64 extra fields) must match the raw records Android reads.
pub(crate) fn open<R: Read + Seek>(mut reader: R) -> Result<(ZipArchive<R>, ApkLayout), ZipError> {
    let layout = ApkLayout::read(&mut reader)?;
    let cd = &layout.central_directory;
    let mut central_directory = vec![0; (cd.end - cd.start) as usize];
    read_at(&mut reader, cd.start, &mut central_directory)?;
    let records = central_records(&central_directory)?;
    if records.len() != usize::from(layout.entry_count()) {
        return Err(ZipError::AmbiguousCentralDirectory);
    }
    check_placement(&mut reader, &records, layout.entries_end)?;

    let config = Config { archive_offset: ArchiveOffset::Known(0) };
    let mut archive = ZipArchive::with_config(config, reader)?;
    if archive.central_directory_start() != layout.central_directory.start {
        return Err(ZipError::AmbiguousCentralDirectory);
    }
    // The `zip` crate collapses duplicate names into one entry.
    if archive.len() != records.len() {
        return Err(ZipError::DuplicateNames { listed: layout.entry_count(), distinct: archive.len() });
    }
    archive.validate_header_consistency()?;
    for (index, record) in records.iter().enumerate() {
        let entry = archive.by_index_data(index)?;
        let agrees = entry.name_raw() == record.name
            && entry.header_start() == u64::from(record.offset)
            && entry.compressed_size() == u64::from(record.compressed_size)
            && entry.size() == u64::from(record.size);
        if !agrees {
            return Err(ZipError::AmbiguousCentralDirectory);
        }
    }
    Ok((archive, layout))
}

/// What a central directory record says about where its entry is.
struct CentralRecord<'a> {
    name: &'a [u8],
    flags: u16,
    compressed_size: u32,
    size: u32,
    offset: u32,
}

/// The records of a central directory, as Android reads them.
fn central_records(mut central_directory: &[u8]) -> Result<Vec<CentralRecord<'_>>, ZipError> {
    let mut records = Vec::new();
    while !central_directory.is_empty() {
        let fixed = central_directory.get(..46).filter(|fixed| fixed[..4] == *b"PK\x01\x02");
        let fixed = fixed.ok_or(ZipError::AmbiguousCentralDirectory)?;
        let u16_at = |at: usize| u16::from_le_bytes([fixed[at], fixed[at + 1]]);
        let u32_at = |at: usize| u32::from_le_bytes([fixed[at], fixed[at + 1], fixed[at + 2], fixed[at + 3]]);
        let [name_len, extra_len, comment_len] = [28, 30, 32].map(|at| usize::from(u16_at(at)));
        let (compressed_size, size, offset) = (u32_at(20), u32_at(24), u32_at(42));
        if [compressed_size, size, offset].contains(&u32::MAX) {
            return Err(ZipError::Zip64);
        }
        let record_len = 46 + name_len + extra_len + comment_len;
        let name = central_directory.get(46..46 + name_len).ok_or(ZipError::AmbiguousCentralDirectory)?;
        records.push(CentralRecord { name, flags: u16_at(8), compressed_size, size, offset });
        central_directory = central_directory.get(record_len..).ok_or(ZipError::AmbiguousCentralDirectory)?;
    }
    Ok(records)
}

/// Checks that the first entry starts the file and that every entry's data
/// (and data descriptor) ends by `entries_end`.
fn check_placement(
    reader: &mut (impl Read + Seek),
    records: &[CentralRecord<'_>],
    entries_end: u64,
) -> Result<(), ZipError> {
    if let Some(first) = records.iter().map(|record| record.offset).min()
        && first != 0
    {
        return Err(ZipError::DataBeforeEntries { offset: first.into() });
    }
    const DATA_DESCRIPTOR: u16 = 1 << 3;
    for (index, record) in records.iter().enumerate() {
        let start = u64::from(record.offset);
        let mut local = [0; 30];
        read_at(reader, start, &mut local).map_err(|_| ZipError::BadLocalHeader { index })?;
        if local[..4] != *b"PK\x03\x04" {
            return Err(ZipError::BadLocalHeader { index });
        }
        let variable = u64::from(u16::from_le_bytes([local[26], local[27]]))
            + u64::from(u16::from_le_bytes([local[28], local[29]]));
        let mut end = start + 30 + variable + u64::from(record.compressed_size);
        if record.flags & DATA_DESCRIPTOR != 0 {
            // A descriptor holds the CRC and sizes, after an optional signature.
            let mut signature = [0; 4];
            let signed =
                end + 4 <= entries_end && read_at(reader, end, &mut signature).is_ok() && signature == *b"PK\x07\x08";
            end += if signed { 16 } else { 12 };
        }
        if end > entries_end {
            return Err(ZipError::EntryOutOfBounds { index });
        }
    }
    Ok(())
}

fn read_at(reader: &mut (impl Read + Seek), offset: u64, buf: &mut [u8]) -> Result<(), ZipError> {
    reader.seek(SeekFrom::Start(offset))?;
    reader.read_exact(buf)?;
    Ok(())
}

/// The name of entry `index`: its raw bytes, which Android reads as UTF-8.
pub(crate) fn entry_name<R: Read + Seek>(archive: &ZipArchive<R>, index: usize) -> Result<String, ZipError> {
    let file = archive.by_index_data(index)?;
    String::from_utf8(file.name_raw().to_vec()).map_err(|_| ZipError::NonUtf8Name { index })
}

/// Reads and decompresses entry `name`, failing past `limit` bytes. Declared
/// sizes are not trusted.
pub(crate) fn read_entry<R: Read + Seek>(
    archive: &mut ZipArchive<R>,
    name: &str,
    limit: u64,
) -> Result<Vec<u8>, ZipError> {
    let entry = match archive.by_name(name) {
        Ok(entry) => entry,
        Err(zip::result::ZipError::FileNotFound) => return Err(ZipError::MissingEntry { name: name.to_owned() }),
        Err(error) => return Err(error.into()),
    };
    read_capped(entry, name, limit)
}

/// Decompresses `reader` into memory, failing past `limit` bytes.
pub(crate) fn read_capped(reader: impl Read, name: &str, limit: u64) -> Result<Vec<u8>, ZipError> {
    const INITIAL_CAPACITY: u64 = 1 << 20;
    let mut bytes = Vec::with_capacity(limit.min(INITIAL_CAPACITY) as usize);
    reader.take(limit.saturating_add(1)).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > limit {
        return Err(ZipError::EntryTooLarge { name: name.to_owned(), limit });
    }
    Ok(bytes)
}

#[cfg(test)]
mod tests {
    use std::io::Cursor;

    use zip::write::SimpleFileOptions;

    use super::*;
    use crate::types::ArchivePath;

    fn archive() -> Vec<u8> {
        let mut writer = ApkWriter::new(Cursor::new(Vec::new()));
        writer.add(&ArchivePath::known("a.txt"), b"alpha", EntryOptions::STORED).unwrap();
        writer.add(&ArchivePath::known("b.txt"), b"beta", EntryOptions::DEFLATED).unwrap();
        writer.finish().unwrap().into_inner()
    }

    /// The offsets of the central directory records in `bytes`.
    fn record_offsets(bytes: &[u8]) -> Vec<usize> {
        let layout = ApkLayout::read(&mut Cursor::new(bytes)).unwrap();
        let mut offsets = Vec::new();
        let mut at = layout.central_directory.start as usize;
        while bytes[at..at + 4] == *b"PK\x01\x02" {
            offsets.push(at);
            let len = |field: usize| usize::from(u16::from_le_bytes([bytes[at + field], bytes[at + field + 1]]));
            at += 46 + len(28) + len(30) + len(32);
        }
        offsets
    }

    fn add_u32(bytes: &mut [u8], at: usize, delta: u32) {
        let value = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) + delta;
        bytes[at..at + 4].copy_from_slice(&value.to_le_bytes());
    }

    #[test]
    fn opens_well_formed_archives() {
        let (archive, layout) = open(Cursor::new(archive())).unwrap();
        assert_eq!((archive.len(), layout.entry_count()), (2, 2));
    }

    #[test]
    fn data_before_the_first_entry_is_rejected() {
        // A consistent archive behind a prefix, as in the Janus attack.
        let original = archive();
        let prefix = b"dex\n035\0";
        let mut bytes = [prefix.as_slice(), &original].concat();
        let shift = prefix.len() as u32;
        for record in record_offsets(&original) {
            add_u32(&mut bytes, record + prefix.len() + 42, shift);
        }
        let eocd = bytes.len() - 22;
        add_u32(&mut bytes, eocd + 16, shift);
        assert!(matches!(open(Cursor::new(&bytes)), Err(ZipError::DataBeforeEntries { offset: 8 })));
    }

    #[test]
    fn entry_data_must_end_before_the_central_directory() {
        let mut bytes = archive();
        let last = *record_offsets(&bytes).last().unwrap();
        add_u32(&mut bytes, last + 20, 1000);
        assert!(matches!(open(Cursor::new(&bytes)), Err(ZipError::EntryOutOfBounds { index: 1 })));
    }

    #[test]
    fn unicode_path_fields_cannot_rename_entries() {
        let crc32 = |bytes: &[u8]| {
            let mut crc = !0u32;
            for &byte in bytes {
                crc ^= u32::from(byte);
                for _ in 0..8 {
                    crc = if crc & 1 != 0 { (crc >> 1) ^ 0xedb8_8320 } else { crc >> 1 };
                }
            }
            !crc
        };
        // An Info-ZIP Unicode path field naming `evil.txt` for the raw name `a.txt`.
        let field = [&[1][..], &crc32(b"a.txt").to_le_bytes(), b"evil.txt"].concat();
        let mut options = SimpleFileOptions::default().into_full_options();
        options.add_extra_field(0x7075, &field, false).unwrap();
        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
        zip.start_file("a.txt", options).unwrap();
        io::Write::write_all(&mut zip, b"alpha").unwrap();
        let bytes = zip.finish().unwrap().into_inner();
        assert!(matches!(open(Cursor::new(&bytes)), Err(ZipError::AmbiguousCentralDirectory)));
    }
}