verifyos-cli 0.13.1

AI agent-friendly Rust CLI for scanning iOS app bundles for App Store rejection risks before submission.
Documentation
use crate::parsers::zip_extractor::extract_ipa;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SizeReport {
    pub app_path: String,
    pub total_bytes: u64,
    pub categories: Vec<CategorySummary>,
    pub top_files: Vec<SizeEntry>,
}

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct CategorySummary {
    pub category: String,
    pub bytes: u64,
    pub file_count: usize,
    pub percent_of_total: f64,
}

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SizeEntry {
    pub path: String,
    pub category: String,
    pub bytes: u64,
}

pub fn analyze_app_size(path: &Path, top_n: usize) -> Result<SizeReport, miette::Report> {
    let extension = path.extension().and_then(|ext| ext.to_str());
    match extension {
        Some("ipa") => {
            let extracted = extract_ipa(path).map_err(|err| {
                miette::miette!("Failed to extract IPA {}: {}", path.display(), err)
            })?;
            let app_bundle = extracted
                .get_app_bundle_path()
                .map_err(|err| {
                    miette::miette!(
                        "Failed to inspect extracted IPA payload for {}: {}",
                        path.display(),
                        err
                    )
                })?
                .ok_or_else(|| {
                    miette::miette!(
                        "No .app bundle found inside extracted IPA {}",
                        path.display()
                    )
                })?;
            analyze_app_bundle(&app_bundle, top_n)
        }
        Some("app") | None => analyze_app_bundle(path, top_n),
        Some(other) => Err(miette::miette!(
            "Unsupported app artifact `{}`. Expected .ipa or .app",
            other
        )),
    }
}

pub fn analyze_app_bundle(
    app_bundle_path: &Path,
    top_n: usize,
) -> Result<SizeReport, miette::Report> {
    if !app_bundle_path.exists() {
        return Err(miette::miette!(
            "App bundle does not exist: {}",
            app_bundle_path.display()
        ));
    }

    let mut entries = Vec::new();
    collect_files(app_bundle_path, app_bundle_path, &mut entries)?;
    entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));

    let total_bytes = entries.iter().map(|item| item.bytes).sum::<u64>();
    let categories = summarize_categories(&entries, total_bytes);
    let top_files = entries.into_iter().take(top_n).collect();

    Ok(SizeReport {
        app_path: app_bundle_path.display().to_string(),
        total_bytes,
        categories,
        top_files,
    })
}

fn collect_files(
    root: &Path,
    current: &Path,
    entries: &mut Vec<SizeEntry>,
) -> Result<(), miette::Report> {
    for entry in fs::read_dir(current)
        .map_err(|err| miette::miette!("Failed to read {}: {}", current.display(), err))?
    {
        let entry = entry
            .map_err(|err| miette::miette!("Failed to walk {}: {}", current.display(), err))?;
        let path = entry.path();
        if path.is_dir() {
            collect_files(root, &path, entries)?;
            continue;
        }

        let metadata = entry
            .metadata()
            .map_err(|err| miette::miette!("Failed to stat {}: {}", path.display(), err))?;
        let relative = normalized_relative_path(root, &path);
        entries.push(SizeEntry {
            path: relative,
            category: classify_path(root, &path).to_string(),
            bytes: metadata.len(),
        });
    }

    Ok(())
}

fn summarize_categories(entries: &[SizeEntry], total_bytes: u64) -> Vec<CategorySummary> {
    let mut by_category: BTreeMap<String, (u64, usize)> = BTreeMap::new();
    for entry in entries {
        let row = by_category.entry(entry.category.clone()).or_insert((0, 0));
        row.0 += entry.bytes;
        row.1 += 1;
    }

    let mut categories: Vec<CategorySummary> = by_category
        .into_iter()
        .map(|(category, (bytes, file_count))| CategorySummary {
            category,
            bytes,
            file_count,
            percent_of_total: if total_bytes == 0 {
                0.0
            } else {
                ((bytes as f64 / total_bytes as f64) * 1000.0).round() / 10.0
            },
        })
        .collect();
    categories.sort_by(|a, b| {
        b.bytes
            .cmp(&a.bytes)
            .then_with(|| a.category.cmp(&b.category))
    });
    categories
}

fn classify_path(root: &Path, path: &Path) -> &'static str {
    let relative = path.strip_prefix(root).unwrap_or(path);
    let parts: Vec<String> = relative
        .components()
        .map(|part| part.as_os_str().to_string_lossy().to_string())
        .collect();

    if parts.iter().any(|part| part == "Frameworks") {
        return "framework";
    }
    if parts
        .iter()
        .any(|part| part == "PlugIns" || part == "Extensions")
    {
        return "extension";
    }

    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("");
    let extension = path
        .extension()
        .and_then(|ext| ext.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();

    if matches!(
        file_name,
        "Info.plist" | "PkgInfo" | "embedded.mobileprovision" | "PrivacyInfo.xcprivacy"
    ) || matches!(extension.as_str(), "plist" | "strings" | "mobileprovision")
    {
        return "metadata";
    }

    if matches!(
        extension.as_str(),
        "png"
            | "jpg"
            | "jpeg"
            | "gif"
            | "webp"
            | "heic"
            | "car"
            | "pdf"
            | "json"
            | "ttf"
            | "otf"
            | "mp3"
            | "mp4"
            | "wav"
            | "storyboardc"
            | "nib"
    ) || file_name == "Assets.car"
    {
        return "asset";
    }

    if extension.is_empty() || matches!(extension.as_str(), "dylib" | "metallib") {
        return "binary";
    }

    "resource"
}

fn normalized_relative_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .components()
        .map(|part| part.as_os_str().to_string_lossy().to_string())
        .collect::<Vec<String>>()
        .join("/")
}

#[cfg(test)]
mod tests {
    use super::analyze_app_bundle;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn analyzes_bundle_size_breakdown() {
        let dir = tempdir().expect("temp dir");
        let app = dir.path().join("Demo.app");
        fs::create_dir_all(app.join("Frameworks/Foo.framework")).expect("framework dir");
        fs::create_dir_all(app.join("PlugIns/Share.appex")).expect("appex dir");
        fs::write(app.join("Demo"), vec![0u8; 10]).expect("write binary");
        fs::write(app.join("Assets.car"), vec![0u8; 20]).expect("write asset");
        fs::write(app.join("Frameworks/Foo.framework/Foo"), vec![0u8; 30])
            .expect("write framework");
        fs::write(app.join("PlugIns/Share.appex/Share"), vec![0u8; 15]).expect("write appex");
        fs::write(app.join("Info.plist"), vec![0u8; 5]).expect("write plist");

        let report = analyze_app_bundle(&app, 3).expect("analyze bundle");

        assert_eq!(report.total_bytes, 80);
        assert_eq!(report.top_files.len(), 3);
        assert_eq!(report.top_files[0].path, "Frameworks/Foo.framework/Foo");
        assert_eq!(report.top_files[0].category, "framework");
        assert_eq!(report.categories[0].category, "framework");
        assert_eq!(report.categories[0].bytes, 30);
        assert!(report
            .categories
            .iter()
            .any(|item| item.category == "asset"));
        assert!(report
            .categories
            .iter()
            .any(|item| item.category == "binary"));
        assert!(report
            .categories
            .iter()
            .any(|item| item.category == "extension"));
        assert!(report
            .categories
            .iter()
            .any(|item| item.category == "metadata"));
    }
}