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::rules::core::{
    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
};
use std::time::{SystemTime, UNIX_EPOCH};

const APP_STORE_CONNECT_REQUIREMENT_DATE: &str = "April 28, 2026";
const APP_STORE_CONNECT_REQUIREMENT_UNIX: u64 = 1_777_334_400;
const MIN_XCODE_BUILD_NUMBER: u32 = 2600;
const MIN_IOS_SDK_MAJOR: u32 = 26;

pub struct XcodeVersionRule;

impl AppStoreRule for XcodeVersionRule {
    fn id(&self) -> &'static str {
        "RULE_XCODE_26_MANDATE"
    }

    fn name(&self) -> &'static str {
        "Xcode 26 / iOS 26 SDK Mandate"
    }

    fn category(&self) -> RuleCategory {
        RuleCategory::Metadata
    }

    fn severity(&self) -> Severity {
        Severity::Warning
    }

    fn recommendation(&self) -> &'static str {
        "Starting April 28, 2026, iOS and iPadOS apps uploaded to App Store Connect must be built with Xcode 26 and the iOS 26 SDK or later."
    }

    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
        let Some(plist) = artifact.info_plist else {
            return Ok(RuleReport {
                status: RuleStatus::Skip,
                message: Some("Info.plist not found".to_string()),
                evidence: None,
            });
        };

        let mut failures = Vec::new();

        match plist.get_string("DTXcode") {
            Some(version) => match version.parse::<u32>() {
                Ok(build_number) if build_number >= MIN_XCODE_BUILD_NUMBER => {}
                Ok(_) => failures.push(format!(
                    "DTXcode={} is below the minimum build number {}",
                    version, MIN_XCODE_BUILD_NUMBER
                )),
                Err(_) => failures.push(format!("DTXcode={} is not a valid build number", version)),
            },
            None => failures.push("DTXcode key missing".to_string()),
        }

        match plist.get_string("DTPlatformVersion") {
            Some(version) => match parse_major_version(version) {
                Some(major) if major >= MIN_IOS_SDK_MAJOR => {}
                Some(_) => failures.push(format!(
                    "DTPlatformVersion={} is below {}.0",
                    version, MIN_IOS_SDK_MAJOR
                )),
                None => failures.push(format!(
                    "DTPlatformVersion={} is not a valid platform version",
                    version
                )),
            },
            None => failures.push("DTPlatformVersion key missing".to_string()),
        }

        match plist.get_string("DTSDKName") {
            Some(name) => match parse_sdk_major_version(name) {
                Some(major) if major >= MIN_IOS_SDK_MAJOR => {}
                Some(_) => failures.push(format!(
                    "DTSDKName={} is below iphoneos{}",
                    name, MIN_IOS_SDK_MAJOR
                )),
                None => failures.push(format!(
                    "DTSDKName={} does not look like an iPhone OS SDK identifier",
                    name
                )),
            },
            None => failures.push("DTSDKName key missing".to_string()),
        }

        if failures.is_empty() {
            return Ok(RuleReport {
                status: RuleStatus::Pass,
                message: Some(success_message().to_string()),
                evidence: None,
            });
        }

        Ok(RuleReport {
            status: RuleStatus::Fail,
            message: Some(failure_message().to_string()),
            evidence: Some(failures.join("; ")),
        })
    }
}

fn parse_major_version(value: &str) -> Option<u32> {
    value.trim().split('.').next()?.parse::<u32>().ok()
}

fn parse_sdk_major_version(value: &str) -> Option<u32> {
    let suffix = value.trim().strip_prefix("iphoneos")?;
    parse_major_version(suffix)
}

fn requirement_is_live() -> bool {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs() >= APP_STORE_CONNECT_REQUIREMENT_UNIX)
        .unwrap_or(true)
}

fn success_message() -> &'static str {
    if requirement_is_live() {
        "App meets the current App Store Connect Xcode 26 / iOS 26 SDK requirement"
    } else {
        "App already meets the upcoming App Store Connect Xcode 26 / iOS 26 SDK requirement"
    }
}

fn failure_message() -> String {
    if requirement_is_live() {
        "App does not meet the current App Store Connect Xcode 26 / iOS 26 SDK requirement"
            .to_string()
    } else {
        format!(
            "App does not meet the upcoming App Store Connect requirement that takes effect on {APP_STORE_CONNECT_REQUIREMENT_DATE}"
        )
    }
}

#[cfg(test)]
mod tests {
    use super::{parse_major_version, parse_sdk_major_version};

    #[test]
    fn parses_major_platform_version() {
        assert_eq!(parse_major_version("26.1"), Some(26));
        assert_eq!(parse_major_version("26"), Some(26));
        assert_eq!(parse_major_version(""), None);
        assert_eq!(parse_major_version("abc"), None);
    }

    #[test]
    fn parses_sdk_major_version() {
        assert_eq!(parse_sdk_major_version("iphoneos26.0"), Some(26));
        assert_eq!(parse_sdk_major_version("iphoneos26"), Some(26));
        assert_eq!(parse_sdk_major_version("iphonesimulator26.0"), None);
        assert_eq!(parse_sdk_major_version("iphoneos"), None);
    }
}