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