verifyos-cli 0.13.1

AI agent-friendly Rust CLI for scanning iOS app bundles for App Store rejection risks before submission.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

const NESTED_BUNDLE_CONTAINER_DIRS: &[&str] = &[
    "Frameworks",
    "PlugIns",
    "Extensions",
    "AppClips",
    "Watch",
    "XPCServices",
];
const NESTED_BUNDLE_EXTENSIONS: &[&str] = &["app", "appex", "framework", "xpc"];

#[derive(Debug, thiserror::Error)]
pub enum BundleScanError {
    #[error("IO Error: {0}")]
    Io(#[from] std::io::Error),
}

#[derive(Debug, Clone)]
pub struct BundleTarget {
    pub bundle_path: PathBuf,
    pub display_name: String,
}

pub fn find_nested_bundles(app_bundle_path: &Path) -> Result<Vec<BundleTarget>, BundleScanError> {
    let mut bundles = Vec::new();

    for dir_name in NESTED_BUNDLE_CONTAINER_DIRS {
        let dir = app_bundle_path.join(dir_name);
        collect_bundles_in_dir(&dir, &mut bundles)?;
    }

    bundles.sort_by(|a, b| a.bundle_path.cmp(&b.bundle_path));
    bundles.dedup_by(|a, b| a.bundle_path == b.bundle_path);

    Ok(bundles)
}

fn collect_bundles_in_dir(
    dir: &Path,
    bundles: &mut Vec<BundleTarget>,
) -> Result<(), BundleScanError> {
    if !dir.exists() {
        return Ok(());
    }

    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();

        if is_nested_bundle(&path) {
            bundles.push(BundleTarget {
                display_name: path
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("")
                    .to_string(),
                bundle_path: path.clone(),
            });
        }

        if path.is_dir() {
            collect_bundles_in_dir(&path, bundles)?;
        }
    }

    Ok(())
}

fn is_nested_bundle(path: &Path) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .map(|extension| {
            let extension = extension.to_ascii_lowercase();
            NESTED_BUNDLE_EXTENSIONS
                .iter()
                .any(|expected| expected == &extension)
        })
        .unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::find_nested_bundles;
    use std::path::{Path, PathBuf};
    use tempfile::tempdir;

    #[test]
    fn finds_nested_bundles_in_additional_container_directories() {
        let dir = tempdir().expect("temp dir");
        let app_path = dir.path().join("Demo.app");

        std::fs::create_dir_all(app_path.join("Frameworks/Foo.framework"))
            .expect("create framework");
        std::fs::create_dir_all(app_path.join("PlugIns/Share.appex")).expect("create appex");
        std::fs::create_dir_all(app_path.join("Watch/WatchApp.app")).expect("create watch app");
        std::fs::create_dir_all(app_path.join("AppClips/Clip.app")).expect("create app clip");
        std::fs::create_dir_all(app_path.join("XPCServices/Service.xpc")).expect("create xpc");

        let bundles = find_nested_bundles(&app_path).expect("nested bundles");
        let paths: Vec<PathBuf> = bundles
            .into_iter()
            .map(|bundle| {
                bundle
                    .bundle_path
                    .strip_prefix(&app_path)
                    .expect("relative path")
                    .to_path_buf()
            })
            .collect();

        assert!(paths.contains(&Path::new("Frameworks").join("Foo.framework")));
        assert!(paths.contains(&Path::new("PlugIns").join("Share.appex")));
        assert!(paths.contains(&Path::new("Watch").join("WatchApp.app")));
        assert!(paths.contains(&Path::new("AppClips").join("Clip.app")));
        assert!(paths.contains(&Path::new("XPCServices").join("Service.xpc")));
    }
}