rust-apk 1.0.0

Build, align, sign, verify, and inspect Android APKs in pure Rust
Documentation
//! Locating an APK's sections: ZIP entries, APK signing block, central
//! directory, and end of central directory record.

use std::io::{Cursor, Read, Seek, SeekFrom};
use std::ops::Range;

use binrw::{BinRead, BinWrite, binrw};

use super::{ZipError, ZipLimit};

/// The magic that ends an APK signing block.
pub(crate) const SIGNING_BLOCK_MAGIC: [u8; 16] = *b"APK Sig Block 42";
/// The block's trailing size field and magic.
const SIGNING_BLOCK_FOOTER: u64 = 24;
/// The ZIP64 end of central directory locator, which precedes the EOCD
/// record of a ZIP64 archive.
const ZIP64_LOCATOR: (u64, [u8; 4]) = (20, *b"PK\x06\x07");

/// The end of central directory record (APPNOTE 4.3.16).
#[binrw]
#[brw(little, magic = b"PK\x05\x06")]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct EndOfCentralDirectory {
    disk: u16,
    central_directory_disk: u16,
    disk_entries: u16,
    total_entries: u16,
    central_directory_size: u32,
    central_directory_offset: u32,
    #[br(temp)]
    #[bw(calc = comment.len() as u16)]
    comment_len: u16,
    #[br(count = comment_len)]
    comment: Vec<u8>,
}

impl EndOfCentralDirectory {
    const FIXED_SIZE: usize = 22;
    const SIGNATURE: [u8; 4] = *b"PK\x05\x06";
}

/// The byte ranges of an APK's sections.
///
/// ```text
/// [ ZIP entries | APK signing block? | central directory | EOCD ]
///   0..entries_end                     central_directory   eocd_offset..
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ApkLayout {
    /// Where ZIP entry data ends: the signing block's start, or the central
    /// directory's when unsigned.
    pub entries_end: u64,
    /// The APK signing block, including its size fields and magic.
    pub signing_block: Option<Range<u64>>,
    pub central_directory: Range<u64>,
    pub eocd_offset: u64,
    eocd: EndOfCentralDirectory,
}

impl ApkLayout {
    /// Reads the layout of the archive in `source`. ZIP64 and multi-disk
    /// archives, which APK signature schemes do not support, are rejected.
    pub fn read(source: &mut (impl Read + Seek)) -> Result<Self, ZipError> {
        let (eocd_offset, eocd) = find_eocd(source)?;
        let (locator_len, locator) = ZIP64_LOCATOR;
        if eocd_offset >= locator_len {
            let mut signature = [0; 4];
            read_at(source, eocd_offset - locator_len, &mut signature)?;
            if signature == locator {
                return Err(ZipError::Zip64);
            }
        }
        if eocd.total_entries == u16::MAX
            || eocd.central_directory_offset == u32::MAX
            || eocd.central_directory_size == u32::MAX
        {
            return Err(ZipError::Zip64);
        }
        if eocd.disk != 0 || eocd.central_directory_disk != 0 || eocd.disk_entries != eocd.total_entries {
            return Err(ZipError::MultiDisk);
        }
        let cd_start = u64::from(eocd.central_directory_offset);
        let signing_block = find_signing_block(source, cd_start)?;
        let entries_end = signing_block.as_ref().map_or(cd_start, |block| block.start);
        Ok(Self { entries_end, signing_block, central_directory: cd_start..eocd_offset, eocd_offset, eocd })
    }

    /// The number of entries the EOCD record declares.
    pub fn entry_count(&self) -> u16 {
        self.eocd.total_entries
    }

    /// The archive comment.
    pub fn comment(&self) -> &[u8] {
        &self.eocd.comment
    }

    /// The EOCD record as it would read with the central directory at
    /// `offset`: how signature schemes digest it, and how a signing block is
    /// inserted.
    pub(crate) fn eocd_with_central_directory_at(&self, offset: u64) -> Result<Vec<u8>, ZipError> {
        let offset = u32::try_from(offset)
            .ok()
            .filter(|&offset| offset != u32::MAX)
            .ok_or(ZipError::LimitExceeded { limit: ZipLimit::Size })?;
        let eocd = EndOfCentralDirectory { central_directory_offset: offset, ..self.eocd.clone() };
        let mut bytes = Cursor::new(Vec::with_capacity(EndOfCentralDirectory::FIXED_SIZE + eocd.comment.len()));
        eocd.write(&mut bytes).expect("an EOCD record read from an archive encodes into a Vec");
        Ok(bytes.into_inner())
    }
}

/// Finds the EOCD record: the last one whose comment ends exactly at the end
/// of the file and whose central directory ends where the record starts.
/// Comments that imitate a record are skipped.
fn find_eocd(source: &mut (impl Read + Seek)) -> Result<(u64, EndOfCentralDirectory), ZipError> {
    let len = source.seek(SeekFrom::End(0))?;
    let fixed = EndOfCentralDirectory::FIXED_SIZE as u64;
    if len < fixed {
        return Err(ZipError::MissingEndOfCentralDirectory);
    }
    let tail_len = len.min(fixed + u64::from(u16::MAX));
    let tail_start = len - tail_len;
    let mut tail = vec![0; tail_len as usize];
    read_at(source, tail_start, &mut tail)?;
    let mut inconsistent = None;
    for pos in (0..=tail.len() - EndOfCentralDirectory::FIXED_SIZE).rev() {
        if tail[pos..pos + 4] != EndOfCentralDirectory::SIGNATURE {
            continue;
        }
        let comment_len = usize::from(u16::from_le_bytes([tail[pos + 20], tail[pos + 21]]));
        if pos + EndOfCentralDirectory::FIXED_SIZE + comment_len != tail.len() {
            continue;
        }
        let eocd = EndOfCentralDirectory::read(&mut Cursor::new(&tail[pos..]))
            .expect("a signature-prefixed record that spans the tail parses");
        let offset = tail_start + pos as u64;
        let (cd_offset, cd_size) = (u64::from(eocd.central_directory_offset), u64::from(eocd.central_directory_size));
        if cd_offset + cd_size == offset {
            return Ok((offset, eocd));
        }
        inconsistent.get_or_insert(ZipError::InconsistentCentralDirectory {
            offset: cd_offset,
            size: cd_size,
            eocd: offset,
        });
    }
    Err(inconsistent.unwrap_or(ZipError::MissingEndOfCentralDirectory))
}

/// Finds the APK signing block that immediately precedes the central directory.
fn find_signing_block(source: &mut (impl Read + Seek), cd_start: u64) -> Result<Option<Range<u64>>, ZipError> {
    if cd_start < SIGNING_BLOCK_FOOTER + 8 {
        return Ok(None);
    }
    let mut footer = [0u8; SIGNING_BLOCK_FOOTER as usize];
    read_at(source, cd_start - SIGNING_BLOCK_FOOTER, &mut footer)?;
    if footer[8..] != SIGNING_BLOCK_MAGIC {
        return Ok(None);
    }
    let size = u64::from_le_bytes(footer[..8].try_into().expect("eight bytes"));
    let start = cd_start
        .checked_sub(size)
        .and_then(|start| start.checked_sub(8))
        .filter(|_| size >= SIGNING_BLOCK_FOOTER)
        .ok_or(ZipError::SigningBlockOutOfBounds { size })?;
    let mut leading = [0u8; 8];
    read_at(source, start, &mut leading)?;
    let leading = u64::from_le_bytes(leading);
    if leading != size {
        return Err(ZipError::SigningBlockSizeMismatch { leading, trailing: size });
    }
    Ok(Some(start..cd_start))
}

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

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

    fn read_layout(bytes: &[u8]) -> Result<ApkLayout, ZipError> {
        ApkLayout::read(&mut Cursor::new(bytes))
    }

    /// An archive of `entries` (bytes standing in for entry data and a
    /// central directory) whose EOCD has `comment`.
    fn archive(entries: &[u8], comment: &[u8]) -> Vec<u8> {
        let eocd = EndOfCentralDirectory {
            central_directory_size: entries.len() as u32,
            comment: comment.to_vec(),
            ..Default::default()
        };
        let mut bytes = Cursor::new(entries.to_vec());
        bytes.set_position(entries.len() as u64);
        eocd.write(&mut bytes).unwrap();
        bytes.into_inner()
    }

    #[test]
    fn finds_the_eocd_behind_a_comment_that_imitates_one() {
        let mut fake = EndOfCentralDirectory::SIGNATURE.to_vec();
        fake.resize(EndOfCentralDirectory::FIXED_SIZE, 0);
        let bytes = archive(b"central", &fake);
        let layout = read_layout(&bytes).unwrap();
        assert_eq!((layout.central_directory.clone(), layout.eocd_offset), (0..7, 7));
        assert_eq!(layout.comment(), fake);
    }

    #[test]
    fn rejects_unsupported_and_inconsistent_archives() {
        let eocd = 7;
        let mut bytes = archive(b"central", b"");
        bytes[eocd + 4] = 1;
        assert!(matches!(read_layout(&bytes), Err(ZipError::MultiDisk)));

        let mut bytes = archive(b"central", b"");
        bytes[eocd + 16] = 9; // central directory offset
        assert!(matches!(read_layout(&bytes), Err(ZipError::InconsistentCentralDirectory { .. })));

        let mut bytes = archive(b"central", b"");
        bytes[eocd + 10..eocd + 12].copy_from_slice(&u16::MAX.to_le_bytes());
        assert!(matches!(read_layout(&bytes), Err(ZipError::Zip64)));

        assert!(matches!(read_layout(&[0u8; 21]), Err(ZipError::MissingEndOfCentralDirectory)));
    }

    #[test]
    fn validates_signing_block_size_fields() {
        let block = |leading: u64, trailing: u64| {
            let mut bytes = leading.to_le_bytes().to_vec();
            bytes.extend([0; 8]);
            bytes.extend(trailing.to_le_bytes());
            bytes.extend(SIGNING_BLOCK_MAGIC);
            bytes
        };
        let with_block = |block: Vec<u8>| {
            let start = block.len();
            let mut bytes = archive(&[block, b"central".to_vec()].concat(), b"");
            let eocd = bytes.len() - EndOfCentralDirectory::FIXED_SIZE;
            bytes[eocd + 12..eocd + 16].copy_from_slice(&7u32.to_le_bytes());
            bytes[eocd + 16..eocd + 20].copy_from_slice(&(start as u32).to_le_bytes());
            bytes
        };
        let size = 8 + 24;
        let layout = read_layout(&with_block(block(size, size))).unwrap();
        assert_eq!((layout.signing_block, layout.entries_end), (Some(0..40), 0));
        assert_eq!(layout.central_directory, 40..47);
        assert!(matches!(
            read_layout(&with_block(block(size + 1, size))),
            Err(ZipError::SigningBlockSizeMismatch { leading: 33, trailing: 32 })
        ));
        assert!(matches!(
            read_layout(&with_block(block(size, 1 << 40))),
            Err(ZipError::SigningBlockOutOfBounds { .. })
        ));
    }

    #[test]
    fn patches_the_central_directory_offset() {
        let layout = read_layout(&archive(b"central", b"note")).unwrap();
        let patched = layout.eocd_with_central_directory_at(0x1234).unwrap();
        assert_eq!(patched[16..20], 0x1234u32.to_le_bytes());
        assert_eq!(&patched[22..], b"note");
        assert!(layout.eocd_with_central_directory_at(u64::from(u32::MAX)).is_err());
    }
}