use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
use crate::parser_warn as warn;
use packageurl::PackageUrl;
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use super::PackageParser;
pub struct GradleLockfileParser;
impl PackageParser for GradleLockfileParser {
const PACKAGE_TYPE: PackageType = PackageType::Maven;
fn is_match(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "gradle.lockfile")
}
fn extract_packages(path: &Path) -> Vec<PackageData> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) => {
warn!("Failed to open gradle.lockfile at {:?}: {}", path, e);
return vec![default_package_data()];
}
};
let reader = BufReader::new(file);
let dependencies = extract_dependencies(reader);
vec![PackageData {
package_type: Some(Self::PACKAGE_TYPE),
namespace: None,
name: None,
version: None,
qualifiers: None,
subpath: None,
primary_language: None,
description: None,
release_date: None,
parties: Vec::new(),
keywords: Vec::new(),
homepage_url: None,
download_url: None,
size: None,
sha1: None,
md5: None,
sha256: None,
sha512: None,
bug_tracking_url: None,
code_view_url: None,
vcs_url: None,
copyright: None,
holder: None,
declared_license_expression: None,
declared_license_expression_spdx: None,
license_detections: Vec::new(),
other_license_expression: None,
other_license_expression_spdx: None,
other_license_detections: Vec::new(),
extracted_license_statement: None,
notice_text: None,
source_packages: Vec::new(),
file_references: Vec::new(),
is_private: false,
is_virtual: false,
extra_data: None,
dependencies,
repository_homepage_url: None,
repository_download_url: None,
api_data_url: None,
datasource_id: Some(DatasourceId::GradleLockfile),
purl: None,
}]
}
}
fn extract_dependencies<R: BufRead>(reader: R) -> Vec<Dependency> {
let mut dependencies = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
warn!("Failed to read line from gradle.lockfile: {}", e);
continue;
}
};
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(dep) = parse_dependency_line(line) {
dependencies.push(dep);
}
}
dependencies
}
fn parse_dependency_line(line: &str) -> Option<Dependency> {
let (gav_part, hash_part) = line.split_once('=')?;
let hash = if hash_part.is_empty() {
None
} else {
Some(hash_part.to_string())
};
let parts: Vec<&str> = gav_part.split(':').collect();
if parts.len() != 3 {
return None;
}
let group = parts[0].to_string();
let artifact = parts[1].to_string();
let version = parts[2].to_string();
let purl = PackageUrl::new("maven", &artifact).ok().and_then(|mut p| {
p.with_namespace(&group).ok()?;
p.with_version(&version).ok()?;
Some(p.to_string())
});
let mut extra_data: Option<HashMap<String, serde_json::Value>> = None;
if !group.is_empty() || !artifact.is_empty() {
let mut map = HashMap::new();
if !group.is_empty() {
map.insert(
"group".to_string(),
serde_json::Value::String(group.clone()),
);
}
if !artifact.is_empty() {
map.insert(
"artifact".to_string(),
serde_json::Value::String(artifact.clone()),
);
}
if let Some(ref h) = hash {
map.insert("hash".to_string(), serde_json::Value::String(h.clone()));
}
extra_data = Some(map);
}
let resolved_package = ResolvedPackage {
primary_language: None,
download_url: None,
sha1: None,
sha256: None,
sha512: None,
md5: None,
is_virtual: false,
extra_data: None,
dependencies: Vec::new(),
repository_homepage_url: None,
repository_download_url: None,
api_data_url: None,
datasource_id: Some(DatasourceId::GradleLockfile),
purl: purl.clone(),
..ResolvedPackage::new(PackageType::Maven, group, artifact, version)
};
Some(Dependency {
purl,
extracted_requirement: None,
scope: None,
is_pinned: Some(true),
is_direct: None,
is_optional: Some(false),
is_runtime: Some(true),
resolved_package: Some(Box::new(resolved_package)),
extra_data,
})
}
fn default_package_data() -> PackageData {
PackageData {
package_type: Some(GradleLockfileParser::PACKAGE_TYPE),
datasource_id: Some(DatasourceId::GradleLockfile),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_is_match_gradle_lockfile() {
assert!(GradleLockfileParser::is_match(Path::new("gradle.lockfile")));
assert!(GradleLockfileParser::is_match(Path::new(
"/path/to/gradle.lockfile"
)));
}
#[test]
fn test_is_match_not_gradle_lockfile() {
assert!(!GradleLockfileParser::is_match(Path::new("package.json")));
assert!(!GradleLockfileParser::is_match(Path::new("Cargo.lock")));
assert!(!GradleLockfileParser::is_match(Path::new("gradle.lock")));
}
#[test]
fn test_parse_dependency_line_simple() {
let line = "com.example:my-lib:1.0.0=abc123";
let dep = parse_dependency_line(line).expect("Failed to parse dependency");
assert_eq!(
dep.resolved_package.as_ref().unwrap().name,
"my-lib".to_string()
);
assert_eq!(
dep.resolved_package.as_ref().unwrap().version,
"1.0.0".to_string()
);
assert_eq!(
dep.resolved_package.as_ref().unwrap().namespace,
"com.example".to_string()
);
assert_eq!(
dep.resolved_package.as_ref().unwrap().package_type,
PackageType::Maven
);
}
#[test]
fn test_parse_dependency_line_complex_group() {
let line = "org.springframework.boot:spring-boot-starter-web:2.7.0=def456";
let dep = parse_dependency_line(line).expect("Failed to parse dependency");
assert_eq!(
dep.resolved_package.as_ref().unwrap().name,
"spring-boot-starter-web".to_string()
);
assert_eq!(
dep.resolved_package.as_ref().unwrap().version,
"2.7.0".to_string()
);
assert_eq!(
dep.resolved_package.as_ref().unwrap().namespace,
"org.springframework.boot".to_string()
);
}
#[test]
fn test_parse_dependency_line_no_hash() {
let line = "com.example:my-lib:1.0.0=";
let dep = parse_dependency_line(line).expect("Failed to parse dependency");
assert_eq!(
dep.resolved_package.as_ref().unwrap().name,
"my-lib".to_string()
);
assert_eq!(
dep.resolved_package.as_ref().unwrap().version,
"1.0.0".to_string()
);
}
#[test]
fn test_parse_dependency_line_invalid_format() {
let line = "com.example:my-lib=abc123";
assert!(parse_dependency_line(line).is_none());
let line = "com.example:my-lib:1.0.0";
assert!(parse_dependency_line(line).is_none());
}
#[test]
fn test_extract_dependencies_multiple_lines() {
let content =
"com.example:lib1:1.0.0=hash1\ncom.example:lib2:2.0.0=hash2\ncom.test:lib3:3.0.0=hash3";
let reader = Cursor::new(content);
let deps = extract_dependencies(reader);
assert_eq!(deps.len(), 3);
assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
assert_eq!(deps[2].resolved_package.as_ref().unwrap().name, "lib3");
}
#[test]
fn test_extract_dependencies_with_comments_and_empty_lines() {
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";
let reader = Cursor::new(content);
let deps = extract_dependencies(reader);
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
}
#[test]
fn test_extract_dependencies_empty_file() {
let content = "";
let reader = Cursor::new(content);
let deps = extract_dependencies(reader);
assert_eq!(deps.len(), 0);
}
#[test]
fn test_extract_dependencies_only_comments() {
let content = "# Comment 1\n# Comment 2\n# Comment 3";
let reader = Cursor::new(content);
let deps = extract_dependencies(reader);
assert_eq!(deps.len(), 0);
}
#[test]
fn test_extract_first_package_returns_correct_package_type() {
let content = "com.example:lib:1.0.0=hash";
let reader = Cursor::new(content);
let deps = extract_dependencies(reader);
assert!(!deps.is_empty());
assert_eq!(
deps[0].resolved_package.as_ref().unwrap().package_type,
PackageType::Maven
);
}
#[test]
fn test_parse_dependency_generates_purl() {
let line = "com.google.guava:guava:30.1-jre=abc123";
let dep = parse_dependency_line(line).expect("Failed to parse dependency");
assert!(dep.purl.is_some());
let purl = dep.purl.unwrap();
assert!(purl.contains("maven"));
assert!(purl.contains("guava"));
assert!(purl.contains("30.1-jre"));
}
#[test]
fn test_parse_dependency_extra_data_contains_group_and_artifact() {
let line = "org.junit.jupiter:junit-jupiter-api:5.8.0=hash123";
let dep = parse_dependency_line(line).expect("Failed to parse dependency");
assert!(dep.extra_data.is_some());
let extra = dep.extra_data.unwrap();
assert!(extra.contains_key("group"));
assert!(extra.contains_key("artifact"));
assert!(extra.contains_key("hash"));
}
#[test]
fn test_extract_dependencies_malformed_lines_ignored() {
let content = "com.example:lib1:1.0.0=hash1\ninvalid-line\ncom.example:lib2:2.0.0=hash2";
let reader = Cursor::new(content);
let deps = extract_dependencies(reader);
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
}
#[test]
fn test_dependency_has_correct_flags() {
let line = "com.example:lib:1.0.0=hash";
let dep = parse_dependency_line(line).expect("Failed to parse dependency");
assert_eq!(dep.is_pinned, Some(true));
assert_eq!(dep.is_optional, Some(false));
assert_eq!(dep.is_runtime, Some(true));
}
}
crate::register_parser!(
"Gradle lockfile",
&["**/gradle.lockfile"],
"maven",
"Java",
Some("https://docs.gradle.org/current/userguide/dependency_locking.html"),
);