Skip to main content

provenant/parsers/
carthage.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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);