Skip to content

Commit c58b59c

Browse files
committed
feat(cli): support xcworkspace project context
1 parent 4a431e6 commit c58b59c

3 files changed

Lines changed: 189 additions & 17 deletions

File tree

src/main.rs

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -233,35 +233,33 @@ fn main() -> Result<()> {
233233
let project_path = if let Some(p) = args.project {
234234
Some(p)
235235
} else {
236-
// Auto-detect sibling .xcodeproj
237-
let mut xcode_proj = None;
236+
// Auto-detect sibling .xcworkspace or .xcodeproj
237+
let mut workspace = None;
238+
let mut project = None;
238239
if let Some(parent) = app_path.parent() {
239240
if let Ok(entries) = std::fs::read_dir(parent) {
240241
for entry in entries.flatten() {
241242
let path = entry.path();
242-
if path.extension().is_some_and(|ext| ext == "xcodeproj") {
243-
xcode_proj = Some(path);
244-
break;
243+
if workspace.is_none()
244+
&& path
245+
.extension()
246+
.is_some_and(|ext| ext.eq_ignore_ascii_case("xcworkspace"))
247+
{
248+
workspace = Some(path.clone());
249+
}
250+
if project.is_none() && path.extension().is_some_and(|ext| ext == "xcodeproj") {
251+
project = Some(path);
245252
}
246253
}
247254
}
248255
}
249-
xcode_proj
256+
workspace.or(project)
250257
};
251258

252259
if let Some(path) = project_path {
253260
pb.set_message(format!("Loading Xcode project: {}...", path.display()));
254-
match verifyos_cli::parsers::xcode_parser::XcodeProject::from_path(&path) {
255-
Ok(proj) => engine.xcode_project = Some(proj),
256-
Err(e) => {
257-
pb.suspend(|| {
258-
eprintln!(
259-
"Warning: Failed to load Xcode project at {}: {}",
260-
path.display(),
261-
e
262-
);
263-
});
264-
}
261+
if let Some(project) = load_xcode_project(&path) {
262+
engine.xcode_project = Some(project);
265263
}
266264
}
267265

@@ -361,6 +359,63 @@ fn run_scan_for_agent_pack(
361359
Ok(agent_pack)
362360
}
363361

362+
fn load_xcode_project(
363+
path: &std::path::Path,
364+
) -> Option<verifyos_cli::parsers::xcode_parser::XcodeProject> {
365+
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
366+
if extension.eq_ignore_ascii_case("xcworkspace") {
367+
match verifyos_cli::parsers::xcworkspace_parser::Xcworkspace::from_path(path) {
368+
Ok(workspace) => {
369+
for project_path in workspace.project_paths {
370+
match verifyos_cli::parsers::xcode_parser::XcodeProject::from_path(
371+
&project_path,
372+
) {
373+
Ok(project) => return Some(project),
374+
Err(err) => {
375+
eprintln!(
376+
"Warning: Failed to load Xcode project at {}: {}",
377+
project_path.display(),
378+
err
379+
);
380+
}
381+
}
382+
}
383+
eprintln!(
384+
"Warning: No usable .xcodeproj found in workspace {}",
385+
path.display()
386+
);
387+
None
388+
}
389+
Err(err) => {
390+
eprintln!(
391+
"Warning: Failed to read Xcode workspace at {}: {}",
392+
path.display(),
393+
err
394+
);
395+
None
396+
}
397+
}
398+
} else if extension.eq_ignore_ascii_case("xcodeproj") {
399+
match verifyos_cli::parsers::xcode_parser::XcodeProject::from_path(path) {
400+
Ok(project) => Some(project),
401+
Err(err) => {
402+
eprintln!(
403+
"Warning: Failed to load Xcode project at {}: {}",
404+
path.display(),
405+
err
406+
);
407+
None
408+
}
409+
}
410+
} else {
411+
eprintln!(
412+
"Warning: Unsupported project type at {} (expected .xcodeproj or .xcworkspace)",
413+
path.display()
414+
);
415+
None
416+
}
417+
}
418+
364419
fn render_rule_inventory(output_format: OutputFormat) -> Result<()> {
365420
let inventory = rule_inventory();
366421
match output_format {

src/parsers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ pub mod macho_scanner;
44
pub mod plist_reader;
55
pub mod provisioning_profile;
66
pub mod xcode_parser;
7+
pub mod xcworkspace_parser;
78
pub mod zip_extractor;

src/parsers/xcworkspace_parser.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use miette::Diagnostic;
2+
use std::path::{Path, PathBuf};
3+
use thiserror::Error;
4+
5+
#[derive(Debug, Error, Diagnostic)]
6+
pub enum WorkspaceError {
7+
#[error("Failed to read Xcode workspace at {path}")]
8+
ReadError { path: String, description: String },
9+
#[error("Missing contents.xcworkspacedata in workspace {path}")]
10+
MissingContents { path: String },
11+
}
12+
13+
#[derive(Debug, Clone)]
14+
pub struct Xcworkspace {
15+
pub project_paths: Vec<PathBuf>,
16+
}
17+
18+
impl Xcworkspace {
19+
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
20+
let path = path.as_ref();
21+
let contents_path = if path
22+
.extension()
23+
.is_some_and(|ext| ext.eq_ignore_ascii_case("xcworkspacedata"))
24+
{
25+
path.to_path_buf()
26+
} else {
27+
let workspace_root = path;
28+
let contents = workspace_root.join("contents.xcworkspacedata");
29+
if !contents.exists() {
30+
return Err(WorkspaceError::MissingContents {
31+
path: workspace_root.display().to_string(),
32+
});
33+
}
34+
contents
35+
};
36+
37+
let data =
38+
std::fs::read_to_string(&contents_path).map_err(|e| WorkspaceError::ReadError {
39+
path: contents_path.display().to_string(),
40+
description: format!("{e}"),
41+
})?;
42+
43+
let workspace_dir = contents_path
44+
.parent()
45+
.map(Path::to_path_buf)
46+
.unwrap_or_else(|| PathBuf::from("."));
47+
48+
let mut project_paths = Vec::new();
49+
for location in extract_locations(&data) {
50+
if let Some(path) = resolve_location(&workspace_dir, &location) {
51+
if path.extension().is_some_and(|ext| ext == "xcodeproj") {
52+
project_paths.push(path);
53+
}
54+
}
55+
}
56+
57+
Ok(Self { project_paths })
58+
}
59+
}
60+
61+
fn extract_locations(data: &str) -> Vec<String> {
62+
let mut locations = Vec::new();
63+
let needle = "location=\"";
64+
let mut start = 0;
65+
while let Some(pos) = data[start..].find(needle) {
66+
let idx = start + pos + needle.len();
67+
if let Some(end) = data[idx..].find('"') {
68+
locations.push(data[idx..idx + end].to_string());
69+
start = idx + end + 1;
70+
} else {
71+
break;
72+
}
73+
}
74+
locations
75+
}
76+
77+
fn resolve_location(workspace_dir: &Path, location: &str) -> Option<PathBuf> {
78+
if let Some(rest) = location.strip_prefix("group:") {
79+
Some(workspace_dir.join(rest))
80+
} else if let Some(rest) = location.strip_prefix("container:") {
81+
Some(workspace_dir.join(rest))
82+
} else if let Some(rest) = location.strip_prefix("absolute:") {
83+
Some(PathBuf::from(rest))
84+
} else if location.starts_with('/') {
85+
Some(PathBuf::from(location))
86+
} else {
87+
Some(workspace_dir.join(location))
88+
}
89+
}
90+
91+
#[cfg(test)]
92+
mod tests {
93+
use super::*;
94+
use std::fs;
95+
use tempfile::tempdir;
96+
97+
#[test]
98+
fn parses_workspace_file_refs() {
99+
let dir = tempdir().expect("tempdir");
100+
let workspace_dir = dir.path().join("Demo.xcworkspace");
101+
fs::create_dir_all(&workspace_dir).expect("workspace dir");
102+
let contents = workspace_dir.join("contents.xcworkspacedata");
103+
fs::write(
104+
&contents,
105+
r#"<?xml version="1.0" encoding="UTF-8"?>
106+
<Workspace version="1.0">
107+
<FileRef location="group:Demo.xcodeproj"></FileRef>
108+
</Workspace>"#,
109+
)
110+
.expect("write contents");
111+
112+
let workspace = Xcworkspace::from_path(&workspace_dir).expect("parse workspace");
113+
assert_eq!(workspace.project_paths.len(), 1);
114+
assert!(workspace.project_paths[0].ends_with("Demo.xcodeproj"));
115+
}
116+
}

0 commit comments

Comments
 (0)