Skip to main content

apk/archive/
mod.rs

1//! The ZIP container, with the rules APKs add on top.
2//!
3//! Entry data is read and written by the [`zip`] crate. This module adds what
4//! APK tooling needs: one strict view of an archive's entries,
5//! deterministic and aligned writing, and the exact byte ranges that
6//! signature schemes cover ([`ApkLayout`]).
7
8mod layout;
9mod source;
10mod writer;
11
12use std::io::{self, Read, Seek, SeekFrom};
13
14use zip::ZipArchive;
15use zip::read::{ArchiveOffset, Config};
16
17pub use layout::ApkLayout;
18pub(crate) use layout::SIGNING_BLOCK_MAGIC;
19pub(crate) use source::ReadAt;
20pub(crate) use writer::ApkWriter;
21pub use writer::{Compression, EntryOptions};
22
23use crate::types::ArchivePathError;
24
25pub use crate::Content;
26pub use crate::digest::DigestAlgorithm;
27
28/// Whether an entry is covered by a JAR signature.
29pub(crate) fn is_signed_entry(name: &str) -> bool {
30    !name.ends_with('/') && !is_signature_file(name)
31}
32
33/// JAR signature files replaced when re-signing an archive.
34pub(crate) fn is_signature_file(name: &str) -> bool {
35    let Some(file) = name.strip_prefix("META-INF/").filter(|f| !f.contains('/')) else { return false };
36    let file = file.to_ascii_lowercase();
37    file == "manifest.mf"
38        || file.starts_with("sig-")
39        || [".sf", ".rsa", ".dsa", ".ec"].iter().any(|ext| file.ends_with(ext))
40}
41
42/// A classic ZIP limit that an archive would exceed.
43#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
44pub enum ZipLimit {
45    /// Archives and entries are limited to 4 GiB without ZIP64.
46    Size,
47    /// At most 65534 entries; `0xffff` is reserved for ZIP64.
48    EntryCount,
49}
50
51/// A malformed, unsupported, or unrepresentable ZIP archive.
52#[derive(Debug, thiserror::Error)]
53#[non_exhaustive]
54pub enum ZipError {
55    #[error(transparent)]
56    Io(#[from] io::Error),
57    #[error(transparent)]
58    Zip(#[from] zip::result::ZipError),
59    #[error("no end of central directory record found")]
60    MissingEndOfCentralDirectory,
61    #[error("ZIP64 archives are not supported by APK signature schemes")]
62    Zip64,
63    #[error("multi-disk archives are not supported")]
64    MultiDisk,
65    #[error("central directory at {offset:#x} with size {size} does not end at the EOCD record at {eocd:#x}")]
66    InconsistentCentralDirectory { offset: u64, size: u64, eocd: u64 },
67    #[error("the central directory reads differently to different ZIP parsers")]
68    AmbiguousCentralDirectory,
69    #[error("the first entry starts at offset {offset:#x}, not at the start of the file")]
70    DataBeforeEntries { offset: u64 },
71    #[error("entry {index}'s local header is malformed")]
72    BadLocalHeader { index: usize },
73    #[error("entry {index}'s data runs past the end of the entries, into the signing block or central directory")]
74    EntryOutOfBounds { index: usize },
75    #[error("APK signing block size fields disagree ({leading} vs {trailing})")]
76    SigningBlockSizeMismatch { leading: u64, trailing: u64 },
77    #[error("APK signing block of {size} bytes does not fit before the central directory")]
78    SigningBlockOutOfBounds { size: u64 },
79    #[error("entry {index} has a name that is not valid UTF-8")]
80    NonUtf8Name { index: usize },
81    #[error("entry name {name:?} is unsafe: {source}")]
82    UnsafeName { name: String, source: ArchivePathError },
83    #[error("the archive has no entry {name:?}")]
84    MissingEntry { name: String },
85    #[error("entry {name:?} decompresses to more than {limit} bytes")]
86    EntryTooLarge { name: String, limit: u64 },
87    #[error("entry {name:?} appears more than once")]
88    DuplicateEntry { name: String },
89    #[error("the central directory lists {listed} entries but only {distinct} distinct names")]
90    DuplicateNames { listed: u16, distinct: usize },
91    #[error("entry {name:?} is encrypted")]
92    EncryptedEntry { name: String },
93    #[error("archive exceeds the classic ZIP {limit:?} limit")]
94    LimitExceeded { limit: ZipLimit },
95    #[error("input {name:?} changed size while it was archived (expected {expected} bytes, read {actual})")]
96    SourceChanged { name: String, expected: u64, actual: u64 },
97}
98
99/// Opens a classic APK ZIP and checks header and layout consistency.
100///
101/// The first entry must start the file, and every entry's data must end
102/// before the signing block, so signatures cover all of it. Names must be
103/// unique, local headers must agree with the central directory, entries must
104/// not overlap, and the `zip` crate's view (which honors Unicode path and
105/// ZIP64 extra fields) must match the raw records Android reads.
106pub(crate) fn open<R: Read + Seek>(mut reader: R) -> Result<(ZipArchive<R>, ApkLayout), ZipError> {
107    let layout = ApkLayout::read(&mut reader)?;
108    let cd = &layout.central_directory;
109    let mut central_directory = vec![0; (cd.end - cd.start) as usize];
110    read_at(&mut reader, cd.start, &mut central_directory)?;
111    let records = central_records(&central_directory)?;
112    if records.len() != usize::from(layout.entry_count()) {
113        return Err(ZipError::AmbiguousCentralDirectory);
114    }
115    check_placement(&mut reader, &records, layout.entries_end)?;
116
117    let config = Config { archive_offset: ArchiveOffset::Known(0) };
118    let mut archive = ZipArchive::with_config(config, reader)?;
119    if archive.central_directory_start() != layout.central_directory.start {
120        return Err(ZipError::AmbiguousCentralDirectory);
121    }
122    // The `zip` crate collapses duplicate names into one entry.
123    if archive.len() != records.len() {
124        return Err(ZipError::DuplicateNames { listed: layout.entry_count(), distinct: archive.len() });
125    }
126    archive.validate_header_consistency()?;
127    for (index, record) in records.iter().enumerate() {
128        let entry = archive.by_index_data(index)?;
129        let agrees = entry.name_raw() == record.name
130            && entry.header_start() == u64::from(record.offset)
131            && entry.compressed_size() == u64::from(record.compressed_size)
132            && entry.size() == u64::from(record.size);
133        if !agrees {
134            return Err(ZipError::AmbiguousCentralDirectory);
135        }
136    }
137    Ok((archive, layout))
138}
139
140/// What a central directory record says about where its entry is.
141struct CentralRecord<'a> {
142    name: &'a [u8],
143    flags: u16,
144    compressed_size: u32,
145    size: u32,
146    offset: u32,
147}
148
149/// The records of a central directory, as Android reads them.
150fn central_records(mut central_directory: &[u8]) -> Result<Vec<CentralRecord<'_>>, ZipError> {
151    let mut records = Vec::new();
152    while !central_directory.is_empty() {
153        let fixed = central_directory.get(..46).filter(|fixed| fixed[..4] == *b"PK\x01\x02");
154        let fixed = fixed.ok_or(ZipError::AmbiguousCentralDirectory)?;
155        let u16_at = |at: usize| u16::from_le_bytes([fixed[at], fixed[at + 1]]);
156        let u32_at = |at: usize| u32::from_le_bytes([fixed[at], fixed[at + 1], fixed[at + 2], fixed[at + 3]]);
157        let [name_len, extra_len, comment_len] = [28, 30, 32].map(|at| usize::from(u16_at(at)));
158        let (compressed_size, size, offset) = (u32_at(20), u32_at(24), u32_at(42));
159        if [compressed_size, size, offset].contains(&u32::MAX) {
160            return Err(ZipError::Zip64);
161        }
162        let record_len = 46 + name_len + extra_len + comment_len;
163        let name = central_directory.get(46..46 + name_len).ok_or(ZipError::AmbiguousCentralDirectory)?;
164        records.push(CentralRecord { name, flags: u16_at(8), compressed_size, size, offset });
165        central_directory = central_directory.get(record_len..).ok_or(ZipError::AmbiguousCentralDirectory)?;
166    }
167    Ok(records)
168}
169
170/// Checks that the first entry starts the file and that every entry's data
171/// (and data descriptor) ends by `entries_end`.
172fn check_placement(
173    reader: &mut (impl Read + Seek),
174    records: &[CentralRecord<'_>],
175    entries_end: u64,
176) -> Result<(), ZipError> {
177    if let Some(first) = records.iter().map(|record| record.offset).min()
178        && first != 0
179    {
180        return Err(ZipError::DataBeforeEntries { offset: first.into() });
181    }
182    const DATA_DESCRIPTOR: u16 = 1 << 3;
183    for (index, record) in records.iter().enumerate() {
184        let start = u64::from(record.offset);
185        let mut local = [0; 30];
186        read_at(reader, start, &mut local).map_err(|_| ZipError::BadLocalHeader { index })?;
187        if local[..4] != *b"PK\x03\x04" {
188            return Err(ZipError::BadLocalHeader { index });
189        }
190        let variable = u64::from(u16::from_le_bytes([local[26], local[27]]))
191            + u64::from(u16::from_le_bytes([local[28], local[29]]));
192        let mut end = start + 30 + variable + u64::from(record.compressed_size);
193        if record.flags & DATA_DESCRIPTOR != 0 {
194            // A descriptor holds the CRC and sizes, after an optional signature.
195            let mut signature = [0; 4];
196            let signed =
197                end + 4 <= entries_end && read_at(reader, end, &mut signature).is_ok() && signature == *b"PK\x07\x08";
198            end += if signed { 16 } else { 12 };
199        }
200        if end > entries_end {
201            return Err(ZipError::EntryOutOfBounds { index });
202        }
203    }
204    Ok(())
205}
206
207fn read_at(reader: &mut (impl Read + Seek), offset: u64, buf: &mut [u8]) -> Result<(), ZipError> {
208    reader.seek(SeekFrom::Start(offset))?;
209    reader.read_exact(buf)?;
210    Ok(())
211}
212
213/// The name of entry `index`: its raw bytes, which Android reads as UTF-8.
214pub(crate) fn entry_name<R: Read + Seek>(archive: &ZipArchive<R>, index: usize) -> Result<String, ZipError> {
215    let file = archive.by_index_data(index)?;
216    String::from_utf8(file.name_raw().to_vec()).map_err(|_| ZipError::NonUtf8Name { index })
217}
218
219/// Reads and decompresses entry `name`, failing past `limit` bytes. Declared
220/// sizes are not trusted.
221pub(crate) fn read_entry<R: Read + Seek>(
222    archive: &mut ZipArchive<R>,
223    name: &str,
224    limit: u64,
225) -> Result<Vec<u8>, ZipError> {
226    let entry = match archive.by_name(name) {
227        Ok(entry) => entry,
228        Err(zip::result::ZipError::FileNotFound) => return Err(ZipError::MissingEntry { name: name.to_owned() }),
229        Err(error) => return Err(error.into()),
230    };
231    read_capped(entry, name, limit)
232}
233
234/// Decompresses `reader` into memory, failing past `limit` bytes.
235pub(crate) fn read_capped(reader: impl Read, name: &str, limit: u64) -> Result<Vec<u8>, ZipError> {
236    const INITIAL_CAPACITY: u64 = 1 << 20;
237    let mut bytes = Vec::with_capacity(limit.min(INITIAL_CAPACITY) as usize);
238    reader.take(limit.saturating_add(1)).read_to_end(&mut bytes)?;
239    if bytes.len() as u64 > limit {
240        return Err(ZipError::EntryTooLarge { name: name.to_owned(), limit });
241    }
242    Ok(bytes)
243}
244
245#[cfg(test)]
246mod tests {
247    use std::io::Cursor;
248
249    use zip::write::SimpleFileOptions;
250
251    use super::*;
252    use crate::types::ArchivePath;
253
254    fn archive() -> Vec<u8> {
255        let mut writer = ApkWriter::new(Cursor::new(Vec::new()));
256        writer.add(&ArchivePath::known("a.txt"), b"alpha", EntryOptions::STORED).unwrap();
257        writer.add(&ArchivePath::known("b.txt"), b"beta", EntryOptions::DEFLATED).unwrap();
258        writer.finish().unwrap().into_inner()
259    }
260
261    /// The offsets of the central directory records in `bytes`.
262    fn record_offsets(bytes: &[u8]) -> Vec<usize> {
263        let layout = ApkLayout::read(&mut Cursor::new(bytes)).unwrap();
264        let mut offsets = Vec::new();
265        let mut at = layout.central_directory.start as usize;
266        while bytes[at..at + 4] == *b"PK\x01\x02" {
267            offsets.push(at);
268            let len = |field: usize| usize::from(u16::from_le_bytes([bytes[at + field], bytes[at + field + 1]]));
269            at += 46 + len(28) + len(30) + len(32);
270        }
271        offsets
272    }
273
274    fn add_u32(bytes: &mut [u8], at: usize, delta: u32) {
275        let value = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) + delta;
276        bytes[at..at + 4].copy_from_slice(&value.to_le_bytes());
277    }
278
279    #[test]
280    fn opens_well_formed_archives() {
281        let (archive, layout) = open(Cursor::new(archive())).unwrap();
282        assert_eq!((archive.len(), layout.entry_count()), (2, 2));
283    }
284
285    #[test]
286    fn data_before_the_first_entry_is_rejected() {
287        // A consistent archive behind a prefix, as in the Janus attack.
288        let original = archive();
289        let prefix = b"dex\n035\0";
290        let mut bytes = [prefix.as_slice(), &original].concat();
291        let shift = prefix.len() as u32;
292        for record in record_offsets(&original) {
293            add_u32(&mut bytes, record + prefix.len() + 42, shift);
294        }
295        let eocd = bytes.len() - 22;
296        add_u32(&mut bytes, eocd + 16, shift);
297        assert!(matches!(open(Cursor::new(&bytes)), Err(ZipError::DataBeforeEntries { offset: 8 })));
298    }
299
300    #[test]
301    fn entry_data_must_end_before_the_central_directory() {
302        let mut bytes = archive();
303        let last = *record_offsets(&bytes).last().unwrap();
304        add_u32(&mut bytes, last + 20, 1000);
305        assert!(matches!(open(Cursor::new(&bytes)), Err(ZipError::EntryOutOfBounds { index: 1 })));
306    }
307
308    #[test]
309    fn unicode_path_fields_cannot_rename_entries() {
310        let crc32 = |bytes: &[u8]| {
311            let mut crc = !0u32;
312            for &byte in bytes {
313                crc ^= u32::from(byte);
314                for _ in 0..8 {
315                    crc = if crc & 1 != 0 { (crc >> 1) ^ 0xedb8_8320 } else { crc >> 1 };
316                }
317            }
318            !crc
319        };
320        // An Info-ZIP Unicode path field naming `evil.txt` for the raw name `a.txt`.
321        let field = [&[1][..], &crc32(b"a.txt").to_le_bytes(), b"evil.txt"].concat();
322        let mut options = SimpleFileOptions::default().into_full_options();
323        options.add_extra_field(0x7075, &field, false).unwrap();
324        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
325        zip.start_file("a.txt", options).unwrap();
326        io::Write::write_all(&mut zip, b"alpha").unwrap();
327        let bytes = zip.finish().unwrap().into_inner();
328        assert!(matches!(open(Cursor::new(&bytes)), Err(ZipError::AmbiguousCentralDirectory)));
329    }
330}