diff --git a/.env.versions b/.env.versions index f8578123c01d3..c7c7e28136479 100644 --- a/.env.versions +++ b/.env.versions @@ -10,6 +10,7 @@ DART_VERSION=2.18.4 DOTNET_VERSION=6.0 GO_VERSION=1.25.0 HASKELL_STACK_VERSION=2.13.1 +IVY_VERSION=2.5.3 JAVA_VERSION=21 LICENSEE_VERSION=9.18.0 NODEJS_VERSION=24.10.0 diff --git a/Dockerfile b/Dockerfile index 84a12f14c6ee9..b515a45cdf7b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -363,6 +363,25 @@ RUN curl -L https://github.com/sbt/sbt/releases/download/v$SBT_VERSION/sbt-$SBT_ FROM scratch AS scala COPY --from=scalabuild /opt/sbt /opt/sbt +#------------------------------------------------------------------------ +# APACHE IVY +FROM base AS ivybuild + +ARG IVY_VERSION + +ENV IVY_HOME=/opt/ivy +ENV PATH=$PATH:$IVY_HOME/bin + +RUN mkdir -p $IVY_HOME/bin \ + && curl -L https://archive.apache.org/dist/ant/ivy/$IVY_VERSION/apache-ivy-$IVY_VERSION-bin.tar.gz \ + | tar -xz -C $IVY_HOME --strip-components=1 \ + && echo '#!/bin/sh' > $IVY_HOME/bin/ivy \ + && echo 'exec java -jar '"$IVY_HOME"'/ivy-'"$IVY_VERSION"'.jar "$@"' >> $IVY_HOME/bin/ivy \ + && chmod a+x $IVY_HOME/bin/ivy + +FROM scratch AS ivy +COPY --from=ivybuild /opt/ivy /opt/ivy + #------------------------------------------------------------------------ # SWIFT FROM base AS swiftbuild @@ -540,6 +559,11 @@ ENV SBT_HOME=/opt/sbt ENV PATH=$PATH:$SBT_HOME/bin COPY --from=scala --chown=$USER:$USER $SBT_HOME $SBT_HOME +# Apache Ivy +ENV IVY_HOME=/opt/ivy +ENV PATH=$PATH:$IVY_HOME/bin +COPY --from=ivy --chown=$USER:$USER $IVY_HOME $IVY_HOME + # Dart ENV DART_SDK=/opt/dart-sdk ENV PATH=$PATH:$DART_SDK/bin diff --git a/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt b/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt index b7bfaa1fd3915..29e3542023cdf 100644 --- a/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt +++ b/analyzer/src/funTest/kotlin/PackageManagerFunTest.kt @@ -56,6 +56,7 @@ class PackageManagerFunTest : WordSpec({ "gomod/go.mod", "gradle-groovy/build.gradle", "gradle-kotlin/build.gradle.kts", + "ivy/ivy.xml", "maven/pom.xml", // Note that the NPM, PNPM and Yarn implementations share code. Internal logic decides dynamically whether to @@ -111,6 +112,7 @@ class PackageManagerFunTest : WordSpec({ "gradle-groovy/build.gradle", "gradle-kotlin/build.gradle.kts" ) + managedFilesById["Ivy"] should containExactly("ivy/ivy.xml") managedFilesById["Maven"] should containExactly("maven/pom.xml") managedFilesById["NPM"] should containExactly("npm-pnpm-and-yarn/package.json") managedFilesById["NuGet"] should containExactlyInAnyOrder( diff --git a/examples/ivy.ort.yml b/examples/ivy.ort.yml new file mode 100644 index 0000000000000..3bd3e416d6e7b --- /dev/null +++ b/examples/ivy.ort.yml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2025 The ORT Project Authors +# +# SPDX-License-Identifier: Apache-2.0 + +# Example ORT configuration for Apache Ivy projects + +# Path excludes +excludes: + paths: + # Exclude test resources + - pattern: "**/test/**" + reason: "TEST_OF" + comment: "Test code and resources" + + # Scope excludes (Ivy configurations) + scopes: + - pattern: "test" + reason: "TEST_DEPENDENCY_OF" + comment: "Test dependencies are not distributed" + + - pattern: "provided" + reason: "PROVIDED_DEPENDENCY_OF" + comment: "Provided dependencies are supplied by runtime environment" diff --git a/integrations/schemas/package-managers-schema.json b/integrations/schemas/package-managers-schema.json index f30ac0ff7ee23..7e324eb7db133 100644 --- a/integrations/schemas/package-managers-schema.json +++ b/integrations/schemas/package-managers-schema.json @@ -15,6 +15,7 @@ "GoMod", "Gradle", "GradleInspector", + "Ivy", "Maven", "NPM", "NuGet", diff --git a/model/src/main/kotlin/config/AnalyzerConfiguration.kt b/model/src/main/kotlin/config/AnalyzerConfiguration.kt index 39aa2a8ad60f6..3009c36ab29be 100644 --- a/model/src/main/kotlin/config/AnalyzerConfiguration.kt +++ b/model/src/main/kotlin/config/AnalyzerConfiguration.kt @@ -57,6 +57,7 @@ data class AnalyzerConfiguration( "Conan", "GoMod", "GradleInspector", + "Ivy", "Maven", "NPM", "NuGet", diff --git a/plugins/package-managers/gradle-inspector/src/main/kotlin/GradleDependencyHandler.kt b/plugins/package-managers/gradle-inspector/src/main/kotlin/GradleDependencyHandler.kt index ea2f469961758..6cda8aa90ad43 100644 --- a/plugins/package-managers/gradle-inspector/src/main/kotlin/GradleDependencyHandler.kt +++ b/plugins/package-managers/gradle-inspector/src/main/kotlin/GradleDependencyHandler.kt @@ -72,12 +72,29 @@ internal class GradleDependencyHandler( val id = identifierFor(dependency) val model = dependency.mavenModel ?: run { - issues += createAndLogIssue( - source = GradleInspectorFactory.descriptor.displayName, - message = "No Maven model available for '${id.toCoordinates()}'." - ) + // If no Maven model is available, this might be an Ivy dependency or artifact without metadata + // Only warn for non-Ivy dependencies, as Ivy dependencies are expected to not have Maven POMs + if (id.type != "Ivy") { + issues += createAndLogIssue( + source = GradleInspectorFactory.descriptor.displayName, + message = "No POM found for component '${id.toCoordinates()}'.", + severity = org.ossreviewtoolkit.model.Severity.WARNING + ) + } - return null + // Create a basic package with minimal information + return Package( + id = id, + authors = emptySet(), + declaredLicenses = emptySet(), + declaredLicensesProcessed = DeclaredLicenseProcessor.process(emptySet()), + description = "", + homepageUrl = "", + binaryArtifact = RemoteArtifact.EMPTY, + sourceArtifact = RemoteArtifact.EMPTY, + vcs = VcsInfo.EMPTY, + vcsProcessed = VcsInfo.EMPTY + ) } val isSpringMetadataProject = with(id) { @@ -262,7 +279,23 @@ private fun createRemoteArtifact( extension: String? = null ): RemoteArtifact { val algorithm = "sha1" - val artifactBaseUrl = pomUrl?.removeSuffix(".pom") ?: return RemoteArtifact.EMPTY + + // Handle both Maven POM files (.pom) and Ivy descriptor files (ivy-*.xml) + val artifactBaseUrl = when { + pomUrl == null -> return RemoteArtifact.EMPTY + pomUrl.endsWith(".pom") -> pomUrl.removeSuffix(".pom") + pomUrl.contains("/ivy-") && pomUrl.endsWith(".xml") -> { + // For Ivy descriptors like .../ivy-4.9.2.4.xml, extract the artifact name and version + // Pattern: .../[module]/[version]/ivy-[version].xml -> .../[module]/[version]/[module]-[version] + val pathParts = pomUrl.split("/") + val version = pathParts[pathParts.size - 2] + val module = pathParts[pathParts.size - 3] + val basePath = pathParts.dropLast(1).joinToString("/") + "$basePath/$module-$version" + } + + else -> pomUrl.removeSuffix(".xml") + } val artifactUrl = buildString { append(artifactBaseUrl) diff --git a/plugins/package-managers/gradle-inspector/src/main/resources/template.init.gradle b/plugins/package-managers/gradle-inspector/src/main/resources/template.init.gradle index a979f7a5854f6..b66d383af58ac 100644 --- a/plugins/package-managers/gradle-inspector/src/main/resources/template.init.gradle +++ b/plugins/package-managers/gradle-inspector/src/main/resources/template.init.gradle @@ -27,4 +27,42 @@ initscript { allprojects { apply plugin: OrtModelPlugin + + // Force Gradle to use Ivy descriptors and disable Gradle metadata for Ivy repositories + // This allows legacy targetConfiguration to work + repositories.configureEach { repo -> + if (repo instanceof org.gradle.api.artifacts.repositories.IvyArtifactRepository) { + repo.metadataSources { + ivyDescriptor() + artifact() + // Explicitly disable Gradle metadata which causes variant resolution issues + // with targetConfiguration + } + + // Check if repository should preserve its custom pattern layout + // Set repo.name = "customIvyLayout" in your build.gradle to skip ORT pattern configuration + def skipPatternConfig = repo.name?.contains("customIvyLayout") || + System.getProperty("ort.ivy.preservePatterns") == "true" + + if (!skipPatternConfig) { + // Support multiple Ivy layout patterns for different Artifactory configurations + // Gradle will try each pattern in order until it finds the artifact + // m2compatible = false keeps dots in organization (e.g., com.artifactory stays as com.artifactory) + repo.patternLayout { + // Artifactory Ivy layout with 'ivys' subdirectory (e.g., com.artifactory/module/version/ivys/ivy-version.xml) + ivy '[organisation]/[module]/[revision]/ivys/ivy-[revision].xml' + artifact '[organisation]/[module]/[revision]/jars/[artifact]-[revision](-[classifier])(.[ext])' + artifact '[organisation]/[module]/[revision]/[type]s/[artifact]-[revision](-[classifier])(.[ext])' + + // Standard Ivy layout (e.g., com.artifactory/module/version/ivy-version.xml) + ivy '[organisation]/[module]/[revision]/ivy-[revision].xml' + artifact '[organisation]/[module]/[revision]/[artifact]-[revision](-[classifier])(.[ext])' + + // Keep m2compatible = false to preserve dots in organization names + // If you need Maven-style paths (com/artifactory), repositories should use Maven layout instead + m2compatible = false + } + } + } + } } diff --git a/plugins/package-managers/gradle-model/src/main/kotlin/Extensions.kt b/plugins/package-managers/gradle-model/src/main/kotlin/Extensions.kt index 117b4e7d79a10..52598d6dde360 100644 --- a/plugins/package-managers/gradle-model/src/main/kotlin/Extensions.kt +++ b/plugins/package-managers/gradle-model/src/main/kotlin/Extensions.kt @@ -22,12 +22,13 @@ package org.ossreviewtoolkit.plugins.packagemanagers.gradlemodel import OrtDependency /** - * The type of this Gradle dependency. In case of a project, it is [projectType]. Otherwise it is "Maven" unless there - * is no POM, then it is "Unknown". + * The type of this Gradle dependency. In case of a project, it is [projectType]. Otherwise it is "Maven" if a POM + * file exists, "Ivy" if an ivy.xml exists, or "Unknown" if there is no metadata. */ fun OrtDependency.getIdentifierType(projectType: String) = when { isProjectDependency -> projectType + pomFile?.contains("/ivy-") == true && pomFile?.endsWith(".xml") == true -> "Ivy" pomFile != null -> "Maven" else -> "Unknown" } diff --git a/plugins/package-managers/gradle-plugin/src/main/kotlin/OrtModelBuilder.kt b/plugins/package-managers/gradle-plugin/src/main/kotlin/OrtModelBuilder.kt index 5ac3d37bbdd4f..d5e9de844b1f3 100644 --- a/plugins/package-managers/gradle-plugin/src/main/kotlin/OrtModelBuilder.kt +++ b/plugins/package-managers/gradle-plugin/src/main/kotlin/OrtModelBuilder.kt @@ -31,6 +31,7 @@ import org.gradle.api.artifacts.component.ComponentIdentifier import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.artifacts.component.ProjectComponentIdentifier import org.gradle.api.artifacts.component.ProjectComponentSelector +import org.gradle.api.artifacts.repositories.IvyArtifactRepository import org.gradle.api.artifacts.repositories.UrlArtifactRepository import org.gradle.api.artifacts.result.DependencyResult import org.gradle.api.artifacts.result.ResolvedArtifactResult @@ -43,6 +44,8 @@ import org.gradle.api.internal.artifacts.result.ResolvedComponentResultInternal import org.gradle.api.logging.Logging import org.gradle.internal.component.external.model.DefaultModuleComponentIdentifier import org.gradle.internal.resolve.ModuleVersionResolveException +import org.gradle.ivy.IvyDescriptorArtifact +import org.gradle.ivy.IvyModule import org.gradle.maven.MavenModule import org.gradle.maven.MavenPomArtifact import org.gradle.tooling.provider.model.ToolingModelBuilder @@ -126,26 +129,61 @@ internal class OrtModelBuilder : ToolingModelBuilder { FileModelSource(pomFile) } - return pomFiles.associate { - // Trigger resolution of parent POMs by building the POM model. - it.id.componentIdentifier.toString() to fileModelBuilder.buildModel(it.file) - } + return pomFiles.mapNotNull { artifact -> + // Skip Ivy descriptors - they cannot be built as Maven models + val isIvyDescriptor = artifact.file.name.startsWith("ivy-") && artifact.file.name.endsWith(".xml") + if (isIvyDescriptor) { + logger.info("Skipping Maven model building for Ivy descriptor: ${artifact.file.name}") + null + } else { + runCatching { + // Trigger resolution of parent POMs by building the POM model. + artifact.id.componentIdentifier.toString() to fileModelBuilder.buildModel(artifact.file) + }.getOrElse { e -> + logger.warn("Failed to build Maven model for ${artifact.id.componentIdentifier}: ${e.message}") + null + } + } + }.toMap() } /** * Resolve the POM files for the given [componentIds] and return them. + * If a POM is not found, try to resolve the Ivy descriptor instead. */ private fun Project.resolvePoms(componentIds: List): List { - val resolutionResult = dependencies.createArtifactResolutionQuery() + // Try to resolve Maven POMs first + val mavenResolutionResult = dependencies.createArtifactResolutionQuery() .forComponents(componentIds) .withArtifacts(MavenModule::class.java, MavenPomArtifact::class.java) .execute() - return resolutionResult.resolvedComponents.flatMap { + val resolvedPoms = mavenResolutionResult.resolvedComponents.flatMap { it.getArtifacts(MavenPomArtifact::class.java) }.filterIsInstance() + + // Find component IDs that didn't resolve to POMs + val resolvedComponentIds = resolvedPoms.map { it.id.componentIdentifier }.toSet() + val unresolvedComponentIds = componentIds.filterNot { it in resolvedComponentIds } + + // Try to resolve Ivy descriptors for unresolved components + val ivyDescriptors = if (unresolvedComponentIds.isNotEmpty()) { + val ivyResolutionResult = dependencies.createArtifactResolutionQuery() + .forComponents(unresolvedComponentIds) + .withArtifacts(IvyModule::class.java, IvyDescriptorArtifact::class.java) + .execute() + + ivyResolutionResult.resolvedComponents.flatMap { + it.getArtifacts(IvyDescriptorArtifact::class.java) + }.filterIsInstance() + } else { + emptyList() + } + + return resolvedPoms + ivyDescriptors } + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun Collection.toOrtDependencies( poms: Map, visited: Set @@ -188,20 +226,38 @@ internal class OrtModelBuilder : ToolingModelBuilder { }.getOrNull() repositories[repositoryId]?.let { repository -> - // Note: Only Maven-style layout is supported for now. + // Check if this is an Ivy repository + val isIvyRepository = repository is IvyArtifactRepository + buildString { append(repository.url.toString().removeSuffix("/")) append('/') - append(id.group.replace('.', '/')) - append('/') - append(id.module) - append('/') - append(id.version) - append('/') - append(id.module) - append('-') - append(id.version) - append(".pom") + + if (isIvyRepository) { + // Ivy layout: [organisation]/[module]/[revision]/ivy-[revision].xml + // Note: organization uses '/' as separator, like Maven + append(id.group.replace('.', '/')) + append('/') + append(id.module) + append('/') + append(id.version) + append('/') + append("ivy-") + append(id.version) + append(".xml") + } else { + // Maven layout: [group]/[artifact]/[version]/[artifact]-[version].pom + append(id.group.replace('.', '/')) + append('/') + append(id.module) + append('/') + append(id.version) + append('/') + append(id.module) + append('-') + append(id.version) + append(".pom") + } } } } else { @@ -209,8 +265,10 @@ internal class OrtModelBuilder : ToolingModelBuilder { } val modelBuildingResult = poms[id.toString()] - if (modelBuildingResult == null) { - val message = "No POM found for component '$id'." + val isIvyDescriptor = pomFile?.endsWith(".xml") == true && pomFile.contains("/ivy-") + + if (modelBuildingResult == null && !isIvyDescriptor) { + val message = "No Maven POM or Ivy descriptor found for component '$id'." logger.warn(message) warnings += message } @@ -298,10 +356,41 @@ internal class OrtModelBuilder : ToolingModelBuilder { appendCauses(dep.failure) } - logger.error(message) - errors += message - - null + // Check if this is a targetConfiguration error (legacy Ivy feature) + val isTargetConfigurationError = + message.contains("no variant with that configuration name exists") || + message.contains("Cannot select a variant by configuration name") + + if ( + isTargetConfigurationError && + dep.attempted is org.gradle.api.artifacts.component.ModuleComponentSelector + ) { + // This is likely an Ivy dependency with legacy targetConfiguration + // Try to create a basic package from the available information + val selector = dep.attempted as org.gradle.api.artifacts.component.ModuleComponentSelector + + logger.warn("$message [Creating basic package without full metadata]") + warnings += message + + OrtDependencyImpl( + groupId = selector.group, + artifactId = selector.module, + version = selector.version, + classifier = "", + extension = "", + variants = emptySet(), + dependencies = emptyList(), + error = null, + warning = "Dependency resolved with limited metadata due to legacy targetConfiguration", + pomFile = null, + mavenModel = null, + localPath = null + ) + } else { + logger.error(message) + errors += message + null + } } else -> { diff --git a/plugins/package-managers/gradle/src/main/kotlin/GradleDependencyHandler.kt b/plugins/package-managers/gradle/src/main/kotlin/GradleDependencyHandler.kt index 85dbf4f380189..071ef781e3d2d 100644 --- a/plugins/package-managers/gradle/src/main/kotlin/GradleDependencyHandler.kt +++ b/plugins/package-managers/gradle/src/main/kotlin/GradleDependencyHandler.kt @@ -31,7 +31,9 @@ import org.ossreviewtoolkit.model.Identifier import org.ossreviewtoolkit.model.Issue import org.ossreviewtoolkit.model.Package import org.ossreviewtoolkit.model.PackageLinkage +import org.ossreviewtoolkit.model.RemoteArtifact import org.ossreviewtoolkit.model.Severity +import org.ossreviewtoolkit.model.VcsInfo import org.ossreviewtoolkit.model.createAndLogIssue import org.ossreviewtoolkit.model.utils.DependencyHandler import org.ossreviewtoolkit.plugins.packagemanagers.gradlemodel.getIdentifierType @@ -39,6 +41,7 @@ import org.ossreviewtoolkit.plugins.packagemanagers.gradlemodel.isProjectDepende import org.ossreviewtoolkit.plugins.packagemanagers.maven.utils.MavenSupport import org.ossreviewtoolkit.plugins.packagemanagers.maven.utils.identifier import org.ossreviewtoolkit.utils.common.collectMessages +import org.ossreviewtoolkit.utils.ort.DeclaredLicenseProcessor import org.ossreviewtoolkit.utils.ort.showStackTrace /** @@ -58,6 +61,23 @@ internal class GradleDependencyHandler( */ var repositories = emptyList() + /** + * Create a package with empty metadata for Ivy dependencies or artifacts without metadata. + */ + private fun createEmptyPackage(id: Identifier) = + Package( + id = id, + authors = emptySet(), + declaredLicenses = emptySet(), + declaredLicensesProcessed = DeclaredLicenseProcessor.process(emptySet()), + description = "", + homepageUrl = "", + binaryArtifact = RemoteArtifact.EMPTY, + sourceArtifact = RemoteArtifact.EMPTY, + vcs = VcsInfo.EMPTY, + vcsProcessed = VcsInfo.EMPTY + ) + override fun identifierFor(dependency: OrtDependency): Identifier = with(dependency) { Identifier(getIdentifierType(projectType), groupId, artifactId, version) } @@ -94,8 +114,43 @@ internal class GradleDependencyHandler( dependency.extension, dependency.version ) + // Check if this dependency has Ivy metadata (ivy.xml) instead of Maven POM + val hasIvyMetadata = dependency.pomFile?.endsWith("ivy.xml") == true + return runCatching { - maven.parsePackage(artifact, repositories, useReposFromDependencies = false) + if (hasIvyMetadata) { + // For Ivy dependencies, create a package with basic information + // TODO: Parse ivy.xml to extract license, description, etc. + createEmptyPackage(identifierFor(dependency)) + } else { + maven.parsePackage(artifact, repositories, useReposFromDependencies = false) + } + }.recoverCatching { e -> + // If Maven parsing fails due to missing POM, try to treat it as an Ivy dependency + when (e) { + is ProjectBuildingException, is RepositoryException -> { + val errorMessage = e.collectMessages() + + // Check if the error is specifically about missing POM file + if (errorMessage.contains("pom") && errorMessage.contains("Could not find artifact")) { + // Assume this might be an Ivy dependency and create a basic package + // Log a warning but don't fail + issues += createAndLogIssue( + source = GradleFactory.descriptor.displayName, + message = "No Maven POM found for '${artifact.identifier()}', treating as Ivy or " + + "artifact without metadata.", + severity = Severity.WARNING + ) + + createEmptyPackage(identifierFor(dependency)) + } else { + // For other errors, propagate them + throw e + } + } + + else -> throw e + } }.getOrElse { e -> when (e) { is ProjectBuildingException, is RepositoryException -> { diff --git a/plugins/package-managers/gradle/src/main/resources/init.gradle b/plugins/package-managers/gradle/src/main/resources/init.gradle index 137b4c79d6749..173fc9fbc7ba2 100644 --- a/plugins/package-managers/gradle/src/main/resources/init.gradle +++ b/plugins/package-managers/gradle/src/main/resources/init.gradle @@ -239,14 +239,13 @@ class AbstractOrtDependencyTreePlugin implements Plugin { List repositories = project.repositories.findResults { if (it instanceof DefaultMavenArtifactRepository) { new OrtRepositoryImpl(it.url.toString(), it.credentials?.username, it.credentials?.password) + } else if (it instanceof DefaultIvyArtifactRepository) { + // Support Ivy repositories (e.g., Artifactory with Ivy layout) + new OrtRepositoryImpl(it.url.toString(), it.credentials?.username, it.credentials?.password) } else if (it instanceof DefaultFlatDirArtifactRepository) { warnings.add('Project uses a flat dir repository which is not supported by the analyzer. ' + "Dependencies from this repository will be ignored: ${it.dirs}".toString()) null - } else if (it instanceof DefaultIvyArtifactRepository) { - warnings.add('Project uses an Ivy repository which is not supported by the analyzer. ' + - "Dependencies from this repository will be ignored: ${it.url}".toString()) - null } else { errors.add("Unknown repository type: ${it.getClass().name}".toString()) null @@ -343,6 +342,7 @@ class AbstractOrtDependencyTreePlugin implements Plugin { ComponentIdentifier id = dependencyResult.selected.id if (id instanceof ModuleComponentIdentifier) { + // Try to resolve Maven POM first def resolvedComponents = project.dependencies.createArtifactResolutionQuery() .forComponents(id) .withArtifacts(MavenModule, MavenPomArtifact) @@ -352,6 +352,17 @@ class AbstractOrtDependencyTreePlugin implements Plugin { // Imitate Kotlin's "firstOrNull()". def result = resolvedComponents?.find { true }?.getArtifacts(MavenPomArtifact)?.find { true } + // If Maven POM not found, try Ivy descriptor + if (result == null) { + resolvedComponents = project.dependencies.createArtifactResolutionQuery() + .forComponents(id) + .withArtifacts(IvyModule, IvyDescriptorArtifact) + .execute() + .resolvedComponents + + result = resolvedComponents?.find { true }?.getArtifacts(IvyDescriptorArtifact)?.find { true } + } + String error = null String warning = null String pomFile = null @@ -361,7 +372,8 @@ class AbstractOrtDependencyTreePlugin implements Plugin { } else if (result instanceof UnresolvedArtifactResult) { error = collectCauses(result.failure).toString() } else if (result == null) { - error = 'Resolution did not return any artifacts' + // Neither Maven POM nor Ivy descriptor found - this might be okay for some dependencies + warning = 'Resolution did not return any metadata artifacts (POM or Ivy descriptor)' } else { error = "Unknown ArtifactResult type: ${result.getClass().name}".toString() } diff --git a/plugins/package-managers/ivy/README.md b/plugins/package-managers/ivy/README.md new file mode 100644 index 0000000000000..9c90b40b396ac --- /dev/null +++ b/plugins/package-managers/ivy/README.md @@ -0,0 +1,299 @@ +# Apache Ivy Package Manager Plugin + +This plugin provides support for analyzing projects that use [Apache Ivy](https://ant.apache.org/ivy/) for dependency management. + +## Overview + +Apache Ivy is a dependency manager focused on flexibility and simplicity. +It is typically used with Apache Ant, but can also be used standalone or with other build tools. + +## Supported Features + +### Core Features (Always Available) + +* Parsing `ivy.xml` module descriptors +* Extracting project metadata (organization, module name, revision) +* Extracting direct dependencies from ivy.xml +* Mapping Ivy configurations to ORT scopes (compile, runtime, test, etc.) +* Extracting license information from module descriptors +* Support for multiple configurations in a single ivy.xml + +### Advanced Features (Requires Ivy CLI) + +* **Full transitive dependency resolution** using Ivy's resolve functionality +* **Dynamic version resolution** (e.g., `latest.integration`, `1.0.+`) +* **Conflict resolution** and version eviction handling +* **Complete dependency tree** with all transitive dependencies +* **Artifact location tracking** from Ivy resolvers + +## Configuration Files + +The plugin recognizes the following definition files: + +* `ivy.xml` - Ivy module descriptor file + +## Configuration + +### Basic Usage (Direct Dependencies Only) + +By default, the plugin only parses `ivy.xml` and extracts direct dependencies. +No external tools are required. + +```yaml +# .ort.yml +analyzer: + enabled_package_managers: + - Ivy +``` + +**Note:** The default is `resolveTransitive: false` for compatibility and performance reasons. + +### Advanced Usage (Full Transitive Resolution) + +To enable full transitive dependency resolution, you need: + +1. **Install Apache Ivy** and ensure it's available in your PATH +2. **Enable transitive resolution** in your ORT configuration: + +```yaml +# .ort.yml +analyzer: + package_managers: + Ivy: + options: + resolveTransitive: "true" +``` + +**Installing Ivy:** + +```bash +# Using package manager +brew install ivy # macOS +apt-get install ivy # Debian/Ubuntu + +# Or download from Apache +wget https://downloads.apache.org/ant/ivy/2.5.2/apache-ivy-2.5.2-bin.tar.gz +tar -xzf apache-ivy-2.5.2-bin.tar.gz +export PATH=$PATH:/path/to/ivy/bin +``` + +## Ivy Module Structure + +A typical `ivy.xml` file contains: + +```xml + + + + + My project description + + + + + + + + + + + + + + + + +``` + +## How It Works + +### Mode 1: Direct Dependencies (Default) + +When `resolveTransitive: false` or Ivy CLI is not available: + +1. Parses `ivy.xml` using XML parsing +2. Extracts direct dependencies as declared +3. Groups dependencies by configuration (scope) +4. **Limitation:** Dynamic versions remain unresolved, transitive dependencies are not included + +**Use case:** Quick analysis, CI/CD where Ivy is not installed, basic dependency overview + +### Mode 2: Transitive Resolution (Opt-in) + +When `resolveTransitive: true` and Ivy CLI is available: + +1. Parses `ivy.xml` to understand project structure +2. For each configuration, runs `ivy -ivy ivy.xml -confs -xml report.xml` +3. Parses the generated resolve report XML +4. Builds complete dependency tree with all transitive dependencies +5. Resolves dynamic versions to concrete versions +6. Handles version conflicts and evictions + +**Use case:** Complete SBOM generation, security scanning, license compliance + +## Limitations and Known Issues + +### Current Limitations + +1. **Repository Configuration:** + * `ivysettings.xml` files are not processed by ORT + * Uses Ivy's default resolver configuration + * Custom repositories must be configured in Ivy's global settings + +2. **Module Metadata:** + * Package metadata (licenses, descriptions, URLs) from POM files in repositories is not extracted + * Only information from ivy.xml is captured + * Recommendation: Use additional ORT features for metadata enrichment + +3. **Build Files:** + * Does not parse or execute `build.xml` (Ant build files) + * Only analyzes dependency declarations in `ivy.xml` + +4. **Performance:** + * Transitive resolution requires running Ivy CLI for each configuration + * Can be slow for projects with many configurations or large dependency trees + * Ivy downloads artifacts during resolution (uses local cache) + +### Workarounds + +* **For missing metadata:** Use ORT's package curation features to enrich package information +* **For custom repositories:** Configure `ivysettings.xml` in Ivy's global configuration directory +* **For performance:** Use caching strategies, limit configurations, or run analysis on dedicated build machines + +## Comparison: Direct vs Transitive Resolution + +| Feature | Direct Mode | Transitive Mode | +|---------|-------------|-----------------| +| External Dependencies | None | Requires Ivy CLI | +| Dependency Depth | Direct only | Full tree | +| Dynamic Versions | Not resolved | Resolved to concrete versions | +| Version Conflicts | Not detected | Handled by Ivy | +| Analysis Speed | Fast | Slower (depends on dependencies) | +| Accuracy | Basic | Complete | +| Use Case | Quick scan | Production SBOM | + +## Example Output + +### Direct Dependencies Mode + +```yaml +project: + id: "Ivy::com.example:my-project:1.0.0" + scopes: + - name: "compile" + dependencies: + - id: "Maven::commons-lang:commons-lang:2.6" + dependencies: [] # No transitive deps +``` + +### Transitive Resolution Mode + +```yaml +project: + id: "Ivy::com.example:my-project:1.0.0" + scopes: + - name: "compile" + dependencies: + - id: "Maven::commons-lang:commons-lang:2.6" + dependencies: + - id: "Maven::commons-logging:commons-logging:1.1.1" # Transitive! + dependencies: [] +``` + +## Example Projects + +See the `src/funTest/assets/projects/synthetic/` directory for example projects that can be analyzed with this plugin. + +## Requirements + +### Basic Mode (Default) + +* No external tools required +* Works with any JVM (Java 11+) + +### Transitive Mode (Optional) + +* Apache Ivy 2.4.0 or later installed and in PATH +* Internet connection or configured Ivy cache for dependency resolution +* `ivysettings.xml` configured if using private repositories + +### Container/Docker Mode + +* Apache Ivy 2.5.2 is **pre-installed** in ORT container images (`ort:latest`) +* No manual installation required when using containers +* See the "Docker/Podman Support" section below for detailed container usage instructions + +## Testing + +Run the functional tests with: + +```bash +# Test basic parsing +./gradlew :plugins:package-managers:ivy:funTest + +# Test with transitive resolution (requires Ivy installed) +./gradlew :plugins:package-managers:ivy:funTest -DresolveTransitive=true +``` + +## Troubleshooting + +### "Ivy CLI not found in PATH" + +* Install Apache Ivy and ensure the `ivy` command is available +* Check with: `ivy -version` + +### "Ivy resolve failed for configuration" + +* Check that your `ivy.xml` is valid +* Ensure repositories are accessible +* Review Ivy's cache in `~/.ivy2/cache` +* Enable debug logging: `export IVY_OPTS="-verbose"` + +### "Dynamic version not resolved" + +* This is expected in direct mode +* Enable `resolveTransitive: true` for dynamic version resolution + +### Slow resolution + +* Use Ivy's cache (enabled by default) +* Reduce number of configurations if possible +* Consider using Maven Central mirror for faster downloads + +## Docker/Podman Support + +Apache Ivy is included in the ORT container image. +When using containers, Ivy is already installed and configured. + +### Quick Start with Container + +```bash +# Using Docker +docker run --rm -v $(pwd):/project ghcr.io/oss-review-toolkit/ort:latest \ + analyze -i /project -o /project/ort-results + +# Using Podman +podman run --rm -v $(pwd):/project:z ghcr.io/oss-review-toolkit/ort:latest \ + analyze -i /project -o /project/ort-results +``` + +For detailed container usage including: + +* Persistent Ivy cache configuration +* Custom `ivysettings.xml` mounting +* Network proxy configuration +* Multi-architecture support + +Please refer to the ORT documentation for complete container setup and configuration details. + +## Contributing + +When contributing improvements to this plugin: + +1. Ensure backward compatibility with direct parsing mode +2. Add appropriate tests for both modes +3. Document any new configuration options +4. Update this README with new features or limitations diff --git a/plugins/package-managers/ivy/build.gradle.kts b/plugins/package-managers/ivy/build.gradle.kts new file mode 100644 index 0000000000000..fa65830a04fd8 --- /dev/null +++ b/plugins/package-managers/ivy/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2025 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 + */ + +plugins { + // Apply precompiled plugins. + id("ort-plugin-conventions") +} + +dependencies { + api(projects.analyzer) + api(projects.model) + api(projects.utils.commonUtils) { + because("This is a CommandLineTool.") + } + + api(libs.semver4j) { + because("This is a CommandLineTool.") + } + + implementation(projects.downloader) + implementation(projects.utils.ortUtils) + + implementation(jacksonLibs.jacksonDataformatXml) + implementation(jacksonLibs.jacksonModuleKotlin) + + ksp(projects.analyzer) + + funTestImplementation(projects.plugins.versionControlSystems.gitVersionControlSystem) + funTestImplementation(testFixtures(projects.analyzer)) +} diff --git a/plugins/package-managers/ivy/src/funTest/assets/projects/synthetic/ivy-simple/expected-output.yml b/plugins/package-managers/ivy/src/funTest/assets/projects/synthetic/ivy-simple/expected-output.yml new file mode 100644 index 0000000000000..90d961fefab34 --- /dev/null +++ b/plugins/package-managers/ivy/src/funTest/assets/projects/synthetic/ivy-simple/expected-output.yml @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2025 The ORT Project Authors +# +# SPDX-License-Identifier: Apache-2.0 + +--- +projects: +- id: "Ivy:com.example:sample-project:1.0.0" + definition_file_path: "" + declared_licenses: + - "Apache-2.0" + declared_licenses_processed: + spdx_expression: "Apache-2.0" + vcs: + type: "Git" + url: "" + revision: "" + path: "" + vcs_processed: + type: "Git" + url: "" + revision: "" + path: "" + homepage_url: "" + scopes: + - name: "compile" + dependencies: + - id: "Maven:commons-lang:commons-lang:2.6" + - name: "runtime" + dependencies: + - id: "Maven:org.apache.logging.log4j:log4j-core:2.17.1" + - name: "test" + dependencies: + - id: "Maven:junit:junit:4.13.2" +packages: +- id: "Maven:commons-lang:commons-lang:2.6" + purl: "pkg:maven/commons-lang/commons-lang@2.6" + declared_licenses: [] + declared_licenses_processed: {} + description: "" + homepage_url: "" + binary_artifact: + url: "" + hash: + value: "" + algorithm: "" + source_artifact: + url: "" + hash: + value: "" + algorithm: "" + vcs: + type: "" + url: "" + revision: "" + path: "" + vcs_processed: + type: "" + url: "" + revision: "" + path: "" +- id: "Maven:junit:junit:4.13.2" + purl: "pkg:maven/junit/junit@4.13.2" + declared_licenses: [] + declared_licenses_processed: {} + description: "" + homepage_url: "" + binary_artifact: + url: "" + hash: + value: "" + algorithm: "" + source_artifact: + url: "" + hash: + value: "" + algorithm: "" + vcs: + type: "" + url: "" + revision: "" + path: "" + vcs_processed: + type: "" + url: "" + revision: "" + path: "" +- id: "Maven:org.apache.logging.log4j:log4j-core:2.17.1" + purl: "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.1" + declared_licenses: [] + declared_licenses_processed: {} + description: "" + homepage_url: "" + binary_artifact: + url: "" + hash: + value: "" + algorithm: "" + source_artifact: + url: "" + hash: + value: "" + algorithm: "" + vcs: + type: "" + url: "" + revision: "" + path: "" + vcs_processed: + type: "" + url: "" + revision: "" + path: "" diff --git a/plugins/package-managers/ivy/src/funTest/assets/projects/synthetic/ivy-simple/ivy.xml b/plugins/package-managers/ivy/src/funTest/assets/projects/synthetic/ivy-simple/ivy.xml new file mode 100644 index 0000000000000..ead10eb195091 --- /dev/null +++ b/plugins/package-managers/ivy/src/funTest/assets/projects/synthetic/ivy-simple/ivy.xml @@ -0,0 +1,26 @@ + + + + + A sample Ivy project for testing ORT + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/package-managers/ivy/src/funTest/kotlin/IvyTest.kt b/plugins/package-managers/ivy/src/funTest/kotlin/IvyTest.kt new file mode 100644 index 0000000000000..d2d88b18e2956 --- /dev/null +++ b/plugins/package-managers/ivy/src/funTest/kotlin/IvyTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2025 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.packagemanagers.ivy + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.containExactlyInAnyOrder +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe + +import org.ossreviewtoolkit.analyzer.analyze +import org.ossreviewtoolkit.analyzer.getAnalyzerResult + +class IvyTest : StringSpec({ + "Project dependencies are detected correctly for ivy-simple" { + val definitionFile = projectDir("ivy-simple").resolve("ivy.xml") + + val result = analyze(definitionFile).getAnalyzerResult() + val project = result.projects.single() + + // Verify project basic info + project.id.type shouldBe "Ivy" + project.id.namespace shouldBe "com.example" + project.id.name shouldBe "sample-project" + project.id.version shouldBe "1.0.0" + project.declaredLicenses shouldBe setOf("Apache-2.0") + project.homepageUrl shouldBe "" + + // Verify scopes + val scopeNames = project.scopes.map { it.name } + scopeNames should containExactlyInAnyOrder("compile", "runtime", "test") + + // Verify dependencies per scope + val compileDeps = project.scopes.find { it.name == "compile" }?.dependencies?.map { it.id.toCoordinates() } + compileDeps shouldBe setOf("Maven:commons-lang:commons-lang:2.6") + + val runtimeDeps = project.scopes.find { it.name == "runtime" }?.dependencies?.map { it.id.toCoordinates() } + runtimeDeps shouldBe setOf("Maven:org.apache.logging.log4j:log4j-core:2.17.1") + + val testDeps = project.scopes.find { it.name == "test" }?.dependencies?.map { it.id.toCoordinates() } + testDeps shouldBe setOf("Maven:junit:junit:4.13.2") + + // Verify packages + val packageIds = result.packages.mapTo(mutableSetOf()) { it.id.toCoordinates() } + packageIds should containExactlyInAnyOrder( + "Maven:commons-lang:commons-lang:2.6", + "Maven:org.apache.logging.log4j:log4j-core:2.17.1", + "Maven:junit:junit:4.13.2" + ) + } + + "Dependencies are grouped by configuration" { + val definitionFile = projectDir("ivy-simple").resolve("ivy.xml") + + val result = analyze(definitionFile).getAnalyzerResult() + + result.projects.single().scopes.map { it.name } should containExactlyInAnyOrder( + "compile", + "runtime", + "test" + ) + } + + "Project metadata is parsed correctly" { + val definitionFile = projectDir("ivy-simple").resolve("ivy.xml") + + val result = analyze(definitionFile).getAnalyzerResult() + val project = result.projects.single() + + project.id.namespace shouldBe "com.example" + project.id.name shouldBe "sample-project" + project.id.version shouldBe "1.0.0" + project.declaredLicenses shouldBe setOf("Apache-2.0") + } +}) + +private fun projectDir(name: String) = java.io.File("src/funTest/assets/projects/synthetic/$name").absoluteFile diff --git a/plugins/package-managers/ivy/src/main/kotlin/Ivy.kt b/plugins/package-managers/ivy/src/main/kotlin/Ivy.kt new file mode 100644 index 0000000000000..d2b4c3ccf9163 --- /dev/null +++ b/plugins/package-managers/ivy/src/main/kotlin/Ivy.kt @@ -0,0 +1,368 @@ +/* + * Copyright (C) 2025 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.packagemanagers.ivy + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule + +import java.io.File + +import org.apache.logging.log4j.kotlin.logger + +import org.ossreviewtoolkit.analyzer.PackageManager +import org.ossreviewtoolkit.analyzer.PackageManagerFactory +import org.ossreviewtoolkit.model.Identifier +import org.ossreviewtoolkit.model.Issue +import org.ossreviewtoolkit.model.Package +import org.ossreviewtoolkit.model.PackageLinkage +import org.ossreviewtoolkit.model.PackageReference +import org.ossreviewtoolkit.model.Project +import org.ossreviewtoolkit.model.ProjectAnalyzerResult +import org.ossreviewtoolkit.model.RemoteArtifact +import org.ossreviewtoolkit.model.Scope +import org.ossreviewtoolkit.model.Severity +import org.ossreviewtoolkit.model.VcsInfo +import org.ossreviewtoolkit.model.config.AnalyzerConfiguration +import org.ossreviewtoolkit.model.config.Excludes +import org.ossreviewtoolkit.model.createAndLogIssue +import org.ossreviewtoolkit.plugins.api.OrtPlugin +import org.ossreviewtoolkit.plugins.api.OrtPluginOption +import org.ossreviewtoolkit.plugins.api.PluginDescriptor +import org.ossreviewtoolkit.utils.common.collectMessages +import org.ossreviewtoolkit.utils.ort.DeclaredLicenseProcessor + +/** + * Configuration options for the Ivy package manager. + */ +data class IvyConfig( + /** + * Enable transitive dependency resolution using Ivy CLI. + * If true, requires Apache Ivy to be installed and available in PATH. + * If false, only direct dependencies from ivy.xml are parsed. + * Default is false (parse ivy.xml only) for compatibility and performance. + */ + @OrtPluginOption(defaultValue = "false") + val resolveTransitive: Boolean +) + +/** + * The [Apache Ivy](https://ant.apache.org/ivy/) package manager for Java. + * + * This package manager supports projects that use Apache Ivy for dependency management. + * Ivy uses `ivy.xml` files to declare dependencies and configurations. + * + * ## Features + * - Transitive dependency resolution via Ivy CLI (enabled by default, requires Ivy to be installed) + * - Fallback to parsing ivy.xml for direct dependencies only + * - Support for multiple configurations (compile, runtime, test, etc.) + * - Dynamic version resolution when using Ivy CLI + * + * ## Configuration + * Transitive resolution is enabled by default. To disable it and only parse direct dependencies: + * ```yaml + * analyzer: + * package_managers: + * Ivy: + * options: + * resolveTransitive: "false" + * ``` + */ +@OrtPlugin( + id = "Ivy", + displayName = "Apache Ivy", + description = "The Apache Ivy package manager for Java.", + factory = PackageManagerFactory::class +) +class Ivy( + override val descriptor: PluginDescriptor = IvyFactory.descriptor, + private val config: IvyConfig +) : PackageManager("Ivy") { + companion object { + private val XML_MAPPER = XmlMapper.builder() + .addModule(KotlinModule.Builder().build()) + .build() + } + + override val globsForDefinitionFiles = listOf("ivy.xml") + + override fun resolveDependencies( + analysisRoot: File, + definitionFile: File, + excludes: Excludes, + analyzerConfig: AnalyzerConfiguration, + labels: Map + ): List { + val workingDir = definitionFile.parentFile + + logger.info { "Parsing Ivy module descriptor from '$definitionFile'..." } + + val ivyModule = runCatching { + XML_MAPPER.readValue(definitionFile, IvyModule::class.java) + }.getOrElse { e -> + logger.error { "Failed to parse ivy.xml: ${e.message}" } + return emptyList() + } + + val info = ivyModule.info ?: run { + logger.warn { "No info section found in ivy.xml" } + return emptyList() + } + + val organisation = info.organisation.orEmpty() + val moduleName = info.module.orEmpty() + val revision = info.revision ?: "unspecified" + + val projectId = Identifier( + type = projectType, + namespace = organisation, + name = moduleName, + version = revision + ) + + logger.info { "Resolved project: $projectId" } + + // Choose resolution strategy based on configuration + val (scopes, packages, issues) = if (config.resolveTransitive && IvyCommand.isInPath()) { + logger.info { "Resolving transitive dependencies using Ivy CLI..." } + resolveTransitiveDependencies(workingDir, definitionFile, ivyModule, excludes) + } else { + if (config.resolveTransitive) { + logger.warn { + "Transitive resolution requested but Ivy CLI not found in PATH. " + + "Falling back to parsing ivy.xml only." + } + } else { + logger.info { "Parsing direct dependencies from ivy.xml only (transitive resolution disabled)..." } + } + + resolveDirectDependencies(ivyModule, excludes) + } + + // Create project metadata + val project = Project( + id = projectId, + definitionFilePath = definitionFile.relativeTo(analysisRoot).path, + authors = emptySet(), + declaredLicenses = info.license?.name?.let { setOf(it) }.orEmpty(), + declaredLicensesProcessed = DeclaredLicenseProcessor.process( + info.license?.name?.let { setOf(it) }.orEmpty() + ), + vcs = processProjectVcs(workingDir), + vcsProcessed = processProjectVcs(workingDir), + homepageUrl = info.homepage.orEmpty(), + scopeDependencies = scopes, + scopeNames = null + ) + + return listOf(ProjectAnalyzerResult(project, packages, issues)) + } + + /** + * Create a package with empty metadata. + * Used for Ivy dependencies or artifacts without available metadata. + */ + private fun createEmptyPackage(id: Identifier) = + Package( + id = id, + authors = emptySet(), + declaredLicenses = emptySet(), + declaredLicensesProcessed = DeclaredLicenseProcessor.process(emptySet()), + description = "", + homepageUrl = "", + binaryArtifact = RemoteArtifact.EMPTY, + sourceArtifact = RemoteArtifact.EMPTY, + vcs = VcsInfo.EMPTY, + isMetadataOnly = false, + isModified = false + ) + + /** + * Resolve only direct dependencies by parsing ivy.xml. + */ + private fun resolveDirectDependencies( + ivyModule: IvyModule, + excludes: Excludes + ): Triple, Set, List> { + val scopes = mutableSetOf() + val packages = mutableSetOf() + val issues = mutableListOf() + + ivyModule.dependencies?.dependency?.forEach { dep -> + val depOrg = dep.org.orEmpty() + val depName = dep.name.orEmpty() + val depRev = dep.rev ?: "latest.integration" + val depConf = dep.conf ?: "default" + + // Extract the scope name (configuration on the left side of ->) + val scopeName = depConf.substringBefore("->").trim() + + // Skip if scope is excluded + if (excludes.isScopeExcluded(scopeName)) { + logger.debug { "Skipping dependency $depOrg:$depName in excluded scope '$scopeName'" } + return@forEach + } + + val packageId = Identifier( + type = "Maven", // Most Ivy dependencies are Maven artifacts + namespace = depOrg, + name = depName, + version = depRev + ) + + logger.debug { "Processing direct dependency: $packageId in configuration '$scopeName'" } + + // Add warning for dynamic versions + if (depRev.contains("latest") || depRev.contains("+")) { + issues += createAndLogIssue( + "Dynamic version '$depRev' found for $depOrg:$depName. " + + "Enable transitive resolution to resolve concrete versions.", + Severity.WARNING + ) + } + + // Create package reference + val packageRef = PackageReference( + id = packageId, + linkage = PackageLinkage.DYNAMIC, + dependencies = emptySet() + ) + + // Add to appropriate scope + var scope = scopes.find { it.name == scopeName } + if (scope == null) { + scope = Scope( + name = scopeName, + dependencies = mutableSetOf() + ) + scopes.add(scope) + } + + (scope.dependencies as MutableSet).add(packageRef) + + // Create package metadata + packages.add(createEmptyPackage(packageId)) + } + + return Triple(scopes, packages, issues) + } + + /** + * Resolve transitive dependencies using Ivy CLI. + */ + private fun resolveTransitiveDependencies( + workingDir: File, + definitionFile: File, + ivyModule: IvyModule, + excludes: Excludes + ): Triple, Set, List> { + val scopes = mutableSetOf() + val packages = mutableSetOf() + val issues = mutableListOf() + + // Get list of configurations to resolve + val configurations = ivyModule.configurations?.conf?.map { it.name.orEmpty() }?.filter { it.isNotEmpty() } + ?: listOf("default") + + configurations.forEach { conf -> + if (excludes.isScopeExcluded(conf)) { + logger.debug { "Skipping excluded configuration: $conf" } + return@forEach + } + + logger.info { "Resolving configuration: $conf" } + + runCatching { + // Run ivy resolve + val result = IvyCommand.run( + workingDir, + "-ivy", definitionFile.name, + "-confs", conf + ) + + if (!result.isSuccess) { + issues += createAndLogIssue( + "Ivy resolve failed for configuration '$conf': ${result.stderr}", + Severity.ERROR + ) + return@forEach + } + + // Parse the output to extract resolved dependencies + val (scopeRefs, scopePackages) = parseDependenciesFromOutput(result.stdout) + + val scope = Scope( + name = conf, + dependencies = scopeRefs + ) + scopes.add(scope) + packages.addAll(scopePackages) + }.onFailure { e -> + issues += createAndLogIssue( + "Failed to resolve configuration '$conf': ${e.collectMessages()}", + Severity.ERROR + ) + } + } + + return Triple(scopes, packages, issues) + } + + /** + * Parse Ivy output to extract resolved dependencies. + * Output format: "found org.apache.commons#commons-lang3;3.14.0 in public" + */ + private fun parseDependenciesFromOutput(output: String): Pair, Set> { + val packageRefs = mutableSetOf() + val packages = mutableSetOf() + + // Pattern: "found #; in " + val pattern = Regex("""^\s*found\s+([^#]+)#([^;]+);([^\s]+)\s+in\s+""") + + output.lines().forEach { line -> + pattern.find(line)?.let { match -> + val org = match.groupValues[1].trim() + val module = match.groupValues[2].trim() + val version = match.groupValues[3].trim() + + val packageId = Identifier( + type = "Maven", + namespace = org, + name = module, + version = version + ) + + logger.debug { "Found dependency: $packageId" } + + val packageRef = PackageReference( + id = packageId, + linkage = PackageLinkage.DYNAMIC, + dependencies = emptySet() + ) + packageRefs.add(packageRef) + + packages.add(createEmptyPackage(packageId)) + } + } + + logger.info { "Parsed ${packages.size} dependencies from Ivy output" } + + return Pair(packageRefs, packages) + } +} diff --git a/plugins/package-managers/ivy/src/main/kotlin/IvyCommand.kt b/plugins/package-managers/ivy/src/main/kotlin/IvyCommand.kt new file mode 100644 index 0000000000000..594ab61348719 --- /dev/null +++ b/plugins/package-managers/ivy/src/main/kotlin/IvyCommand.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2025 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.packagemanagers.ivy + +import java.io.File + +import org.ossreviewtoolkit.utils.common.CommandLineTool + +import org.semver4j.range.RangeList +import org.semver4j.range.RangeListFactory + +/** + * Command line interface for Apache Ivy. + */ +internal object IvyCommand : CommandLineTool { + override fun command(workingDir: File?) = "ivy" + + override fun getVersionArguments() = "-version" + + override fun getVersionRequirement(): RangeList = RangeListFactory.create(">=2.4.0") + + override fun transformVersion(output: String): String { + // Ivy version output format: "Apache Ivy 2.5.0 - 20191020104435" + return output.substringAfter("Apache Ivy ").substringBefore(" -").trim() + } +} diff --git a/plugins/package-managers/ivy/src/main/kotlin/IvyModule.kt b/plugins/package-managers/ivy/src/main/kotlin/IvyModule.kt new file mode 100644 index 0000000000000..d5355a3a65dfc --- /dev/null +++ b/plugins/package-managers/ivy/src/main/kotlin/IvyModule.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2025 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.packagemanagers.ivy + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty + +/** + * Data class representing an Ivy module descriptor (ivy.xml). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +internal data class IvyModule( + @JacksonXmlProperty(isAttribute = true) + val version: String? = null, + + val info: Info? = null, + + val configurations: Configurations? = null, + + val publications: Publications? = null, + + val dependencies: Dependencies? = null +) { + @JsonIgnoreProperties(ignoreUnknown = true) + data class Info( + @JacksonXmlProperty(isAttribute = true) + val organisation: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val module: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val revision: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val status: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val publication: String? = null, + + val license: License? = null, + + val description: String? = null, + + val homepage: String? = null + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class License( + @JacksonXmlProperty(isAttribute = true) + val name: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val url: String? = null + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class Configurations( + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "conf") + val conf: List? = null + ) { + @JsonIgnoreProperties(ignoreUnknown = true) + data class Conf( + @JacksonXmlProperty(isAttribute = true) + val name: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val visibility: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val description: String? = null + ) + } + + @JsonIgnoreProperties(ignoreUnknown = true) + data class Publications( + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "artifact") + val artifact: List? = null + ) { + @JsonIgnoreProperties(ignoreUnknown = true) + data class Artifact( + @JacksonXmlProperty(isAttribute = true) + val name: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val type: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val ext: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val conf: String? = null + ) + } + + @JsonIgnoreProperties(ignoreUnknown = true) + data class Dependencies( + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "dependency") + val dependency: List? = null + ) { + @JsonIgnoreProperties(ignoreUnknown = true) + data class Dependency( + @JacksonXmlProperty(isAttribute = true) + val org: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val name: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val rev: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val conf: String? = null, + + @JacksonXmlProperty(isAttribute = true) + val transitive: Boolean? = null + ) + } +} diff --git a/scripts/docker_build.sh b/scripts/docker_build.sh index 79196737051d5..6a54860d9dfce 100755 --- a/scripts/docker_build.sh +++ b/scripts/docker_build.sh @@ -134,6 +134,12 @@ image_build scala ort/scala "$SBT_VERSION" \ --build-context "base=docker-image://${DOCKER_IMAGE_ROOT}/ort/base:latest" \ "$@" +# Apache Ivy +image_build ivy ort/ivy "$IVY_VERSION" \ + --build-arg IVY_VERSION="$IVY_VERSION" \ + --build-context "base=docker-image://${DOCKER_IMAGE_ROOT}/ort/base:latest" \ + "$@" + # Dart image_build dart ort/dart "$DART_VERSION" \ --build-arg DART_VERSION="$DART_VERSION" \ @@ -164,4 +170,5 @@ image_build run ort "$ORT_VERSION" \ --build-context "dart=docker-image://${DOCKER_IMAGE_ROOT}/ort/dart:latest" \ --build-context "haskell=docker-image://${DOCKER_IMAGE_ROOT}/ort/haskell:latest" \ --build-context "scala=docker-image://${DOCKER_IMAGE_ROOT}/ort/scala:latest" \ + --build-context "ivy=docker-image://${DOCKER_IMAGE_ROOT}/ort/ivy:latest" \ "$@" diff --git a/website/docs/tools/analyzer.md b/website/docs/tools/analyzer.md index 7a8580d7a56ff..61754934c19dc 100644 --- a/website/docs/tools/analyzer.md +++ b/website/docs/tools/analyzer.md @@ -29,6 +29,7 @@ Currently, the following package managers (grouped by the programming language t * [Stack](https://haskellstack.org/) * Java * [Gradle](https://gradle.org/) + * [Ivy](https://ant.apache.org/ivy/) * [Maven](https://maven.apache.org/) (limitations: [default profile only](https://github.com/oss-review-toolkit/ort/issues/1774)) * Including the [Tycho](https://tycho.eclipseprojects.io/doc/main/index.html) extension for building OSGi bundles and Eclipse IDE plug-ins. * JavaScript / Node.js