Skip to main content

provenant/parsers/
swift_manifest_json.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use super::utils::{MAX_ITERATION_COUNT, truncate_field};
8
9use crate::parser_warn as warn;
10use packageurl::PackageUrl;
11use serde_json::Value;
12
13use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
14
15use super::PackageParser;
16
17/// Swift Package Manager manifest parser.
18///
19/// The parser reads pre-generated manifest JSON surfaces such as
20/// `Package.swift.json` and `Package.swift.deplock`.
21pub struct SwiftManifestJsonParser;
22
23impl PackageParser for SwiftManifestJsonParser {
24    const PACKAGE_TYPE: PackageType = PackageType::Swift;
25
26    fn extract_packages(path: &Path) -> Vec<PackageData> {
27        let filename = path.file_name().and_then(|n| n.to_str());
28
29        vec![if filename
30            .map(|n| n.ends_with(".swift.json") || n.ends_with(".swift.deplock"))
31            .unwrap_or(false)
32        {
33            let json_content = match read_swift_manifest_json(path) {
34                Ok(content) => content,
35                Err(e) => {
36                    warn!(
37                        "Failed to read or parse Swift manifest JSON at {:?}: {}",
38                        path, e
39                    );
40                    return vec![default_package_data(path)];
41                }
42            };
43            parse_swift_manifest(&json_content)
44        } else {
45            default_package_data(path)
46        }]
47    }
48
49    fn is_match(path: &Path) -> bool {
50        path.file_name()
51            .and_then(|name| name.to_str())
52            .is_some_and(|name| name.ends_with(".swift.json") || name.ends_with(".swift.deplock"))
53    }
54
55    fn metadata() -> Vec<super::metadata::ParserMetadata> {
56        vec![super::metadata::ParserMetadata {
57            description: "Swift Package Manager manifest JSON (Package.swift.json, Package.swift.deplock)",
58            file_patterns: &["**/Package.swift.json", "**/Package.swift.deplock"],
59            package_type: "swift",
60            primary_language: "Swift",
61            documentation_url: Some(
62                "https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html",
63            ),
64        }]
65    }
66}
67
68fn read_swift_manifest_json(path: &Path) -> Result<Value, String> {
69    let content = crate::parsers::utils::read_file_to_string(path, None)
70        .map_err(|e| format!("Failed to read file: {}", e))?;
71
72    serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
73}
74
75fn parse_swift_manifest(manifest: &Value) -> PackageData {
76    let name = manifest
77        .get("name")
78        .and_then(|v| v.as_str())
79        .map(|s| truncate_field(s.to_string()));
80
81    let dependencies = get_dependencies(manifest.get("dependencies"));
82    let platforms = manifest.get("platforms").cloned();
83
84    let tools_version = manifest
85        .get("toolsVersion")
86        .and_then(|tv| tv.get("_version"))
87        .and_then(|v| v.as_str())
88        .map(|s| truncate_field(s.to_string()));
89
90    let mut extra_data = HashMap::new();
91    if let Some(platforms_val) = platforms {
92        extra_data.insert("platforms".to_string(), platforms_val);
93    }
94    if let Some(ref tv) = tools_version {
95        extra_data.insert(
96            "swift_tools_version".to_string(),
97            serde_json::Value::String(tv.clone()),
98        );
99    }
100
101    let purl = create_package_url(&name, &None).map(truncate_field);
102
103    PackageData {
104        package_type: Some(SwiftManifestJsonParser::PACKAGE_TYPE),
105        namespace: None,
106        name,
107        version: None,
108        qualifiers: None,
109        subpath: None,
110        primary_language: Some("Swift".to_string()),
111        description: None,
112        release_date: None,
113        parties: Vec::new(),
114        keywords: Vec::new(),
115        homepage_url: None,
116        download_url: None,
117        size: None,
118        sha1: None,
119        md5: None,
120        sha256: None,
121        sha512: None,
122        bug_tracking_url: None,
123        code_view_url: None,
124        vcs_url: None,
125        copyright: None,
126        holder: None,
127        declared_license_expression: None,
128        declared_license_expression_spdx: None,
129        license_detections: Vec::new(),
130        other_license_expression: None,
131        other_license_expression_spdx: None,
132        other_license_detections: Vec::new(),
133        extracted_license_statement: None,
134        notice_text: None,
135        source_packages: Vec::new(),
136        file_references: Vec::new(),
137        is_private: false,
138        is_virtual: false,
139        extra_data: if extra_data.is_empty() {
140            None
141        } else {
142            Some(extra_data)
143        },
144        dependencies,
145        repository_homepage_url: None,
146        repository_download_url: None,
147        api_data_url: None,
148        datasource_id: Some(DatasourceId::SwiftPackageManifestJson),
149        purl,
150    }
151}
152
153fn get_dependencies(dependencies: Option<&Value>) -> Vec<Dependency> {
154    let Some(deps_array) = dependencies.and_then(|v| v.as_array()) else {
155        return Vec::new();
156    };
157
158    let mut dependent_packages = Vec::new();
159
160    for dependency in deps_array.iter().take(MAX_ITERATION_COUNT) {
161        if let Some(dep) = parse_manifest_dependency(dependency) {
162            dependent_packages.push(dep);
163        }
164    }
165
166    dependent_packages
167}
168
169fn parse_manifest_dependency(dependency: &Value) -> Option<Dependency> {
170    if let Some(source_control) = dependency.get("sourceControl").and_then(|v| v.as_array())
171        && let Some(source) = source_control.first()
172    {
173        let identity = source
174            .get("identity")
175            .and_then(|v| v.as_str())
176            .unwrap_or_default();
177
178        let (mut namespace, mut dep_name) = extract_namespace_and_name(source, identity);
179        namespace = namespace.map(truncate_field);
180        dep_name = truncate_field(dep_name);
181        let (version, is_pinned, requirement_kind) = extract_version_requirement(source);
182        let version = version.map(truncate_field);
183        let purl = truncate_field(create_dependency_purl(
184            &namespace, &dep_name, &version, is_pinned,
185        ));
186        let mut extra_data = HashMap::from([
187            (
188                "dependency_kind".to_string(),
189                serde_json::Value::String("sourceControl".to_string()),
190            ),
191            (
192                "requirement_kind".to_string(),
193                serde_json::Value::String(requirement_kind.to_string()),
194            ),
195        ]);
196        if let Some(remote) = source
197            .get("location")
198            .and_then(|loc| loc.get("remote"))
199            .and_then(|remote| remote.as_array())
200            .and_then(|arr| arr.first())
201            .and_then(|first| first.get("urlString"))
202            .and_then(|v| v.as_str())
203        {
204            extra_data.insert(
205                "location".to_string(),
206                serde_json::Value::String(remote.to_string()),
207            );
208        }
209
210        return Some(Dependency {
211            purl: Some(purl),
212            extracted_requirement: version,
213            scope: Some("dependencies".to_string()),
214            is_runtime: None,
215            is_optional: Some(false),
216            is_pinned: Some(is_pinned),
217            is_direct: Some(true),
218            resolved_package: None,
219            extra_data: Some(extra_data),
220        });
221    }
222
223    if let Some(file_system) = dependency.get("fileSystem").and_then(|v| v.as_array())
224        && let Some(source) = file_system.first()
225    {
226        let identity = source
227            .get("identity")
228            .and_then(|v| v.as_str())
229            .or_else(|| source.get("name").and_then(|v| v.as_str()))
230            .unwrap_or_default();
231        if identity.is_empty() {
232            return None;
233        }
234
235        let dep_name = truncate_field(identity.to_string());
236        let purl = truncate_field(create_dependency_purl(&None, &dep_name, &None, false));
237        let mut extra_data = HashMap::from([(
238            "dependency_kind".to_string(),
239            serde_json::Value::String("fileSystem".to_string()),
240        )]);
241        if let Some(path) = source.get("path").and_then(|v| v.as_str()) {
242            extra_data.insert(
243                "path".to_string(),
244                serde_json::Value::String(path.to_string()),
245            );
246        }
247
248        return Some(Dependency {
249            purl: Some(purl),
250            extracted_requirement: None,
251            scope: Some("dependencies".to_string()),
252            is_runtime: None,
253            is_optional: Some(false),
254            is_pinned: Some(false),
255            is_direct: Some(true),
256            resolved_package: None,
257            extra_data: Some(extra_data),
258        });
259    }
260
261    None
262}
263
264fn extract_namespace_and_name(source: &Value, identity: &str) -> (Option<String>, String) {
265    let url = source
266        .get("location")
267        .and_then(|loc| loc.get("remote"))
268        .and_then(|remote| remote.as_array())
269        .and_then(|arr| arr.first())
270        .and_then(|first| first.get("urlString"))
271        .and_then(|v| v.as_str());
272
273    match url {
274        Some(url_str) => get_namespace_and_name(url_str),
275        None => (None, identity.to_string()),
276    }
277}
278
279/// Parses a repository URL into (namespace, name).
280///
281/// Example: `https://github.com/apple/swift-argument-parser.git`
282/// yields namespace=`"github.com/apple"`, name=`"swift-argument-parser"`
283pub fn get_namespace_and_name(url: &str) -> (Option<String>, String) {
284    let (hostname, path) = if let Some(stripped) = url.strip_prefix("https://") {
285        let rest = stripped.trim_end_matches('/');
286        match rest.find('/') {
287            Some(idx) => (Some(&rest[..idx]), &rest[idx + 1..]),
288            None => (Some(rest), ""),
289        }
290    } else if let Some(stripped) = url.strip_prefix("http://") {
291        let rest = stripped.trim_end_matches('/');
292        match rest.find('/') {
293            Some(idx) => (Some(&rest[..idx]), &rest[idx + 1..]),
294            None => (Some(rest), ""),
295        }
296    } else {
297        (None, url)
298    };
299
300    let clean_path = path
301        .strip_suffix(".git")
302        .unwrap_or(path)
303        .trim_end_matches('/');
304
305    if let Some(host) = hostname {
306        let canonical = format!("{}/{}", host, clean_path);
307        match canonical.rsplit_once('/') {
308            Some((ns, name)) => (Some(ns.to_string()), name.to_string()),
309            None => (None, canonical),
310        }
311    } else {
312        match clean_path.rsplit_once('/') {
313            Some((ns, name)) => (Some(ns.to_string()), name.to_string()),
314            None => (None, clean_path.to_string()),
315        }
316    }
317}
318
319/// Handles four requirement types:
320/// - `exact`: `["1.0.0"]` -> version="1.0.0", is_pinned=true
321/// - `range`: `[{"lowerBound": "1.0.0", "upperBound": "2.0.0"}]` -> version="vers:swift/>=1.0.0|<2.0.0", is_pinned=false
322/// - `branch`: `["main"]` -> version="main", is_pinned=false
323/// - `revision`: `["abc123"]` -> version="abc123", is_pinned=true
324fn extract_version_requirement(source: &Value) -> (Option<String>, bool, &'static str) {
325    let Some(requirement) = source.get("requirement") else {
326        return (None, false, "unknown");
327    };
328
329    if let Some(exact) = requirement.get("exact").and_then(|v| v.as_array())
330        && let Some(version) = exact.first().and_then(|v| v.as_str())
331    {
332        return (Some(version.to_string()), true, "exact");
333    }
334
335    if let Some(range) = requirement.get("range").and_then(|v| v.as_array())
336        && let Some(bound) = range.first()
337    {
338        let lower = bound.get("lowerBound").and_then(|v| v.as_str());
339        let upper = bound.get("upperBound").and_then(|v| v.as_str());
340        if let (Some(lb), Some(ub)) = (lower, upper) {
341            let vers = format!("vers:swift/>={lb}|<{ub}");
342            return (Some(vers), false, "range");
343        }
344    }
345
346    if let Some(branch) = requirement.get("branch").and_then(|v| v.as_array())
347        && let Some(branch_name) = branch.first().and_then(|v| v.as_str())
348    {
349        return (Some(branch_name.to_string()), false, "branch");
350    }
351
352    if let Some(revision) = requirement.get("revision").and_then(|v| v.as_array())
353        && let Some(rev) = revision.first().and_then(|v| v.as_str())
354    {
355        return (Some(rev.to_string()), true, "revision");
356    }
357
358    (None, false, "unknown")
359}
360
361fn create_dependency_purl(
362    namespace: &Option<String>,
363    name: &str,
364    version: &Option<String>,
365    is_pinned: bool,
366) -> String {
367    let mut purl = match PackageUrl::new(SwiftManifestJsonParser::PACKAGE_TYPE.as_str(), name) {
368        Ok(p) => p,
369        Err(e) => {
370            warn!(
371                "Failed to create PackageUrl for swift dependency '{}': {}",
372                name, e
373            );
374            return match (namespace, is_pinned.then_some(version.as_deref()).flatten()) {
375                (Some(ns), Some(v)) => format!("pkg:swift/{}/{}@{}", ns, name, v),
376                (Some(ns), None) => format!("pkg:swift/{}/{}", ns, name),
377                (None, Some(v)) => format!("pkg:swift/{}@{}", name, v),
378                (None, None) => format!("pkg:swift/{}", name),
379            };
380        }
381    };
382
383    if let Some(ns) = namespace
384        && let Err(e) = purl.with_namespace(ns)
385    {
386        warn!(
387            "Failed to set namespace '{}' for swift dependency '{}': {}",
388            ns, name, e
389        );
390    }
391
392    if is_pinned
393        && let Some(v) = version
394        && let Err(e) = purl.with_version(v)
395    {
396        warn!(
397            "Failed to set version '{}' for swift dependency '{}': {}",
398            v, name, e
399        );
400    }
401
402    purl.to_string()
403}
404
405fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
406    name.as_ref().and_then(|name| {
407        let mut package_url =
408            match PackageUrl::new(SwiftManifestJsonParser::PACKAGE_TYPE.as_str(), name) {
409                Ok(p) => p,
410                Err(e) => {
411                    warn!(
412                        "Failed to create PackageUrl for swift package '{}': {}",
413                        name, e
414                    );
415                    return None;
416                }
417            };
418
419        if let Some(v) = version
420            && let Err(e) = package_url.with_version(v)
421        {
422            warn!(
423                "Failed to set version '{}' for swift package '{}': {}",
424                v, name, e
425            );
426            return None;
427        }
428
429        Some(package_url.to_string())
430    })
431}
432
433fn default_package_data(path: &Path) -> PackageData {
434    let _ = path;
435
436    PackageData {
437        package_type: Some(SwiftManifestJsonParser::PACKAGE_TYPE),
438        primary_language: Some("Swift".to_string()),
439        datasource_id: Some(DatasourceId::SwiftPackageManifestJson),
440        ..Default::default()
441    }
442}