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::{capped_iteration_limit, 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 let limit = capped_iteration_limit(pins.len(), "Package.resolved v2/v3 pins");
188 pins.iter()
189 .take(limit)
190 .filter_map(pin_v2_to_dependency)
191 .collect()
192}
193
194fn parse_v1_pins(pins: &[PinV1]) -> Vec<Dependency> {
195 let limit = capped_iteration_limit(pins.len(), "Package.resolved v1 pins");
196 pins.iter()
197 .take(limit)
198 .filter_map(pin_v1_to_dependency)
199 .collect()
200}
201
202fn pin_v2_to_dependency(pin: &PinV2) -> Option<Dependency> {
203 let mut name = pin.identity.clone().map(truncate_field);
204 let mut namespace: Option<String> = None;
205
206 if let Some(location) = &pin.location
207 && pin.kind.as_deref() == Some("remoteSourceControl")
208 && let Some((ns, n)) = get_namespace_and_name(location)
209 {
210 namespace = Some(ns);
211 name = Some(n);
212 }
213
214 let name = name?;
215
216 let version = pin
217 .state
218 .version
219 .clone()
220 .or_else(|| pin.state.revision.clone())
221 .map(truncate_field);
222
223 let purl = build_purl(&name, namespace.as_deref(), version.as_deref());
224
225 Some(Dependency {
226 purl: purl.map(truncate_field),
227 extracted_requirement: version,
228 scope: Some("dependencies".to_string()),
229 is_runtime: None,
230 is_optional: None,
231 is_pinned: Some(true),
232 is_direct: None,
233 resolved_package: None,
234 extra_data: None,
235 })
236}
237
238fn pin_v1_to_dependency(pin: &PinV1) -> Option<Dependency> {
239 let mut name = pin.package.clone().map(truncate_field);
240 let mut namespace: Option<String> = None;
241
242 if let Some(url) = &pin.repository_url
243 && let Some((ns, n)) = get_namespace_and_name(url)
244 {
245 namespace = Some(ns);
246 name = Some(n);
247 }
248
249 let name = name?;
250
251 let version = pin
252 .state
253 .version
254 .clone()
255 .or_else(|| pin.state.revision.clone())
256 .map(truncate_field);
257
258 let purl = build_purl(&name, namespace.as_deref(), version.as_deref());
259
260 Some(Dependency {
261 purl: purl.map(truncate_field),
262 extracted_requirement: version,
263 scope: Some("dependencies".to_string()),
264 is_runtime: None,
265 is_optional: None,
266 is_pinned: Some(true),
267 is_direct: None,
268 resolved_package: None,
269 extra_data: None,
270 })
271}
272
273fn get_namespace_and_name(url: &str) -> Option<(String, String)> {
277 let parsed = Url::parse(url).ok()?;
278 let hostname = parsed.host_str()?;
279
280 let path = parsed.path().trim_start_matches('/');
281 let path = path.strip_suffix(".git").unwrap_or(path);
282
283 let canonical = format!("{}/{}", hostname, path);
284
285 let (ns, name) = canonical.rsplit_once('/')?;
286
287 if name.is_empty() {
288 return None;
289 }
290
291 Some((
292 truncate_field(ns.to_string()),
293 truncate_field(name.to_string()),
294 ))
295}
296
297fn build_purl(name: &str, namespace: Option<&str>, version: Option<&str>) -> Option<String> {
298 let namespace = namespace?;
301 let mut purl = PackageUrl::new("swift", name).ok()?;
302 purl.with_namespace(namespace).ok()?;
303 if let Some(v) = version {
304 purl.with_version(v).ok()?;
305 }
306 Some(purl.to_string())
307}
308
309fn read_file(path: &Path) -> Result<String, String> {
310 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))
311}
312
313fn default_package_data() -> PackageData {
314 PackageData {
315 package_type: Some(SwiftPackageResolvedParser::PACKAGE_TYPE),
316 primary_language: Some("Swift".to_string()),
317 datasource_id: Some(DatasourceId::SwiftPackageResolved),
318 ..Default::default()
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_get_namespace_and_name_github_with_git() {
328 let (ns, name) =
329 get_namespace_and_name("https://github.com/mapbox/turf-swift.git").unwrap();
330 assert_eq!(ns, "github.com/mapbox");
331 assert_eq!(name, "turf-swift");
332 }
333
334 #[test]
335 fn test_get_namespace_and_name_github_without_git() {
336 let (ns, name) = get_namespace_and_name("https://github.com/vapor/vapor").unwrap();
337 assert_eq!(ns, "github.com/vapor");
338 assert_eq!(name, "vapor");
339 }
340
341 #[test]
342 fn test_get_namespace_and_name_deep_path() {
343 let (ns, name) =
344 get_namespace_and_name("https://github.com/swift-server/async-http-client.git")
345 .unwrap();
346 assert_eq!(ns, "github.com/swift-server");
347 assert_eq!(name, "async-http-client");
348 }
349
350 #[test]
351 fn test_get_namespace_and_name_invalid_url() {
352 assert!(get_namespace_and_name("not-a-url").is_none());
353 }
354
355 #[test]
356 fn test_build_purl_with_all_fields() {
357 let purl = build_purl("turf-swift", Some("github.com/mapbox"), Some("2.8.0"));
358 assert_eq!(
359 purl.as_deref(),
360 Some("pkg:swift/github.com/mapbox/turf-swift@2.8.0")
361 );
362 }
363
364 #[test]
365 fn test_build_purl_without_version() {
366 let purl = build_purl("turf-swift", Some("github.com/mapbox"), None);
367 assert_eq!(
368 purl.as_deref(),
369 Some("pkg:swift/github.com/mapbox/turf-swift")
370 );
371 }
372
373 #[test]
374 fn test_build_purl_without_namespace_is_omitted() {
375 let purl = build_purl("MyPackage", None, Some("1.0.0"));
377 assert_eq!(purl, None);
378 }
379}