Skip to main content

provenant/parsers/
swift_resolved.rs

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