Skip to main content

provenant/parsers/
swift_manifest_json.rs

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