|
| 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 | +} |
0 commit comments