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