Skip to main content

verifyos_cli/rules/
os_version.rs

1use crate::rules::core::{
2    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
3};
4
5pub struct OSVersionConsistencyRule;
6
7impl AppStoreRule for OSVersionConsistencyRule {
8    fn id(&self) -> &'static str {
9        "RULE_OS_VERSION_CONSISTENCY"
10    }
11
12    fn name(&self) -> &'static str {
13        "Minimum OS Version Consistency Check"
14    }
15
16    fn category(&self) -> RuleCategory {
17        RuleCategory::Bundling
18    }
19
20    fn severity(&self) -> Severity {
21        Severity::Warning
22    }
23
24    fn recommendation(&self) -> &'static str {
25        "Ensure MinimumOSVersion is consistent across the main app and all extensions. Extensions should generally not have a higher MinimumOSVersion than the main app."
26    }
27
28    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
29        let Some(main_plist) = artifact.info_plist else {
30            return Ok(RuleReport {
31                status: RuleStatus::Skip,
32                message: Some("Main Info.plist not found".to_string()),
33                evidence: None,
34            });
35        };
36
37        let main_version = main_plist.get_string("MinimumOSVersion").unwrap_or("0.0");
38        let mut issues = Vec::new();
39
40        if let Ok(nested) = artifact.nested_bundles() {
41            for bundle in nested {
42                // We only care about app extensions for this specific check,
43                // but frameworks also have MinimumOSVersion.
44                let bundle_name = &bundle.display_name;
45                if let Ok(Some(sub_plist)) = artifact.bundle_info_plist(&bundle.bundle_path) {
46                    if let Some(sub_version) = sub_plist.get_string("MinimumOSVersion") {
47                        if is_version_higher(sub_version, main_version) {
48                            issues.push(format!(
49                                "{} has higher MinimumOSVersion ({}) than main app ({})",
50                                bundle_name, sub_version, main_version
51                            ));
52                        }
53                    }
54                }
55            }
56        }
57
58        if issues.is_empty() {
59            return Ok(RuleReport {
60                status: RuleStatus::Pass,
61                message: Some(format!(
62                    "MinimumOSVersion ({}) is consistent across all bundles",
63                    main_version
64                )),
65                evidence: None,
66            });
67        }
68
69        Ok(RuleReport {
70            status: RuleStatus::Fail,
71            message: Some("Inconsistent MinimumOSVersion detected".to_string()),
72            evidence: Some(issues.join(" | ")),
73        })
74    }
75}
76
77fn is_version_higher(v1: &str, v2: &str) -> bool {
78    let p1: Vec<u32> = v1
79        .split('.')
80        .filter_map(|s| s.parse::<u32>().ok())
81        .collect();
82    let p2: Vec<u32> = v2
83        .split('.')
84        .filter_map(|s| s.parse::<u32>().ok())
85        .collect();
86
87    for i in 0..std::cmp::max(p1.len(), p2.len()) {
88        let n1 = *p1.get(i).unwrap_or(&0);
89        let n2 = *p2.get(i).unwrap_or(&0);
90        if n1 > n2 {
91            return true;
92        }
93        if n1 < n2 {
94            return false;
95        }
96    }
97    false
98}