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