1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
22use crate::parser_warn as warn;
23use packageurl::PackageUrl;
24use std::collections::HashMap;
25use std::fs::File;
26use std::io::{BufRead, BufReader};
27use std::path::Path;
28
29use super::PackageParser;
30
31pub struct GradleLockfileParser;
35
36impl PackageParser for GradleLockfileParser {
37 const PACKAGE_TYPE: PackageType = PackageType::Maven;
38
39 fn is_match(path: &Path) -> bool {
40 path.file_name()
41 .and_then(|name| name.to_str())
42 .is_some_and(|name| name == "gradle.lockfile")
43 }
44
45 fn extract_packages(path: &Path) -> Vec<PackageData> {
46 let file = match File::open(path) {
47 Ok(f) => f,
48 Err(e) => {
49 warn!("Failed to open gradle.lockfile at {:?}: {}", path, e);
50 return vec![default_package_data()];
51 }
52 };
53
54 let reader = BufReader::new(file);
55 let dependencies = extract_dependencies(reader);
56
57 vec![PackageData {
58 package_type: Some(Self::PACKAGE_TYPE),
59 namespace: None,
60 name: None,
61 version: None,
62 qualifiers: None,
63 subpath: None,
64 primary_language: None,
65 description: None,
66 release_date: None,
67 parties: Vec::new(),
68 keywords: Vec::new(),
69 homepage_url: None,
70 download_url: None,
71 size: None,
72 sha1: None,
73 md5: None,
74 sha256: None,
75 sha512: None,
76 bug_tracking_url: None,
77 code_view_url: None,
78 vcs_url: None,
79 copyright: None,
80 holder: None,
81 declared_license_expression: None,
82 declared_license_expression_spdx: None,
83 license_detections: Vec::new(),
84 other_license_expression: None,
85 other_license_expression_spdx: None,
86 other_license_detections: Vec::new(),
87 extracted_license_statement: None,
88 notice_text: None,
89 source_packages: Vec::new(),
90 file_references: Vec::new(),
91 is_private: false,
92 is_virtual: false,
93 extra_data: None,
94 dependencies,
95 repository_homepage_url: None,
96 repository_download_url: None,
97 api_data_url: None,
98 datasource_id: Some(DatasourceId::GradleLockfile),
99 purl: None,
100 }]
101 }
102}
103
104fn extract_dependencies<R: BufRead>(reader: R) -> Vec<Dependency> {
106 let mut dependencies = Vec::new();
107
108 for line in reader.lines() {
109 let line = match line {
110 Ok(l) => l,
111 Err(e) => {
112 warn!("Failed to read line from gradle.lockfile: {}", e);
113 continue;
114 }
115 };
116
117 let line = line.trim();
118
119 if line.is_empty() || line.starts_with('#') {
121 continue;
122 }
123
124 if let Some(dep) = parse_dependency_line(line) {
126 dependencies.push(dep);
127 }
128 }
129
130 dependencies
131}
132
133fn parse_dependency_line(line: &str) -> Option<Dependency> {
138 let (gav_part, configurations_part) = line.split_once('=')?;
140
141 if gav_part == "empty" {
142 return None;
143 }
144
145 let configurations: Vec<String> = configurations_part
146 .split(',')
147 .map(str::trim)
148 .filter(|value| !value.is_empty())
149 .map(ToString::to_string)
150 .collect();
151
152 let parts: Vec<&str> = gav_part.split(':').collect();
154 if parts.len() != 3 {
155 return None;
156 }
157
158 let group = parts[0].to_string();
159 let artifact = parts[1].to_string();
160 let version = parts[2].to_string();
161
162 let purl = PackageUrl::new("maven", &artifact).ok().and_then(|mut p| {
164 p.with_namespace(&group).ok()?;
165 p.with_version(&version).ok()?;
166 Some(p.to_string())
167 });
168
169 let mut extra_data: Option<HashMap<String, serde_json::Value>> = None;
171 if !group.is_empty() || !artifact.is_empty() {
172 let mut map = HashMap::new();
173 if !group.is_empty() {
174 map.insert(
175 "group".to_string(),
176 serde_json::Value::String(group.clone()),
177 );
178 }
179 if !artifact.is_empty() {
180 map.insert(
181 "artifact".to_string(),
182 serde_json::Value::String(artifact.clone()),
183 );
184 }
185 if !configurations.is_empty() {
186 map.insert(
187 "configurations".to_string(),
188 serde_json::Value::Array(
189 configurations
190 .iter()
191 .cloned()
192 .map(serde_json::Value::String)
193 .collect(),
194 ),
195 );
196 }
197 extra_data = Some(map);
198 }
199
200 let resolved_package = ResolvedPackage {
202 primary_language: None,
203 download_url: None,
204 sha1: None,
205 sha256: None,
206 sha512: None,
207 md5: None,
208 is_virtual: false,
209 extra_data: None,
210 dependencies: Vec::new(),
211 repository_homepage_url: None,
212 repository_download_url: None,
213 api_data_url: None,
214 datasource_id: Some(DatasourceId::GradleLockfile),
215 purl: purl.clone(),
216 ..ResolvedPackage::new(PackageType::Maven, group, artifact, version)
217 };
218
219 Some(Dependency {
220 purl,
221 extracted_requirement: None,
222 scope: None,
223 is_pinned: Some(true),
224 is_direct: None,
225 is_optional: None,
226 is_runtime: None,
227 resolved_package: Some(Box::new(resolved_package)),
228 extra_data,
229 })
230}
231
232fn default_package_data() -> PackageData {
234 PackageData {
235 package_type: Some(GradleLockfileParser::PACKAGE_TYPE),
236 datasource_id: Some(DatasourceId::GradleLockfile),
237 ..Default::default()
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use std::io::Cursor;
245
246 #[test]
247 fn test_is_match_gradle_lockfile() {
248 assert!(GradleLockfileParser::is_match(Path::new("gradle.lockfile")));
249 assert!(GradleLockfileParser::is_match(Path::new(
250 "/path/to/gradle.lockfile"
251 )));
252 }
253
254 #[test]
255 fn test_is_match_not_gradle_lockfile() {
256 assert!(!GradleLockfileParser::is_match(Path::new("package.json")));
257 assert!(!GradleLockfileParser::is_match(Path::new("Cargo.lock")));
258 assert!(!GradleLockfileParser::is_match(Path::new("gradle.lock")));
259 }
260
261 #[test]
262 fn test_parse_dependency_line_simple() {
263 let line = "com.example:my-lib:1.0.0=compileClasspath,runtimeClasspath";
264 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
265
266 assert_eq!(
267 dep.resolved_package.as_ref().unwrap().name,
268 "my-lib".to_string()
269 );
270 assert_eq!(
271 dep.resolved_package.as_ref().unwrap().version,
272 "1.0.0".to_string()
273 );
274 assert_eq!(
275 dep.resolved_package.as_ref().unwrap().namespace,
276 "com.example".to_string()
277 );
278 assert_eq!(
279 dep.resolved_package.as_ref().unwrap().package_type,
280 PackageType::Maven
281 );
282 }
283
284 #[test]
285 fn test_parse_dependency_line_complex_group() {
286 let line = "org.springframework.boot:spring-boot-starter-web:2.7.0=compileClasspath";
287 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
288
289 assert_eq!(
290 dep.resolved_package.as_ref().unwrap().name,
291 "spring-boot-starter-web".to_string()
292 );
293 assert_eq!(
294 dep.resolved_package.as_ref().unwrap().version,
295 "2.7.0".to_string()
296 );
297 assert_eq!(
298 dep.resolved_package.as_ref().unwrap().namespace,
299 "org.springframework.boot".to_string()
300 );
301 }
302
303 #[test]
304 fn test_parse_dependency_line_with_single_configuration() {
305 let line = "com.example:my-lib:1.0.0=runtimeClasspath";
306 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
307
308 assert_eq!(
309 dep.resolved_package.as_ref().unwrap().name,
310 "my-lib".to_string()
311 );
312 assert_eq!(
313 dep.resolved_package.as_ref().unwrap().version,
314 "1.0.0".to_string()
315 );
316 }
317
318 #[test]
319 fn test_parse_dependency_line_invalid_format() {
320 let line = "com.example:my-lib=abc123";
322 assert!(parse_dependency_line(line).is_none());
323
324 let line = "com.example:my-lib:1.0.0";
326 assert!(parse_dependency_line(line).is_none());
327 }
328
329 #[test]
330 fn test_extract_dependencies_multiple_lines() {
331 let content = "com.example:lib1:1.0.0=compileClasspath\ncom.example:lib2:2.0.0=runtimeClasspath\ncom.test:lib3:3.0.0=testRuntimeClasspath";
332 let reader = Cursor::new(content);
333 let deps = extract_dependencies(reader);
334
335 assert_eq!(deps.len(), 3);
336 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
337 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
338 assert_eq!(deps[2].resolved_package.as_ref().unwrap().name, "lib3");
339 }
340
341 #[test]
342 fn test_extract_dependencies_with_comments_and_empty_lines() {
343 let content = "# This is a comment\ncom.example:lib1:1.0.0=compileClasspath\n\n# Another comment\ncom.example:lib2:2.0.0=runtimeClasspath\n";
344 let reader = Cursor::new(content);
345 let deps = extract_dependencies(reader);
346
347 assert_eq!(deps.len(), 2);
348 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
349 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
350 }
351
352 #[test]
353 fn test_extract_dependencies_empty_file() {
354 let content = "";
355 let reader = Cursor::new(content);
356 let deps = extract_dependencies(reader);
357
358 assert_eq!(deps.len(), 0);
359 }
360
361 #[test]
362 fn test_extract_dependencies_only_comments() {
363 let content = "# Comment 1\n# Comment 2\n# Comment 3";
364 let reader = Cursor::new(content);
365 let deps = extract_dependencies(reader);
366
367 assert_eq!(deps.len(), 0);
368 }
369
370 #[test]
371 fn test_extract_first_package_returns_correct_package_type() {
372 let content = "com.example:lib:1.0.0=compileClasspath";
373 let reader = Cursor::new(content);
374 let deps = extract_dependencies(reader);
375
376 assert!(!deps.is_empty());
377 assert_eq!(
378 deps[0].resolved_package.as_ref().unwrap().package_type,
379 PackageType::Maven
380 );
381 }
382
383 #[test]
384 fn test_parse_dependency_generates_purl() {
385 let line = "com.google.guava:guava:30.1-jre=runtimeClasspath";
386 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
387
388 assert!(dep.purl.is_some());
389 let purl = dep.purl.unwrap();
390 assert!(purl.contains("maven"));
391 assert!(purl.contains("guava"));
392 assert!(purl.contains("30.1-jre"));
393 }
394
395 #[test]
396 fn test_parse_dependency_extra_data_contains_group_and_artifact() {
397 let line =
398 "org.junit.jupiter:junit-jupiter-api:5.8.0=testRuntimeClasspath,compileClasspath";
399 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
400
401 assert!(dep.extra_data.is_some());
402 let extra = dep.extra_data.unwrap();
403 assert!(extra.contains_key("group"));
404 assert!(extra.contains_key("artifact"));
405 assert!(extra.contains_key("configurations"));
406 }
407
408 #[test]
409 fn test_extract_dependencies_malformed_lines_ignored() {
410 let content = "com.example:lib1:1.0.0=compileClasspath\ninvalid-line\ncom.example:lib2:2.0.0=runtimeClasspath";
411 let reader = Cursor::new(content);
412 let deps = extract_dependencies(reader);
413
414 assert_eq!(deps.len(), 2);
416 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
417 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
418 }
419
420 #[test]
421 fn test_dependency_has_correct_flags() {
422 let line = "com.example:lib:1.0.0=compileClasspath";
423 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
424
425 assert_eq!(dep.is_pinned, Some(true));
426 assert_eq!(dep.is_optional, None);
427 assert_eq!(dep.is_runtime, None);
428 }
429
430 #[test]
431 fn test_parse_dependency_line_preserves_configurations_not_runtime_semantics() {
432 let line = "com.example:my-lib:1.0.0=compileClasspath,runtimeClasspath";
433 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
434
435 assert_eq!(dep.is_runtime, None);
436 assert_eq!(dep.is_optional, None);
437 assert_eq!(dep.is_direct, None);
438
439 let extra = dep.extra_data.as_ref().expect("expected extra_data");
440 assert_eq!(
441 extra.get("configurations"),
442 Some(&serde_json::json!(["compileClasspath", "runtimeClasspath"]))
443 );
444 }
445
446 #[test]
447 fn test_parse_dependency_line_skips_empty_configuration_marker() {
448 assert!(parse_dependency_line("empty=annotationProcessor").is_none());
449 }
450}
451
452crate::register_parser!(
453 "Gradle lockfile",
454 &["**/gradle.lockfile"],
455 "maven",
456 "Java",
457 Some("https://docs.gradle.org/current/userguide/dependency_locking.html"),
458);