Skip to content

Commit 48916b3

Browse files
authored
Add Java/Gradle gradle.lockfile support (#20)
Adds GradleParser that parses line-oriented gradle.lockfile entries in <group>:<artifact>:<version>=<configurations> format. Marks dependencies as Dev when they only appear in test* configurations. Produces pkg:maven/ PURLs with Sonatype Central external references; the artifact portion is used as the component display name. Closes #13
1 parent 2d216be commit 48916b3

11 files changed

Lines changed: 421 additions & 2 deletions

File tree

internal/lockfile/gradle.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package lockfile
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"io"
7+
"strings"
8+
)
9+
10+
// GradleParser handles Gradle dependency lockfiles (gradle.lockfile).
11+
type GradleParser struct{}
12+
13+
func (p *GradleParser) Type() LockfileType { return TypeGradleLock }
14+
func (p *GradleParser) Filenames() []string { return []string{"gradle.lockfile"} }
15+
16+
func (p *GradleParser) Parse(ctx context.Context, r io.Reader) (*LockfileResult, error) {
17+
result := &LockfileResult{Type: TypeGradleLock}
18+
19+
scanner := bufio.NewScanner(r)
20+
for scanner.Scan() {
21+
line := strings.TrimSpace(scanner.Text())
22+
if line == "" || strings.HasPrefix(line, "#") {
23+
continue
24+
}
25+
26+
// Lines like "empty=..." list configurations with no dependencies; skip.
27+
if strings.HasPrefix(line, "empty=") {
28+
continue
29+
}
30+
31+
eqIdx := strings.LastIndex(line, "=")
32+
if eqIdx < 0 {
33+
continue
34+
}
35+
coord := line[:eqIdx]
36+
configs := line[eqIdx+1:]
37+
38+
parts := strings.Split(coord, ":")
39+
if len(parts) != 3 {
40+
continue
41+
}
42+
group, artifact, version := parts[0], parts[1], parts[2]
43+
if group == "" || artifact == "" || version == "" {
44+
continue
45+
}
46+
47+
result.Packages = append(result.Packages, Package{
48+
Name: group + ":" + artifact,
49+
Version: version,
50+
Dev: isGradleTestOnly(configs),
51+
})
52+
}
53+
54+
return result, scanner.Err()
55+
}
56+
57+
// isGradleTestOnly returns true when every configuration in the CSV starts
58+
// with "test" (case-insensitive). Empty input returns false.
59+
func isGradleTestOnly(configs string) bool {
60+
configs = strings.TrimSpace(configs)
61+
if configs == "" {
62+
return false
63+
}
64+
for _, c := range strings.Split(configs, ",") {
65+
c = strings.TrimSpace(c)
66+
if c == "" {
67+
continue
68+
}
69+
if !strings.HasPrefix(strings.ToLower(c), "test") {
70+
return false
71+
}
72+
}
73+
return true
74+
}

internal/lockfile/gradle_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package lockfile
2+
3+
import (
4+
"context"
5+
"os"
6+
"testing"
7+
)
8+
9+
func TestGradleParser(t *testing.T) {
10+
f, err := os.Open("../../testdata/lockfiles/gradle/gradle.lockfile")
11+
if err != nil {
12+
t.Fatal(err)
13+
}
14+
defer f.Close()
15+
16+
parser := &GradleParser{}
17+
result, err := parser.Parse(context.Background(), f)
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
22+
if result.Type != TypeGradleLock {
23+
t.Errorf("expected type %s, got %s", TypeGradleLock, result.Type)
24+
}
25+
26+
if len(result.Packages) != 10 {
27+
t.Fatalf("expected 10 packages, got %d", len(result.Packages))
28+
}
29+
30+
found := make(map[string]Package)
31+
for _, pkg := range result.Packages {
32+
found[pkg.Name] = pkg
33+
}
34+
35+
// Coordinate format: group:artifact.
36+
if pkg, ok := found["org.springframework:spring-core"]; !ok {
37+
t.Error("missing spring-core")
38+
} else if pkg.Version != "6.1.2" {
39+
t.Errorf("spring-core version: got %s, want 6.1.2", pkg.Version)
40+
}
41+
42+
// Production dep should not be dev.
43+
if pkg := found["com.google.guava:guava"]; pkg.Dev {
44+
t.Error("guava should not be marked dev")
45+
}
46+
47+
// Test-only deps should be Dev=true.
48+
if pkg, ok := found["org.junit.jupiter:junit-jupiter-api"]; !ok {
49+
t.Error("missing junit-jupiter-api")
50+
} else if !pkg.Dev {
51+
t.Error("junit-jupiter-api should be marked dev")
52+
}
53+
if pkg := found["org.mockito:mockito-core"]; !pkg.Dev {
54+
t.Error("mockito-core should be marked dev")
55+
}
56+
}
57+
58+
func TestGradleDevDetection(t *testing.T) {
59+
tests := []struct {
60+
configs string
61+
want bool
62+
}{
63+
{"compileClasspath,runtimeClasspath", false},
64+
{"testCompileClasspath,testRuntimeClasspath", true},
65+
{"compileClasspath,testCompileClasspath", false},
66+
{"testImplementation", true},
67+
{"", false},
68+
}
69+
for _, tt := range tests {
70+
got := isGradleTestOnly(tt.configs)
71+
if got != tt.want {
72+
t.Errorf("isGradleTestOnly(%q) = %v, want %v", tt.configs, got, tt.want)
73+
}
74+
}
75+
}
76+
77+
func TestGradleParserType(t *testing.T) {
78+
p := &GradleParser{}
79+
if p.Type() != TypeGradleLock {
80+
t.Errorf("expected %s, got %s", TypeGradleLock, p.Type())
81+
}
82+
if fnames := p.Filenames(); len(fnames) != 1 || fnames[0] != "gradle.lockfile" {
83+
t.Errorf("unexpected filenames: %v", fnames)
84+
}
85+
}

internal/lockfile/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
TypeUV LockfileType = "uv"
1717
TypeGoMod LockfileType = "gomod"
1818
TypeCargoLock LockfileType = "cargo"
19+
TypeGradleLock LockfileType = "gradle"
1920
)
2021

2122
// Package represents a resolved dependency from a lockfile.

internal/lockfile/parser.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ func init() {
5151
&GoModParser{},
5252
// Rust
5353
&CargoParser{},
54+
// Java/Gradle
55+
&GradleParser{},
5456
}
5557
}
5658

internal/sbom/cyclonedx.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,16 @@ import (
1313
func mapComponent(pkg lockfile.Package, ecosystem string) cdx.Component {
1414
purl := BuildPURL(pkg.Name, pkg.Version, ecosystem)
1515

16+
displayName := pkg.Name
17+
if ecosystem == "maven" {
18+
if idx := strings.Index(pkg.Name, ":"); idx > 0 {
19+
displayName = pkg.Name[idx+1:]
20+
}
21+
}
22+
1623
comp := cdx.Component{
1724
Type: cdx.ComponentTypeLibrary,
18-
Name: pkg.Name,
25+
Name: displayName,
1926
Version: pkg.Version,
2027
BOMRef: purl,
2128
PackageURL: purl,
@@ -35,6 +42,8 @@ func mapComponent(pkg lockfile.Package, ecosystem string) cdx.Component {
3542
registryURL = "https://pkg.go.dev/" + pkg.Name
3643
case "cargo":
3744
registryURL = "https://crates.io/crates/" + pkg.Name
45+
case "maven":
46+
registryURL = mavenRegistryURL(pkg.Name)
3847
default:
3948
registryURL = npmRegistryURL(pkg.Name)
4049
}
@@ -103,6 +112,15 @@ func npmRegistryURL(name string) string {
103112
return "https://www.npmjs.com/package/" + name
104113
}
105114

115+
// mavenRegistryURL returns the Sonatype Central URL for a "group:artifact" coordinate.
116+
func mavenRegistryURL(name string) string {
117+
parts := strings.SplitN(name, ":", 2)
118+
if len(parts) != 2 {
119+
return "https://central.sonatype.com/artifact/" + name
120+
}
121+
return "https://central.sonatype.com/artifact/" + parts[0] + "/" + parts[1]
122+
}
123+
106124
// hashContent computes SHA-256 of the given content.
107125
func hashContent(data []byte) string {
108126
h := sha256.Sum256(data)

internal/sbom/generator.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ func ecosystemFromLockfileType(lt lockfile.LockfileType) string {
4343
return "golang"
4444
case lockfile.TypeCargoLock:
4545
return "cargo"
46+
case lockfile.TypeGradleLock:
47+
return "maven"
4648
default:
4749
return "npm"
4850
}

internal/sbom/purl.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ func BuildPURL(name, version, ecosystem string) string {
1717
return BuildGolangPURL(name, version)
1818
case "cargo":
1919
return BuildCargoPURL(name, version)
20+
case "maven":
21+
return BuildMavenPURL(name, version)
2022
default:
2123
return BuildNPMPURL(name, version)
2224
}
@@ -28,6 +30,20 @@ func BuildCargoPURL(name, version string) string {
2830
return purl.ToString()
2931
}
3032

33+
// BuildMavenPURL constructs a Package URL for a Maven artifact.
34+
// The input `name` is expected to be "group:artifact".
35+
func BuildMavenPURL(name, version string) string {
36+
var group, artifact string
37+
if idx := strings.Index(name, ":"); idx > 0 {
38+
group = name[:idx]
39+
artifact = name[idx+1:]
40+
} else {
41+
artifact = name
42+
}
43+
purl := packageurl.NewPackageURL("maven", group, artifact, version, nil, "")
44+
return purl.ToString()
45+
}
46+
3147
// BuildGolangPURL constructs a Package URL for a Go module.
3248
// The full module path (e.g., github.com/gorilla/mux) is split on the last
3349
// '/' segment: namespace is the prefix, name is the last segment.

internal/sbom/purl_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ func TestBuildPURL(t *testing.T) {
2828
// Cargo crates
2929
{"serde", "1.0.124", "cargo", "pkg:cargo/serde@1.0.124"},
3030
{"tokio", "1.36.0", "cargo", "pkg:cargo/tokio@1.36.0"},
31+
// Maven (group:artifact in name)
32+
{"org.apache.logging.log4j:log4j-core", "2.14.1", "maven", "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"},
33+
{"com.google.guava:guava", "32.1.3-jre", "maven", "pkg:maven/com.google.guava/guava@32.1.3-jre"},
3134
}
3235

3336
for _, tt := range tests {

internal/sbom/snapshot_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func discoverSnapshotCases(t *testing.T) []snapshotCase {
127127
knownFiles := []string{
128128
"bun.lock", "pnpm-lock.yaml", "yarn.lock", "package-lock.json",
129129
"uv.lock", "poetry.lock", "pdm.lock", "requirements.txt",
130-
"go.mod", "Cargo.lock",
130+
"go.mod", "Cargo.lock", "gradle.lockfile",
131131
}
132132

133133
var cases []snapshotCase
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# This is a Gradle generated file for dependency locking.
2+
# Manual edits can break the build and are not advised.
3+
# This file is expected to be part of source control.
4+
com.fasterxml.jackson.core:jackson-core:2.15.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
5+
com.google.guava:guava:32.1.3-jre=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
6+
org.apache.commons:commons-lang3:3.13.0=compileClasspath,runtimeClasspath
7+
org.slf4j:slf4j-api:2.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
8+
org.springframework:spring-core:6.1.2=compileClasspath,runtimeClasspath
9+
org.springframework:spring-context:6.1.2=compileClasspath,runtimeClasspath
10+
ch.qos.logback:logback-classic:1.4.14=runtimeClasspath,testRuntimeClasspath
11+
org.junit.jupiter:junit-jupiter-api:5.10.1=testCompileClasspath,testRuntimeClasspath
12+
org.junit.jupiter:junit-jupiter-engine:5.10.1=testRuntimeClasspath
13+
org.mockito:mockito-core:5.8.0=testCompileClasspath,testRuntimeClasspath
14+
empty=annotationProcessor,testAnnotationProcessor

0 commit comments

Comments
 (0)