Skip to main content

provenant/parsers/
gradle_lock.rs

1//! Parser for gradle.lockfile dependency lock files.
2//!
3//! Extracts resolved dependency information from Gradle's gradle.lockfile format.
4//! This format is used by Gradle to lock exact dependency versions.
5//!
6//! # Supported Formats
7//! - gradle.lockfile (text-based dependency declarations)
8//!
9//! # Key Features
10//! - Exact version resolution from lockfile
11//! - Group and artifact extraction
12//! - Preserves lockfile configuration membership per dependency
13//! - Package URL (purl) generation for Maven packages
14//!
15//! # Implementation Notes
16//! - gradle.lockfile is a simple text format with dependency lines
17//! - Format: `<group>:<artifact>:<version>=<configuration>[,<configuration>...]` (one per line)
18//! - Comments and empty lines are skipped
19//! - All dependencies are pinned (is_pinned: true)
20
21use 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
31/// Gradle gradle.lockfile parser.
32///
33/// Extracts pinned dependency versions from Gradle's dependency lock files.
34pub 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
104/// Extract dependencies from gradle.lockfile
105fn 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        // Skip empty lines and comments
120        if line.is_empty() || line.starts_with('#') {
121            continue;
122        }
123
124        // Parse dependency line format: group:artifact:version=config[,config...]
125        if let Some(dep) = parse_dependency_line(line) {
126            dependencies.push(dep);
127        }
128    }
129
130    dependencies
131}
132
133/// Parse a single dependency line from gradle.lockfile
134///
135/// Expected format: `group:artifact:version=configuration[,configuration...]`
136/// Example: `com.example:my-lib:1.0.0=compileClasspath,runtimeClasspath`
137fn parse_dependency_line(line: &str) -> Option<Dependency> {
138    // Split by = to separate GAV from the list of configurations that include this dependency.
139    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    // Parse GAV (group:artifact:version)
153    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    // Generate purl
163    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    // Build extra_data with group and artifact separately
170    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    // Create resolved_package
201    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
232/// Returns a default empty PackageData for error cases
233fn 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        // Missing version
321        let line = "com.example:my-lib=abc123";
322        assert!(parse_dependency_line(line).is_none());
323
324        // No configuration separator
325        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        // Only valid dependencies are extracted
415        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);