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