Skip to main content

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