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::{capped_iteration_limit, 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    // swift requires a namespace (VCS host + owner); a Package.swift manifest
104    // does not declare its own source URL, so the root package's PURL is omitted
105    // rather than emitted namespace-less and spec-invalid.
106    let purl: Option<String> = None;
107
108    PackageData {
109        package_type: Some(SwiftManifestJsonParser::PACKAGE_TYPE),
110        namespace: None,
111        name,
112        version: None,
113        qualifiers: None,
114        subpath: None,
115        primary_language: Some("Swift".to_string()),
116        description: None,
117        release_date: None,
118        parties: Vec::new(),
119        keywords: Vec::new(),
120        homepage_url: None,
121        download_url: None,
122        size: None,
123        sha1: None,
124        md5: None,
125        sha256: None,
126        sha512: None,
127        bug_tracking_url: None,
128        code_view_url: None,
129        vcs_url: None,
130        copyright: None,
131        holder: None,
132        declared_license_expression: None,
133        declared_license_expression_spdx: None,
134        license_detections: Vec::new(),
135        other_license_expression: None,
136        other_license_expression_spdx: None,
137        other_license_detections: Vec::new(),
138        extracted_license_statement: None,
139        notice_text: None,
140        source_packages: Vec::new(),
141        file_references: Vec::new(),
142        is_private: false,
143        is_virtual: false,
144        extra_data: if extra_data.is_empty() {
145            None
146        } else {
147            Some(extra_data)
148        },
149        dependencies,
150        repository_homepage_url: None,
151        repository_download_url: None,
152        api_data_url: None,
153        datasource_id: Some(DatasourceId::SwiftPackageManifestJson),
154        purl,
155    }
156}
157
158fn get_dependencies(dependencies: Option<&Value>) -> Vec<Dependency> {
159    let Some(deps_array) = dependencies.and_then(|v| v.as_array()) else {
160        return Vec::new();
161    };
162
163    let mut dependent_packages = Vec::new();
164
165    let limit = capped_iteration_limit(deps_array.len(), "Swift manifest dependencies");
166    for dependency in deps_array.iter().take(limit) {
167        if let Some(dep) = parse_manifest_dependency(dependency) {
168            dependent_packages.push(dep);
169        }
170    }
171
172    dependent_packages
173}
174
175fn parse_manifest_dependency(dependency: &Value) -> Option<Dependency> {
176    if let Some(source_control) = dependency.get("sourceControl").and_then(|v| v.as_array())
177        && let Some(source) = source_control.first()
178    {
179        let identity = source
180            .get("identity")
181            .and_then(|v| v.as_str())
182            .unwrap_or_default();
183
184        let (mut namespace, mut dep_name) = extract_namespace_and_name(source, identity);
185        namespace = namespace.map(truncate_field);
186        dep_name = truncate_field(dep_name);
187        let (version, is_pinned, requirement_kind) = extract_version_requirement(source);
188        let version = version.map(truncate_field);
189        let purl =
190            create_dependency_purl(&namespace, &dep_name, &version, is_pinned).map(truncate_field);
191        let mut extra_data = HashMap::from([
192            (
193                "dependency_kind".to_string(),
194                serde_json::Value::String("sourceControl".to_string()),
195            ),
196            (
197                "requirement_kind".to_string(),
198                serde_json::Value::String(requirement_kind.to_string()),
199            ),
200        ]);
201        if let Some(remote) = source
202            .get("location")
203            .and_then(|loc| loc.get("remote"))
204            .and_then(|remote| remote.as_array())
205            .and_then(|arr| arr.first())
206            .and_then(|first| first.get("urlString"))
207            .and_then(|v| v.as_str())
208        {
209            extra_data.insert(
210                "location".to_string(),
211                serde_json::Value::String(remote.to_string()),
212            );
213        }
214
215        return Some(Dependency {
216            purl,
217            extracted_requirement: version,
218            scope: Some("dependencies".to_string()),
219            is_runtime: None,
220            is_optional: Some(false),
221            is_pinned: Some(is_pinned),
222            is_direct: Some(true),
223            resolved_package: None,
224            extra_data: Some(extra_data),
225        });
226    }
227
228    if let Some(file_system) = dependency.get("fileSystem").and_then(|v| v.as_array())
229        && let Some(source) = file_system.first()
230    {
231        let identity = source
232            .get("identity")
233            .and_then(|v| v.as_str())
234            .or_else(|| source.get("name").and_then(|v| v.as_str()))
235            .unwrap_or_default();
236        if identity.is_empty() {
237            return None;
238        }
239
240        let dep_name = truncate_field(identity.to_string());
241        let purl = create_dependency_purl(&None, &dep_name, &None, false).map(truncate_field);
242        let mut extra_data = HashMap::from([(
243            "dependency_kind".to_string(),
244            serde_json::Value::String("fileSystem".to_string()),
245        )]);
246        if let Some(path) = source.get("path").and_then(|v| v.as_str()) {
247            extra_data.insert(
248                "path".to_string(),
249                serde_json::Value::String(path.to_string()),
250            );
251        }
252
253        return Some(Dependency {
254            purl,
255            extracted_requirement: None,
256            scope: Some("dependencies".to_string()),
257            is_runtime: None,
258            is_optional: Some(false),
259            is_pinned: Some(false),
260            is_direct: Some(true),
261            resolved_package: None,
262            extra_data: Some(extra_data),
263        });
264    }
265
266    None
267}
268
269fn extract_namespace_and_name(source: &Value, identity: &str) -> (Option<String>, String) {
270    let url = source
271        .get("location")
272        .and_then(|loc| loc.get("remote"))
273        .and_then(|remote| remote.as_array())
274        .and_then(|arr| arr.first())
275        .and_then(|first| first.get("urlString"))
276        .and_then(|v| v.as_str());
277
278    match url {
279        Some(url_str) => get_namespace_and_name(url_str),
280        None => (None, identity.to_string()),
281    }
282}
283
284/// Parses a repository URL into (namespace, name).
285///
286/// Example: `https://github.com/apple/swift-argument-parser.git`
287/// yields namespace=`"github.com/apple"`, name=`"swift-argument-parser"`
288pub fn get_namespace_and_name(url: &str) -> (Option<String>, String) {
289    let (hostname, path) = if let Some(stripped) = url.strip_prefix("https://") {
290        let rest = stripped.trim_end_matches('/');
291        match rest.find('/') {
292            Some(idx) => (Some(&rest[..idx]), &rest[idx + 1..]),
293            None => (Some(rest), ""),
294        }
295    } else if let Some(stripped) = url.strip_prefix("http://") {
296        let rest = stripped.trim_end_matches('/');
297        match rest.find('/') {
298            Some(idx) => (Some(&rest[..idx]), &rest[idx + 1..]),
299            None => (Some(rest), ""),
300        }
301    } else {
302        (None, url)
303    };
304
305    let clean_path = path
306        .strip_suffix(".git")
307        .unwrap_or(path)
308        .trim_end_matches('/');
309
310    if let Some(host) = hostname {
311        let canonical = format!("{}/{}", host, clean_path);
312        match canonical.rsplit_once('/') {
313            Some((ns, name)) => (Some(ns.to_string()), name.to_string()),
314            None => (None, canonical),
315        }
316    } else {
317        match clean_path.rsplit_once('/') {
318            Some((ns, name)) => (Some(ns.to_string()), name.to_string()),
319            None => (None, clean_path.to_string()),
320        }
321    }
322}
323
324/// Handles four requirement types:
325/// - `exact`: `["1.0.0"]` -> version="1.0.0", is_pinned=true
326/// - `range`: `[{"lowerBound": "1.0.0", "upperBound": "2.0.0"}]` -> version="vers:swift/>=1.0.0|<2.0.0", is_pinned=false
327/// - `branch`: `["main"]` -> version="main", is_pinned=false
328/// - `revision`: `["abc123"]` -> version="abc123", is_pinned=true
329fn extract_version_requirement(source: &Value) -> (Option<String>, bool, &'static str) {
330    let Some(requirement) = source.get("requirement") else {
331        return (None, false, "unknown");
332    };
333
334    if let Some(exact) = requirement.get("exact").and_then(|v| v.as_array())
335        && let Some(version) = exact.first().and_then(|v| v.as_str())
336    {
337        return (Some(version.to_string()), true, "exact");
338    }
339
340    if let Some(range) = requirement.get("range").and_then(|v| v.as_array())
341        && let Some(bound) = range.first()
342    {
343        let lower = bound.get("lowerBound").and_then(|v| v.as_str());
344        let upper = bound.get("upperBound").and_then(|v| v.as_str());
345        if let (Some(lb), Some(ub)) = (lower, upper) {
346            let vers = format!("vers:swift/>={lb}|<{ub}");
347            return (Some(vers), false, "range");
348        }
349    }
350
351    if let Some(branch) = requirement.get("branch").and_then(|v| v.as_array())
352        && let Some(branch_name) = branch.first().and_then(|v| v.as_str())
353    {
354        return (Some(branch_name.to_string()), false, "branch");
355    }
356
357    if let Some(revision) = requirement.get("revision").and_then(|v| v.as_array())
358        && let Some(rev) = revision.first().and_then(|v| v.as_str())
359    {
360        return (Some(rev.to_string()), true, "revision");
361    }
362
363    (None, false, "unknown")
364}
365
366fn create_dependency_purl(
367    namespace: &Option<String>,
368    name: &str,
369    version: &Option<String>,
370    is_pinned: bool,
371) -> Option<String> {
372    // swift requires a namespace (VCS host + owner). File-system/path deps and
373    // any source without a resolvable host have none, so omit the PURL rather
374    // than emit a namespace-less, spec-invalid one.
375    let namespace = namespace.as_deref()?;
376    let mut purl = PackageUrl::new(SwiftManifestJsonParser::PACKAGE_TYPE.as_str(), name).ok()?;
377    purl.with_namespace(namespace).ok()?;
378
379    if is_pinned
380        && let Some(v) = version
381        && let Err(e) = purl.with_version(v)
382    {
383        warn!(
384            "Failed to set version '{}' for swift dependency '{}': {}",
385            v, name, e
386        );
387    }
388
389    Some(purl.to_string())
390}
391
392fn default_package_data(path: &Path) -> PackageData {
393    let _ = path;
394
395    PackageData {
396        package_type: Some(SwiftManifestJsonParser::PACKAGE_TYPE),
397        primary_language: Some("Swift".to_string()),
398        datasource_id: Some(DatasourceId::SwiftPackageManifestJson),
399        ..Default::default()
400    }
401}