use std::sync::Arc;
use ahash::AHashMap;
use log::warn;
use winnow::binary::{le_u16, le_u32};
use winnow::combinator::repeat;
use winnow::error::{ErrMode, Needed};
use winnow::prelude::*;
use winnow::token::take;
use crate::anomaly::ZipAnomaly;
#[derive(Debug)]
pub(crate) struct CentralDirectoryEntry {
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) version_made_by: u16,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) version_needed: u16,
pub(crate) general_purpose: u16,
pub(crate) compression_method: u16,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) last_mod_time: u16,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) last_mod_date: u16,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) crc32: u32,
pub(crate) compressed_size: u64,
pub(crate) uncompressed_size: u64,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) file_name_length: u16,
#[cfg_attr(not(test), expect(dead_code, reason = "read only by the tests"))]
pub(crate) extra_field_length: u16,
#[cfg_attr(not(test), expect(dead_code, reason = "read only by the tests"))]
pub(crate) file_comment_length: u16,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) disk_number_start: u16,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) internal_attrs: u16,
#[expect(dead_code, reason = "parsed for completeness, not read yet")]
pub(crate) external_attrs: u32,
pub(crate) local_header_offset: u64,
pub(crate) file_name: Arc<str>,
pub(crate) raw_file_name: Option<Arc<[u8]>>,
}
impl CentralDirectoryEntry {
const MAGIC: u32 = 0x02014b50;
const ZIP64_EXTRA_ID: u16 = 0x0001;
#[inline(always)]
fn parse(input: &mut &[u8]) -> ModalResult<CentralDirectoryEntry> {
let (
_,
version_made_by,
version_needed,
general_purpose,
compression_method,
last_mod_time,
last_mod_date,
crc32,
compressed_size,
uncompressed_size,
) = (
le_u32.verify(|magic| *magic == Self::MAGIC), le_u16, le_u16, le_u16, le_u16, le_u16, le_u16, le_u32, le_u32, le_u32, )
.parse_next(input)?;
let (
file_name_length,
extra_field_length,
file_comment_length,
disk_number_start,
internal_attrs,
external_attrs,
local_header_offset,
) = (
le_u16, le_u16, le_u16, le_u16, le_u16, le_u32, le_u32, )
.parse_next(input)?;
let (file_name, extra_field, _) = (
take(file_name_length),
take(extra_field_length),
take(file_comment_length),
)
.parse_next(input)?;
let (file_name, raw_file_name) = match std::str::from_utf8(file_name) {
Ok(name) => (Arc::from(name), None),
Err(_) => (
Arc::from(String::from_utf8_lossy(file_name).as_ref()),
Some(Arc::from(file_name)),
),
};
let (uncompressed_size, compressed_size, local_header_offset) = Self::apply_zip64(
extra_field,
uncompressed_size,
compressed_size,
local_header_offset,
);
Ok(CentralDirectoryEntry {
version_made_by,
version_needed,
general_purpose,
compression_method,
last_mod_time,
last_mod_date,
crc32,
compressed_size,
uncompressed_size,
file_name_length,
extra_field_length,
file_comment_length,
disk_number_start,
internal_attrs,
external_attrs,
local_header_offset,
file_name,
raw_file_name,
})
}
fn apply_zip64(
mut extra: &[u8],
uncompressed_size: u32,
compressed_size: u32,
local_header_offset: u32,
) -> (u64, u64, u64) {
let mut fields = [
u64::from(uncompressed_size),
u64::from(compressed_size),
u64::from(local_header_offset),
];
if !fields.contains(&u64::from(u32::MAX)) {
return (fields[0], fields[1], fields[2]);
}
while let Some((header, rest)) = extra.split_first_chunk::<4>() {
let id = u16::from_le_bytes([header[0], header[1]]);
let size = u16::from_le_bytes([header[2], header[3]]) as usize;
let Some(data) = rest.get(..size) else {
break;
};
extra = &rest[size..];
if id != Self::ZIP64_EXTRA_ID {
continue;
}
let mut data = data;
for field in fields
.iter_mut()
.filter(|field| **field == u64::from(u32::MAX))
{
let Some((value, rest)) = data.split_first_chunk::<8>() else {
break;
};
*field = u64::from_le_bytes(*value);
data = rest;
}
break;
}
(fields[0], fields[1], fields[2])
}
}
#[derive(Debug)]
pub(crate) struct CentralDirectory {
pub(crate) entries: AHashMap<Arc<str>, CentralDirectoryEntry>,
pub(crate) ordered_names: Vec<Arc<str>>,
pub(crate) anomalies: Vec<ZipAnomaly>,
}
impl CentralDirectory {
pub(crate) fn parse(
input: &[u8],
cd_offset: usize,
expected_entries: usize,
) -> ModalResult<CentralDirectory> {
let mut input = input
.get(cd_offset..)
.ok_or(ErrMode::Incomplete(Needed::Unknown))?;
let parsed = repeat::<_, CentralDirectoryEntry, Vec<CentralDirectoryEntry>, _, _>(
0..=expected_entries,
CentralDirectoryEntry::parse,
)
.parse_next(&mut input)?;
let mut anomalies = Vec::new();
if parsed.len() < expected_entries {
anomalies.push(ZipAnomaly::MissingCentralDirectoryRecords {
declared: expected_entries as u64,
parsed: parsed.len() as u64,
});
} else {
let mut extra = 0u64;
while CentralDirectoryEntry::parse(&mut input).is_ok() {
extra += 1;
}
if extra != 0 {
anomalies.push(ZipAnomaly::ExtraCentralDirectoryRecords {
declared: expected_entries as u64,
extra,
});
}
}
let mut entries = AHashMap::with_capacity(parsed.len());
let mut ordered_names = Vec::with_capacity(parsed.len());
for (index, entry) in parsed.into_iter().enumerate() {
if entry.raw_file_name.is_some() {
warn!(
"entry {index}: name is not valid UTF-8, read as {:?}; Android rejects this archive",
entry.file_name
);
anomalies.push(ZipAnomaly::InvalidUtf8Name {
entry: entry.file_name.to_string(),
});
}
let name = Arc::clone(&entry.file_name);
if entries
.insert(Arc::clone(&entry.file_name), entry)
.is_none()
{
ordered_names.push(name);
} else {
anomalies.push(ZipAnomaly::DuplicateEntry {
entry: name.to_string(),
});
}
}
for name in &ordered_names {
if let Some(file) = name
.match_indices('/')
.map(|(end, _)| &name[..end])
.find(|parent| entries.contains_key(*parent))
{
anomalies.push(ZipAnomaly::EntryPathConflict {
entry: name.to_string(),
file: file.to_owned(),
});
}
}
Ok(CentralDirectory {
entries,
ordered_names,
anomalies,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cde_record(
file_name: &str,
extra_field: &[u8],
comment: &[u8],
compressed_size: u32,
uncompressed_size: u32,
local_header_offset: u32,
) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&CentralDirectoryEntry::MAGIC.to_le_bytes()); data.extend_from_slice(&45u16.to_le_bytes()); data.extend_from_slice(&20u16.to_le_bytes()); data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&8u16.to_le_bytes()); data.extend_from_slice(&1234u16.to_le_bytes()); data.extend_from_slice(&5678u16.to_le_bytes()); data.extend_from_slice(&0xAABBCCDDu32.to_le_bytes()); data.extend_from_slice(&compressed_size.to_le_bytes());
data.extend_from_slice(&uncompressed_size.to_le_bytes());
data.extend_from_slice(&(file_name.len() as u16).to_le_bytes());
data.extend_from_slice(&(extra_field.len() as u16).to_le_bytes());
data.extend_from_slice(&(comment.len() as u16).to_le_bytes());
data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&0x11223344u32.to_le_bytes()); data.extend_from_slice(&local_header_offset.to_le_bytes());
data.extend_from_slice(file_name.as_bytes()); data.extend_from_slice(extra_field); data.extend_from_slice(comment); data
}
#[test]
fn test_parse_valid_cde_entry() {
let file_name = "hello.txt";
let extra = b"extra field";
let comment = b"comment field";
let data = make_cde_record(file_name, extra, comment, 111, 222, 333);
let mut input = &data[..];
let entry = CentralDirectoryEntry::parse(&mut input).unwrap();
assert_eq!(entry.file_name.as_ref(), file_name);
assert_eq!(entry.extra_field_length as usize, extra.len());
assert_eq!(entry.file_comment_length as usize, comment.len());
assert_eq!(entry.compressed_size, 111);
assert_eq!(entry.uncompressed_size, 222);
assert_eq!(entry.local_header_offset, 333);
assert!(input.is_empty());
}
#[test]
fn test_parse_invalid_magic() {
let mut data = make_cde_record("x", &[], &[], 1, 2, 3);
data[0] = 0x00; let mut input = &data[..];
let result = CentralDirectoryEntry::parse(&mut input);
assert!(result.is_err(), "expected error on invalid magic");
}
#[test]
fn test_parse_non_utf8_filename() {
let bad_bytes = [b'a', 0xFF, 0xFE, b'b'];
let mut data = make_cde_record("", &[], &[], 0, 0, 0);
let name_len = bad_bytes.len() as u16;
data[28..30].copy_from_slice(&name_len.to_le_bytes());
data.extend_from_slice(&bad_bytes);
let mut input = &data[..];
let entry = CentralDirectoryEntry::parse(&mut input).unwrap();
assert_eq!(entry.file_name.as_ref(), "a\u{fffd}\u{fffd}b");
assert_eq!(entry.raw_file_name.as_deref(), Some(&bad_bytes[..]));
}
#[test]
fn non_utf8_name_does_not_end_the_directory() {
let mut bad = make_cde_record("ab", &[], &[], 0, 0, 0);
let name_at = bad.len() - 2;
bad[name_at] = 0xC0;
let mut data = make_cde_record("first.txt", &[], &[], 0, 0, 0);
data.extend_from_slice(&bad);
data.extend_from_slice(&make_cde_record("resources.arsc", &[], &[], 0, 0, 0));
let cd = CentralDirectory::parse(&data, 0, 3).unwrap();
assert_eq!(cd.ordered_names.len(), 3);
assert!(cd.entries.contains_key("resources.arsc"));
assert!(cd.entries.contains_key("\u{fffd}b"));
}
#[test]
fn test_parse_multiple_entries_in_directory() {
let e1 = make_cde_record("a.txt", b"", b"", 10, 20, 30);
let e2 = make_cde_record("b.txt", b"extra information", b"comment field", 40, 50, 60);
let mut data = Vec::new();
data.extend_from_slice(&e1);
data.extend_from_slice(&e2);
let cd = CentralDirectory::parse(&data, 0, 2).unwrap();
assert_eq!(cd.entries.len(), 2);
assert!(cd.entries.contains_key("a.txt"));
assert!(cd.entries.contains_key("b.txt"));
let b = cd.entries.get("b.txt").unwrap();
assert_eq!(b.extra_field_length as usize, b"extra information".len());
assert_eq!(b.file_comment_length as usize, b"comment field".len());
}
#[test]
fn test_parse_central_directory_with_offset() {
let entry = make_cde_record("offset.txt", b"", b"", 100, 200, 300);
let mut file = vec![0xAA; 50]; let offset = file.len();
file.extend_from_slice(&entry);
let cd = CentralDirectory::parse(&file, offset, 1).unwrap();
assert_eq!(cd.entries.len(), 1);
assert!(cd.entries.contains_key("offset.txt"));
}
#[test]
fn test_parse_central_directory_invalid_offset() {
let data = vec![0x00; 10];
let result = CentralDirectory::parse(&data, 9999, 1);
assert!(result.is_err(), "expected error for out-of-bounds offset");
}
fn zip64_extra(values: &[u64]) -> Vec<u8> {
let mut extra = Vec::new();
extra.extend_from_slice(&0x0001u16.to_le_bytes());
extra.extend_from_slice(&((values.len() * 8) as u16).to_le_bytes());
for value in values {
extra.extend_from_slice(&value.to_le_bytes());
}
extra
}
#[test]
fn zip64_extra_replaces_saturated_fields() {
let mut extra = vec![0xfe, 0xca, 0x02, 0x00, 0xaa, 0xbb];
extra.extend(zip64_extra(&[5 << 32, 4 << 32, 6 << 32]));
let data = make_cde_record("big.obb", &extra, b"", u32::MAX, u32::MAX, u32::MAX);
let entry = CentralDirectoryEntry::parse(&mut &data[..]).unwrap();
assert_eq!(entry.uncompressed_size, 5 << 32);
assert_eq!(entry.compressed_size, 4 << 32);
assert_eq!(entry.local_header_offset, 6 << 32);
}
#[test]
fn zip64_extra_holds_only_saturated_fields() {
let extra = zip64_extra(&[7 << 32]);
let data = make_cde_record("base.apk", &extra, b"", 10, 20, u32::MAX);
let entry = CentralDirectoryEntry::parse(&mut &data[..]).unwrap();
assert_eq!(entry.compressed_size, 10);
assert_eq!(entry.uncompressed_size, 20);
assert_eq!(entry.local_header_offset, 7 << 32);
}
#[test]
fn short_zip64_extra_keeps_32_bit_values() {
let mut extra = zip64_extra(&[1 << 32]);
extra.truncate(8); let data = make_cde_record("x", &extra, b"", u32::MAX, u32::MAX, 0);
let entry = CentralDirectoryEntry::parse(&mut &data[..]).unwrap();
assert_eq!(entry.uncompressed_size, u64::from(u32::MAX));
assert_eq!(entry.compressed_size, u64::from(u32::MAX));
}
#[test]
fn test_decoy_record_beyond_num_records_is_ignored() {
let real = make_cde_record("AndroidManifest.xml", b"", b"", 10, 20, 30);
let decoy = make_cde_record("AndroidManifest.xml", b"", b"", 99, 99, 999);
let mut data = Vec::new();
data.extend_from_slice(&real);
data.extend_from_slice(&decoy);
let cd = CentralDirectory::parse(&data, 0, 1).unwrap();
assert_eq!(cd.entries.len(), 1);
let entry = cd.entries.get("AndroidManifest.xml").unwrap();
assert_eq!(entry.local_header_offset, 30, "decoy must not win");
assert_eq!(entry.compressed_size, 10);
}
}