Skip to main content

resopt/
package_diff.rs

1//! Compare two built packages (APK, AAB, IPA or any ZIP) entry by entry.
2//!
3//! Source-file savings are not package savings: AAPT2 re-compresses PNGs, Xcode
4//! compiles asset catalogs, and the archive compresses entries again. This
5//! reads the ZIP central directories of two real builds and reports the
6//! measured difference.
7use 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    /// Sum of compressed entry sizes: what the package stores.
25    pub before_compressed_bytes: u64,
26    pub after_compressed_bytes: u64,
27    pub added: usize,
28    pub removed: usize,
29    pub changed: usize,
30    /// Largest differences first.
31    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
42/// Entry name → compressed size, from the central directory.
43fn 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    /// Minimal stored-entry zip writer for fixtures.
125    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}