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