Skip to main content

verifyos_cli/core/
engine.rs

1use 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            // Direct directory scan: mock ExtractedIpa discover_targets behavior
64            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            // Fallback to original logic if no specific targets found
103            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                // Try to find a project context for this app if it's in a larger folder
114                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            // Tag results with target name
156            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            // Merge stats
167            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}