Skip to main content

provenant/parsers/
swift_manifest_json.rs

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