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