Skip to main content

provenant/parsers/
gradle_lock.rs

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