Skip to main content

verifyos_cli/
size_analysis.rs

1use crate::parsers::zip_extractor::extract_ipa;
2use serde::Serialize;
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, PartialEq)]
8pub struct SizeReport {
9    pub app_path: String,
10    pub total_bytes: u64,
11    pub categories: Vec<CategorySummary>,
12    pub top_files: Vec<SizeEntry>,
13}
14
15#[derive(Debug, Clone, Serialize, PartialEq)]
16pub struct CategorySummary {
17    pub category: String,
18    pub bytes: u64,
19    pub file_count: usize,
20    pub percent_of_total: f64,
21}
22
23#[derive(Debug, Clone, Serialize, PartialEq)]
24pub struct SizeEntry {
25    pub path: String,
26    pub category: String,
27    pub bytes: u64,
28}
29
30pub fn analyze_app_size(path: &Path, top_n: usize) -> Result<SizeReport, miette::Report> {
31    let extension = path.extension().and_then(|ext| ext.to_str());
32    match extension {
33        Some("ipa") => {
34            let extracted = extract_ipa(path).map_err(|err| {
35                miette::miette!("Failed to extract IPA {}: {}", path.display(), err)
36            })?;
37            let app_bundle = extracted
38                .get_app_bundle_path()
39                .map_err(|err| {
40                    miette::miette!(
41                        "Failed to inspect extracted IPA payload for {}: {}",
42                        path.display(),
43                        err
44                    )
45                })?
46                .ok_or_else(|| {
47                    miette::miette!(
48                        "No .app bundle found inside extracted IPA {}",
49                        path.display()
50                    )
51                })?;
52            analyze_app_bundle(&app_bundle, top_n)
53        }
54        Some("app") | None => analyze_app_bundle(path, top_n),
55        Some(other) => Err(miette::miette!(
56            "Unsupported app artifact `{}`. Expected .ipa or .app",
57            other
58        )),
59    }
60}
61
62pub fn analyze_app_bundle(
63    app_bundle_path: &Path,
64    top_n: usize,
65) -> Result<SizeReport, miette::Report> {
66    if !app_bundle_path.exists() {
67        return Err(miette::miette!(
68            "App bundle does not exist: {}",
69            app_bundle_path.display()
70        ));
71    }
72
73    let mut entries = Vec::new();
74    collect_files(app_bundle_path, app_bundle_path, &mut entries)?;
75    entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
76
77    let total_bytes = entries.iter().map(|item| item.bytes).sum::<u64>();
78    let categories = summarize_categories(&entries, total_bytes);
79    let top_files = entries.into_iter().take(top_n).collect();
80
81    Ok(SizeReport {
82        app_path: app_bundle_path.display().to_string(),
83        total_bytes,
84        categories,
85        top_files,
86    })
87}
88
89fn collect_files(
90    root: &Path,
91    current: &Path,
92    entries: &mut Vec<SizeEntry>,
93) -> Result<(), miette::Report> {
94    for entry in fs::read_dir(current)
95        .map_err(|err| miette::miette!("Failed to read {}: {}", current.display(), err))?
96    {
97        let entry = entry
98            .map_err(|err| miette::miette!("Failed to walk {}: {}", current.display(), err))?;
99        let path = entry.path();
100        if path.is_dir() {
101            collect_files(root, &path, entries)?;
102            continue;
103        }
104
105        let metadata = entry
106            .metadata()
107            .map_err(|err| miette::miette!("Failed to stat {}: {}", path.display(), err))?;
108        let relative = normalized_relative_path(root, &path);
109        entries.push(SizeEntry {
110            path: relative,
111            category: classify_path(root, &path).to_string(),
112            bytes: metadata.len(),
113        });
114    }
115
116    Ok(())
117}
118
119fn summarize_categories(entries: &[SizeEntry], total_bytes: u64) -> Vec<CategorySummary> {
120    let mut by_category: BTreeMap<String, (u64, usize)> = BTreeMap::new();
121    for entry in entries {
122        let row = by_category.entry(entry.category.clone()).or_insert((0, 0));
123        row.0 += entry.bytes;
124        row.1 += 1;
125    }
126
127    let mut categories: Vec<CategorySummary> = by_category
128        .into_iter()
129        .map(|(category, (bytes, file_count))| CategorySummary {
130            category,
131            bytes,
132            file_count,
133            percent_of_total: if total_bytes == 0 {
134                0.0
135            } else {
136                ((bytes as f64 / total_bytes as f64) * 1000.0).round() / 10.0
137            },
138        })
139        .collect();
140    categories.sort_by(|a, b| {
141        b.bytes
142            .cmp(&a.bytes)
143            .then_with(|| a.category.cmp(&b.category))
144    });
145    categories
146}
147
148fn classify_path(root: &Path, path: &Path) -> &'static str {
149    let relative = path.strip_prefix(root).unwrap_or(path);
150    let parts: Vec<String> = relative
151        .components()
152        .map(|part| part.as_os_str().to_string_lossy().to_string())
153        .collect();
154
155    if parts.iter().any(|part| part == "Frameworks") {
156        return "framework";
157    }
158    if parts
159        .iter()
160        .any(|part| part == "PlugIns" || part == "Extensions")
161    {
162        return "extension";
163    }
164
165    let file_name = path
166        .file_name()
167        .and_then(|name| name.to_str())
168        .unwrap_or("");
169    let extension = path
170        .extension()
171        .and_then(|ext| ext.to_str())
172        .unwrap_or("")
173        .to_ascii_lowercase();
174
175    if matches!(
176        file_name,
177        "Info.plist" | "PkgInfo" | "embedded.mobileprovision" | "PrivacyInfo.xcprivacy"
178    ) || matches!(extension.as_str(), "plist" | "strings" | "mobileprovision")
179    {
180        return "metadata";
181    }
182
183    if matches!(
184        extension.as_str(),
185        "png"
186            | "jpg"
187            | "jpeg"
188            | "gif"
189            | "webp"
190            | "heic"
191            | "car"
192            | "pdf"
193            | "json"
194            | "ttf"
195            | "otf"
196            | "mp3"
197            | "mp4"
198            | "wav"
199            | "storyboardc"
200            | "nib"
201    ) || file_name == "Assets.car"
202    {
203        return "asset";
204    }
205
206    if extension.is_empty() || matches!(extension.as_str(), "dylib" | "metallib") {
207        return "binary";
208    }
209
210    "resource"
211}
212
213fn normalized_relative_path(root: &Path, path: &Path) -> String {
214    path.strip_prefix(root)
215        .unwrap_or(path)
216        .components()
217        .map(|part| part.as_os_str().to_string_lossy().to_string())
218        .collect::<Vec<String>>()
219        .join("/")
220}
221
222#[cfg(test)]
223mod tests {
224    use super::analyze_app_bundle;
225    use std::fs;
226    use tempfile::tempdir;
227
228    #[test]
229    fn analyzes_bundle_size_breakdown() {
230        let dir = tempdir().expect("temp dir");
231        let app = dir.path().join("Demo.app");
232        fs::create_dir_all(app.join("Frameworks/Foo.framework")).expect("framework dir");
233        fs::create_dir_all(app.join("PlugIns/Share.appex")).expect("appex dir");
234        fs::write(app.join("Demo"), vec![0u8; 10]).expect("write binary");
235        fs::write(app.join("Assets.car"), vec![0u8; 20]).expect("write asset");
236        fs::write(app.join("Frameworks/Foo.framework/Foo"), vec![0u8; 30])
237            .expect("write framework");
238        fs::write(app.join("PlugIns/Share.appex/Share"), vec![0u8; 15]).expect("write appex");
239        fs::write(app.join("Info.plist"), vec![0u8; 5]).expect("write plist");
240
241        let report = analyze_app_bundle(&app, 3).expect("analyze bundle");
242
243        assert_eq!(report.total_bytes, 80);
244        assert_eq!(report.top_files.len(), 3);
245        assert_eq!(report.top_files[0].path, "Frameworks/Foo.framework/Foo");
246        assert_eq!(report.top_files[0].category, "framework");
247        assert_eq!(report.categories[0].category, "framework");
248        assert_eq!(report.categories[0].bytes, 30);
249        assert!(report
250            .categories
251            .iter()
252            .any(|item| item.category == "asset"));
253        assert!(report
254            .categories
255            .iter()
256            .any(|item| item.category == "binary"));
257        assert!(report
258            .categories
259            .iter()
260            .any(|item| item.category == "extension"));
261        assert!(report
262            .categories
263            .iter()
264            .any(|item| item.category == "metadata"));
265    }
266}