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