Skip to main content

provenant/parsers/
swift_resolved.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
6//! Parser for Swift Package.resolved lockfiles (v1, v2, v3).
7//!
8//! Format differences:
9//! - **v1**: Pins under `object.pins[]`, uses `package` and `repositoryURL` fields
10//! - **v2/v3**: Pins under `pins[]`, uses `identity`, `location`, and `kind` fields
11
12use std::path::Path;
13
14use crate::parser_warn as warn;
15use packageurl::PackageUrl;
16use serde::Deserialize;
17use url::Url;
18
19use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
20use crate::parsers::PackageParser;
21use crate::parsers::utils::{capped_iteration_limit, read_file_to_string, truncate_field};
22
23/// Parses Swift Package Manager lockfiles (Package.resolved).
24///
25/// Extracts pinned dependency versions from Swift Package Manager lockfiles.
26/// Supports all three format versions (v1, v2, v3).
27///
28/// # Format Versions
29/// - **v1**: Legacy format with `object.pins` array
30/// - **v2**: Standard format with `pins` array at root
31/// - **v3**: Latest format with `pins` array and enhanced metadata
32///
33/// # Features
34/// - Extracts package identity, repository URL, version/branch/revision
35/// - Generates namespace from repository URL (e.g., github.com/apple)
36/// - Handles exact versions, branch references, and commit SHAs
37///
38/// Typical usage is calling `SwiftPackageResolvedParser::extract_first_package()`
39/// on a `Package.resolved` or `.package.resolved` file.
40pub struct SwiftPackageResolvedParser;
41
42impl PackageParser for SwiftPackageResolvedParser {
43    const PACKAGE_TYPE: PackageType = PackageType::Swift;
44
45    fn is_match(path: &Path) -> bool {
46        path.file_name()
47            .and_then(|name| name.to_str())
48            .is_some_and(|name| name == "Package.resolved" || name == ".package.resolved")
49    }
50
51    fn extract_packages(path: &Path) -> Vec<PackageData> {
52        vec![match parse_resolved(path) {
53            Ok(data) => data,
54            Err(e) => {
55                warn!(
56                    "Failed to parse Swift Package.resolved at {:?}: {}",
57                    path, e
58                );
59                default_package_data()
60            }
61        }]
62    }
63
64    fn metadata() -> Vec<super::metadata::ParserMetadata> {
65        vec![super::metadata::ParserMetadata {
66            description: "Swift Package.resolved lockfile",
67            file_patterns: &["**/Package.resolved", "**/.package.resolved"],
68            package_type: "swift",
69            primary_language: "Swift",
70            documentation_url: Some(
71                "https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html#package-dependency",
72            ),
73        }]
74    }
75}
76
77#[derive(Deserialize)]
78struct ResolvedFile {
79    version: u32,
80    #[serde(default)]
81    pins: Vec<PinV2>,
82    #[serde(default)]
83    object: Option<ObjectV1>,
84}
85
86#[derive(Deserialize)]
87struct ObjectV1 {
88    #[serde(default)]
89    pins: Vec<PinV1>,
90}
91
92#[derive(Deserialize)]
93struct PinV2 {
94    identity: Option<String>,
95    kind: Option<String>,
96    location: Option<String>,
97    #[serde(default)]
98    state: PinState,
99}
100
101#[derive(Deserialize)]
102struct PinV1 {
103    package: Option<String>,
104    #[serde(rename = "repositoryURL")]
105    repository_url: Option<String>,
106    #[serde(default)]
107    state: PinState,
108}
109
110#[derive(Deserialize, Default)]
111struct PinState {
112    version: Option<String>,
113    revision: Option<String>,
114}
115
116fn parse_resolved(path: &Path) -> Result<PackageData, String> {
117    let content = read_file(path)?;
118    let resolved: ResolvedFile =
119        serde_json::from_str(&content).map_err(|e| format!("JSON parse error: {}", e))?;
120
121    let dependencies = match resolved.version {
122        2 | 3 => parse_v2_v3_pins(&resolved.pins),
123        1 => {
124            let pins = resolved
125                .object
126                .as_ref()
127                .map(|o| o.pins.as_slice())
128                .unwrap_or(&[]);
129            parse_v1_pins(pins)
130        }
131        other => {
132            warn!(
133                "Unknown Package.resolved version {}, attempting v2/v3 format",
134                other
135            );
136            parse_v2_v3_pins(&resolved.pins)
137        }
138    };
139
140    Ok(PackageData {
141        package_type: Some(SwiftPackageResolvedParser::PACKAGE_TYPE),
142        namespace: None,
143        name: None,
144        version: None,
145        qualifiers: None,
146        subpath: None,
147        primary_language: Some("Swift".to_string()),
148        description: None,
149        release_date: None,
150        parties: Vec::new(),
151        keywords: Vec::new(),
152        homepage_url: None,
153        download_url: None,
154        size: None,
155        sha1: None,
156        md5: None,
157        sha256: None,
158        sha512: None,
159        bug_tracking_url: None,
160        code_view_url: None,
161        vcs_url: None,
162        copyright: None,
163        holder: None,
164        declared_license_expression: None,
165        declared_license_expression_spdx: None,
166        license_detections: Vec::new(),
167        other_license_expression: None,
168        other_license_expression_spdx: None,
169        other_license_detections: Vec::new(),
170        extracted_license_statement: None,
171        notice_text: None,
172        source_packages: Vec::new(),
173        file_references: Vec::new(),
174        is_private: false,
175        is_virtual: false,
176        extra_data: None,
177        dependencies,
178        repository_homepage_url: None,
179        repository_download_url: None,
180        api_data_url: None,
181        datasource_id: Some(DatasourceId::SwiftPackageResolved),
182        purl: None,
183    })
184}
185
186fn parse_v2_v3_pins(pins: &[PinV2]) -> Vec<Dependency> {
187    let limit = capped_iteration_limit(pins.len(), "Package.resolved v2/v3 pins");
188    pins.iter()
189        .take(limit)
190        .filter_map(pin_v2_to_dependency)
191        .collect()
192}
193
194fn parse_v1_pins(pins: &[PinV1]) -> Vec<Dependency> {
195    let limit = capped_iteration_limit(pins.len(), "Package.resolved v1 pins");
196    pins.iter()
197        .take(limit)
198        .filter_map(pin_v1_to_dependency)
199        .collect()
200}
201
202fn pin_v2_to_dependency(pin: &PinV2) -> Option<Dependency> {
203    let mut name = pin.identity.clone().map(truncate_field);
204    let mut namespace: Option<String> = None;
205
206    if let Some(location) = &pin.location
207        && pin.kind.as_deref() == Some("remoteSourceControl")
208        && let Some((ns, n)) = get_namespace_and_name(location)
209    {
210        namespace = Some(ns);
211        name = Some(n);
212    }
213
214    let name = name?;
215
216    let version = pin
217        .state
218        .version
219        .clone()
220        .or_else(|| pin.state.revision.clone())
221        .map(truncate_field);
222
223    let purl = build_purl(&name, namespace.as_deref(), version.as_deref());
224
225    Some(Dependency {
226        purl: purl.map(truncate_field),
227        extracted_requirement: version,
228        scope: Some("dependencies".to_string()),
229        is_runtime: None,
230        is_optional: None,
231        is_pinned: Some(true),
232        is_direct: None,
233        resolved_package: None,
234        extra_data: None,
235    })
236}
237
238fn pin_v1_to_dependency(pin: &PinV1) -> Option<Dependency> {
239    let mut name = pin.package.clone().map(truncate_field);
240    let mut namespace: Option<String> = None;
241
242    if let Some(url) = &pin.repository_url
243        && let Some((ns, n)) = get_namespace_and_name(url)
244    {
245        namespace = Some(ns);
246        name = Some(n);
247    }
248
249    let name = name?;
250
251    let version = pin
252        .state
253        .version
254        .clone()
255        .or_else(|| pin.state.revision.clone())
256        .map(truncate_field);
257
258    let purl = build_purl(&name, namespace.as_deref(), version.as_deref());
259
260    Some(Dependency {
261        purl: purl.map(truncate_field),
262        extracted_requirement: version,
263        scope: Some("dependencies".to_string()),
264        is_runtime: None,
265        is_optional: None,
266        is_pinned: Some(true),
267        is_direct: None,
268        resolved_package: None,
269        extra_data: None,
270    })
271}
272
273/// Extracts `(namespace, name)` from a repository URL.
274///
275/// `https://github.com/mapbox/turf-swift.git` -> `("github.com/mapbox", "turf-swift")`
276fn get_namespace_and_name(url: &str) -> Option<(String, String)> {
277    let parsed = Url::parse(url).ok()?;
278    let hostname = parsed.host_str()?;
279
280    let path = parsed.path().trim_start_matches('/');
281    let path = path.strip_suffix(".git").unwrap_or(path);
282
283    let canonical = format!("{}/{}", hostname, path);
284
285    let (ns, name) = canonical.rsplit_once('/')?;
286
287    if name.is_empty() {
288        return None;
289    }
290
291    Some((
292        truncate_field(ns.to_string()),
293        truncate_field(name.to_string()),
294    ))
295}
296
297fn build_purl(name: &str, namespace: Option<&str>, version: Option<&str>) -> Option<String> {
298    // swift requires a namespace (VCS host + owner). Without it we cannot emit a
299    // spec-valid PURL, so omit it entirely rather than a name-only best-effort.
300    let namespace = namespace?;
301    let mut purl = PackageUrl::new("swift", name).ok()?;
302    purl.with_namespace(namespace).ok()?;
303    if let Some(v) = version {
304        purl.with_version(v).ok()?;
305    }
306    Some(purl.to_string())
307}
308
309fn read_file(path: &Path) -> Result<String, String> {
310    read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))
311}
312
313fn default_package_data() -> PackageData {
314    PackageData {
315        package_type: Some(SwiftPackageResolvedParser::PACKAGE_TYPE),
316        primary_language: Some("Swift".to_string()),
317        datasource_id: Some(DatasourceId::SwiftPackageResolved),
318        ..Default::default()
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_get_namespace_and_name_github_with_git() {
328        let (ns, name) =
329            get_namespace_and_name("https://github.com/mapbox/turf-swift.git").unwrap();
330        assert_eq!(ns, "github.com/mapbox");
331        assert_eq!(name, "turf-swift");
332    }
333
334    #[test]
335    fn test_get_namespace_and_name_github_without_git() {
336        let (ns, name) = get_namespace_and_name("https://github.com/vapor/vapor").unwrap();
337        assert_eq!(ns, "github.com/vapor");
338        assert_eq!(name, "vapor");
339    }
340
341    #[test]
342    fn test_get_namespace_and_name_deep_path() {
343        let (ns, name) =
344            get_namespace_and_name("https://github.com/swift-server/async-http-client.git")
345                .unwrap();
346        assert_eq!(ns, "github.com/swift-server");
347        assert_eq!(name, "async-http-client");
348    }
349
350    #[test]
351    fn test_get_namespace_and_name_invalid_url() {
352        assert!(get_namespace_and_name("not-a-url").is_none());
353    }
354
355    #[test]
356    fn test_build_purl_with_all_fields() {
357        let purl = build_purl("turf-swift", Some("github.com/mapbox"), Some("2.8.0"));
358        assert_eq!(
359            purl.as_deref(),
360            Some("pkg:swift/github.com/mapbox/turf-swift@2.8.0")
361        );
362    }
363
364    #[test]
365    fn test_build_purl_without_version() {
366        let purl = build_purl("turf-swift", Some("github.com/mapbox"), None);
367        assert_eq!(
368            purl.as_deref(),
369            Some("pkg:swift/github.com/mapbox/turf-swift")
370        );
371    }
372
373    #[test]
374    fn test_build_purl_without_namespace_is_omitted() {
375        // swift requires a namespace; without one the PURL is omitted.
376        let purl = build_purl("MyPackage", None, Some("1.0.0"));
377        assert_eq!(purl, None);
378    }
379}