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