Skip to main content

verifyos_cli/rules/
xcode_requirements.rs

1use crate::rules::core::{
2    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
3};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6const APP_STORE_CONNECT_REQUIREMENT_DATE: &str = "April 28, 2026";
7const APP_STORE_CONNECT_REQUIREMENT_UNIX: u64 = 1_777_334_400;
8const MIN_XCODE_BUILD_NUMBER: u32 = 2600;
9const MIN_IOS_SDK_MAJOR: u32 = 26;
10
11pub struct XcodeVersionRule;
12
13impl AppStoreRule for XcodeVersionRule {
14    fn id(&self) -> &'static str {
15        "RULE_XCODE_26_MANDATE"
16    }
17
18    fn name(&self) -> &'static str {
19        "Xcode 26 / iOS 26 SDK Mandate"
20    }
21
22    fn category(&self) -> RuleCategory {
23        RuleCategory::Metadata
24    }
25
26    fn severity(&self) -> Severity {
27        Severity::Warning
28    }
29
30    fn recommendation(&self) -> &'static str {
31        "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."
32    }
33
34    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
35        let Some(plist) = artifact.info_plist else {
36            return Ok(RuleReport {
37                status: RuleStatus::Skip,
38                message: Some("Info.plist not found".to_string()),
39                evidence: None,
40            });
41        };
42
43        let mut failures = Vec::new();
44
45        match plist.get_string("DTXcode") {
46            Some(version) => match version.parse::<u32>() {
47                Ok(build_number) if build_number >= MIN_XCODE_BUILD_NUMBER => {}
48                Ok(_) => failures.push(format!(
49                    "DTXcode={} is below the minimum build number {}",
50                    version, MIN_XCODE_BUILD_NUMBER
51                )),
52                Err(_) => failures.push(format!("DTXcode={} is not a valid build number", version)),
53            },
54            None => failures.push("DTXcode key missing".to_string()),
55        }
56
57        match plist.get_string("DTPlatformVersion") {
58            Some(version) => match parse_major_version(version) {
59                Some(major) if major >= MIN_IOS_SDK_MAJOR => {}
60                Some(_) => failures.push(format!(
61                    "DTPlatformVersion={} is below {}.0",
62                    version, MIN_IOS_SDK_MAJOR
63                )),
64                None => failures.push(format!(
65                    "DTPlatformVersion={} is not a valid platform version",
66                    version
67                )),
68            },
69            None => failures.push("DTPlatformVersion key missing".to_string()),
70        }
71
72        match plist.get_string("DTSDKName") {
73            Some(name) => match parse_sdk_major_version(name) {
74                Some(major) if major >= MIN_IOS_SDK_MAJOR => {}
75                Some(_) => failures.push(format!(
76                    "DTSDKName={} is below iphoneos{}",
77                    name, MIN_IOS_SDK_MAJOR
78                )),
79                None => failures.push(format!(
80                    "DTSDKName={} does not look like an iPhone OS SDK identifier",
81                    name
82                )),
83            },
84            None => failures.push("DTSDKName key missing".to_string()),
85        }
86
87        if failures.is_empty() {
88            return Ok(RuleReport {
89                status: RuleStatus::Pass,
90                message: Some(success_message().to_string()),
91                evidence: None,
92            });
93        }
94
95        Ok(RuleReport {
96            status: RuleStatus::Fail,
97            message: Some(failure_message().to_string()),
98            evidence: Some(failures.join("; ")),
99        })
100    }
101}
102
103fn parse_major_version(value: &str) -> Option<u32> {
104    value.trim().split('.').next()?.parse::<u32>().ok()
105}
106
107fn parse_sdk_major_version(value: &str) -> Option<u32> {
108    let suffix = value.trim().strip_prefix("iphoneos")?;
109    parse_major_version(suffix)
110}
111
112fn requirement_is_live() -> bool {
113    SystemTime::now()
114        .duration_since(UNIX_EPOCH)
115        .map(|duration| duration.as_secs() >= APP_STORE_CONNECT_REQUIREMENT_UNIX)
116        .unwrap_or(true)
117}
118
119fn success_message() -> &'static str {
120    if requirement_is_live() {
121        "App meets the current App Store Connect Xcode 26 / iOS 26 SDK requirement"
122    } else {
123        "App already meets the upcoming App Store Connect Xcode 26 / iOS 26 SDK requirement"
124    }
125}
126
127fn failure_message() -> String {
128    if requirement_is_live() {
129        "App does not meet the current App Store Connect Xcode 26 / iOS 26 SDK requirement"
130            .to_string()
131    } else {
132        format!(
133            "App does not meet the upcoming App Store Connect requirement that takes effect on {APP_STORE_CONNECT_REQUIREMENT_DATE}"
134        )
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::{parse_major_version, parse_sdk_major_version};
141
142    #[test]
143    fn parses_major_platform_version() {
144        assert_eq!(parse_major_version("26.1"), Some(26));
145        assert_eq!(parse_major_version("26"), Some(26));
146        assert_eq!(parse_major_version(""), None);
147        assert_eq!(parse_major_version("abc"), None);
148    }
149
150    #[test]
151    fn parses_sdk_major_version() {
152        assert_eq!(parse_sdk_major_version("iphoneos26.0"), Some(26));
153        assert_eq!(parse_sdk_major_version("iphoneos26"), Some(26));
154        assert_eq!(parse_sdk_major_version("iphonesimulator26.0"), None);
155        assert_eq!(parse_sdk_major_version("iphoneos"), None);
156    }
157}