verifyos_cli/core/
engine.rs1use crate::parsers::plist_reader::{InfoPlist, PlistError};
2use crate::parsers::zip_extractor::{extract_ipa, ExtractionError};
3use crate::rules::core::{
4 AppStoreRule, ArtifactCacheStats, ArtifactContext, RuleCategory, RuleError, RuleReport,
5 Severity,
6};
7use rayon::prelude::*;
8use std::path::Path;
9use std::time::Instant;
10
11#[derive(Debug, thiserror::Error)]
12pub enum OrchestratorError {
13 #[error("Extraction failed: {0}")]
14 Extraction(#[from] ExtractionError),
15 #[error("Failed to parse Info.plist: {0}")]
16 PlistParse(#[from] PlistError),
17 #[error("Could not locate App Bundle (.app) inside {0}. Found entries: {1}")]
18 AppBundleNotFoundWithContext(String, String),
19 #[error("Could not locate App Bundle (.app) inside IPAPayload")]
20 AppBundleNotFound,
21}
22
23pub struct EngineResult {
24 pub rule_id: &'static str,
25 pub rule_name: &'static str,
26 pub category: RuleCategory,
27 pub severity: Severity,
28 pub target: String,
29 pub recommendation: &'static str,
30 pub report: Result<RuleReport, RuleError>,
31 pub duration_ms: u128,
32}
33
34pub struct EngineRun {
35 pub results: Vec<EngineResult>,
36 pub total_duration_ms: u128,
37 pub cache_stats: ArtifactCacheStats,
38}
39
40pub struct Engine {
41 rules: Vec<Box<dyn AppStoreRule>>,
42 pub xcode_project: Option<crate::parsers::xcode_parser::XcodeProject>,
43}
44
45impl Engine {
46 pub fn new() -> Self {
47 Self {
48 rules: Vec::new(),
49 xcode_project: None,
50 }
51 }
52
53 pub fn register_rule(&mut self, rule: Box<dyn AppStoreRule>) {
54 self.rules.push(rule);
55 }
56
57 pub fn run<P: AsRef<Path>>(&self, path_or_ipa: P) -> Result<EngineRun, OrchestratorError> {
58 let run_started = Instant::now();
59 let path = path_or_ipa.as_ref();
60
61 let mut extracted = None;
62 let targets = if path.is_dir() {
63 let mut targets = Vec::new();
65 let mut queue = vec![path.to_path_buf()];
66 while let Some(dir) = queue.pop() {
67 if let Ok(entries) = std::fs::read_dir(dir) {
68 for entry in entries.flatten() {
69 let p = entry.path();
70 if p.is_dir() {
71 let extension = p.extension().and_then(|e| e.to_str());
72 match extension {
73 Some("app") => targets.push((p.clone(), "app".to_string())),
74 Some("xcodeproj") => {
75 targets.push((p.clone(), "project".to_string()))
76 }
77 Some("xcworkspace") => {
78 targets.push((p.clone(), "workspace".to_string()))
79 }
80 _ => queue.push(p),
81 }
82 } else if p.extension().and_then(|e| e.to_str()) == Some("ipa") {
83 targets.push((p.clone(), "ipa".to_string()));
84 }
85 }
86 }
87 }
88 targets
89 } else {
90 let extracted_ipa = extract_ipa(path)?;
91 let t = extracted_ipa
92 .discover_targets()
93 .map_err(|e| OrchestratorError::Extraction(ExtractionError::Io(e)))?;
94 extracted = Some(extracted_ipa);
95 t
96 };
97
98 let mut all_results = Vec::new();
99 let mut total_cache_stats = ArtifactCacheStats::default();
100
101 if targets.is_empty() {
102 let res = if let Some(ref ext) = extracted {
104 self.run_on_bundle_internal(&ext.payload_dir, run_started, None)?
105 } else {
106 self.run_on_bundle_internal(path, run_started, None)?
107 };
108 return Ok(res);
109 }
110
111 for (target_path, target_type) in targets {
112 let project_context = if target_type == "app" {
113 let mut p_context = None;
115 if let Some(parent) = target_path.parent() {
116 if let Ok(entries) = std::fs::read_dir(parent) {
117 for entry in entries.flatten() {
118 let p = entry.path();
119 if p.extension().is_some_and(|e| e == "xcworkspace") {
120 p_context =
121 crate::parsers::xcworkspace_parser::Xcworkspace::from_path(&p)
122 .ok()
123 .and_then(|ws| ws.project_paths.first().cloned())
124 .and_then(|proj_path| {
125 crate::parsers::xcode_parser::XcodeProject::from_path(
126 proj_path,
127 )
128 .ok()
129 });
130 break;
131 } else if p.extension().is_some_and(|e| e == "xcodeproj") {
132 p_context =
133 crate::parsers::xcode_parser::XcodeProject::from_path(&p).ok();
134 }
135 }
136 }
137 }
138 p_context
139 } else if target_type == "project" {
140 crate::parsers::xcode_parser::XcodeProject::from_path(&target_path).ok()
141 } else if target_type == "workspace" {
142 crate::parsers::xcworkspace_parser::Xcworkspace::from_path(&target_path)
143 .ok()
144 .and_then(|ws| ws.project_paths.first().cloned())
145 .and_then(|proj_path| {
146 crate::parsers::xcode_parser::XcodeProject::from_path(proj_path).ok()
147 })
148 } else {
149 None
150 };
151
152 let app_results =
153 self.run_on_bundle_internal(&target_path, run_started, project_context)?;
154
155 let target_name = target_path
157 .file_name()
158 .map(|n| n.to_string_lossy().into_owned())
159 .unwrap_or_else(|| "Unknown".to_string());
160
161 for mut res in app_results.results {
162 res.target = target_name.clone();
163 all_results.push(res);
164 }
165
166 total_cache_stats.nested_bundles.hits += app_results.cache_stats.nested_bundles.hits;
168 total_cache_stats.nested_bundles.misses +=
169 app_results.cache_stats.nested_bundles.misses;
170 }
171
172 Ok(EngineRun {
173 results: all_results,
174 total_duration_ms: run_started.elapsed().as_millis(),
175 cache_stats: total_cache_stats,
176 })
177 }
178
179 pub fn run_on_bundle<P: AsRef<Path>>(
180 &self,
181 app_bundle_path: P,
182 run_started: Instant,
183 ) -> Result<EngineRun, OrchestratorError> {
184 self.run_on_bundle_internal(app_bundle_path, run_started, None)
185 }
186
187 fn run_on_bundle_internal<P: AsRef<Path>>(
188 &self,
189 app_bundle_path: P,
190 run_started: Instant,
191 project_override: Option<crate::parsers::xcode_parser::XcodeProject>,
192 ) -> Result<EngineRun, OrchestratorError> {
193 let app_bundle_path = app_bundle_path.as_ref();
194 let info_plist_path = app_bundle_path.join("Info.plist");
195 let info_plist = if info_plist_path.exists() {
196 Some(InfoPlist::from_file(&info_plist_path)?)
197 } else {
198 None
199 };
200
201 let context = ArtifactContext::new(
202 app_bundle_path,
203 info_plist.as_ref(),
204 project_override.as_ref().or(self.xcode_project.as_ref()),
205 );
206
207 let results: Vec<EngineResult> = self
208 .rules
209 .par_iter()
210 .map(|rule| {
211 let rule_started = Instant::now();
212 let res = rule.evaluate(&context);
213 EngineResult {
214 rule_id: rule.id(),
215 rule_name: rule.name(),
216 category: rule.category(),
217 severity: rule.severity(),
218 target: app_bundle_path
219 .file_name()
220 .map(|n| n.to_string_lossy().into_owned())
221 .unwrap_or_else(|| "Bundle".to_string()),
222 recommendation: rule.recommendation(),
223 report: res,
224 duration_ms: rule_started.elapsed().as_millis(),
225 }
226 })
227 .collect();
228
229 Ok(EngineRun {
230 results,
231 total_duration_ms: run_started.elapsed().as_millis(),
232 cache_stats: context.cache_stats(),
233 })
234 }
235}
236
237impl Default for Engine {
238 fn default() -> Self {
239 Self::new()
240 }
241}