Skip to main content

verifyos_cli/rules/
bundle_metadata.rs

1use crate::parsers::bundle_scanner::find_nested_bundles;
2use crate::parsers::plist_reader::InfoPlist;
3use crate::rules::core::{
4    AppStoreRule, ArtifactContext, RuleCategory, RuleError, RuleReport, RuleStatus, Severity,
5};
6
7pub struct BundleMetadataConsistencyRule;
8
9impl AppStoreRule for BundleMetadataConsistencyRule {
10    fn id(&self) -> &'static str {
11        "RULE_BUNDLE_METADATA_CONSISTENCY"
12    }
13
14    fn name(&self) -> &'static str {
15        "Bundle Metadata Consistency"
16    }
17
18    fn category(&self) -> RuleCategory {
19        RuleCategory::Metadata
20    }
21
22    fn severity(&self) -> Severity {
23        Severity::Warning
24    }
25
26    fn recommendation(&self) -> &'static str {
27        "Align CFBundleIdentifier and versioning across app and extensions."
28    }
29
30    fn evaluate(&self, artifact: &ArtifactContext) -> Result<RuleReport, RuleError> {
31        let Some(app_plist) = artifact.info_plist else {
32            return Ok(RuleReport {
33                status: RuleStatus::Skip,
34                message: Some("Info.plist not found".to_string()),
35                evidence: None,
36            });
37        };
38
39        let app_bundle_id = app_plist.get_string("CFBundleIdentifier");
40        let app_short_version = app_plist.get_string("CFBundleShortVersionString");
41        let app_build_version = app_plist.get_string("CFBundleVersion");
42
43        let bundles = find_nested_bundles(artifact.app_bundle_path)
44            .map_err(|_| crate::rules::entitlements::EntitlementsError::ParseFailure)?;
45
46        if bundles.is_empty() {
47            return Ok(RuleReport {
48                status: RuleStatus::Pass,
49                message: Some("No nested bundles found".to_string()),
50                evidence: None,
51            });
52        }
53
54        let mut mismatches = Vec::new();
55
56        for bundle in bundles {
57            if !bundle
58                .bundle_path
59                .extension()
60                .and_then(|e| e.to_str())
61                .map(|ext| ext == "appex" || ext == "app")
62                .unwrap_or(false)
63            {
64                continue;
65            }
66
67            let plist_path = bundle.bundle_path.join("Info.plist");
68            if !plist_path.exists() {
69                continue;
70            }
71
72            let plist = match InfoPlist::from_file(&plist_path) {
73                Ok(plist) => plist,
74                Err(_) => continue,
75            };
76
77            if let (Some(app_id), Some(child_id)) =
78                (app_bundle_id, plist.get_string("CFBundleIdentifier"))
79            {
80                if !child_id.starts_with(app_id) {
81                    mismatches.push(format!(
82                        "{}: CFBundleIdentifier {} not under {}",
83                        bundle.display_name, child_id, app_id
84                    ));
85                }
86            }
87
88            if let (Some(app_short), Some(child_short)) = (
89                app_short_version,
90                plist.get_string("CFBundleShortVersionString"),
91            ) {
92                if child_short != app_short {
93                    mismatches.push(format!(
94                        "{}: CFBundleShortVersionString {} != {}",
95                        bundle.display_name, child_short, app_short
96                    ));
97                }
98            }
99
100            if let (Some(app_build), Some(child_build)) =
101                (app_build_version, plist.get_string("CFBundleVersion"))
102            {
103                if child_build != app_build {
104                    mismatches.push(format!(
105                        "{}: CFBundleVersion {} != {}",
106                        bundle.display_name, child_build, app_build
107                    ));
108                }
109            }
110        }
111
112        if mismatches.is_empty() {
113            return Ok(RuleReport {
114                status: RuleStatus::Pass,
115                message: None,
116                evidence: None,
117            });
118        }
119
120        Ok(RuleReport {
121            status: RuleStatus::Fail,
122            message: Some("Bundle metadata mismatches".to_string()),
123            evidence: Some(mismatches.join("; ")),
124        })
125    }
126}