1use std::path::Path;
5
6use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
7use crate::parser_warn as warn;
8use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
9use packageurl::PackageUrl;
10
11use super::PackageParser;
12
13pub struct CarthageCartfileParser;
14
15impl PackageParser for CarthageCartfileParser {
16 const PACKAGE_TYPE: PackageType = PackageType::Carthage;
17
18 fn extract_packages(path: &Path) -> Vec<PackageData> {
19 let is_private = is_private_cartfile_path(path);
20 let content = match read_file_to_string(path, None) {
21 Ok(c) => c,
22 Err(e) => {
23 warn!("Failed to read Cartfile at {:?}: {}", path, e);
24 return vec![default_cartfile_package_data(is_private)];
25 }
26 };
27
28 let dependencies = parse_cartfile_lines(&content, false);
29
30 vec![PackageData {
31 package_type: Some(Self::PACKAGE_TYPE),
32 primary_language: Some("Objective-C".to_string()),
33 is_private,
34 dependencies,
35 datasource_id: Some(DatasourceId::CarthageCartfile),
36 ..Default::default()
37 }]
38 }
39
40 fn is_match(path: &Path) -> bool {
41 path.file_name()
42 .is_some_and(|name| name == "Cartfile" || name == "Cartfile.private")
43 }
44}
45
46pub struct CarthageCartfileResolvedParser;
47
48impl PackageParser for CarthageCartfileResolvedParser {
49 const PACKAGE_TYPE: PackageType = PackageType::Carthage;
50
51 fn extract_packages(path: &Path) -> Vec<PackageData> {
52 let content = match read_file_to_string(path, None) {
53 Ok(c) => c,
54 Err(e) => {
55 warn!("Failed to read Cartfile.resolved at {:?}: {}", path, e);
56 return vec![default_cartfile_resolved_package_data()];
57 }
58 };
59
60 let dependencies = parse_cartfile_lines(&content, true);
61
62 vec![PackageData {
63 package_type: Some(Self::PACKAGE_TYPE),
64 primary_language: Some("Objective-C".to_string()),
65 dependencies,
66 datasource_id: Some(DatasourceId::CarthageCartfileResolved),
67 ..Default::default()
68 }]
69 }
70
71 fn is_match(path: &Path) -> bool {
72 path.file_name()
73 .is_some_and(|name| name == "Cartfile.resolved")
74 }
75}
76
77#[derive(Debug, PartialEq)]
78enum OriginType {
79 Github,
80 Git,
81 Binary,
82}
83
84struct ParsedLine {
85 origin: OriginType,
86 source: String,
87 version_spec: Option<String>,
88}
89
90fn parse_cartfile_lines(content: &str, is_resolved: bool) -> Vec<Dependency> {
91 let mut dependencies = Vec::new();
92
93 for line in content.lines().take(MAX_ITERATION_COUNT) {
94 let line = line.trim();
95 if line.is_empty() || line.starts_with('#') {
96 continue;
97 }
98
99 let Some(parsed) = parse_line(line) else {
100 warn!("Failed to parse Cartfile line: {}", line);
101 continue;
102 };
103
104 let purl_version = if is_resolved {
105 parsed.version_spec.as_deref()
106 } else {
107 None
108 };
109
110 let (purl, name) = match parsed.origin {
111 OriginType::Github => make_github_purl(&parsed.source, purl_version),
112 OriginType::Git => make_git_dep_info(&parsed.source),
113 OriginType::Binary => make_binary_dep_info(&parsed.source),
114 };
115
116 let extracted_requirement = parsed.version_spec.map(truncate_field);
117
118 let is_pinned = if is_resolved { Some(true) } else { None };
119
120 dependencies.push(Dependency {
121 purl: purl.map(truncate_field),
122 extracted_requirement,
123 scope: Some("dependencies".to_string()),
124 is_runtime: None,
125 is_optional: None,
126 is_pinned,
127 is_direct: Some(true),
128 resolved_package: None,
129 extra_data: name.map(|n| {
130 let mut map = std::collections::HashMap::new();
131 map.insert("name".to_string(), serde_json::json!(n));
132 map
133 }),
134 });
135 }
136
137 dependencies
138}
139
140fn parse_line(line: &str) -> Option<ParsedLine> {
141 let (origin, rest) = if let Some(rest) = line.strip_prefix("github") {
142 (OriginType::Github, rest.trim())
143 } else if let Some(rest) = line.strip_prefix("git") {
144 (OriginType::Git, rest.trim())
145 } else if let Some(rest) = line.strip_prefix("binary") {
146 (OriginType::Binary, rest.trim())
147 } else {
148 return None;
149 };
150
151 let (source, remaining) = extract_quoted_string(rest)?;
152
153 let version_spec = extract_version_spec(remaining.trim());
154
155 Some(ParsedLine {
156 origin,
157 source,
158 version_spec,
159 })
160}
161
162fn extract_quoted_string(s: &str) -> Option<(String, &str)> {
163 let s = s.trim();
164 if !s.starts_with('"') {
165 return None;
166 }
167 let rest = &s[1..];
168 let end = rest.find('"')?;
169 Some((rest[..end].to_string(), &rest[end + 1..]))
170}
171
172fn extract_version_spec(s: &str) -> Option<String> {
173 let s = strip_inline_comment(s.trim());
174 if s.is_empty() || s.starts_with('#') {
175 return None;
176 }
177
178 let spec = if let Some(rest) = s.strip_prefix("~>") {
179 format!("~> {}", rest.trim())
180 } else if let Some(rest) = s.strip_prefix(">=") {
181 format!(">= {}", rest.trim())
182 } else if let Some(rest) = s.strip_prefix("==") {
183 format!("== {}", rest.trim())
184 } else if s.starts_with('"') {
185 let (version, _) = extract_quoted_string(s)?;
186 version
187 } else {
188 s.to_string()
189 };
190
191 if spec.is_empty() { None } else { Some(spec) }
192}
193
194fn strip_inline_comment(s: &str) -> &str {
195 s.find('#').map_or(s, |i| s[..i].trim_end())
196}
197
198fn make_github_purl(source: &str, version: Option<&str>) -> (Option<String>, Option<String>) {
199 let parts: Vec<&str> = source.splitn(2, '/').collect();
200 if parts.len() != 2 {
201 warn!("Invalid GitHub source in Cartfile: {}", source);
202 return (None, Some(source.to_string()));
203 }
204
205 let namespace = parts[0];
206 let name = parts[1];
207
208 let purl = match PackageUrl::new("github", name) {
209 Ok(mut p) => {
210 if let Err(e) = p.with_namespace(namespace) {
211 warn!(
212 "Failed to set namespace for github purl '{}': {}",
213 source, e
214 );
215 return (None, Some(name.to_string()));
216 }
217 if let Some(v) = version
218 && let Err(e) = p.with_version(v)
219 {
220 warn!(
221 "Failed to set version '{}' for github purl '{}': {}",
222 v, source, e
223 );
224 }
225 Some(p.to_string())
226 }
227 Err(e) => {
228 warn!("Failed to create PackageUrl for github '{}': {}", source, e);
229 None
230 }
231 };
232
233 (purl, Some(name.to_string()))
234}
235
236fn make_git_dep_info(source: &str) -> (Option<String>, Option<String>) {
237 let name = source
238 .rsplit('/')
239 .next()
240 .map(|s| s.strip_suffix(".git").unwrap_or(s))
241 .filter(|s| !s.is_empty())
242 .map(String::from);
243
244 (None, name)
245}
246
247fn make_binary_dep_info(source: &str) -> (Option<String>, Option<String>) {
248 let name = source
249 .rsplit('/')
250 .next()
251 .and_then(|s| s.strip_suffix(".json"))
252 .filter(|s| !s.is_empty())
253 .map(String::from);
254
255 (None, name)
256}
257
258fn is_private_cartfile_path(path: &Path) -> bool {
259 path.file_name()
260 .is_some_and(|name| name == "Cartfile.private")
261}
262
263fn default_cartfile_package_data(is_private: bool) -> PackageData {
264 PackageData {
265 package_type: Some(PackageType::Carthage),
266 primary_language: Some("Objective-C".to_string()),
267 is_private,
268 datasource_id: Some(DatasourceId::CarthageCartfile),
269 ..Default::default()
270 }
271}
272
273fn default_cartfile_resolved_package_data() -> PackageData {
274 PackageData {
275 package_type: Some(PackageType::Carthage),
276 primary_language: Some("Objective-C".to_string()),
277 datasource_id: Some(DatasourceId::CarthageCartfileResolved),
278 ..Default::default()
279 }
280}
281
282crate::register_parser!(
283 "Carthage Cartfile dependency manifest",
284 &["**/Cartfile", "**/Cartfile.private"],
285 "carthage",
286 "Objective-C",
287 Some("https://github.com/Carthage/Carthage/blob/master/Documentation/Artifacts.md"),
288);
289
290crate::register_parser!(
291 "Carthage Cartfile.resolved pinned dependencies",
292 &["**/Cartfile.resolved"],
293 "carthage",
294 "Objective-C",
295 Some("https://github.com/Carthage/Carthage/blob/master/Documentation/Artifacts.md"),
296);