Skip to content

Commit c1ed4df

Browse files
SONARSWIFT-874 Add license validation to sonar-swift plugin (#101)
1 parent 76d484b commit c1ed4df

File tree

2 files changed

+221
-0
lines changed

2 files changed

+221
-0
lines changed
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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.nio.file.Files
20+
import org.gradle.api.GradleException
21+
import org.gradle.kotlin.dsl.create
22+
import org.gradle.kotlin.dsl.findByType
23+
import org.sonarsource.cloudnative.gradle.SwiftLicenseGenerationConfig
24+
import org.sonarsource.cloudnative.gradle.areDirectoriesEqual
25+
import org.sonarsource.cloudnative.gradle.copyDirectory
26+
27+
/**
28+
* This plugin collects license files from third-party Swift runtime dependencies and places them
29+
* into a resources folder. It provides:
30+
* - A task to collect licenses from Swift packages resolved by Swift Package Manager
31+
* - A validation task to ensure committed license files are up-to-date
32+
* - A task to regenerate the license files into the resources folder
33+
*/
34+
35+
val swiftLicenseConfig =
36+
extensions.findByType<SwiftLicenseGenerationConfig>()
37+
?: extensions.create<SwiftLicenseGenerationConfig>("swiftLicenseGenerationConfig")
38+
39+
swiftLicenseConfig.buildSwiftLicenseFilesDir.convention(
40+
project.layout.buildDirectory.dir("swift-licenses").get().asFile
41+
)
42+
swiftLicenseConfig.projectLicenseFile.convention(project.rootDir.resolve("LICENSE"))
43+
swiftLicenseConfig.analyzerDir.convention(
44+
project.rootDir.resolve("analyzer")
45+
)
46+
47+
val resourceLicenseDir = project.rootDir.resolve("sonar-swift-plugin/src/main/resources/swift-licenses")
48+
49+
/**
50+
* Data class representing a Swift package dependency from `swift package show-dependencies --format json`.
51+
*/
52+
data class SwiftPackageDep(
53+
val identity: String,
54+
val name: String,
55+
val url: String,
56+
val path: String,
57+
)
58+
59+
val collectSwiftLicenses = tasks.register("collectSwiftLicenses") {
60+
description = "Collects license files from Swift Package Manager dependencies"
61+
group = "licenses"
62+
63+
doLast {
64+
val packages = parseNonDevPackages(swiftLicenseConfig.analyzerDir.get())
65+
66+
val outputDir = swiftLicenseConfig.buildSwiftLicenseFilesDir.get().resolve("THIRD_PARTY_LICENSES")
67+
outputDir.deleteRecursively()
68+
outputDir.mkdirs()
69+
70+
logger.lifecycle("Collecting licenses for ${packages.size} runtime Swift packages...")
71+
72+
var collected = 0
73+
for (pkg in packages.sortedBy { it.identity }) {
74+
val pkgDir = File(pkg.path)
75+
76+
val licenseFile = findLicenseFile(pkgDir)
77+
if (licenseFile != null) {
78+
licenseFile.copyTo(outputDir.resolve("${pkg.identity}-LICENSE.txt"), overwrite = true)
79+
collected++
80+
} else {
81+
logger.warn("No LICENSE file found for package: ${pkg.identity} (looked in ${pkg.path})")
82+
}
83+
}
84+
85+
// Copy project license
86+
val projectLicense = swiftLicenseConfig.projectLicenseFile.get()
87+
projectLicense.copyTo(
88+
swiftLicenseConfig.buildSwiftLicenseFilesDir.get().resolve("LICENSE"),
89+
overwrite = true
90+
)
91+
92+
logger.lifecycle("Collected $collected license files from ${packages.size} packages.")
93+
}
94+
}
95+
96+
val validateSwiftLicenses = tasks.register("validateSwiftLicenseFiles") {
97+
description = "Validate that generated Swift license files match the committed ones"
98+
group = "validation"
99+
dependsOn(collectSwiftLicenses)
100+
101+
doLast {
102+
val generated = swiftLicenseConfig.buildSwiftLicenseFilesDir.get()
103+
val committed = resourceLicenseDir
104+
if (!areDirectoriesEqual(generated, committed, logger)) {
105+
throw GradleException(
106+
"""
107+
[FAILURE] Swift license file validation failed!
108+
Generated license files differ from committed files at $resourceLicenseDir.
109+
To update the committed license files, run './gradlew generateSwiftLicenseResources' and commit the changes.
110+
""".trimIndent()
111+
)
112+
}
113+
logger.lifecycle("Swift license file validation succeeded: generated files match committed ones.")
114+
}
115+
}
116+
117+
val generateSwiftLicenseResources = tasks.register("generateSwiftLicenseResources") {
118+
description = "Copies generated Swift license files to the resources directory"
119+
group = "licenses"
120+
dependsOn(collectSwiftLicenses)
121+
122+
doLast {
123+
val generated = swiftLicenseConfig.buildSwiftLicenseFilesDir.get()
124+
val destination = resourceLicenseDir
125+
Files.createDirectories(destination.toPath())
126+
copyDirectory(generated, destination, logger)
127+
}
128+
}
129+
130+
/**
131+
* Runs `swift package show-dependencies --format json` in the analyzer directory and parses the output
132+
* to extract non-dev dependency packages (all resolved packages, since Swift SPM dependencies
133+
* listed in Package.swift are used by production targets).
134+
*
135+
* Recursively flattens the dependency tree to include transitive dependencies.
136+
*/
137+
fun parseNonDevPackages(analyzerDir: File): Set<SwiftPackageDep> {
138+
val process = ProcessBuilder("swift", "package", "show-dependencies", "--format", "json")
139+
.directory(analyzerDir)
140+
.redirectErrorStream(false)
141+
.start()
142+
val output = process.inputStream.bufferedReader().readText()
143+
val exitCode = process.waitFor()
144+
if (exitCode != 0) {
145+
val stderr = process.errorStream.bufferedReader().readText()
146+
error("swift package show-dependencies failed with exit code $exitCode: $stderr")
147+
}
148+
149+
val json = JsonSlurper().parseText(output) as? Map<*, *>
150+
?: error("Invalid output from swift package show-dependencies")
151+
152+
val packages = mutableSetOf<SwiftPackageDep>()
153+
flattenDependencies(json, packages)
154+
return packages
155+
}
156+
157+
/**
158+
* Recursively extracts all dependencies from the JSON tree produced by
159+
* `swift package show-dependencies --format json`.
160+
*/
161+
fun flattenDependencies(
162+
node: Map<*, *>,
163+
result: MutableSet<SwiftPackageDep>,
164+
) {
165+
val deps = node["dependencies"] as? List<*> ?: return
166+
for (dep in deps) {
167+
val depMap = dep as? Map<*, *> ?: continue
168+
val identity = depMap["identity"] as? String ?: continue
169+
val name = depMap["name"] as? String ?: identity
170+
val url = depMap["url"] as? String ?: ""
171+
val path = depMap["path"] as? String ?: continue
172+
173+
result.add(SwiftPackageDep(identity, name, url, path))
174+
flattenDependencies(depMap, result)
175+
}
176+
}
177+
178+
/**
179+
* Finds a LICENSE file in the given directory.
180+
* Looks for common license file names: LICENSE, LICENSE.md, LICENSE.txt, LICENCE, etc.
181+
*/
182+
fun findLicenseFile(dir: File): File? {
183+
if (!dir.isDirectory) return null
184+
val candidates = listOf("LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt")
185+
for (candidate in candidates) {
186+
val file = dir.resolve(candidate)
187+
if (file.isFile) return file
188+
}
189+
return null
190+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 SwiftLicenseGenerationConfig {
23+
/** Directory where collected Swift license files are placed during build. */
24+
val buildSwiftLicenseFilesDir: 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 directory (used to run `swift package show-dependencies`). */
30+
val analyzerDir: Property<File>
31+
}

0 commit comments

Comments
 (0)