Skip to content

Commit

Permalink
LicenseInfoResolver: Apply license choices by package
Browse files Browse the repository at this point in the history
Apply license choices specified for a package defined in the repository
configuration file.
The choice reflects in the ResolvedLicense where only the chosen
licenses get added to the final list of resolved licenses.
The information of the non-chosen license is not lost as it is still
present in the `originalLicenseExpressions`.

Relates to oss-review-toolkit#3396.

Signed-off-by: Stephanie Neubauer <stephanie.neubauer@bosch.io>
Signed-off-by: Marcel Bochtler <marcel.bochtler@bosch.io>
  • Loading branch information
MarcelBochtler committed Mar 9, 2021
1 parent 2aafd71 commit d4ea6f1
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 11 deletions.
3 changes: 2 additions & 1 deletion cli/src/main/kotlin/commands/EvaluatorCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@ class EvaluatorCommand : CliktCommand(name = "evaluate", help = "Evaluate ORT re
provider = DefaultLicenseInfoProvider(finalOrtResult, packageConfigurationProvider),
copyrightGarbage = copyrightGarbage,
archiver = globalOptionsForSubcommands.config.scanner?.archive.createFileArchiver(),
licenseFilenamePatterns = LicenseFilenamePatterns.getInstance()
licenseFilenamePatterns = LicenseFilenamePatterns.getInstance(),
licenseChoices = finalOrtResult.repository.config.licenseChoices
)

val licenseClassifications =
Expand Down
49 changes: 49 additions & 0 deletions docs/config-file-ort-yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,52 @@ resolutions:
reason: "LICENSE_ACQUIRED_EXCEPTION"
comment: "Commercial Qt license for the project was purchased, for details see https://jira.example.com/issues/SOURCING-5678"
```

## License Choices

### When to Use License Choices

For multi-licensed dependencies a specific license can be selected.
The license choice can be applied to a package.
A choice is only valid for licenses combined with the SPDX operator `OR`.
The given choice will be applied to concluded, detected and declared licenses.

### License Choice by Package

To select a license from a multi-licensed dependency an SPDX expression must be `given` as well as a `choice`.
If the `choice` only refers to part of the license a `sub-expression` of that licenses can be given, and a `choice` will
only be applied to the `sub-expression`.

e.g.
```yaml
license_choices:
package_license_choice:
- package_id: "Maven:com.example:first:0.0.1"
license_choices:
- given: A OR B
choice: A
- given: (C OR D) AND E
choice: C
sub_expressions: C OR D
- package_id: "Maven:com.example:second:2.3.4"
license_choices:
# Without a 'sub_expression', the 'choice' is applied to the 'given' license expression.
- given: (C OR D) AND E
choice: C AND E
```

---
**NOTE**

If no `sub-expression` is given, the choice will be applied to the WHOLE `given` license.
If the choice does not provide a valid result an exception will be thrown.

e.g. invalid configuration:
```yaml
# This is invalid, as 'E' must be in the resulting license.
- given: (C OR D) AND E
choice: C
```

---
47 changes: 41 additions & 6 deletions model/src/main/kotlin/licenses/LicenseInfoResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import org.ossreviewtoolkit.model.Identifier
import org.ossreviewtoolkit.model.LicenseSource
import org.ossreviewtoolkit.model.Provenance
import org.ossreviewtoolkit.model.config.CopyrightGarbage
import org.ossreviewtoolkit.model.config.LicenseChoices
import org.ossreviewtoolkit.model.config.LicenseFilenamePatterns
import org.ossreviewtoolkit.model.config.PackageLicenseChoice
import org.ossreviewtoolkit.model.config.PathExclude
import org.ossreviewtoolkit.model.utils.FileArchiver
import org.ossreviewtoolkit.model.utils.FindingCurationMatcher
Expand All @@ -44,7 +46,8 @@ class LicenseInfoResolver(
val provider: LicenseInfoProvider,
val copyrightGarbage: CopyrightGarbage,
val archiver: FileArchiver?,
val licenseFilenamePatterns: LicenseFilenamePatterns = LicenseFilenamePatterns.DEFAULT
val licenseFilenamePatterns: LicenseFilenamePatterns = LicenseFilenamePatterns.DEFAULT,
val licenseChoices: LicenseChoices = LicenseChoices()
) {
private val resolvedLicenseInfo: ConcurrentMap<Identifier, ResolvedLicenseInfo> = ConcurrentHashMap()
private val resolvedLicenseFiles: ConcurrentMap<Identifier, ResolvedLicenseFileInfo> = ConcurrentHashMap()
Expand All @@ -67,8 +70,17 @@ class LicenseInfoResolver(
private fun createLicenseInfo(id: Identifier): ResolvedLicenseInfo {
val licenseInfo = provider.get(id)

val concludedLicenses = licenseInfo.concludedLicenseInfo.concludedLicense?.decompose().orEmpty()
val declaredLicenses = licenseInfo.declaredLicenseInfo.processed.spdxExpression?.decompose().orEmpty()
val packageLicenseChoice = licenseChoices.packageLicenseChoices.singleOrNull { it.packageId == id }

val concludedLicenses = applyChoiceIfApplicable(
packageLicenseChoice,
licenseInfo.concludedLicenseInfo.concludedLicense
)?.decompose().orEmpty()

val declaredLicenses = applyChoiceIfApplicable(
packageLicenseChoice,
licenseInfo.declaredLicenseInfo.processed.spdxExpression
)?.decompose().orEmpty()

val resolvedLicenses = mutableMapOf<SpdxSingleLicenseExpression, ResolvedLicenseBuilder>()

Expand Down Expand Up @@ -103,7 +115,7 @@ class LicenseInfoResolver(
licenseInfo.detectedLicenseInfo.filterCopyrightGarbage(copyrightGarbageFindings)

val unmatchedCopyrights = mutableMapOf<Provenance, MutableSet<CopyrightFinding>>()
val resolvedLocations = resolveLocations(filteredDetectedLicenseInfo, unmatchedCopyrights)
val resolvedLocations = resolveLocations(filteredDetectedLicenseInfo, unmatchedCopyrights, packageLicenseChoice)

resolvedLocations.keys.forEach { license ->
license.builder().apply {
Expand All @@ -124,6 +136,25 @@ class LicenseInfoResolver(
)
}

private fun applyChoiceIfApplicable(
packageLicenseChoice: PackageLicenseChoice?,
license: SpdxExpression?
): SpdxExpression? {
if (packageLicenseChoice != null) {
val licenseChoice = packageLicenseChoice.licenseChoices.singleOrNull { it.given == license }

if (licenseChoice != null) {
return if (licenseChoice.subExpression != null) {
license?.applyChoice(licenseChoice.choice, licenseChoice.subExpression)
} else {
license?.applyChoice(licenseChoice.choice)
}
}
}

return license
}

private fun DetectedLicenseInfo.filterCopyrightGarbage(
copyrightGarbageFindings: MutableMap<Provenance, Set<CopyrightFinding>>
): DetectedLicenseInfo {
Expand All @@ -139,7 +170,8 @@ class LicenseInfoResolver(

private fun resolveLocations(
detectedLicenseInfo: DetectedLicenseInfo,
unmatchedCopyrights: MutableMap<Provenance, MutableSet<CopyrightFinding>>
unmatchedCopyrights: MutableMap<Provenance, MutableSet<CopyrightFinding>>,
packageLicenseChoice: PackageLicenseChoice?
): Map<SpdxSingleLicenseExpression, Set<ResolvedLicenseLocation>> {
val resolvedLocations = mutableMapOf<SpdxSingleLicenseExpression, MutableSet<ResolvedLicenseLocation>>()
val curationMatcher = FindingCurationMatcher()
Expand Down Expand Up @@ -174,7 +206,10 @@ class LicenseInfoResolver(
it.matches(licenseFinding.location.prependPath(findings.relativeFindingsPath))
}

licenseFinding.license.decompose().forEach { singleLicense ->
val chosenLicenses =
applyChoiceIfApplicable(packageLicenseChoice, licenseFinding.license)?.decompose().orEmpty()

chosenLicenses.forEach { singleLicense ->
resolvedLocations.getOrPut(singleLicense) { mutableSetOf() } += ResolvedLicenseLocation(
findings.provenance,
licenseFinding.location,
Expand Down
103 changes: 99 additions & 4 deletions model/src/test/kotlin/licenses/LicenseInfoResolverTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ import org.ossreviewtoolkit.model.TextLocation
import org.ossreviewtoolkit.model.VcsInfo
import org.ossreviewtoolkit.model.VcsType
import org.ossreviewtoolkit.model.config.CopyrightGarbage
import org.ossreviewtoolkit.model.config.LicenseChoice
import org.ossreviewtoolkit.model.config.LicenseChoices
import org.ossreviewtoolkit.model.config.LicenseFilenamePatterns
import org.ossreviewtoolkit.model.config.LicenseFindingCuration
import org.ossreviewtoolkit.model.config.LicenseFindingCurationReason
import org.ossreviewtoolkit.model.config.PackageLicenseChoice
import org.ossreviewtoolkit.model.config.PathExclude
import org.ossreviewtoolkit.model.config.PathExcludeReason
import org.ossreviewtoolkit.model.utils.FileArchiver
Expand Down Expand Up @@ -506,6 +509,76 @@ class LicenseInfoResolverTest : WordSpec() {
result should containNumberOfLocationsForLicense(gplLicense, 2)
result should containNumberOfLocationsForLicense(bsdLicense, 4)
}

"contain only chosen licenses" {
val mitLicense = "MIT"
val apacheLicense = "Apache-2.0 WITH LLVM-exception"
val gplLicense = "GPL-2.0-only"
val bsdLicense = "0BSD"

val licenseInfos = listOf(
createLicenseInfo(
id = pkgId,
declaredLicenses = setOf("$apacheLicense or $gplLicense", mitLicense),
detectedLicenses = listOf(
Findings(
provenance = provenance,
licenses = mapOf(
"$gplLicense OR $bsdLicense" to listOf(
TextLocation("LICENSE", 1),
TextLocation("LICENSE", 21)
),
bsdLicense to listOf(
TextLocation("LICENSE", 31),
TextLocation("LICENSE", 41)
)
).toFindingsSet(),
copyrights = setOf(
CopyrightFinding(
"Copyright GPL 2.0 OR BSD Zero Clause",
TextLocation("LICENSE", 1)
),
CopyrightFinding("Copyright BSD Zero Clause", TextLocation("LICENSE", 31))
),
licenseFindingCurations = emptyList(),
pathExcludes = emptyList(),
relativeFindingsPath = ""
)
),
concludedLicense = "$apacheLicense OR $gplLicense".toSpdx()
)
)

val licenseChoice = LicenseChoices(
listOf(
PackageLicenseChoice(
pkgId,
listOf(
LicenseChoice(
"($apacheLicense OR $gplLicense) AND $mitLicense".toSpdx(),
apacheLicense.toSpdx(),
"$apacheLicense OR $gplLicense".toSpdx()
),
LicenseChoice("$apacheLicense OR $gplLicense".toSpdx(), apacheLicense.toSpdx()),
LicenseChoice("$gplLicense OR $bsdLicense".toSpdx(), bsdLicense.toSpdx())
)
)
)
)

val resolver = createResolver(data = licenseInfos, licenseChoices = licenseChoice)

val result = resolver.resolveLicenseInfo(pkgId)

result should containLicenseExactlyBySource(
LicenseSource.DECLARED, apacheLicense.toSpdx(),
mitLicense.toSpdx()
)

result should containLicenseExactlyBySource(LicenseSource.DETECTED, bsdLicense.toSpdx())

result should containLicenseExactlyBySource(LicenseSource.CONCLUDED, apacheLicense.toSpdx())
}
}

"resolveLicenseFiles()" should {
Expand Down Expand Up @@ -571,11 +644,13 @@ class LicenseInfoResolverTest : WordSpec() {
private fun createResolver(
data: List<LicenseInfo>,
copyrightGarbage: Set<String> = emptySet(),
archiver: FileArchiver = FileArchiver.createDefault()
archiver: FileArchiver = FileArchiver.createDefault(),
licenseChoices: LicenseChoices = LicenseChoices()
) = LicenseInfoResolver(
SimpleLicenseInfoProvider(data),
CopyrightGarbage(copyrightGarbage.toSortedSet()),
archiver
provider = SimpleLicenseInfoProvider(data),
copyrightGarbage = CopyrightGarbage(copyrightGarbage.toSortedSet()),
archiver = archiver,
licenseChoices = licenseChoices
)

private fun createLicenseInfo(
Expand Down Expand Up @@ -739,6 +814,26 @@ fun containLicenseExpressionsExactlyBySource(
)
}

fun containLicenseExactlyBySource(
source: LicenseSource,
vararg licences: SpdxExpression?
): Matcher<ResolvedLicenseInfo?> =
neverNullMatcher { resolvedLicenseInfo ->
val actualLicenses = resolvedLicenseInfo.licenses
.filter { it.sources.contains(source) }
.map { it.license }
.toSet()
val expectedLicenses = licences.toSet()

MatcherResult(
expectedLicenses == actualLicenses,
"ResolvedLicenseInfo for ${source.show().value} licenses should contain exactly " +
"${expectedLicenses.show().value}, but has ${actualLicenses.show().value}.",
"ResolvedLicenseInfo for ${source.show().value} licenses should not contain exactly " +
"${expectedLicenses.show().value}, but has ${actualLicenses.show().value}."
)
}

fun containLicensesExactly(vararg licenses: String): Matcher<Iterable<ResolvedLicense>?> =
neverNullMatcher { value ->
val expected = licenses.map { SpdxExpression.parse(it) as SpdxSingleLicenseExpression }.toSet()
Expand Down

0 comments on commit d4ea6f1

Please sign in to comment.