1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
22use log::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, hash_part) = line.split_once('=')?;
140 let hash = if hash_part.is_empty() {
141 None
142 } else {
143 Some(hash_part.to_string())
144 };
145
146 let parts: Vec<&str> = gav_part.split(':').collect();
148 if parts.len() != 3 {
149 return None;
150 }
151
152 let group = parts[0].to_string();
153 let artifact = parts[1].to_string();
154 let version = parts[2].to_string();
155
156 let purl = PackageUrl::new("maven", &artifact).ok().and_then(|mut p| {
158 p.with_namespace(&group).ok()?;
159 p.with_version(&version).ok()?;
160 Some(p.to_string())
161 });
162
163 let mut extra_data: Option<HashMap<String, serde_json::Value>> = None;
165 if !group.is_empty() || !artifact.is_empty() {
166 let mut map = HashMap::new();
167 if !group.is_empty() {
168 map.insert(
169 "group".to_string(),
170 serde_json::Value::String(group.clone()),
171 );
172 }
173 if !artifact.is_empty() {
174 map.insert(
175 "artifact".to_string(),
176 serde_json::Value::String(artifact.clone()),
177 );
178 }
179 if let Some(ref h) = hash {
180 map.insert("hash".to_string(), serde_json::Value::String(h.clone()));
181 }
182 extra_data = Some(map);
183 }
184
185 let resolved_package = ResolvedPackage {
187 package_type: PackageType::Maven,
188 namespace: group,
189 name: artifact,
190 version,
191 primary_language: None,
192 download_url: None,
193 sha1: None,
194 sha256: None,
195 sha512: None,
196 md5: None,
197 is_virtual: false,
198 extra_data: None,
199 dependencies: Vec::new(),
200 repository_homepage_url: None,
201 repository_download_url: None,
202 api_data_url: None,
203 datasource_id: Some(DatasourceId::GradleLockfile),
204 purl: purl.clone(),
205 };
206
207 Some(Dependency {
208 purl,
209 extracted_requirement: None,
210 scope: None,
211 is_pinned: Some(true),
212 is_direct: None,
213 is_optional: Some(false),
214 is_runtime: Some(true),
215 resolved_package: Some(Box::new(resolved_package)),
216 extra_data,
217 })
218}
219
220fn default_package_data() -> PackageData {
222 PackageData {
223 package_type: Some(GradleLockfileParser::PACKAGE_TYPE),
224 datasource_id: Some(DatasourceId::GradleLockfile),
225 ..Default::default()
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::io::Cursor;
233
234 #[test]
235 fn test_is_match_gradle_lockfile() {
236 assert!(GradleLockfileParser::is_match(Path::new("gradle.lockfile")));
237 assert!(GradleLockfileParser::is_match(Path::new(
238 "/path/to/gradle.lockfile"
239 )));
240 }
241
242 #[test]
243 fn test_is_match_not_gradle_lockfile() {
244 assert!(!GradleLockfileParser::is_match(Path::new("package.json")));
245 assert!(!GradleLockfileParser::is_match(Path::new("Cargo.lock")));
246 assert!(!GradleLockfileParser::is_match(Path::new("gradle.lock")));
247 }
248
249 #[test]
250 fn test_parse_dependency_line_simple() {
251 let line = "com.example:my-lib:1.0.0=abc123";
252 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
253
254 assert_eq!(
255 dep.resolved_package.as_ref().unwrap().name,
256 "my-lib".to_string()
257 );
258 assert_eq!(
259 dep.resolved_package.as_ref().unwrap().version,
260 "1.0.0".to_string()
261 );
262 assert_eq!(
263 dep.resolved_package.as_ref().unwrap().namespace,
264 "com.example".to_string()
265 );
266 assert_eq!(
267 dep.resolved_package.as_ref().unwrap().package_type,
268 PackageType::Maven
269 );
270 }
271
272 #[test]
273 fn test_parse_dependency_line_complex_group() {
274 let line = "org.springframework.boot:spring-boot-starter-web:2.7.0=def456";
275 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
276
277 assert_eq!(
278 dep.resolved_package.as_ref().unwrap().name,
279 "spring-boot-starter-web".to_string()
280 );
281 assert_eq!(
282 dep.resolved_package.as_ref().unwrap().version,
283 "2.7.0".to_string()
284 );
285 assert_eq!(
286 dep.resolved_package.as_ref().unwrap().namespace,
287 "org.springframework.boot".to_string()
288 );
289 }
290
291 #[test]
292 fn test_parse_dependency_line_no_hash() {
293 let line = "com.example:my-lib:1.0.0=";
294 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
295
296 assert_eq!(
297 dep.resolved_package.as_ref().unwrap().name,
298 "my-lib".to_string()
299 );
300 assert_eq!(
301 dep.resolved_package.as_ref().unwrap().version,
302 "1.0.0".to_string()
303 );
304 }
305
306 #[test]
307 fn test_parse_dependency_line_invalid_format() {
308 let line = "com.example:my-lib=abc123";
310 assert!(parse_dependency_line(line).is_none());
311
312 let line = "com.example:my-lib:1.0.0";
314 assert!(parse_dependency_line(line).is_none());
315 }
316
317 #[test]
318 fn test_extract_dependencies_multiple_lines() {
319 let content =
320 "com.example:lib1:1.0.0=hash1\ncom.example:lib2:2.0.0=hash2\ncom.test:lib3:3.0.0=hash3";
321 let reader = Cursor::new(content);
322 let deps = extract_dependencies(reader);
323
324 assert_eq!(deps.len(), 3);
325 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
326 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
327 assert_eq!(deps[2].resolved_package.as_ref().unwrap().name, "lib3");
328 }
329
330 #[test]
331 fn test_extract_dependencies_with_comments_and_empty_lines() {
332 let content = "# This is a comment\ncom.example:lib1:1.0.0=hash1\n\n# Another comment\ncom.example:lib2:2.0.0=hash2\n";
333 let reader = Cursor::new(content);
334 let deps = extract_dependencies(reader);
335
336 assert_eq!(deps.len(), 2);
337 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
338 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
339 }
340
341 #[test]
342 fn test_extract_dependencies_empty_file() {
343 let content = "";
344 let reader = Cursor::new(content);
345 let deps = extract_dependencies(reader);
346
347 assert_eq!(deps.len(), 0);
348 }
349
350 #[test]
351 fn test_extract_dependencies_only_comments() {
352 let content = "# Comment 1\n# Comment 2\n# Comment 3";
353 let reader = Cursor::new(content);
354 let deps = extract_dependencies(reader);
355
356 assert_eq!(deps.len(), 0);
357 }
358
359 #[test]
360 fn test_extract_first_package_returns_correct_package_type() {
361 let content = "com.example:lib:1.0.0=hash";
362 let reader = Cursor::new(content);
363 let deps = extract_dependencies(reader);
364
365 assert!(!deps.is_empty());
366 assert_eq!(
367 deps[0].resolved_package.as_ref().unwrap().package_type,
368 PackageType::Maven
369 );
370 }
371
372 #[test]
373 fn test_parse_dependency_generates_purl() {
374 let line = "com.google.guava:guava:30.1-jre=abc123";
375 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
376
377 assert!(dep.purl.is_some());
378 let purl = dep.purl.unwrap();
379 assert!(purl.contains("maven"));
380 assert!(purl.contains("guava"));
381 assert!(purl.contains("30.1-jre"));
382 }
383
384 #[test]
385 fn test_parse_dependency_extra_data_contains_group_and_artifact() {
386 let line = "org.junit.jupiter:junit-jupiter-api:5.8.0=hash123";
387 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
388
389 assert!(dep.extra_data.is_some());
390 let extra = dep.extra_data.unwrap();
391 assert!(extra.contains_key("group"));
392 assert!(extra.contains_key("artifact"));
393 assert!(extra.contains_key("hash"));
394 }
395
396 #[test]
397 fn test_extract_dependencies_malformed_lines_ignored() {
398 let content = "com.example:lib1:1.0.0=hash1\ninvalid-line\ncom.example:lib2:2.0.0=hash2";
399 let reader = Cursor::new(content);
400 let deps = extract_dependencies(reader);
401
402 assert_eq!(deps.len(), 2);
404 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
405 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
406 }
407
408 #[test]
409 fn test_dependency_has_correct_flags() {
410 let line = "com.example:lib:1.0.0=hash";
411 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
412
413 assert_eq!(dep.is_pinned, Some(true));
414 assert_eq!(dep.is_optional, Some(false));
415 assert_eq!(dep.is_runtime, Some(true));
416 }
417}
418
419crate::register_parser!(
420 "Gradle lockfile",
421 &["**/gradle.lockfile"],
422 "maven",
423 "Java",
424 Some("https://docs.gradle.org/current/userguide/dependency_locking.html"),
425);