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