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