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