verifyos-cli 0.2.1

A pure Rust CLI tool to scan Apple app bundles for App Store rejection risks before submission.
Documentation
use std::collections::HashSet;
use std::path::{Path, PathBuf};

#[derive(Debug, thiserror::Error)]
pub enum UsageScanError {
    #[error("Failed to read executable: {0}")]
    Io(#[from] std::io::Error),
    #[error("Unable to resolve app executable")]
    MissingExecutable,
}

#[derive(Debug, Default)]
pub struct UsageScan {
    pub required_keys: HashSet<&'static str>,
    pub requires_location_key: bool,
    pub evidence: HashSet<&'static str>,
}

#[derive(Debug, Default)]
pub struct CapabilityScan {
    pub detected: HashSet<&'static str>,
    pub evidence: HashSet<&'static str>,
}

pub fn scan_usage_from_app_bundle(app_bundle_path: &Path) -> Result<UsageScan, UsageScanError> {
    let executable =
        resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
    scan_usage_from_executable(&executable)
}

#[derive(Debug, Default)]
pub struct PrivateApiScan {
    pub hits: Vec<&'static str>,
}

pub fn scan_private_api_from_app_bundle(
    app_bundle_path: &Path,
) -> Result<PrivateApiScan, UsageScanError> {
    let executable =
        resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
    scan_private_api_from_executable(&executable)
}

pub fn scan_capabilities_from_app_bundle(
    app_bundle_path: &Path,
) -> Result<CapabilityScan, UsageScanError> {
    let executable =
        resolve_executable_path(app_bundle_path).ok_or(UsageScanError::MissingExecutable)?;
    scan_capabilities_from_executable(&executable)
}

fn resolve_executable_path(app_bundle_path: &Path) -> Option<PathBuf> {
    let app_name = app_bundle_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .trim_end_matches(".app");

    if app_name.is_empty() {
        return None;
    }

    let executable_path = app_bundle_path.join(app_name);
    if executable_path.exists() {
        Some(executable_path)
    } else {
        None
    }
}

fn scan_usage_from_executable(path: &Path) -> Result<UsageScan, UsageScanError> {
    let bytes = std::fs::read(path)?;
    let mut scan = UsageScan::default();

    for (signature, requirement) in SIGNATURES {
        if contains_subslice(&bytes, signature.as_bytes()) {
            scan.evidence.insert(*signature);
            match requirement {
                Requirement::Key(key) => {
                    scan.required_keys.insert(*key);
                }
                Requirement::AnyLocation => {
                    scan.requires_location_key = true;
                }
            }
        }
    }

    Ok(scan)
}

fn scan_private_api_from_executable(path: &Path) -> Result<PrivateApiScan, UsageScanError> {
    let bytes = std::fs::read(path)?;
    let mut scan = PrivateApiScan::default();

    for signature in PRIVATE_API_SIGNATURES {
        if contains_subslice(&bytes, signature.as_bytes()) {
            scan.hits.push(*signature);
        }
    }

    scan.hits.sort_unstable();
    scan.hits.dedup();

    Ok(scan)
}

fn scan_capabilities_from_executable(path: &Path) -> Result<CapabilityScan, UsageScanError> {
    let bytes = std::fs::read(path)?;
    let mut scan = CapabilityScan::default();

    for (signature, capability) in CAPABILITY_SIGNATURES {
        if contains_subslice(&bytes, signature.as_bytes()) {
            scan.evidence.insert(*signature);
            scan.detected.insert(*capability);
        }
    }

    Ok(scan)
}

fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
    haystack
        .windows(needle.len())
        .any(|window| window == needle)
}

#[derive(Clone, Copy)]
enum Requirement {
    Key(&'static str),
    AnyLocation,
}

const SIGNATURES: &[(&str, Requirement)] = &[
    (
        "AVCaptureDevice",
        Requirement::Key("NSCameraUsageDescription"),
    ),
    (
        "AVAudioSession",
        Requirement::Key("NSMicrophoneUsageDescription"),
    ),
    (
        "AVAudioRecorder",
        Requirement::Key("NSMicrophoneUsageDescription"),
    ),
    (
        "PHPhotoLibrary",
        Requirement::Key("NSPhotoLibraryUsageDescription"),
    ),
    (
        "PHPhotoLibraryAddOnly",
        Requirement::Key("NSPhotoLibraryAddUsageDescription"),
    ),
    ("CLLocationManager", Requirement::AnyLocation),
    (
        "CBCentralManager",
        Requirement::Key("NSBluetoothAlwaysUsageDescription"),
    ),
    (
        "CBPeripheralManager",
        Requirement::Key("NSBluetoothAlwaysUsageDescription"),
    ),
    (
        "CBPeripheral",
        Requirement::Key("NSBluetoothPeripheralUsageDescription"),
    ),
    ("LAContext", Requirement::Key("NSFaceIDUsageDescription")),
    (
        "EKEventStore",
        Requirement::Key("NSCalendarsUsageDescription"),
    ),
    (
        "EKReminder",
        Requirement::Key("NSRemindersUsageDescription"),
    ),
    (
        "CNContactStore",
        Requirement::Key("NSContactsUsageDescription"),
    ),
    (
        "SFSpeechRecognizer",
        Requirement::Key("NSSpeechRecognitionUsageDescription"),
    ),
    (
        "CMMotionManager",
        Requirement::Key("NSMotionUsageDescription"),
    ),
    ("CMPedometer", Requirement::Key("NSMotionUsageDescription")),
    (
        "MPMediaLibrary",
        Requirement::Key("NSAppleMusicUsageDescription"),
    ),
    (
        "HKHealthStore",
        Requirement::Key("NSHealthShareUsageDescription"),
    ),
];

const PRIVATE_API_SIGNATURES: &[&str] = &[
    "LSApplicationWorkspace",
    "LSApplicationProxy",
    "LSAppWorkspace",
    "SBApplication",
    "SpringBoard",
    "MobileGestalt",
    "UICallApplication",
    "UIGetScreenImage",
    "_MGCopyAnswer",
];

const CAPABILITY_SIGNATURES: &[(&str, &str)] = &[
    ("AVCaptureDevice", "camera"),
    ("CLLocationManager", "location"),
];