1use anyhow::{Context, Result, bail, ensure};
8use serde::Serialize;
9use std::{collections::BTreeMap, fs, path::Path};
10
11const MAX_PACKAGE_BYTES: u64 = 4 * 1024 * 1024 * 1024 - 1;
12
13#[derive(Debug, Serialize)]
14pub struct EntryChange {
15 pub name: String,
16 pub before_bytes: Option<u64>,
17 pub after_bytes: Option<u64>,
18}
19
20#[derive(Debug, Serialize)]
21pub struct PackageDiff {
22 pub before_file_bytes: u64,
23 pub after_file_bytes: u64,
24 pub before_compressed_bytes: u64,
26 pub after_compressed_bytes: u64,
27 pub added: usize,
28 pub removed: usize,
29 pub changed: usize,
30 pub entries: Vec<EntryChange>,
32}
33
34fn le(bytes: &[u8], at: usize, width: usize) -> Result<u64> {
35 let slice = bytes.get(at..at + width).context("truncated zip record")?;
36 Ok(slice
37 .iter()
38 .rev()
39 .fold(0_u64, |value, byte| (value << 8) | u64::from(*byte)))
40}
41
42fn entries(bytes: &[u8]) -> Result<BTreeMap<String, u64>> {
44 ensure!(bytes.len() >= 22, "not a zip archive");
45 let start = bytes.len().saturating_sub(22 + 65_535);
46 let eocd = (start..=bytes.len() - 22)
47 .rev()
48 .find(|&at| bytes[at..at + 4] == *b"PK\x05\x06")
49 .context("not a zip archive (no end-of-central-directory record)")?;
50 let count = le(bytes, eocd + 10, 2)? as usize;
51 let mut offset = le(bytes, eocd + 16, 4)? as usize;
52 ensure!(
53 count != 0xffff && offset != 0xffff_ffff,
54 "zip64 archives are not supported"
55 );
56 let mut found = BTreeMap::new();
57 for _ in 0..count {
58 ensure!(
59 bytes.get(offset..offset + 4) == Some(b"PK\x01\x02"),
60 "corrupt zip central directory"
61 );
62 let compressed = le(bytes, offset + 20, 4)?;
63 let name_length = le(bytes, offset + 28, 2)? as usize;
64 let extra = le(bytes, offset + 30, 2)? as usize;
65 let comment = le(bytes, offset + 32, 2)? as usize;
66 let name = bytes
67 .get(offset + 46..offset + 46 + name_length)
68 .context("truncated zip entry name")?;
69 found.insert(String::from_utf8_lossy(name).into_owned(), compressed);
70 offset = offset
71 .checked_add(46 + name_length + extra + comment)
72 .context("zip offset overflow")?;
73 }
74 Ok(found)
75}
76
77fn read(path: &Path) -> Result<Vec<u8>> {
78 let size = fs::metadata(path)
79 .with_context(|| format!("reading {}", path.display()))?
80 .len();
81 if size > MAX_PACKAGE_BYTES {
82 bail!("{} is larger than 4 GiB", path.display());
83 }
84 Ok(fs::read(path)?)
85}
86
87pub fn package_diff(before: impl AsRef<Path>, after: impl AsRef<Path>) -> Result<PackageDiff> {
88 let (before_bytes, after_bytes) = (read(before.as_ref())?, read(after.as_ref())?);
89 let old = entries(&before_bytes).with_context(|| before.as_ref().display().to_string())?;
90 let new = entries(&after_bytes).with_context(|| after.as_ref().display().to_string())?;
91 let mut changes: Vec<EntryChange> = old
92 .keys()
93 .chain(new.keys().filter(|name| !old.contains_key(*name)))
94 .filter(|name| old.get(*name) != new.get(*name))
95 .map(|name| EntryChange {
96 name: name.clone(),
97 before_bytes: old.get(name).copied(),
98 after_bytes: new.get(name).copied(),
99 })
100 .collect();
101 let delta = |c: &EntryChange| {
102 (c.after_bytes.unwrap_or(0) as i64 - c.before_bytes.unwrap_or(0) as i64).unsigned_abs()
103 };
104 changes.sort_by(|a, b| delta(b).cmp(&delta(a)).then(a.name.cmp(&b.name)));
105 Ok(PackageDiff {
106 before_file_bytes: before_bytes.len() as u64,
107 after_file_bytes: after_bytes.len() as u64,
108 before_compressed_bytes: old.values().sum(),
109 after_compressed_bytes: new.values().sum(),
110 added: changes.iter().filter(|c| c.before_bytes.is_none()).count(),
111 removed: changes.iter().filter(|c| c.after_bytes.is_none()).count(),
112 changed: changes
113 .iter()
114 .filter(|c| c.before_bytes.is_some() && c.after_bytes.is_some())
115 .count(),
116 entries: changes,
117 })
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 fn zip(files: &[(&str, &[u8])]) -> Vec<u8> {
126 let (mut body, mut directory) = (Vec::new(), Vec::new());
127 for (name, data) in files {
128 let offset = body.len() as u32;
129 let header = |signature: &[u8; 4], central: bool| {
130 let mut h = signature.to_vec();
131 if central {
132 h.extend([20, 0]);
133 }
134 h.extend([20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
135 h.extend((data.len() as u32).to_le_bytes());
136 h.extend((data.len() as u32).to_le_bytes());
137 h.extend((name.len() as u16).to_le_bytes());
138 h.extend([0, 0]);
139 if central {
140 h.extend([0; 10]);
141 h.extend(offset.to_le_bytes());
142 }
143 h.extend(name.as_bytes());
144 h
145 };
146 body.extend(header(b"PK\x03\x04", false));
147 body.extend(*data);
148 directory.extend(header(b"PK\x01\x02", true));
149 }
150 let start = body.len() as u32;
151 body.extend(&directory);
152 body.extend(b"PK\x05\x06\0\0\0\0");
153 body.extend((files.len() as u16).to_le_bytes());
154 body.extend((files.len() as u16).to_le_bytes());
155 body.extend((directory.len() as u32).to_le_bytes());
156 body.extend(start.to_le_bytes());
157 body.extend([0, 0]);
158 body
159 }
160
161 #[test]
162 fn reports_changed_added_and_removed_entries_by_measured_size() {
163 let dir = tempfile::tempdir().unwrap();
164 let before = dir.path().join("before.apk");
165 let after = dir.path().join("after.apk");
166 fs::write(
167 &before,
168 zip(&[
169 ("res/a.png", &[0; 900]),
170 ("res/b.png", &[0; 50]),
171 ("classes.dex", &[1; 10]),
172 ]),
173 )
174 .unwrap();
175 fs::write(
176 &after,
177 zip(&[
178 ("res/a.webp", &[0; 300]),
179 ("res/b.png", &[0; 40]),
180 ("classes.dex", &[1; 10]),
181 ]),
182 )
183 .unwrap();
184 let diff = package_diff(&before, &after).unwrap();
185 assert_eq!((diff.added, diff.removed, diff.changed), (1, 1, 1));
186 assert_eq!(
187 diff.before_compressed_bytes - diff.after_compressed_bytes,
188 610
189 );
190 assert_eq!(diff.entries[0].name, "res/a.png");
191 assert!(diff.before_file_bytes > diff.after_file_bytes);
192 }
193
194 #[test]
195 fn malformed_archives_are_errors_not_panics() {
196 let dir = tempfile::tempdir().unwrap();
197 let good = dir.path().join("good.zip");
198 fs::write(&good, zip(&[("a", b"x")])).unwrap();
199 for (name, bytes) in [
200 ("empty", Vec::new()),
201 ("text", b"this is not a zip archive at all".to_vec()),
202 ("truncated", zip(&[("a", b"x")])[..30].to_vec()),
203 ("bad-directory", {
204 let mut z = zip(&[("a", b"x")]);
205 let at = z.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
206 z[at] = b'X';
207 z
208 }),
209 ] {
210 let path = dir.path().join(name);
211 fs::write(&path, bytes).unwrap();
212 assert!(package_diff(&good, &path).is_err(), "{name}");
213 }
214 assert!(package_diff(&good, dir.path().join("missing")).is_err());
215 }
216}