Skip to content

Commit

Permalink
license-gather: add license compatibility validation task
Browse files Browse the repository at this point in the history
  • Loading branch information
vlsi committed Jan 12, 2022
1 parent 8ed8f3e commit c09f0a9
Show file tree
Hide file tree
Showing 10 changed files with 777 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ open class GatherLicenseTask @Inject constructor(
objectFactory: ObjectFactory,
private val workerExecutor: WorkerExecutor
) : DefaultTask() {
init {
// TODO: capture [licenseOverrides] as input
outputs.upToDateWhen { false }
}

@InputFiles
val configurations = objectFactory.setProperty<Configuration>()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright 2019 Vladimir Sitnikov <sitnikov.vladimir@gmail.com>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package com.github.vlsi.gradle.license

interface LicenseCompatibilityConfig {
/**
* Clarifies the reason for [LicenseCompatibility] so the output of
* [VerifyLicenseCompatibilityTask] is easier to understand.
*/
fun because(reason: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2019 Vladimir Sitnikov <sitnikov.vladimir@gmail.com>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package com.github.vlsi.gradle.license

import com.github.vlsi.gradle.license.CompatibilityResult.ALLOW
import com.github.vlsi.gradle.license.CompatibilityResult.REJECT
import com.github.vlsi.gradle.license.CompatibilityResult.UNKNOWN
import com.github.vlsi.gradle.license.api.ConjunctionLicenseExpression
import com.github.vlsi.gradle.license.api.DisjunctionLicenseExpression
import com.github.vlsi.gradle.license.api.LicenseEquivalence
import com.github.vlsi.gradle.license.api.LicenseExpression
import com.github.vlsi.gradle.license.api.disjunctions

enum class CompatibilityResult {
ALLOW, UNKNOWN, REJECT;
}

data class LicenseCompatibility(
val type: CompatibilityResult, val reason: String
) : java.io.Serializable

internal data class ResolvedLicenseCompatibility(
val type: CompatibilityResult, val reasons: List<String>
) : Comparable<ResolvedLicenseCompatibility> {
constructor(type: CompatibilityResult, vararg reasons: String) : this(type, reasons.toList())

override fun compareTo(other: ResolvedLicenseCompatibility) =
compareValuesBy(this, other, { it.type }, { it.reasons.toString() })
}

internal fun LicenseCompatibility.asResolved(license: LicenseExpression) =
ResolvedLicenseCompatibility(
type,
if (reason.isEmpty()) "$license: $type" else "$license: $reason"
)

internal class LicenseCompatibilityInterpreter(
private val licenseEquivalence: LicenseEquivalence,
private val resolvedCases: Map<LicenseExpression, LicenseCompatibility>
) {
val resolvedParts = resolvedCases.asSequence().flatMap { (license, _) ->
licenseEquivalence.expand(license).disjunctions().asSequence().map { it to license }
}.groupingBy { it.first }.aggregate { key, acc: LicenseExpression?, element, first ->
if (first) {
element.second
} else {
throw IllegalArgumentException(
"License $key participates in multiple resolved cases: $acc and ${element.second}. " + "Please make sure resolvedCases do not intersect"
)
}
}

override fun toString() = "LicenseCompatibilityInterpreter(resolved=$resolvedCases)"

fun eval(licenseExpression: LicenseExpression?): ResolvedLicenseCompatibility {
if (licenseExpression == null) {
return ResolvedLicenseCompatibility(REJECT, listOf("License is null"))
}
// If the case is already resolved, just return the resolution
resolvedCases[licenseExpression]?.let { return it.asResolved(licenseExpression) }

// Expand the license (e.g. expand OR_LATER into OR ... OR)
val e = licenseEquivalence.expand(licenseExpression)

return when (e) {
is DisjunctionLicenseExpression ->
// A or X => A
e.unordered.takeIf { it.isNotEmpty() }?.map { eval(it) }?.reduce { a, b ->
when {
a.type == b.type -> ResolvedLicenseCompatibility(
a.type,
a.reasons + b.reasons
)
// allow OR (unknown | reject) -> allow
a.type == ALLOW -> a
b.type == ALLOW -> b
// reject OR unknown -> unknown
else -> ResolvedLicenseCompatibility(
UNKNOWN,
a.reasons.map { "${a.type}: $it" } + b.reasons.map { "${b.type}: $it" }
)
}
}
is ConjunctionLicenseExpression -> e.unordered.takeIf { it.isNotEmpty() }
?.map { eval(it) }?.reduce { a, b ->
when {
a.type == b.type -> ResolvedLicenseCompatibility(
a.type,
a.reasons + b.reasons
)
// allow OR next=(unknown | reject) -> next
a.type == ALLOW -> b
b.type == ALLOW -> a
// reject OR unknown -> reject
else -> ResolvedLicenseCompatibility(
REJECT,
a.reasons.map { "${a.type}: $it" } + b.reasons.map { "${b.type}: $it" }
)
}
}
else -> resolvedParts[e]?.let { resolved ->
resolvedCases.getValue(resolved).let {
if (e == resolved) {
it.asResolved(resolved)
} else {
it.asResolved(e)
}
}
}
} ?: ResolvedLicenseCompatibility(UNKNOWN, listOf("No rules found for $licenseExpression"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/*
* Copyright 2019 Vladimir Sitnikov <sitnikov.vladimir@gmail.com>
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package com.github.vlsi.gradle.license

import com.github.vlsi.gradle.license.api.License
import com.github.vlsi.gradle.license.api.LicenseEquivalence
import com.github.vlsi.gradle.license.api.LicenseExpression
import com.github.vlsi.gradle.license.api.asExpression
import org.gradle.api.Action
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.file.ProjectLayout
import org.gradle.api.logging.LogLevel
import org.gradle.api.model.ObjectFactory
import org.gradle.api.tasks.Console
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option
import org.gradle.kotlin.dsl.invoke
import org.gradle.kotlin.dsl.mapProperty
import org.gradle.kotlin.dsl.property
import java.util.*
import javax.inject.Inject

open class VerifyLicenseCompatibilityTask @Inject constructor(
objectFactory: ObjectFactory,
layout: ProjectLayout
) : DefaultTask() {
@InputFiles
val metadata = objectFactory.fileCollection()

@Input
val failOnIncompatibleLicense = objectFactory.property<Boolean>().convention(true)

@Input
val resolvedCases = objectFactory.mapProperty<LicenseExpression, LicenseCompatibility>()

@Input
val licenseSimilarityNormalizationThreshold =
objectFactory.property<Int>().convention(42)

@Option(option = "print", description = "prints the verification results to console")
@Console
val printResults = objectFactory.property<Boolean>().convention(false)

/**
* Outputs the license verification results (incompatible and unknown licenses are listed first).
*/
@OutputFile
val outputFile = objectFactory.fileProperty()
.convention(layout.buildDirectory.file("verifyLicense/$name/verification_result.txt"))

private fun registerResolution(
licenseExpression: LicenseExpression,
type: CompatibilityResult,
action: Action<LicenseCompatibilityConfig>? = null
) {
val reason = action?.let {
object : LicenseCompatibilityConfig {
var reason = ""
override fun because(reason: String) {
this.reason = reason
}
}.let {
action.invoke(it)
it.reason
}
} ?: ""
resolvedCases.put(licenseExpression, LicenseCompatibility(type, reason))
}

@JvmOverloads
fun allow(license: License, action: Action<LicenseCompatibilityConfig>? = null) {
allow(license.asExpression(), action)
}

@JvmOverloads
fun allow(
licenseExpression: LicenseExpression,
action: Action<LicenseCompatibilityConfig>? = null
) {
registerResolution(licenseExpression, CompatibilityResult.ALLOW, action)
}

@JvmOverloads
fun reject(license: License, action: Action<LicenseCompatibilityConfig>? = null) {
reject(license.asExpression(), action)
}

@JvmOverloads
fun reject(
licenseExpression: LicenseExpression,
action: Action<LicenseCompatibilityConfig>? = null
) {
registerResolution(licenseExpression, CompatibilityResult.REJECT, action)
}

@JvmOverloads
fun unknown(license: License, action: Action<LicenseCompatibilityConfig>? = null) {
unknown(license.asExpression(), action)
}

@JvmOverloads
fun unknown(
licenseExpression: LicenseExpression,
action: Action<LicenseCompatibilityConfig>? = null
) {
registerResolution(licenseExpression, CompatibilityResult.UNKNOWN, action)
}

@TaskAction
fun run() {
val dependencies = MetadataStore.load(metadata).dependencies

val licenseNormalizer = GuessBasedNormalizer(
logger, licenseSimilarityNormalizationThreshold.get().toDouble()
)
val licenseCompatibilityInterpreter = LicenseCompatibilityInterpreter(
// TODO: make it configurable
LicenseEquivalence(),
resolvedCases.get().mapKeys {
licenseNormalizer.normalize(it.key)
}
)

val ok = StringBuilder()
val ko = StringBuilder()

dependencies
.asSequence()
.map { (component, licenseInfo) -> component to licenseInfo.license }
.groupByTo(TreeMap()) { (component, license) ->
licenseCompatibilityInterpreter.eval(license).also {
logger.log(
when (it.type) {
CompatibilityResult.ALLOW -> LogLevel.DEBUG
CompatibilityResult.UNKNOWN -> LogLevel.LIFECYCLE
CompatibilityResult.REJECT -> LogLevel.LIFECYCLE
},
"License compatibility for {}: {} -> {}", component, license, it
)
}
}
.forEach { (licenseCompatibility, components) ->
val header =
"${licenseCompatibility.type}: ${licenseCompatibility.reasons.joinToString(", ")}"
val sb = if (licenseCompatibility.type == CompatibilityResult.ALLOW) ok else ko
if (sb.isNotEmpty()) {
sb.append('\n')
}
sb.append(header).append('\n')
sb.append("=".repeat(header.length)).append('\n')
sb.appendComponents(components)
}

val errorMessage = ko.toString()
val result = ko.apply {
if (isNotEmpty() && ok.isNotEmpty()) {
append('\n')
}
append(ok)
while (endsWith('\n')) {
setLength(length - 1)
}
}.toString()

if (printResults.get()) {
println(result)
}

outputFile.get().asFile.apply {
parentFile.mkdirs()
writeText(result)
}

if (errorMessage.isNotEmpty()) {
if (failOnIncompatibleLicense.get()) {
throw GradleException(errorMessage)
} else {
logger.warn(errorMessage)
}
}
}

private fun Appendable.appendComponents(
components: List<Pair<ModuleComponentIdentifier, LicenseExpression?>>
) =
components
.groupByTo(TreeMap(nullsFirst(LicenseExpression.NATURAL_ORDER)),
{ it.second }, { it.first })
.forEach { (license, components) ->
append('\n')
append(license?.toString() ?: "Unknown license").append('\n')
components.forEach {
append("* ${it.displayName}\n")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ package com.github.vlsi.gradle.license.api

import java.net.URI

interface License {
interface License : java.io.Serializable {
val title: String
val uri: List<URI>
}

interface LicenseException {
interface LicenseException : java.io.Serializable {
val title: String
val uri: List<URI>
}
Expand Down
Loading

0 comments on commit c09f0a9

Please sign in to comment.