Skip to main content

provenant/parsers/
swift_resolved.rs

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