Skip to main content

pacenotes_installer\zip/
central_directory.rs

1const SIGNATURE_END_OF_DIRECTORY: u32 = 0x0605_4b50;
2const SIGNATURE_DIRECTORY_ENTRY: u32 = 0x0201_4b50;
3pub const SIGNATURE_LOCAL_HEADER: u32 = 0x0403_4b50;
4const END_OF_DIRECTORY_LENGTH: usize = 22;
5const DIRECTORY_ENTRY_LENGTH: usize = 46;
6/// Zip64 saturates the 16-bit count field.
7const ZIP64_COUNT: usize = 0xFFFF;
8/// Zip64 saturates the 32-bit size and offset fields.
9const ZIP64_FIELD: usize = 0xFFFF_FFFF;
10
11/// One entry as the central directory describes it, which is the authority:
12/// a streaming archiver leaves the local header's sizes zero.
13pub struct Entry {
14    /// Validated, `/`-separated, relative.
15    pub path: String,
16    pub is_dir: bool,
17    pub method: u16,
18    pub crc32: u32,
19    pub compressed: usize,
20    pub uncompressed: usize,
21    pub local_offset: usize,
22}
23
24/// Every entry, refusing the whole archive when any name would leave the target.
25pub fn read(zip: &[u8]) -> Result<Vec<Entry>, super::ZipError> {
26    let end_record = find_end_of_directory(zip)?;
27    let count = u16_at(zip, end_record + 10).ok_or_else(super::ZipError::truncated)? as usize;
28    let size = u32_at(zip, end_record + 12).ok_or_else(super::ZipError::truncated)? as usize;
29    let offset = u32_at(zip, end_record + 16).ok_or_else(super::ZipError::truncated)? as usize;
30    if count == ZIP64_COUNT || size == ZIP64_FIELD || offset == ZIP64_FIELD {
31        return Err(super::ZipError::new(
32            "this package uses the zip64 format, which the installer cannot read",
33        ));
34    }
35    let end = offset
36        .checked_add(size)
37        .filter(|&end| end <= zip.len())
38        .ok_or_else(super::ZipError::truncated)?;
39
40    let mut entries = Vec::with_capacity(count);
41    let mut at = offset;
42    while at < end && u32_at(zip, at) == Some(SIGNATURE_DIRECTORY_ENTRY) {
43        let (entry, next) = read_entry(zip, at)?;
44        entries.push(entry);
45        at = next;
46    }
47    // A short list would become a short ledger, leaving files nothing owns.
48    if entries.len() != count {
49        return Err(super::ZipError::new(format!(
50            "this package is damaged: it lists {count} entries but only {} could be read",
51            entries.len()
52        )));
53    }
54    Ok(keep_last_of_each_path(entries))
55}
56
57/// A zip may list one path twice, or in two cases that NTFS treats as one;
58/// extraction leaves the last on disk, so the listing keeps the last too.
59fn keep_last_of_each_path(entries: Vec<Entry>) -> Vec<Entry> {
60    let mut seen = std::collections::BTreeSet::new();
61    let mut kept: Vec<Entry> = entries
62        .into_iter()
63        .rev()
64        .filter(|entry| seen.insert(entry.path.to_lowercase()))
65        .collect();
66    kept.reverse();
67    kept
68}
69
70/// The entry at `at`, and where the next one starts.
71fn read_entry(zip: &[u8], at: usize) -> Result<(Entry, usize), super::ZipError> {
72    let field16 = |offset: usize| u16_at(zip, at + offset).ok_or_else(super::ZipError::truncated);
73    let field32 = |offset: usize| u32_at(zip, at + offset).ok_or_else(super::ZipError::truncated);
74    let flags = field16(8)?;
75    let method = field16(10)?;
76    let crc32 = field32(16)?;
77    let compressed = field32(20)? as usize;
78    let uncompressed = field32(24)? as usize;
79    let name_length = field16(28)? as usize;
80    let extra_length = field16(30)? as usize;
81    let comment_length = field16(32)? as usize;
82    let local_offset = field32(42)? as usize;
83
84    let name_at = at + DIRECTORY_ENTRY_LENGTH;
85    let raw_name = zip
86        .get(name_at..name_at + name_length)
87        .ok_or_else(super::ZipError::truncated)?;
88    let name = super::entry_name::decode_name(raw_name, flags);
89    // Read before normalizing, which drops the trailing separator marking a directory.
90    let is_dir = name.ends_with('/') || name.ends_with('\\');
91    let path = super::entry_name::safe_relative(&name).ok_or_else(|| {
92        super::ZipError::new(format!(
93            "this package contains an entry that would write outside the \
94             game folder ({name}); it will not be installed"
95        ))
96    })?;
97
98    let entry = Entry {
99        path,
100        is_dir,
101        method,
102        crc32,
103        compressed,
104        uncompressed,
105        local_offset,
106    };
107    Ok((entry, name_at + name_length + extra_length + comment_length))
108}
109
110/// Scans back over the longest comment a 16-bit length allows, since the
111/// end-of-directory record is the only fixed point in a zip.
112fn find_end_of_directory(zip: &[u8]) -> Result<usize, super::ZipError> {
113    if zip.len() < END_OF_DIRECTORY_LENGTH {
114        return Err(super::ZipError::new("this package is not a zip archive"));
115    }
116    let latest = zip.len() - END_OF_DIRECTORY_LENGTH;
117    let earliest = latest.saturating_sub(u16::MAX as usize);
118    (earliest..=latest)
119        .rev()
120        .find(|&at| u32_at(zip, at) == Some(SIGNATURE_END_OF_DIRECTORY))
121        .ok_or_else(|| super::ZipError::new("this package is not a zip archive, or it is damaged"))
122}
123
124pub fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
125    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
126}
127
128pub fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
129    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
130}