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    fn metadata() -> Vec<super::metadata::ParserMetadata> {
63        vec![super::metadata::ParserMetadata {
64            description: "Swift Package.resolved lockfile",
65            file_patterns: &["**/Package.resolved", "**/.package.resolved"],
66            package_type: "swift",
67            primary_language: "Swift",
68            documentation_url: Some(
69                "https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html#package-dependency",
70            ),
71        }]
72    }
73}
74
75#[derive(Deserialize)]
76struct ResolvedFile {
77    version: u32,
78    #[serde(default)]
79    pins: Vec<PinV2>,
80    #[serde(default)]
81    object: Option<ObjectV1>,
82}
83
84#[derive(Deserialize)]
85struct ObjectV1 {
86    #[serde(default)]
87    pins: Vec<PinV1>,
88}
89
90#[derive(Deserialize)]
91struct PinV2 {
92    identity: Option<String>,
93    kind: Option<String>,
94    location: Option<String>,
95    #[serde(default)]
96    state: PinState,
97}
98
99#[derive(Deserialize)]
100struct PinV1 {
101    package: Option<String>,
102    #[serde(rename = "repositoryURL")]
103    repository_url: Option<String>,
104    #[serde(default)]
105    state: PinState,
106}
107
108#[derive(Deserialize, Default)]
109struct PinState {
110    version: Option<String>,
111    revision: Option<String>,
112}
113
114fn parse_resolved(path: &Path) -> Result<PackageData, String> {
115    let content = read_file(path)?;
116    let resolved: ResolvedFile =
117        serde_json::from_str(&content).map_err(|e| format!("JSON parse error: {}", e))?;
118
119    let dependencies = match resolved.version {
120        2 | 3 => parse_v2_v3_pins(&resolved.pins),
121        1 => {
122            let pins = resolved
123                .object
124                .as_ref()
125                .map(|o| o.pins.as_slice())
126                .unwrap_or(&[]);
127            parse_v1_pins(pins)
128        }
129        other => {
130            warn!(
131                "Unknown Package.resolved version {}, attempting v2/v3 format",
132                other
133            );
134            parse_v2_v3_pins(&resolved.pins)
135        }
136    };
137
138    Ok(PackageData {
139        package_type: Some(SwiftPackageResolvedParser::PACKAGE_TYPE),
140        namespace: None,
141        name: None,
142        version: None,
143        qualifiers: None,
144        subpath: None,
145        primary_language: Some("Swift".to_string()),
146        description: None,
147        release_date: None,
148        parties: Vec::new(),
149        keywords: Vec::new(),
150        homepage_url: None,
151        download_url: None,
152        size: None,
153        sha1: None,
154        md5: None,
155        sha256: None,
156        sha512: None,
157        bug_tracking_url: None,
158        code_view_url: None,
159        vcs_url: None,
160        copyright: None,
161        holder: None,
162        declared_license_expression: None,
163        declared_license_expression_spdx: None,
164        license_detections: Vec::new(),
165        other_license_expression: None,
166        other_license_expression_spdx: None,
167        other_license_detections: Vec::new(),
168        extracted_license_statement: None,
169        notice_text: None,
170        source_packages: Vec::new(),
171        file_references: Vec::new(),
172        is_private: false,
173        is_virtual: false,
174        extra_data: None,
175        dependencies,
176        repository_homepage_url: None,
177        repository_download_url: None,
178        api_data_url: None,
179        datasource_id: Some(DatasourceId::SwiftPackageResolved),
180        purl: None,
181    })
182}
183
184fn parse_v2_v3_pins(pins: &[PinV2]) -> Vec<Dependency> {
185    pins.iter()
186        .take(MAX_ITERATION_COUNT)
187        .filter_map(pin_v2_to_dependency)
188        .collect()
189}
190
191fn parse_v1_pins(pins: &[PinV1]) -> Vec<Dependency> {
192    pins.iter()
193        .take(MAX_ITERATION_COUNT)
194        .filter_map(pin_v1_to_dependency)
195        .collect()
196}
197
198fn pin_v2_to_dependency(pin: &PinV2) -> Option<Dependency> {
199    let mut name = pin.identity.clone().map(truncate_field);
200    let mut namespace: Option<String> = None;
201
202    if let Some(location) = &pin.location
203        && pin.kind.as_deref() == Some("remoteSourceControl")
204        && let Some((ns, n)) = get_namespace_and_name(location)
205    {
206        namespace = Some(ns);
207        name = Some(n);
208    }
209
210    let name = name?;
211
212    let version = pin
213        .state
214        .version
215        .clone()
216        .or_else(|| pin.state.revision.clone())
217        .map(truncate_field);
218
219    let purl = build_purl(&name, namespace.as_deref(), version.as_deref());
220
221    Some(Dependency {
222        purl: purl.map(truncate_field),
223        extracted_requirement: version,
224        scope: Some("dependencies".to_string()),
225        is_runtime: None,
226        is_optional: None,
227        is_pinned: Some(true),
228        is_direct: None,
229        resolved_package: None,
230        extra_data: None,
231    })
232}
233
234fn pin_v1_to_dependency(pin: &PinV1) -> Option<Dependency> {
235    let mut name = pin.package.clone().map(truncate_field);
236    let mut namespace: Option<String> = None;
237
238    if let Some(url) = &pin.repository_url
239        && let Some((ns, n)) = get_namespace_and_name(url)
240    {
241        namespace = Some(ns);
242        name = Some(n);
243    }
244
245    let name = name?;
246
247    let version = pin
248        .state
249        .version
250        .clone()
251        .or_else(|| pin.state.revision.clone())
252        .map(truncate_field);
253
254    let purl = build_purl(&name, namespace.as_deref(), version.as_deref());
255
256    Some(Dependency {
257        purl: purl.map(truncate_field),
258        extracted_requirement: version,
259        scope: Some("dependencies".to_string()),
260        is_runtime: None,
261        is_optional: None,
262        is_pinned: Some(true),
263        is_direct: None,
264        resolved_package: None,
265        extra_data: None,
266    })
267}
268
269/// Extracts `(namespace, name)` from a repository URL.
270///
271/// `https://github.com/mapbox/turf-swift.git` -> `("github.com/mapbox", "turf-swift")`
272fn get_namespace_and_name(url: &str) -> Option<(String, String)> {
273    let parsed = Url::parse(url).ok()?;
274    let hostname = parsed.host_str()?;
275
276    let path = parsed.path().trim_start_matches('/');
277    let path = path.strip_suffix(".git").unwrap_or(path);
278
279    let canonical = format!("{}/{}", hostname, path);
280
281    let (ns, name) = canonical.rsplit_once('/')?;
282
283    if name.is_empty() {
284        return None;
285    }
286
287    Some((
288        truncate_field(ns.to_string()),
289        truncate_field(name.to_string()),
290    ))
291}
292
293fn build_purl(name: &str, namespace: Option<&str>, version: Option<&str>) -> Option<String> {
294    let mut purl = PackageUrl::new("swift", name).ok()?;
295    if let Some(ns) = namespace {
296        purl.with_namespace(ns).ok()?;
297    }
298    if let Some(v) = version {
299        purl.with_version(v).ok()?;
300    }
301    Some(purl.to_string())
302}
303
304fn read_file(path: &Path) -> Result<String, String> {
305    read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))
306}
307
308fn default_package_data() -> PackageData {
309    PackageData {
310        package_type: Some(SwiftPackageResolvedParser::PACKAGE_TYPE),
311        primary_language: Some("Swift".to_string()),
312        datasource_id: Some(DatasourceId::SwiftPackageResolved),
313        ..Default::default()
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_get_namespace_and_name_github_with_git() {
323        let (ns, name) =
324            get_namespace_and_name("https://github.com/mapbox/turf-swift.git").unwrap();
325        assert_eq!(ns, "github.com/mapbox");
326        assert_eq!(name, "turf-swift");
327    }
328
329    #[test]
330    fn test_get_namespace_and_name_github_without_git() {
331        let (ns, name) = get_namespace_and_name("https://github.com/vapor/vapor").unwrap();
332        assert_eq!(ns, "github.com/vapor");
333        assert_eq!(name, "vapor");
334    }
335
336    #[test]
337    fn test_get_namespace_and_name_deep_path() {
338        let (ns, name) =
339            get_namespace_and_name("https://github.com/swift-server/async-http-client.git")
340                .unwrap();
341        assert_eq!(ns, "github.com/swift-server");
342        assert_eq!(name, "async-http-client");
343    }
344
345    #[test]
346    fn test_get_namespace_and_name_invalid_url() {
347        assert!(get_namespace_and_name("not-a-url").is_none());
348    }
349
350    #[test]
351    fn test_build_purl_with_all_fields() {
352        let purl = build_purl("turf-swift", Some("github.com/mapbox"), Some("2.8.0"));
353        assert_eq!(
354            purl.as_deref(),
355            Some("pkg:swift/github.com/mapbox/turf-swift@2.8.0")
356        );
357    }
358
359    #[test]
360    fn test_build_purl_without_version() {
361        let purl = build_purl("turf-swift", Some("github.com/mapbox"), None);
362        assert_eq!(
363            purl.as_deref(),
364            Some("pkg:swift/github.com/mapbox/turf-swift")
365        );
366    }
367
368    #[test]
369    fn test_build_purl_without_namespace() {
370        let purl = build_purl("MyPackage", None, Some("1.0.0"));
371        assert_eq!(purl.as_deref(), Some("pkg:swift/MyPackage@1.0.0"));
372    }
373}