diff --git a/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt b/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt index 9562734c15948..b8dc9cbd52558 100644 --- a/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt +++ b/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt @@ -73,7 +73,8 @@ class PackageManagerFunTest : WordSpec({ "spdx-project/project.spdx.yml", "spm-app/Package.resolved", "spm-lib/Package.swift", - "stack/stack.yaml" + "stack/stack.yaml", + "ort-project/ort.project.yml" ) val projectDir = tempdir() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30f972296a809..d7df7f27be171 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ mordant = "3.0.2" okhttp = "5.3.2" postgres = "42.7.8" postgresEmbedded = "1.1.1" +purlkt = "0.0.7" reflections = "0.10.2" retrofit = "3.0.0" s3 = "2.40.1" @@ -167,6 +168,7 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } postgresEmbedded = { module = "com.opentable.components:otj-pg-embedded", version.ref = "postgresEmbedded" } +purlkt = { module = "space.iseki.purlkt:purlkt", version.ref = "purlkt"} reflections = { module = "org.reflections:reflections", version.ref = "reflections" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-converter-jackson = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" } diff --git a/integrations/schemas/package-managers-schema.json b/integrations/schemas/package-managers-schema.json index f30ac0ff7ee23..7fde27525f9cb 100644 --- a/integrations/schemas/package-managers-schema.json +++ b/integrations/schemas/package-managers-schema.json @@ -18,6 +18,7 @@ "Maven", "NPM", "NuGet", + "OrtProjectFile", "PIP", "Pipenv", "PNPM", diff --git a/model/build.gradle.kts b/model/build.gradle.kts index 10e0492ea6f1c..035591ceca968 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(libs.bundles.hoplite) implementation(libs.hikari) implementation(libs.postgres) + implementation(libs.purlkt) implementation(libs.semver4j) implementation(libs.tika) diff --git a/model/src/main/kotlin/Identifier.kt b/model/src/main/kotlin/Identifier.kt index 35c61a2ad29c4..97450c187cd85 100644 --- a/model/src/main/kotlin/Identifier.kt +++ b/model/src/main/kotlin/Identifier.kt @@ -25,6 +25,9 @@ import com.fasterxml.jackson.annotation.JsonValue import org.ossreviewtoolkit.utils.common.AlphaNumericComparator import org.ossreviewtoolkit.utils.common.encodeOr +import space.iseki.purl.PUrl +import space.iseki.purl.PUrlParsingException + /** * A unique identifier for a software component. */ @@ -66,6 +69,16 @@ data class Identifier( // This comparator is consistent with `equals()` as all properties are taken into account. private val COMPARATOR = compareBy({ it.type }, { it.namespace }, { it.name }) .thenComparing({ it.version }, AlphaNumericComparator) + + @Suppress("SwallowedException") + fun fromPurl(purlString: String): Identifier { + try { + val purl = PUrl.parse(purlString) + return Identifier(purl.type, purl.namespace.joinToString("."), purl.name, purl.version) + } catch (ex: PUrlParsingException) { + throw IllegalArgumentException(ex.message) + } + } } private constructor(properties: List) : this( diff --git a/model/src/main/kotlin/config/AnalyzerConfiguration.kt b/model/src/main/kotlin/config/AnalyzerConfiguration.kt index c1e189e581657..7d22b6e1ac176 100644 --- a/model/src/main/kotlin/config/AnalyzerConfiguration.kt +++ b/model/src/main/kotlin/config/AnalyzerConfiguration.kt @@ -60,6 +60,7 @@ data class AnalyzerConfiguration( "Maven", "NPM", "NuGet", + "OrtProjectFile", "PIP", "Pipenv", "PNPM", diff --git a/model/src/test/kotlin/IdentifierTest.kt b/model/src/test/kotlin/IdentifierTest.kt index e97bf908ecd10..6865ef5e868b0 100644 --- a/model/src/test/kotlin/IdentifierTest.kt +++ b/model/src/test/kotlin/IdentifierTest.kt @@ -20,6 +20,7 @@ package org.ossreviewtoolkit.model import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.WordSpec import io.kotest.inspectors.forAll import io.kotest.matchers.maps.containExactly @@ -135,4 +136,34 @@ class IdentifierTest : WordSpec({ } } } + + "Purl representation" should { + "should accept only pkg type purls" { + shouldThrow { + Identifier.fromPurl("nonpkg:someurl/example.com/invalid@1.1") + } + } + + "be parsed correctly for purl with namespace" { + val id = Identifier.fromPurl("pkg:github/example.com/valid-with-namespace@1.0") + + with(id) { + type shouldBe "github" + namespace shouldBe "example.com" + name shouldBe "valid-with-namespace" + version shouldBe "1.0" + } + } + + "be parsed correctly for purl without namespace" { + val id = Identifier.fromPurl("pkg:pypi/valid-without-namespace@2.0") + + with(id) { + type shouldBe "pypi" + namespace shouldBe "" + name shouldBe "valid-without-namespace" + version shouldBe "2.0" + } + } + } }) diff --git a/plugins/package-managers/ortproject/build.gradle.kts b/plugins/package-managers/ortproject/build.gradle.kts new file mode 100644 index 0000000000000..940662e900122 --- /dev/null +++ b/plugins/package-managers/ortproject/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 The ORT Project Copyright Holders + * + * 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 + */ + +plugins { + // Apply precompiled plugins. + id("ort-plugin-conventions") + + // Apply third-party plugins. + alias(libs.plugins.kotlinSerialization) +} + +dependencies { + api(projects.analyzer) + api(projects.model) + + implementation(projects.utils.ortUtils) + + implementation(jacksonLibs.jacksonModuleKotlin) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.yaml) + + ksp(projects.analyzer) + + funTestImplementation(testFixtures(projects.analyzer)) +} diff --git a/plugins/package-managers/ortproject/src/funTest/assets/multiproject/ort.project.yml b/plugins/package-managers/ortproject/src/funTest/assets/multiproject/ort.project.yml new file mode 100644 index 0000000000000..c770cd55cc902 --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/multiproject/ort.project.yml @@ -0,0 +1,35 @@ +projectName: "project-x" +description: "Project X description" +homepageUrl: "https://project_x.example.com" +declaredLicenses: + - "Apache-2.0" +authors: + - "John Doe" + - "Foo Bar" +dependencies: + - purl: "pkg:maven/com.example/full@1.1.0" + description: "Package with fully elaborated model." + vcs: + type: "Mercurial" + url: "https://git.example.com/full/" + revision: "master" + path: "/" + sourceArtifact: + url: "https://repo.example.com/m2/full-1.1.0-sources.jar" + hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709" + declaredLicenses: + - "Apache-2.0" + - "MIT" + homepageUrl: "https://project_x.example.com/full" + labels: + label: "value" + label2: "value2" + authors: + - "John Doe" + - "Foo Bar" + scopes: + - "main" + isModified: false + metadataOnly: false + + - purl: "pkg:maven/com.example/minimal@0.1.0" diff --git a/plugins/package-managers/ortproject/src/funTest/assets/multiproject/subproject1/sub1.ort.project.yml b/plugins/package-managers/ortproject/src/funTest/assets/multiproject/subproject1/sub1.ort.project.yml new file mode 100644 index 0000000000000..17a9cfd8fe417 --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/multiproject/subproject1/sub1.ort.project.yml @@ -0,0 +1,17 @@ +projectName: "subproject-x-1" +description: "Subproject X-1 description" +homepageUrl: "https://project_x-1.example.com" +declaredLicenses: + - "Apache-2.0" +authors: + - "Gerard & Son" +dependencies: + - purl: "pkg:maven/com.example/sub-x1@9.98.0" + description: "Package of subproject 1" + vcs: + type: "Mercurial" + url: "https://git.example.com/full/" + revision: "master" + path: "/" + declaredLicenses: + - "Apache-2.0" diff --git a/plugins/package-managers/ortproject/src/funTest/assets/multiproject/subproject2/ort.project.json b/plugins/package-managers/ortproject/src/funTest/assets/multiproject/subproject2/ort.project.json new file mode 100644 index 0000000000000..34f41e4ea6ffd --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/multiproject/subproject2/ort.project.json @@ -0,0 +1,20 @@ +{ + "projectName": "subproject-x-2", + "description": "Project X-2 description", + "homepageUrl": "https://project_x-2.example.com", + "declaredLicenses": [ + "Apache-2.0" + ], + "authors": [ + "Author with no name" + ], + "dependencies": [ + { + "purl": "pkg:maven/com.example/sub-x2@1.1.0", + "description": "Package of subproject 2", + "declaredLicenses": [ + "Apache-2.0" + ] + } + ] +} diff --git a/plugins/package-managers/ortproject/src/funTest/assets/projects/no-pkg-id-or-purl.ort.project.yml b/plugins/package-managers/ortproject/src/funTest/assets/projects/no-pkg-id-or-purl.ort.project.yml new file mode 100644 index 0000000000000..93990f083bc58 --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/projects/no-pkg-id-or-purl.ort.project.yml @@ -0,0 +1,32 @@ +projectName: "Example ORT project" +description: "Project X description" +homepageUrl: "https://project_x.example.com" +declaredLicenses: + - "Apache-2.0" +authors: + - "John Doe" + - "Foo Bar" +dependencies: + - description: "Package with fully elaborated model." + vcs: + type: "Mercurial" + url: "https://git.example.com/full/" + revision: "master" + path: "/" + sourceArtifact: + url: "https://repo.example.com/m2/full-1.1.0-sources.jar" + hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709" + declaredLicenses: + - "Apache-2.0" + - "MIT" + homepageUrl: "https://project_x.example.com/full" + labels: + label: "value" + label2: "value2" + authors: + - "Doe John" + - "Bar Foo" + scopes: + - "main" + isModified: false + metadataOnly: false diff --git a/plugins/package-managers/ortproject/src/funTest/assets/projects/ort.project.json b/plugins/package-managers/ortproject/src/funTest/assets/projects/ort.project.json new file mode 100644 index 0000000000000..36c4fdebc8953 --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/projects/ort.project.json @@ -0,0 +1,50 @@ +{ + "projectName": "Example ORT project", + "description": "Project X description", + "homepageUrl": "https://project_x.example.com", + "declaredLicenses": [ + "Apache-2.0" + ], + "authors": [ + "John Doe", + "Foo Bar" + ], + "dependencies": [ + { + "purl": "pkg:maven/com.example/full@1.1.0", + "description": "Package with fully elaborated model.", + "vcs": { + "type": "Mercurial", + "url": "https://git.example.com/full/", + "revision": "master", + "path": "/" + }, + "sourceArtifact": { + "url": "https://repo.example.com/m2/full-1.1.0-sources.jar", + "hash": "da39a3ee5e6b4b0d3255bfef95601890afd80709" + }, + "declaredLicenses": [ + "Apache-2.0", + "MIT" + ], + "homepageUrl": "https://project_x.example.com/full", + "labels": { + "label": "value", + "label2": "value2" + }, + "authors": [ + "Doe John", + "Bar Foo" + ], + "scopes": [ + "main", + "some_scope" + ], + "isModified": false, + "metadataOnly": false + }, + { + "purl": "pkg:maven/com.example/minimal@0.1.0" + } + ] +} diff --git a/plugins/package-managers/ortproject/src/funTest/assets/projects/ort.project.yml b/plugins/package-managers/ortproject/src/funTest/assets/projects/ort.project.yml new file mode 100644 index 0000000000000..2e6d481905155 --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/projects/ort.project.yml @@ -0,0 +1,36 @@ +projectName: "Example ORT project" +description: "Project X description" +homepageUrl: "https://project_x.example.com" +declaredLicenses: + - "Apache-2.0" +authors: + - "John Doe" + - "Foo Bar" +dependencies: + - purl: "pkg:maven/com.example/full@1.1.0" + description: "Package with fully elaborated model." + vcs: + type: "Mercurial" + url: "https://git.example.com/full/" + revision: "master" + path: "/" + sourceArtifact: + url: "https://repo.example.com/m2/full-1.1.0-sources.jar" + hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709" + declaredLicenses: + - "Apache-2.0" + - "MIT" + homepageUrl: "https://project_x.example.com/full" + labels: + label: "value" + label2: "value2" + authors: + - "Doe John" + - "Bar Foo" + scopes: + - "main" + - "some_scope" + isModified: false + metadataOnly: false + + - purl: "pkg:maven/com.example/minimal@0.1.0" diff --git a/plugins/package-managers/ortproject/src/funTest/assets/projects/wrong-format.ort.project.json b/plugins/package-managers/ortproject/src/funTest/assets/projects/wrong-format.ort.project.json new file mode 100644 index 0000000000000..e9acd91d6962a --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/projects/wrong-format.ort.project.json @@ -0,0 +1,9 @@ +{ + "projectName": "Example ORT project with wrong format of file", + "dependencies": "OK", + "dependencies": [ + { + "purl": "something:maven/com.example/full@1.1.0" + } + ] +} \ No newline at end of file diff --git a/plugins/package-managers/ortproject/src/funTest/assets/projects/wrong-pkg-name.ort.project.json b/plugins/package-managers/ortproject/src/funTest/assets/projects/wrong-pkg-name.ort.project.json new file mode 100644 index 0000000000000..cfd586ff90c0b --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/assets/projects/wrong-pkg-name.ort.project.json @@ -0,0 +1,8 @@ +{ + "projectName": "Example ORT project with wrong package name", + "dependencies": [ + { + "purl": "something:maven/com.example/full@1.1.0" + } + ] +} \ No newline at end of file diff --git a/plugins/package-managers/ortproject/src/funTest/kotlin/OrtProjectFileFunTest.kt b/plugins/package-managers/ortproject/src/funTest/kotlin/OrtProjectFileFunTest.kt new file mode 100644 index 0000000000000..9907383c34ae3 --- /dev/null +++ b/plugins/package-managers/ortproject/src/funTest/kotlin/OrtProjectFileFunTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2025 The ORT Project Copyright Holders + * + * 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.packagemanagers.ortproject + +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldStartWith + +import org.ossreviewtoolkit.analyzer.analyze +import org.ossreviewtoolkit.analyzer.getAnalyzerResult +import org.ossreviewtoolkit.analyzer.resolveSingleProject +import org.ossreviewtoolkit.model.ProjectAnalyzerResult +import org.ossreviewtoolkit.model.RemoteArtifact +import org.ossreviewtoolkit.model.VcsInfo +import org.ossreviewtoolkit.model.VcsType +import org.ossreviewtoolkit.utils.test.getAssetFile + +class OrtProjectFileFunTest : WordSpec({ + "resolveDependencies()" should { + "return properly resolved packages list based on json file" { + val definitionFile = getAssetFile("projects/ort.project.json") + verifyBasicProject(OrtProjectFileFactory.create().resolveSingleProject(definitionFile)) + } + + "return properly resolved packages list based on yaml file" { + val definitionFile = getAssetFile("projects/ort.project.yml") + verifyBasicProject(OrtProjectFileFactory.create().resolveSingleProject(definitionFile)) + } + + "return issue when json file is wrongly formatted" { + val definitionFile = getAssetFile("projects/wrong-format.ort.project.json") + val project = OrtProjectFileFactory.create().resolveSingleProject(definitionFile) + project.packages.size shouldBe 0 + project.issues.size shouldBe 1 + + with(project.issues[0]) { + message shouldStartWith "Cannot deserialize value of type" + source shouldBe "OrtProjectFile" + } + } + + "return issue when there is no package id or purl defined" { + val definitionFile = getAssetFile("projects/no-pkg-id-or-purl.ort.project.yml") + val project = OrtProjectFileFactory.create().resolveSingleProject(definitionFile) + project.packages.size shouldBe 0 + project.issues.size shouldBe 1 + + with(project.issues[0]) { + message shouldContain("There is no id or purl defined for the package.") + source shouldBe "OrtProjectFile" + } + } + + "use all of files in structure for package mapping" { + val definitionFile = getAssetFile("multiproject/ort.project.yml") + val result = analyze(projectDir = definitionFile.parentFile).getAnalyzerResult() + + with(result) { + projects.size shouldBe 3 + val projectNames = projects.map { it.id.name } + projectNames.shouldContainAll(setOf("project-x", "subproject-x-1", "subproject-x-2")) + + packages.size shouldBe 4 + val packageNames = packages.map { it.purl } + packageNames.shouldContainAll( + setOf( + "pkg:maven/com.example/sub-x1@9.98.0", + "pkg:maven/com.example/sub-x2@1.1.0", + "pkg:maven/com.example/full@1.1.0", + "pkg:maven/com.example/minimal@0.1.0" + ) + ) + } + } + } +}) + +private fun verifyBasicProject(result: ProjectAnalyzerResult) { + with(result) { + with(project.id) { + name shouldBe "Example ORT project" + type shouldBe "OrtProjectFile" + } + + project.authors shouldContainAll setOf("John Doe", "Foo Bar") + + project.vcs shouldNotBe null + + // VCS values may be different depending on the test environment, + // so just check for basic validity. + with(project.vcs) { + type shouldNotBe VcsType.UNKNOWN + url.length shouldBeGreaterThan 0 + revision.length shouldBeGreaterThan 0 + path shouldBe "plugins/package-managers/ortproject/src/funTest/assets/projects" + } + + project.homepageUrl shouldBe "https://project_x.example.com" + + project.scopeDependencies?.size shouldBe 2 + project.scopeDependencies?.map { it.name }?.shouldContainAll(setOf("main", "some_scope")) + + project.scopeDependencies?.find { it.name == "main" }.should { + it?.dependencies?.size shouldBe 1 + it?.dependencies?.map { dep -> dep.id.name }?.shouldContainAll(setOf("full")) + } + + project.scopeDependencies?.find { it.name == "some_scope" }.should { + it?.dependencies?.size shouldBe 1 + it?.dependencies?.map { dep -> dep.id.name }?.shouldContainAll(setOf("full")) + } + + packages.size shouldBe 2 + packages.map { it.purl } shouldContainAll setOf( + "pkg:maven/com.example/full@1.1.0", + "pkg:maven/com.example/minimal@0.1.0" + ) + + packages.find { it.purl == "pkg:maven/com.example/full@1.1.0" }.should { + it?.description shouldBe "Package with fully elaborated model." + + it?.declaredLicenses shouldNotBe null + it?.declaredLicenses?.size.shouldBe(2) + it?.declaredLicenses?.shouldContainExactly(setOf("Apache-2.0", "MIT")) + + it?.vcs shouldNotBe null + it?.vcs?.type shouldBe VcsType.MERCURIAL + it?.vcs?.url shouldBe "https://git.example.com/full/" + it?.vcs?.revision shouldBe "master" + it?.vcs?.path shouldBe "/" + + it?.homepageUrl shouldBe "https://project_x.example.com/full" + + it?.sourceArtifact shouldNotBe null + it?.sourceArtifact?.url shouldBe "https://repo.example.com/m2/full-1.1.0-sources.jar" + + it?.labels?.size.shouldBe(2) + it?.authors?.shouldContainAll(setOf("Doe John", "Bar Foo")) + } + + packages.find { it.purl == "pkg:maven/com.example/minimal@0.1.0" }.should { + it?.description shouldBe "" + + it?.declaredLicenses?.size.shouldBe(0) + + it?.vcs shouldBe VcsInfo.EMPTY + it?.sourceArtifact shouldBe RemoteArtifact.EMPTY + + it?.labels shouldBe emptyMap() + it?.authors shouldBe emptySet() + } + } +} diff --git a/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFile.kt b/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFile.kt new file mode 100644 index 0000000000000..92e613bbe8c44 --- /dev/null +++ b/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFile.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2025 The ORT Project Copyright Holders + * + * 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.packagemanagers.ortproject + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.module.kotlin.readValue + +import java.io.File + +import org.ossreviewtoolkit.analyzer.PackageManager +import org.ossreviewtoolkit.analyzer.PackageManagerFactory +import org.ossreviewtoolkit.model.Issue +import org.ossreviewtoolkit.model.Project +import org.ossreviewtoolkit.model.ProjectAnalyzerResult +import org.ossreviewtoolkit.model.config.AnalyzerConfiguration +import org.ossreviewtoolkit.model.config.Excludes +import org.ossreviewtoolkit.model.mapper +import org.ossreviewtoolkit.plugins.api.OrtPlugin +import org.ossreviewtoolkit.plugins.api.PluginDescriptor + +@OrtPlugin( + displayName = "OrtProjectFile", + description = "The package manager that uses ORT-specific file format as package list source.", + factory = PackageManagerFactory::class +) +class OrtProjectFile(override val descriptor: PluginDescriptor = OrtProjectFileFactory.descriptor) : + PackageManager("OrtProjectFile") { + + override val globsForDefinitionFiles = listOf("ort.project.yml", "ort.project.yaml", "ort.project.json") + + override fun resolveDependencies( + analysisRoot: File, + definitionFile: File, + excludes: Excludes, + analyzerConfig: AnalyzerConfiguration, + labels: Map + ): List { + var parsedProject: OrtProjectFileDto + + try { + parsedProject = definitionFile.mapper().copy().readValue(definitionFile) + } catch (ex: JsonProcessingException) { + return listOf( + ProjectAnalyzerResult( + project = Project.EMPTY, + packages = emptySet(), + issues = listOf(Issue(source = "OrtProjectFile", message = ex.message.toString())) + ) + ) + } + + val project = OrtProjectFileMapper.extractAndMapProject( + parsedProject, + processProjectVcs(definitionFile.parentFile), + definitionFile.parentFile.relativeTo(analysisRoot).invariantSeparatorsPath + ) + + val packagesWithIssues = OrtProjectFileMapper.extractAndMapPackages(parsedProject) + val res = ProjectAnalyzerResult( + project = project, + packages = packagesWithIssues.first, + issues = packagesWithIssues.second + ) + + return listOf(res) + } + + override fun mapDefinitionFiles( + analysisRoot: File, + definitionFiles: List, + analyzerConfig: AnalyzerConfiguration + ): List { + return analysisRoot.walk().filter { isProjectFileName(it) }.toList() + } + + private fun isProjectFileName(fileName: File): Boolean { + if (!fileName.isFile || fileName.isDirectory) { + return false + } + + globsForDefinitionFiles.forEach { + if (fileName.name.endsWith(it)) { + return true + } + } + + return false + } +} diff --git a/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFileDto.kt b/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFileDto.kt new file mode 100644 index 0000000000000..c441f848cab09 --- /dev/null +++ b/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFileDto.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2025 The ORT Project Copyright Holders + * + * 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.packagemanagers.ortproject + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class OrtProjectFileDto( + @JsonProperty("projectName") + val projectName: String?, + @JsonProperty("declaredLicenses") + val declaredLicenses: Set? = emptySet(), + val description: String?, + @JsonProperty("homepageUrl") + val homepageUrl: String?, + val authors: Set? = emptySet(), + val dependencies: List = emptyList() +) + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class DependencyDto( + val id: String?, + val purl: String?, + val description: String?, + val vcs: VcsDto?, + @JsonProperty("sourceArtifact") + val sourceArtifact: SourceArtifactDto?, + @JsonProperty("declaredLicenses") + val declaredLicenses: Set = emptySet(), + @JsonProperty("homepageUrl") + val homepageUrl: String?, + val labels: Map = emptyMap(), + val authors: Set? = emptySet(), + val scopes: Set?, + @JsonProperty("isModified") + val isModified: Boolean?, + @JsonProperty("isMetadataOnly") + val isMetadataOnly: Boolean? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class SourceArtifactDto( + val url: String?, + val hash: String? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class VcsDto( + val type: String?, + val url: String?, + val revision: String?, + val path: String? +) diff --git a/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFileMapper.kt b/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFileMapper.kt new file mode 100644 index 0000000000000..04be3a8dffb28 --- /dev/null +++ b/plugins/package-managers/ortproject/src/main/kotlin/OrtProjectFileMapper.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2025 The ORT Project Copyright Holders + * + * 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.packagemanagers.ortproject + +import kotlin.collections.orEmpty + +import org.ossreviewtoolkit.model.Hash +import org.ossreviewtoolkit.model.Identifier +import org.ossreviewtoolkit.model.Issue +import org.ossreviewtoolkit.model.Package +import org.ossreviewtoolkit.model.PackageReference +import org.ossreviewtoolkit.model.Project +import org.ossreviewtoolkit.model.RemoteArtifact +import org.ossreviewtoolkit.model.Scope +import org.ossreviewtoolkit.model.VcsInfo +import org.ossreviewtoolkit.model.VcsType +import org.ossreviewtoolkit.model.orEmpty +import org.ossreviewtoolkit.model.utils.toPurl + +internal object OrtProjectFileMapper { + private const val DEFAULT_PROJECT_NAME = "unknown" + private const val PROJECT_TYPE = "OrtProjectFile" + + internal fun extractAndMapProject(projectDto: OrtProjectFileDto, vcsInfo: VcsInfo, definitionFilePath: String) = + Project( + id = Identifier( + name = projectDto.projectName?.takeUnless { it.isBlank() } ?: DEFAULT_PROJECT_NAME, + type = PROJECT_TYPE, + namespace = "", + version = "" + ), + vcs = vcsInfo, + description = projectDto.description.orEmpty(), + authors = projectDto.authors.orEmpty(), + homepageUrl = projectDto.homepageUrl.orEmpty(), + definitionFilePath = "/$definitionFilePath", + declaredLicenses = projectDto.declaredLicenses.orEmpty(), + scopeDependencies = projectDto.dependencies.toScopes() + ) + + internal fun extractAndMapPackages(project: OrtProjectFileDto): Pair, List> { + val issues = mutableListOf() + val packages = mutableSetOf() + + project.dependencies.forEach { + val packageWithIssues = it.toPackage() + + packageWithIssues.first?.let { pkg -> packages.add(pkg) } + issues.addAll(packageWithIssues.second) + } + + return Pair(packages, issues) + } + + private fun DependencyDto.toPackage(): Pair> { + try { + val identifiers = getIdentifiers() + val pkg = Package( + id = identifiers.first, + purl = identifiers.second, + sourceArtifact = sourceArtifact?.let { artifact -> + RemoteArtifact( + url = artifact.url.orEmpty(), + hash = if (artifact.hash != null) Hash.create(artifact.hash) else Hash.NONE + ) + }.orEmpty(), + vcs = vcs.toVcsInfo(), + declaredLicenses = declaredLicenses, + description = description.orEmpty(), + homepageUrl = homepageUrl.orEmpty(), + binaryArtifact = RemoteArtifact.EMPTY, + authors = authors.orEmpty(), + labels = labels + ) + + return Pair(pkg, emptyList()) + } catch (ex: IllegalArgumentException) { + val issue = Issue( + message = ex.message.orEmpty(), + source = "OrtProjectFile" + ) + return Pair(null, listOf(issue)) + } + } + + private fun DependencyDto.getIdentifiers(): Pair { + when { + id.isNullOrEmpty() && purl.isNullOrEmpty() -> + throw IllegalArgumentException("There is no id or purl defined for the package.") + + id?.isEmpty() == false && purl?.isEmpty() == false -> + return Pair(Identifier(id), purl) + + id?.isNotEmpty() == true -> { + val identifier = Identifier(id) + return Pair(identifier, identifier.toPurl()) + } + + purl?.isNotEmpty() == true -> + return Pair(Identifier.fromPurl(purl), purl) + + else -> + error("There is something wrong in dependency definition identifiers") + } + } + + private fun VcsDto?.toVcsInfo(): VcsInfo = + if (this == null) { + VcsInfo.EMPTY + } else { + VcsInfo( + type = type?.let { VcsType.forName(it) } ?: VcsType.UNKNOWN, + url = url.orEmpty(), + revision = revision.orEmpty(), + path = path.orEmpty() + ) + } + + private fun Collection.toScopes(): Set { + val scopeMap = mutableMapOf>() + this.forEach { dependency -> + if (dependency.scopes?.isNotEmpty() == true) { + dependency.scopes.forEach { scopeName -> + if (!scopeMap.containsKey(scopeName)) { + scopeMap[scopeName] = mutableListOf() + } + + scopeMap[scopeName]?.add(dependency.getIdentifiers().first) + } + } + } + + return scopeMap.map { (scopeName, identifiers) -> + Scope( + name = scopeName, + dependencies = identifiers.map { id -> PackageReference(id = id) }.toSet() + ) + }.toSet() + } +} diff --git a/website/docs/tools/analyzer.md b/website/docs/tools/analyzer.md index 7a8580d7a56ff..4a190002a600b 100644 --- a/website/docs/tools/analyzer.md +++ b/website/docs/tools/analyzer.md @@ -20,7 +20,7 @@ Currently, the following package managers (grouped by the programming language t * C / C++ * [Bazel](https://bazel.build/) (limitations: see [open tasks](https://github.com/oss-review-toolkit/ort/issues/264)) * [Conan 1.x and 2.x](https://conan.io/) - * Also see: [SPDX documents](#spdx-as-fallback-package-manager) + * Also see: [Fallback Package Managers](#fallback-package-managers) * Dart / Flutter * [Pub](https://pub.dev/) * Go @@ -59,6 +59,14 @@ Currently, the following package managers (grouped by the programming language t * Unmanaged * This is a special "package manager" that manages all files that cannot be associated with any of the other package managers. -## SPDX as Fallback Package Manager +## Fallback Package Managers -If another package manager that is not part of the list above is used (or no package manager at all), the generic fallback to [SPDX documents](https://spdx.dev/specifications/) can be leveraged to describe [projects](https://github.com/oss-review-toolkit/ort/blob/main/plugins/package-managers/spdx/src/funTest/assets/projects/synthetic/inline-packages/project-xyz.spdx.yml) or [packages](https://github.com/oss-review-toolkit/ort/blob/main/plugins/package-managers/spdx/src/funTest/assets/projects/synthetic/libs/curl/package.spdx.yml). +### ORT Project + + For projects that do not use any of the supported package managers, the ORT Project package manager + can be used to manually define projects and their dependencies in an ORT Project definition file. + +### SPDX + + If another package manager that is not part of the list above is used (or no package manager at all), + the generic fallback to [SPDX documents](https://spdx.dev/specifications/) can be leveraged to describe [projects](https://github.com/oss-review-toolkit/ort/blob/main/plugins/package-managers/spdx/src/funTest/assets/projects/synthetic/inline-packages/project-xyz.spdx.yml) or [packages](https://github.com/oss-review-toolkit/ort/blob/main/plugins/package-managers/spdx/src/funTest/assets/projects/synthetic/libs/curl/package.spdx.yml).