Skip to content

Commit 76d484b

Browse files
DART-356 Add license validation to sonar-dart plugin (#100)
1 parent 22e8b25 commit 76d484b

File tree

5 files changed

+232
-1
lines changed

5 files changed

+232
-1
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
* SonarSource Cloud Native Gradle Modules
3+
* Copyright (C) 2024-2026 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 groovy.json.JsonSlurper
18+
import java.io.File
19+
import java.net.URI
20+
import java.nio.file.Files
21+
import org.gradle.api.GradleException
22+
import org.gradle.kotlin.dsl.create
23+
import org.gradle.kotlin.dsl.findByType
24+
import org.sonarsource.cloudnative.gradle.DartLicenseGenerationConfig
25+
import org.sonarsource.cloudnative.gradle.areDirectoriesEqual
26+
import org.sonarsource.cloudnative.gradle.copyDirectory
27+
28+
/**
29+
* This plugin collects license files from third-party Dart runtime dependencies and places them
30+
* into a resources folder. It provides:
31+
* - A task to collect licenses from pub.dev packages referenced in pubspec.lock / package_config.json
32+
* - A validation task to ensure committed license files are up-to-date
33+
* - A task to regenerate the license files into the resources folder
34+
*/
35+
36+
val dartLicenseConfig =
37+
extensions.findByType<DartLicenseGenerationConfig>()
38+
?: extensions.create<DartLicenseGenerationConfig>("dartLicenseGenerationConfig")
39+
40+
dartLicenseConfig.buildDartLicenseFilesDir.convention(
41+
project.layout.buildDirectory.dir("dart-licenses").get().asFile
42+
)
43+
dartLicenseConfig.projectLicenseFile.convention(project.rootDir.resolve("LICENSE"))
44+
dartLicenseConfig.packageConfigFile.convention(
45+
project.rootDir.resolve("analyzer/.dart_tool/package_config.json")
46+
)
47+
dartLicenseConfig.analyzerDir.convention(
48+
project.rootDir.resolve("analyzer")
49+
)
50+
51+
val resourceLicenseDir = project.layout.projectDirectory.dir("src/main/resources/dart-licenses")
52+
53+
val collectDartLicenses = tasks.register("collectDartLicenses") {
54+
description = "Collects license files from Dart pub.dev dependencies"
55+
group = "licenses"
56+
var dartPubGetTasks = getTasksByName("dartPubGet", true)
57+
dependsOn(dartPubGetTasks)
58+
59+
doLast {
60+
val nonDevPackages = parseNonDevPackages(dartLicenseConfig.analyzerDir.get())
61+
val packageRoots = parsePackageRoots(dartLicenseConfig.packageConfigFile.get())
62+
63+
// Only include hosted packages (those resolved from pub.dev with file:// URIs in package_config.json)
64+
val runtimePackages = nonDevPackages.filter { packageRoots.containsKey(it) }
65+
66+
val outputDir = dartLicenseConfig.buildDartLicenseFilesDir.get().resolve("THIRD_PARTY_LICENSES")
67+
outputDir.deleteRecursively()
68+
outputDir.mkdirs()
69+
70+
logger.lifecycle("Collecting licenses for ${runtimePackages.size} runtime Dart packages...")
71+
72+
var collected = 0
73+
for (pkgName in runtimePackages.sorted()) {
74+
val pkgRoot = packageRoots[pkgName]!!
75+
76+
val licenseFile = findLicenseFile(pkgRoot)
77+
if (licenseFile != null) {
78+
licenseFile.copyTo(outputDir.resolve("$pkgName-LICENSE.txt"), overwrite = true)
79+
collected++
80+
} else {
81+
logger.warn("No LICENSE file found for package: $pkgName (looked in $pkgRoot)")
82+
}
83+
}
84+
85+
// Copy project license
86+
val projectLicense = dartLicenseConfig.projectLicenseFile.get()
87+
projectLicense.copyTo(
88+
dartLicenseConfig.buildDartLicenseFilesDir.get().resolve("LICENSE"),
89+
overwrite = true
90+
)
91+
92+
logger.lifecycle("Collected $collected license files from ${runtimePackages.size} packages.")
93+
}
94+
}
95+
96+
val validateDartLicenses = tasks.register("validateDartLicenseFiles") {
97+
description = "Validate that generated Dart license files match the committed ones"
98+
group = "validation"
99+
dependsOn(collectDartLicenses)
100+
101+
doLast {
102+
val generated = dartLicenseConfig.buildDartLicenseFilesDir.get()
103+
val committed = resourceLicenseDir.asFile
104+
if (!areDirectoriesEqual(generated, committed, logger)) {
105+
throw GradleException(
106+
"""
107+
[FAILURE] Dart license file validation failed!
108+
Generated license files differ from committed files at $resourceLicenseDir.
109+
To update the committed license files, run './gradlew generateDartLicenseResources' and commit the changes.
110+
""".trimIndent()
111+
)
112+
}
113+
logger.lifecycle("Dart license file validation succeeded: generated files match committed ones.")
114+
}
115+
}
116+
117+
val generateDartLicenseResources = tasks.register("generateDartLicenseResources") {
118+
description = "Copies generated Dart license files to the resources directory"
119+
group = "licenses"
120+
dependsOn(collectDartLicenses)
121+
122+
doLast {
123+
val generated = dartLicenseConfig.buildDartLicenseFilesDir.get()
124+
val destination = resourceLicenseDir.asFile
125+
Files.createDirectories(destination.toPath())
126+
copyDirectory(generated, destination, logger)
127+
}
128+
}
129+
130+
/**
131+
* Runs `dart pub deps --no-dev --style=compact` in the analyzer directory and parses the output
132+
* to extract non-dev dependency package names.
133+
*
134+
* Output format has lines like: `- package_name 1.0.0 [dep1 dep2]`
135+
*/
136+
fun parseNonDevPackages(analyzerDir: File): Set<String> {
137+
val process = ProcessBuilder("dart", "pub", "deps", "--no-dev", "--style=compact")
138+
.directory(analyzerDir)
139+
.redirectErrorStream(false)
140+
.start()
141+
val output = process.inputStream.bufferedReader().readText()
142+
val exitCode = process.waitFor()
143+
if (exitCode != 0) {
144+
val stderr = process.errorStream.bufferedReader().readText()
145+
error("dart pub deps failed with exit code $exitCode: $stderr")
146+
}
147+
148+
val packageLineRegex = Regex("^- (\\S+) .+$")
149+
return output.lineSequence()
150+
.mapNotNull { packageLineRegex.find(it)?.groupValues?.get(1) }
151+
.toSet()
152+
}
153+
154+
/**
155+
* Parses package_config.json to build a map of package name to its root directory.
156+
* Supports both absolute file:// URIs (hosted packages from pub.dev cache)
157+
* and relative paths (path-based/local packages).
158+
*/
159+
fun parsePackageRoots(packageConfigFile: File): Map<String, File> {
160+
val json = JsonSlurper().parse(packageConfigFile) as? Map<*, *>
161+
?: error("Invalid package_config.json: expected a JSON object")
162+
val packages = json["packages"] as? List<*>
163+
?: error("Invalid package_config.json: missing 'packages' array")
164+
165+
val packageRoots = mutableMapOf<String, File>()
166+
for (entry in packages) {
167+
val pkg = entry as? Map<*, *> ?: continue
168+
val name = pkg["name"] as? String ?: continue
169+
val rootUri = pkg["rootUri"] as? String ?: continue
170+
171+
val rootDir = if (rootUri.startsWith("file://")) {
172+
File(URI(rootUri))
173+
} else {
174+
packageConfigFile.parentFile.resolve(rootUri).canonicalFile
175+
}
176+
packageRoots[name] = rootDir
177+
}
178+
return packageRoots
179+
}
180+
181+
/**
182+
* Finds a LICENSE file in the given directory.
183+
* Looks for common license file names: LICENSE, LICENSE.md, LICENSE.txt, LICENCE, etc.
184+
*/
185+
fun findLicenseFile(dir: File): File? {
186+
if (!dir.isDirectory) return null
187+
val candidates = listOf("LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt")
188+
for (candidate in candidates) {
189+
val file = dir.resolve(candidate)
190+
if (file.isFile) return file
191+
}
192+
return null
193+
}

gradle-modules/src/main/kotlin/org.sonarsource.cloud-native.go-license-file-generator.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ tasks.named("validateLicenseFiles") {
7474
* during 'goLicenseGenerationConfig.generatingGoLicensesGradleTask.' task.
7575
*/
7676
val generateGoLicenses = tasks.register("generateGoLicenses") {
77+
group = "licenses"
7778
// Generating the licenses with go-license tool is done during 'dockerCompileGo' task
7879
dependsOn(goLicenseGenerationConfig.generatingGoLicensesGradleTask.get())
7980
doLast {
@@ -87,6 +88,7 @@ val generateGoLicenses = tasks.register("generateGoLicenses") {
8788

8889
val generateGoLicenseResources = tasks.register("generateGoLicenseResources") {
8990
description = "Copies generated license files to the resources directory"
91+
group = "licenses"
9092
dependsOn(generateGoLicenses)
9193

9294
doLast {

gradle-modules/src/main/kotlin/org.sonarsource.cloud-native.license-file-generator.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ abstract class NoParallelService : BuildService<BuildServiceParameters.None>
7676

7777
// generateLicenseReport is the task exposed by `com.github.jk1.dependency-license-report`
7878
tasks.named("generateLicenseReport") {
79+
group = "licenses"
7980
usesService(
8081
gradle.sharedServices.registerIfAbsent("noParallelProvider", NoParallelService::class) {
8182
// generateLicenseReport requires single threaded run with Gradle 9.0
@@ -99,6 +100,7 @@ tasks.named("generateLicenseReport") {
99100
// Requires LICENSE.txt to be present one level above the (project-)plugin directory
100101
tasks.register("generateLicenseResources") {
101102
description = "Copies generated license files to the resources directory"
103+
group = "licenses"
102104
dependsOn("generateLicenseReport")
103105

104106
doLast {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* SonarSource Cloud Native Gradle Modules
3+
* Copyright (C) 2024-2026 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 java.io.File
20+
import org.gradle.api.provider.Property
21+
22+
interface DartLicenseGenerationConfig {
23+
/** Directory where collected Dart license files are placed during build. */
24+
val buildDartLicenseFilesDir: Property<File>
25+
26+
/** The project's own license file (LICENSE in repo root). */
27+
val projectLicenseFile: Property<File>
28+
29+
/** Path to the analyzer's package_config.json (generated by `dart pub get`). */
30+
val packageConfigFile: Property<File>
31+
32+
/** Path to the analyzer directory (used to run `dart pub deps`). */
33+
val analyzerDir: Property<File>
34+
}

gradle-modules/src/main/kotlin/org/sonarsource/cloudnative/gradle/LicenseGenerationUtils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ fun copyDirectory(
116116
)
117117

118118
if (errors.isEmpty()) {
119-
logger.lifecycle("Directory ${sourceDir.name} copied successfully to ${destinationDir.name}")
119+
logger.lifecycle("Directory ${sourceDir.absolutePath} copied successfully to ${destinationDir.absolutePath}")
120120
} else {
121121
throw GradleException("Failed to copy ${errors.size} files.")
122122
}

0 commit comments

Comments
 (0)