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