1use 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
18pub 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
260fn 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}