Skip to content

Commit 87ef973

Browse files
SONARIAC-2199 Cloud-Security License Packaging Standard (#77)
1 parent adafaab commit 87ef973

8 files changed

Lines changed: 790 additions & 0 deletions

File tree

gradle-modules/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies {
3131
exclude("ch.qos.logback", "logback-core")
3232
}
3333
implementation(libs.shadow)
34+
implementation(libs.license.report)
3435
}
3536

3637
gradlePlugin {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* SonarSource Cloud Native Gradle Modules
3+
* Copyright (C) 2024-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
import com.github.jk1.license.render.ReportRenderer
18+
import java.io.IOException
19+
import java.nio.file.Files
20+
import java.nio.file.StandardCopyOption
21+
import java.security.MessageDigest
22+
import org.sonarsource.cloudnative.gradle.AnalyzerLicensingPackagingRenderer
23+
24+
plugins {
25+
id("com.github.jk1.dependency-license-report")
26+
}
27+
28+
/**
29+
* This plugin is used for generating license files for third-party dependencies into the resources folder.
30+
* It provides a validation task to ensure that the license files in the resource folder are up-to-date.
31+
* It provides a task to regenerate the license files into the resources folder.
32+
* This tasks expects the license of the analyzer to be present one level above the (project-)plugin directory.
33+
*/
34+
var buildLicenseReportDirectory = project.layout.buildDirectory.dir("reports/dependency-license")
35+
var buildLicenseOutputToCopyDir = buildLicenseReportDirectory.get().dir("licenses")
36+
var resourceLicenseDir = project.layout.projectDirectory.dir("src/main/resources/licenses")
37+
var resourceThirdPartyDir = resourceLicenseDir.dir("THIRD_PARTY_LICENSES")
38+
39+
licenseReport {
40+
renderers = arrayOf<ReportRenderer>(AnalyzerLicensingPackagingRenderer(buildLicenseReportDirectory.get().asFile.toPath()))
41+
}
42+
43+
tasks.named("check") {
44+
dependsOn("validateLicenseFiles")
45+
}
46+
47+
tasks.register("validateLicenseFiles") {
48+
description = "Validate that generated license files match the committed ones"
49+
group = "validation"
50+
// generateLicenseReport is the task exposed by `com.github.jk1.dependency-license-report`
51+
dependsOn("generateLicenseReport")
52+
53+
doLast {
54+
if (!areDirectoriesEqual(buildLicenseOutputToCopyDir.asFile, resourceThirdPartyDir.asFile)) {
55+
val message = """
56+
[FAILURE] License file validation failed!
57+
Generated license files differ from committed files at $resourceThirdPartyDir.
58+
To update the committed license files, run './gradlew generateLicenseResources' and commit the changes.
59+
60+
Note: This will completely regenerate all license files under $resourceThirdPartyDir and remove any stale ones.
61+
"""
62+
throw GradleException(message)
63+
} else {
64+
logger.lifecycle("License file validation succeeded: Generated license files match the committed ones.")
65+
}
66+
}
67+
}
68+
69+
/**
70+
* An empty build service to serve as a synchronization point.
71+
* Because `com.github.jk1.dependency-license-report` is not able to run in parallel with Gradle 9.0,
72+
* we force tasks to never run in parallel by configuring this service.
73+
*/
74+
abstract class NoParallelService : BuildService<BuildServiceParameters.None>
75+
76+
// generateLicenseReport is the task exposed by `com.github.jk1.dependency-license-report`
77+
tasks.named("generateLicenseReport") {
78+
usesService(
79+
gradle.sharedServices.registerIfAbsent("noParallelProvider", NoParallelService::class) {
80+
// generateLicenseReport requires single threaded run with Gradle 9.0
81+
maxParallelUsages = 1
82+
}
83+
)
84+
85+
// I'm currently unsure how I could properly cache this, or if this isn't already handled?
86+
outputs.upToDateWhen {
87+
// To be on a safe side, always rerun the generator
88+
false
89+
}
90+
91+
doFirst {
92+
// Clean up previous output to avoid stale files
93+
buildLicenseReportDirectory.get().asFile.deleteRecursively()
94+
Files.createDirectories(buildLicenseOutputToCopyDir.asFile.toPath())
95+
}
96+
}
97+
98+
// Requires LICENSE.txt to be present one level above the (project-)plugin directory
99+
tasks.register("generateLicenseResources") {
100+
description = "Copies generated license files to the resources directory"
101+
dependsOn("generateLicenseReport")
102+
103+
doLast {
104+
val sonarLicenseFile = project.layout.projectDirectory.asFile.parentFile.resolve("LICENSE.txt")
105+
Files.copy(
106+
sonarLicenseFile.toPath(),
107+
resourceLicenseDir.file("LICENSE.txt").asFile.toPath(),
108+
StandardCopyOption.REPLACE_EXISTING
109+
)
110+
copyDirectory(buildLicenseReportDirectory.get().dir("licenses").asFile, resourceThirdPartyDir.asFile)
111+
}
112+
}
113+
114+
/**
115+
* Compares two directories recursively to check for equality.
116+
* * Two directories are considered equal if they have the exact same
117+
* directory structure and all corresponding files have identical content.
118+
*
119+
* @param dir1 The first directory.
120+
* @param dir2 The second directory.
121+
* @return `true` if the directories are equal, `false` otherwise.
122+
*/
123+
fun areDirectoriesEqual(
124+
dir1: File,
125+
dir2: File,
126+
): Boolean {
127+
if (!dir1.isDirectory || !dir2.isDirectory) {
128+
logger.warn("One or both paths are not directories.")
129+
return false
130+
}
131+
logger.lifecycle("Comparing directories: ${dir1.name} and ${dir2.name}")
132+
133+
try {
134+
// 1. Walk both directory trees and map files by their relative path
135+
val files1 = dir1.walk()
136+
.filter { it.isFile }
137+
.associateBy { it.relativeTo(dir1) }
138+
139+
val files2 = dir2.walk()
140+
.filter { it.isFile }
141+
.associateBy { it.relativeTo(dir2) }
142+
143+
// 2. Compare the directory structure (based on file paths)
144+
if (files1.keys != files2.keys) {
145+
logger.warn("Directory structures do not match.")
146+
logger.warn("Files only in ${dir1.name}: ${files1.keys - files2.keys}")
147+
logger.warn("Files only in ${dir2.name}: ${files2.keys - files1.keys}")
148+
return false
149+
}
150+
151+
// 3. Compare the content of each matching file
152+
for (relativePath in files1.keys) {
153+
val file1 = files1[relativePath]!!
154+
val file2 = files2[relativePath]!!
155+
156+
// Quick check: compare file sizes first
157+
if (file1.length() != file2.length()) {
158+
logger.warn("File size mismatch: $relativePath")
159+
return false
160+
}
161+
162+
// Full check: compare byte content
163+
val checksum1 = getFileChecksum(file1)
164+
val checksum2 = getFileChecksum(file2)
165+
if (checksum1 != checksum2) {
166+
logger.warn("File content mismatch: $relativePath")
167+
return false
168+
}
169+
}
170+
171+
// If all checks pass, the directories are equal
172+
return true
173+
} catch (e: IOException) {
174+
logger.error("An error occurred during comparison: ${e.message}")
175+
return false
176+
}
177+
}
178+
179+
fun getFileChecksum(file: File): String {
180+
val md = MessageDigest.getInstance("SHA-256")
181+
val digestBytes = md.digest(file.readBytes())
182+
183+
// 4. Convert the byte array to a hexadecimal string
184+
// "%02x" formats each byte as two lowercase hex digits
185+
return digestBytes.joinToString("") { "%02x".format(it) }
186+
}
187+
188+
fun copyDirectory(
189+
sourceDir: File,
190+
destinationDir: File,
191+
) {
192+
val errors = mutableListOf<String>()
193+
194+
destinationDir.deleteRecursively()
195+
sourceDir.copyRecursively(
196+
target = destinationDir,
197+
overwrite = true,
198+
onError = { file, exception ->
199+
logger.warn("Failed to copy $file: ${exception.message}")
200+
errors.add(file.name)
201+
OnErrorAction.SKIP // Skip this file and continue
202+
}
203+
)
204+
205+
if (errors.isEmpty()) {
206+
logger.lifecycle("Directory ${sourceDir.name} copied successfully to ${destinationDir.name}")
207+
} else {
208+
throw GradleException("Failed to copy ${errors.size} files.")
209+
}
210+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* SonarSource Cloud Native Gradle Modules
3+
* Copyright (C) 2024-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonarsource.cloudnative.gradle
18+
19+
import com.github.jk1.license.LicenseFileDetails
20+
import com.github.jk1.license.ModuleData
21+
import com.github.jk1.license.ProjectData
22+
import com.github.jk1.license.render.ReportRenderer
23+
import java.io.IOException
24+
import java.net.URISyntaxException
25+
import java.nio.file.Files
26+
import java.nio.file.Path
27+
import java.nio.file.StandardCopyOption
28+
import java.nio.file.StandardOpenOption
29+
import java.util.ArrayList
30+
31+
class AnalyzerLicensingPackagingRenderer(
32+
private val buildOutputDir: Path,
33+
) : ReportRenderer {
34+
private var apacheLicenseFileName: String = "Apache-2.0.txt"
35+
private lateinit var generatedLicenseResourcesDirectory: Path
36+
private val licenseTitleToResourceFile: Map<String, String> = buildMap {
37+
put("Apache License, Version 2.0", apacheLicenseFileName)
38+
put("Apache License Version 2.0", apacheLicenseFileName)
39+
put("Apache 2", apacheLicenseFileName)
40+
put("Apache-2.0", apacheLicenseFileName)
41+
put("The Apache Software License, Version 2.0", apacheLicenseFileName)
42+
put("BSD-3-Clause", "BSD-3.txt")
43+
put("GNU LGPL 3", "GNU-LGPL-3.txt")
44+
put("Go License", "Go.txt")
45+
}
46+
private val dependenciesWithUnusableLicenseFileInside: Set<String> = setOf(
47+
"com.fasterxml.jackson.dataformat.jackson-dataformat-smile",
48+
"com.fasterxml.jackson.dataformat.jackson-dataformat-yaml"
49+
)
50+
private val exceptions: ArrayList<String> = ArrayList()
51+
52+
// Generate license files for all dependencies in the licenses folder
53+
override fun render(data: ProjectData) {
54+
generatedLicenseResourcesDirectory = buildOutputDir.resolve("licenses")
55+
try {
56+
generateDependencyFiles(data)
57+
} catch (e: Exception) {
58+
throw RuntimeException(e)
59+
}
60+
if (exceptions.isNotEmpty()) {
61+
val exceptionLog = exceptions.joinToString(separator = "\n")
62+
throw RuntimeException("Exceptions occurred during license file generation:\n$exceptionLog")
63+
}
64+
}
65+
66+
@Throws(IOException::class, URISyntaxException::class)
67+
private fun generateDependencyFiles(data: ProjectData) {
68+
for (dependency in data.allDependencies) {
69+
generateDependencyFile(dependency)
70+
}
71+
}
72+
73+
/**
74+
* Generate a license file for a given dependency.
75+
* First we try to copy the license file included in the dependency itself in `copyIncludedLicenseFromDependency`
76+
* If there is no License file, or the dependency contains an unusable license file,
77+
* we try to derive the license from the pom in `findLicenseIdentifierInPomAndCopyFromResources`.
78+
* In this method we're looking for the identifier of the license, and we copy the corresponding license file from our resources.
79+
* The mapping (license identifier to resource file) is derived from the map `licenseTitleToResourceFile`.
80+
*/
81+
@Throws(IOException::class, URISyntaxException::class)
82+
private fun generateDependencyFile(data: ModuleData) {
83+
val copyIncludedLicenseFile = copyIncludedLicenseFromDependency(data)
84+
if (copyIncludedLicenseFile.success) {
85+
return
86+
}
87+
88+
val copyFromResources = findLicenseIdentifierInPomAndCopyFromResources(data)
89+
if (copyFromResources.success) {
90+
return
91+
}
92+
93+
exceptions.add("${data.group}.${data.name}: ${copyIncludedLicenseFile.message}")
94+
exceptions.add("${data.group}.${data.name}: ${copyFromResources.message}")
95+
}
96+
97+
@Throws(IOException::class)
98+
private fun copyIncludedLicenseFromDependency(data: ModuleData): Status {
99+
if (dependenciesWithUnusableLicenseFileInside.contains("${data.group}.${data.name}")) {
100+
return Status.failure("Excluded copying license from dependency as it's not the right one.")
101+
}
102+
103+
val licenseFileDetails = data.licenseFiles.stream().flatMap { licenseFile -> licenseFile.fileDetails.stream() }
104+
.filter { file: LicenseFileDetails -> file.file.contains("LICENSE") }
105+
.findFirst()
106+
107+
if (licenseFileDetails.isEmpty) {
108+
return Status.failure("No license file data found.")
109+
}
110+
111+
copyLicenseFile(data, buildOutputDir.resolve(licenseFileDetails.get().file))
112+
return Status.success
113+
}
114+
115+
@Throws(IOException::class, URISyntaxException::class)
116+
private fun findLicenseIdentifierInPomAndCopyFromResources(data: ModuleData): Status {
117+
val pomLicense = data.poms.stream().flatMap { pomData -> pomData.licenses.stream() }
118+
.findFirst()
119+
120+
if (pomLicense.isEmpty) {
121+
return Status.failure("No license found in pom data.")
122+
}
123+
124+
copyLicenseFromResources(data, pomLicense.get().name)
125+
return Status.success
126+
}
127+
128+
@Throws(IOException::class)
129+
private fun copyLicenseFile(
130+
data: ModuleData,
131+
fileToCopy: Path,
132+
): Status {
133+
// Modify to use LF line endings
134+
val normalizedFile = Files.readAllLines(fileToCopy).joinToString("\n")
135+
Files.write(
136+
generateLicensePath(data),
137+
normalizedFile.toByteArray(),
138+
StandardOpenOption.CREATE,
139+
StandardOpenOption.TRUNCATE_EXISTING
140+
)
141+
return Status.success
142+
}
143+
144+
@Throws(IOException::class)
145+
private fun copyLicenseFromResources(
146+
data: ModuleData,
147+
licenseName: String,
148+
): Status {
149+
val licenseResourceFileName = licenseTitleToResourceFile[licenseName]
150+
val resourceAsStream = AnalyzerLicensingPackagingRenderer::class.java.getResourceAsStream("/licenses/$licenseResourceFileName")
151+
?: throw IOException("Resource not found for license: $licenseName")
152+
Files.copy(resourceAsStream, generateLicensePath(data), StandardCopyOption.REPLACE_EXISTING)
153+
return Status.success
154+
}
155+
156+
private fun generateLicensePath(data: ModuleData): Path =
157+
generatedLicenseResourcesDirectory.resolve("${data.group}.${data.name}-LICENSE.txt")
158+
159+
private data class Status(
160+
val success: Boolean,
161+
val message: String?,
162+
) {
163+
companion object {
164+
var success: Status = Status(true, null)
165+
166+
fun failure(message: String?): Status = Status(false, message)
167+
}
168+
}
169+
}

0 commit comments

Comments
 (0)