diff --git a/plugins/scanners/fossid/src/main/kotlin/FossId.kt b/plugins/scanners/fossid/src/main/kotlin/FossId.kt index e0e953679cd5..1ba487f1f61d 100644 --- a/plugins/scanners/fossid/src/main/kotlin/FossId.kt +++ b/plugins/scanners/fossid/src/main/kotlin/FossId.kt @@ -467,7 +467,7 @@ class FossId internal constructor( val scanCodeAndId = if (existingScan == null) { logger.info { "No scan found for $url and revision $revision. Creating scan..." } - val scanCode = namingProvider.createScanCode(projectName) + val scanCode = namingProvider.createScanCode(projectName = projectName, branch = revision) val newUrl = urlProvider.getUrl(url) val scanId = createScan(projectCode, scanCode, newUrl, revision) @@ -510,10 +510,6 @@ class FossId internal constructor( val defaultBranch = vcs.getDefaultBranchName(url) logger.info { "Default branch is '$defaultBranch'." } - // If a scan for the default branch is created, put the default branch name in the scan code (the - // FossIdNamingProvider must also have a scan pattern that makes use of it). - val branchLabel = projectRevision.takeIf { defaultBranch == projectRevision }.orEmpty() - if (projectRevision == null) { logger.warn { "No project revision has been given." } } else { @@ -545,13 +541,13 @@ class FossId internal constructor( logger.info { "No scan found for $mappedUrlWithoutCredentials and revision $revision. Creating origin scan..." } - namingProvider.createScanCode(projectName, DeltaTag.ORIGIN, branchLabel) + namingProvider.createScanCode(projectName, DeltaTag.ORIGIN, revision) } else { logger.info { "Scan '${existingScan.code}' found for $mappedUrlWithoutCredentials and revision $revision." } logger.info { "Existing scan has for reference(s): ${existingScan.comment.orEmpty()}. Creating delta scan..." } - namingProvider.createScanCode(projectName, DeltaTag.DELTA, branchLabel) + namingProvider.createScanCode(projectName, DeltaTag.DELTA, revision) } val scanId = createScan(projectCode, scanCode, mappedUrl, revision, projectRevision.orEmpty()) diff --git a/plugins/scanners/fossid/src/main/kotlin/FossIdNamingProvider.kt b/plugins/scanners/fossid/src/main/kotlin/FossIdNamingProvider.kt index 596380369f41..c8bd0d2faac0 100644 --- a/plugins/scanners/fossid/src/main/kotlin/FossIdNamingProvider.kt +++ b/plugins/scanners/fossid/src/main/kotlin/FossIdNamingProvider.kt @@ -37,6 +37,7 @@ import org.apache.logging.log4j.kotlin.logger * * **currentTimestamp**: The current time. * * **deltaTag** (scan code only): If delta scans is enabled, this qualifies the scan as an *origin* scan or a *delta* * scan. + * * **branch**: branch name (revision) given to scan */ class FossIdNamingProvider( private val namingProjectPattern: String?, @@ -46,6 +47,8 @@ class FossIdNamingProvider( companion object { @JvmStatic val FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss") + + const val MAX_SCAN_CODE_LEN = 254 } fun createProjectCode(projectName: String): String = @@ -57,16 +60,86 @@ class FossIdNamingProvider( } ?: projectName fun createScanCode(projectName: String, deltaTag: FossId.DeltaTag? = null, branch: String = ""): String { + return namingScanPattern?.let { + createScanCodeForCustomPattern(namingScanPattern, projectName, deltaTag, branch) + } ?: run { + createScanCodeForDefaultPattern(projectName, deltaTag, branch) + } + } + + private fun createScanCodeForDefaultPattern( + projectName: String, + deltaTag: FossId.DeltaTag? = null, + branch: String = "" + ): String { + val builtins = mutableMapOf("#projectName" to projectName) var defaultPattern = "#projectName_#currentTimestamp" - val builtins = mutableMapOf("#projectName" to projectName, "#branch" to branch) deltaTag?.let { defaultPattern += "_#deltaTag" builtins += "#deltaTag" to deltaTag.name.lowercase() } - val pattern = namingScanPattern ?: defaultPattern - return replaceNamingConventionVariables(pattern, builtins, namingConventionVariables) + if (branch.isNotBlank()) { + val branchName = normalizeBranchName(branch, defaultPattern, builtins) + defaultPattern += "_#branch" + builtins += "#branch" to branchName + } + + return replaceNamingConventionVariables(defaultPattern, builtins, namingConventionVariables) + } + + private fun createScanCodeForCustomPattern( + namingPattern: String, + projectName: String, + deltaTag: FossId.DeltaTag? = null, + branch: String = "" + ): String { + val builtins = mutableMapOf() + + namingPattern.contains("#projectName").let { + builtins += "#projectName" to projectName + } + + namingPattern.contains("#deltaTag").let { + if (deltaTag != null) { + builtins += "#deltaTag" to deltaTag.name.lowercase() + } + } + + namingPattern.contains("#branch").let { + val namingPatternWithoutBranchPlaceholder = namingPattern.replace("#branch", "") + builtins += "#branch" to normalizeBranchName(branch, namingPatternWithoutBranchPlaceholder, builtins) + } + + return replaceNamingConventionVariables(namingPattern, builtins, namingConventionVariables) + } + + /** + * Replaces non-standard characters in branch name and trims its length to one that will not exceed + * maximum length of FossID scan ID, when combined with the rest of variables + */ + private fun normalizeBranchName( + branch: String, + scanCodeNamingPattern: String, + scanCodeVariables: Map + ): String { + val noBranchScanCode = + replaceNamingConventionVariables( + scanCodeNamingPattern, + scanCodeVariables, + namingConventionVariables + ) + + require(noBranchScanCode.length < MAX_SCAN_CODE_LEN) { + throw IllegalArgumentException( + "FossID scan code '$noBranchScanCode' is too long. " + + "It must not exceed $MAX_SCAN_CODE_LEN characters. Please consider shorter naming scan pattern." + ) + } + + val maxBranchNameLength = MAX_SCAN_CODE_LEN - noBranchScanCode.length + return branch.replace(Regex("[^a-zA-Z0-9-_]"), "_").take(maxBranchNameLength) } /** diff --git a/plugins/scanners/fossid/src/test/kotlin/FossIdNamingProviderTest.kt b/plugins/scanners/fossid/src/test/kotlin/FossIdNamingProviderTest.kt new file mode 100644 index 000000000000..f1b690d9ee78 --- /dev/null +++ b/plugins/scanners/fossid/src/test/kotlin/FossIdNamingProviderTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.scanners.fossid + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.WordSpec +import io.kotest.core.test.TestCase +import io.kotest.core.test.TestResult +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.ints.shouldBeLessThanOrEqual + +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll + +import java.time.LocalDateTime + +class FossIdNamingProviderTest : WordSpec() { + + override suspend fun afterEach(testCase: TestCase, result: TestResult) { + unmockkAll() + } + + companion object { + const val MAX_SCAN_CODE_LEN = 255 + } + + init { + "createScanCode" should { + val namingProvider = FossIdNamingProvider(null, null, emptyMap()) + + val mockedDateTime = LocalDateTime.of(2024, 4, 1, 10, 0) + val expectedTimestamp = "20240401_100000" + + val longBranchName = "origin/feature/LOREM-123321_lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit-" + + "aliquam-laoreet-ac-nulla-in-bibendum-phasellus-sodales-vel-lorem-consequat-efficitur-morbi-viverra-a" + + "ccumsan-libero-a-tincidunt-libero-venenatis-nec-nulla-facilisi-vestibulum-pharetra-finibus-mi-vitae-" + + "luctus" + + val longScanPattern = "#projectName_#currentTimestamp_lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-e" + + "lit-aliquam-laoreet-ac-nulla-in-bibendum-phasellus-sodales-vel-lorem-consequat-efficitur-morbi-viver" + + "ra-accumsan-libero-a-tincidunt-libero-venenatis-nec-nulla-facilisi-vestibulum-pharetra-finibus-mi-vi" + + "tae-luctus" + + "create code without branch name, when it's empty" { + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProvider.createScanCode( + "example-project-name", null, "" + ) shouldBeEqual "example-project-name_$expectedTimestamp" + } + } + + "create code with branch name" { + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProvider.createScanCode( + "example-project-name", null, "CODE-2233_Red-dots-added-to-layout" + ) shouldBeEqual "example-project-name_" + expectedTimestamp + "_CODE-2233_Red-dots-added-to-layout" + } + unmockkAll() + } + + "create code with branch name and delta tag" { + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProvider.createScanCode( + "example-project-name", FossId.DeltaTag.DELTA, "CODE-2233_Red-dots-added-to-layout" + ) shouldBeEqual "example-project-name_" + expectedTimestamp + + "_delta_CODE-2233_Red-dots-added-to-layout" + } + unmockkAll() + } + + "remove all non-standard signs from branch name when creating code" { + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProvider.createScanCode( + "example-project-name", null, "feature/CODE-12%%$@@&^_SOME_*&^#!*text!!" + ) shouldBeEqual "example-project-name_" + + expectedTimestamp + "_feature_CODE-12________SOME_______text__" + } + } + + "truncate very long scan id to fit maximum length accepted by FossID (255 chars)" { + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProvider.createScanCode( + "example-project-name", FossId.DeltaTag.DELTA, longBranchName + ).length shouldBeLessThanOrEqual MAX_SCAN_CODE_LEN + } + } + + "create code without branch name form custom naming pattern" { + val customScanPattern = "#projectName_#currentTimestamp" + + val namingProviderWithLongScanPattern = FossIdNamingProvider(null, customScanPattern, emptyMap()) + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProviderWithLongScanPattern.createScanCode( + "example-project-name", null, "" + ) shouldBeEqual "example-project-name_20240401_100000" + } + } + + "create code without branch name form custom naming pattern when branch name is provided" { + val customScanPattern = "#projectName_#currentTimestamp" + + val namingProviderWithLongScanPattern = FossIdNamingProvider(null, customScanPattern, emptyMap()) + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProviderWithLongScanPattern.createScanCode( + "example-project-name", null, "feature/LOREM-3212" + ) shouldBeEqual "example-project-name_20240401_100000" + } + } + + "create code without branch name form custom naming pattern when too long branch name is provided" { + val customScanPattern = "#projectName_#currentTimestamp_#branch" + val namingProviderWithLongScanPattern = FossIdNamingProvider(null, customScanPattern, emptyMap()) + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + namingProviderWithLongScanPattern.createScanCode( + "example-project-name", null, longBranchName + ).length shouldBeLessThanOrEqual MAX_SCAN_CODE_LEN + } + } + + "throw an exception if scan code pattern is too long" { + val namingProviderWithLongScanPattern = FossIdNamingProvider(null, longScanPattern, emptyMap()) + mockkStatic(LocalDateTime::class) { + every { LocalDateTime.now() } returns mockedDateTime + + shouldThrow { + namingProviderWithLongScanPattern.createScanCode("example-project-name", null, "") + } + } + } + } + } +}