Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Composer: Use data classes for deserialization and simplify a bit on top #8932

Merged
merged 9 commits into from
Aug 7, 2024
2 changes: 2 additions & 0 deletions plugins/package-managers/composer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ dependencies {
implementation(projects.utils.ortUtils)
implementation(projects.utils.spdxUtils)

implementation(libs.jackson.core)
implementation(libs.jackson.databind)
implementation(libs.jackson.module.kotlin)

funTestImplementation(testFixtures(projects.analyzer))
}
205 changes: 89 additions & 116 deletions plugins/package-managers/composer/src/main/kotlin/Composer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,14 @@

package org.ossreviewtoolkit.plugins.packagemanagers.composer

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode

import java.io.File
import java.io.IOException

import org.apache.logging.log4j.kotlin.logger

import org.ossreviewtoolkit.analyzer.AbstractPackageManagerFactory
import org.ossreviewtoolkit.analyzer.PackageManager
import org.ossreviewtoolkit.analyzer.PackageManager.Companion.processPackageVcs
import org.ossreviewtoolkit.downloader.VersionControlSystem
import org.ossreviewtoolkit.model.Hash
import org.ossreviewtoolkit.model.Identifier
Expand All @@ -43,24 +41,22 @@ import org.ossreviewtoolkit.model.VcsType
import org.ossreviewtoolkit.model.config.AnalyzerConfiguration
import org.ossreviewtoolkit.model.config.RepositoryConfiguration
import org.ossreviewtoolkit.model.createAndLogIssue
import org.ossreviewtoolkit.model.jsonMapper
import org.ossreviewtoolkit.model.orEmpty
import org.ossreviewtoolkit.model.readTree
import org.ossreviewtoolkit.utils.common.CommandLineTool
import org.ossreviewtoolkit.utils.common.Os
import org.ossreviewtoolkit.utils.common.collectMessages
import org.ossreviewtoolkit.utils.common.fieldNamesOrEmpty
import org.ossreviewtoolkit.utils.common.isNotEmpty
import org.ossreviewtoolkit.utils.common.splitOnWhitespace
import org.ossreviewtoolkit.utils.common.textValueOrEmpty
import org.ossreviewtoolkit.utils.ort.showStackTrace

import org.semver4j.RangesList
import org.semver4j.RangesListFactory
import org.semver4j.Semver

const val COMPOSER_PHAR_BINARY = "composer.phar"
const val COMPOSER_LOCK_FILE = "composer.lock"
private const val COMPOSER_PHAR_BINARY = "composer.phar"
private const val COMPOSER_LOCK_FILE = "composer.lock"
private const val SCOPE_NAME_REQUIRE = "require"
private const val SCOPE_NAME_REQUIRE_DEV = "require-dev"
private val ALL_SCOPE_NAMES = setOf(SCOPE_NAME_REQUIRE, SCOPE_NAME_REQUIRE_DEV)

/**
* The [Composer](https://getcomposer.org/) package manager for PHP.
Expand Down Expand Up @@ -116,10 +112,8 @@ class Composer(
override fun resolveDependencies(definitionFile: File, labels: Map<String, String>): List<ProjectAnalyzerResult> {
val workingDir = definitionFile.parentFile

val manifest = definitionFile.readTree()
val hasDependencies = manifest.fields().asSequence().any { (key, value) ->
key.startsWith("require") && value.isNotEmpty()
}
val projectPackageInfo = parsePackageInfo(definitionFile.readText())
val hasDependencies = projectPackageInfo.require.isNotEmpty()

if (!hasDependencies) {
val project = parseProject(definitionFile, scopes = emptySet())
Expand All @@ -128,25 +122,24 @@ class Composer(
return listOf(result)
}

val lockfile = ensureLockfile(workingDir)

logger.info { "Parsing lockfile at '$lockfile'..." }
val lockfile = ensureLockfile(workingDir).let {
logger.info { "Parsing lockfile at '$it'..." }
parseLockfile(it.readText())
}

val json = jsonMapper.readTree(lockfile)
val packages = parseInstalledPackages(json)
val packages = parseInstalledPackages(lockfile)

// Let's also determine the "virtual" (replaced and provided) packages. These can be declared as
// required, but are not listed in composer.lock as installed.
// If we didn't handle them specifically, we would report them as missing when trying to load the
// dependency information for them. We can't simply put these "virtual" packages in the normal package
// map as this would cause us to report a package which is not actually installed with the contents of
// the "replacing" package.
val virtualPackages = parseVirtualPackageNames(packages, manifest, json)
val virtualPackages = parseVirtualPackageNames(packages, projectPackageInfo, lockfile)

val scopes = setOf(
parseScope("require", manifest, json, packages, virtualPackages),
parseScope("require-dev", manifest, json, packages, virtualPackages)
)
val scopes = ALL_SCOPE_NAMES.mapTo(mutableSetOf()) { scopeName ->
parseScope(scopeName, projectPackageInfo, lockfile, packages, virtualPackages)
}

val project = parseProject(definitionFile, scopes)
val result = ProjectAnalyzerResult(project, packages.values.toSet())
Expand All @@ -156,19 +149,19 @@ class Composer(

private fun parseScope(
scopeName: String,
manifest: JsonNode,
lockfile: JsonNode,
projectPackageInfo: PackageInfo,
lockfile: Lockfile,
packages: Map<String, Package>,
virtualPackages: Set<String>
): Scope {
val requiredPackages = manifest[scopeName].fieldNamesOrEmpty().asSequence()
val requiredPackages = projectPackageInfo.getScopeDependencies(scopeName)
val dependencies = buildDependencyTree(requiredPackages, lockfile, packages, virtualPackages)
return Scope(scopeName, dependencies)
}

private fun buildDependencyTree(
dependencies: Sequence<String>,
lockfile: JsonNode,
dependencies: Set<String>,
lockfile: Lockfile,
packages: Map<String, Package>,
virtualPackages: Set<String>,
dependencyBranch: List<String> = emptyList()
Expand Down Expand Up @@ -216,10 +209,10 @@ class Composer(
private fun parseProject(definitionFile: File, scopes: Set<Scope>): Project {
logger.info { "Parsing project metadata from '$definitionFile'..." }

val json = definitionFile.readTree()
val homepageUrl = json["homepage"].textValueOrEmpty()
val vcs = parseVcsInfo(json)
val rawName = json["name"]?.textValue()
val pkgInfo = parsePackageInfo(definitionFile.readText())
val homepageUrl = pkgInfo.homepage.orEmpty()
val vcs = parseVcsInfo(pkgInfo)
val rawName = pkgInfo.name
val namespace = rawName?.substringBefore("/", missingDelimiterValue = "").orEmpty()
val name = rawName?.substringAfter("/") ?: getFallbackProjectName(analysisRoot, definitionFile)

Expand All @@ -228,55 +221,23 @@ class Composer(
type = managerName,
namespace = namespace,
name = name,
version = json["version"].textValueOrEmpty()
version = pkgInfo.version.orEmpty()
),
definitionFilePath = VersionControlSystem.getPathInfo(definitionFile).path,
authors = parseAuthors(json),
declaredLicenses = parseDeclaredLicenses(json),
authors = parseAuthors(pkgInfo),
declaredLicenses = parseDeclaredLicenses(pkgInfo),
vcs = vcs,
vcsProcessed = processProjectVcs(definitionFile.parentFile, vcs, homepageUrl),
homepageUrl = homepageUrl,
scopeDependencies = scopes
)
}

private fun parseInstalledPackages(json: JsonNode): Map<String, Package> {
val packages = mutableMapOf<String, Package>()

listOf("packages", "packages-dev").forEach {
json[it]?.forEach { pkgInfo ->
val rawName = pkgInfo["name"].textValue()
val version = pkgInfo["version"].textValueOrEmpty()
val homepageUrl = pkgInfo["homepage"].textValueOrEmpty()
val vcsFromPackage = parseVcsInfo(pkgInfo)

// Just warn if the version is missing as Composer itself declares it as optional, see
// https://getcomposer.org/doc/04-schema.md#version.
if (version.isEmpty()) {
logger.warn { "No version information found for package $rawName." }
}

packages[rawName] = Package(
id = Identifier(
type = managerName,
namespace = rawName.substringBefore('/'),
name = rawName.substringAfter('/'),
version = version
),
authors = parseAuthors(pkgInfo),
declaredLicenses = parseDeclaredLicenses(pkgInfo),
description = pkgInfo["description"].textValueOrEmpty(),
homepageUrl = homepageUrl,
binaryArtifact = RemoteArtifact.EMPTY,
sourceArtifact = parseArtifact(pkgInfo),
vcs = vcsFromPackage,
vcsProcessed = processPackageVcs(vcsFromPackage, homepageUrl)
)
}
}

return packages
}
private fun parseInstalledPackages(lockfile: Lockfile): Map<String, Package> =
(lockfile.packages + lockfile.packagesDev).associateBy(
{ checkNotNull(it.name) },
{ it.toPackage() }
)

private fun ensureLockfile(workingDir: File): File {
val lockfile = workingDir.resolve(COMPOSER_LOCK_FILE)
Expand Down Expand Up @@ -308,39 +269,27 @@ private fun String.isPlatformDependency(): Boolean =
private val COMPOSER_PLATFORM_TYPES = setOf("composer", "composer-plugin-api", "composer-runtime-api")
private val PHP_PLATFORM_TYPES = setOf("php", "php-64bit", "php-ipv6", "php-zts", "php-debug")

private fun getRuntimeDependencies(packageName: String, lockfile: JsonNode): Sequence<String> {
listOf("packages", "packages-dev").forEach {
lockfile[it]?.forEach { packageInfo ->
if (packageInfo["name"].textValueOrEmpty() == packageName) {
val requiredPackages = packageInfo["require"]
if (requiredPackages != null && requiredPackages.isObject) {
return (requiredPackages as ObjectNode).fieldNames().asSequence()
}
}
private fun getRuntimeDependencies(packageName: String, lockfile: Lockfile): Set<String> {
(lockfile.packages + lockfile.packagesDev).forEach { packageInfo ->
if (packageInfo.name == packageName) {
return packageInfo.require.keys
}
}

return emptySequence()
return emptySet()
}

private fun parseArtifact(packageInfo: JsonNode): RemoteArtifact =
packageInfo["dist"]?.let {
val shasum = it["shasum"].textValueOrEmpty()
RemoteArtifact(it["url"].textValueOrEmpty(), Hash.create(shasum))
}.orEmpty()
private fun parseAuthors(packageInfo: PackageInfo): Set<String> =
packageInfo.authors.mapNotNullTo(mutableSetOf()) { it.name }

private fun parseAuthors(packageInfo: JsonNode): Set<String> =
packageInfo["authors"]?.mapNotNullTo(mutableSetOf()) { it["name"]?.textValue() }.orEmpty()
private fun parseDeclaredLicenses(packageInfo: PackageInfo): Set<String> = packageInfo.license.toSet()

private fun parseDeclaredLicenses(packageInfo: JsonNode): Set<String> =
packageInfo["license"]?.mapNotNullTo(mutableSetOf()) { it.textValue() }.orEmpty()

private fun parseVcsInfo(packageInfo: JsonNode): VcsInfo =
packageInfo["source"]?.let {
private fun parseVcsInfo(packageInfo: PackageInfo): VcsInfo =
packageInfo.source?.let {
VcsInfo(
type = VcsType.forName(it["type"].textValueOrEmpty()),
url = it["url"].textValueOrEmpty(),
revision = it["reference"].textValueOrEmpty()
type = VcsType.forName(it.type.orEmpty()),
url = it.url.orEmpty(),
revision = it.reference.orEmpty()
)
}.orEmpty()

Expand All @@ -355,27 +304,51 @@ private fun parseVcsInfo(packageInfo: JsonNode): VcsInfo =
*/
private fun parseVirtualPackageNames(
packages: Map<String, Package>,
manifest: JsonNode,
lockfile: JsonNode
): Set<String> {
val replacedNames = mutableSetOf<String>()

// The contents of the manifest file, which can also define replacements, is not included in the lockfile, so
// we parse the manifest file as well.
replacedNames += parseVirtualNames(manifest)

listOf("packages", "packages-dev").forEach { type ->
lockfile[type]?.flatMap { pkgInfo ->
parseVirtualNames(pkgInfo)
}?.let {
replacedNames += it
projectPackageInfo: PackageInfo,
lockfile: Lockfile
): Set<String> =
buildSet {
// The contents of the manifest file, which can also define replacements, is not included in the lockfile, so
// we parse the manifest file as well.
(lockfile.packages + lockfile.packagesDev + projectPackageInfo).flatMapTo(this) {
it.replace.keys + it.provide.keys
}

removeAll(packages.keys)
}

return replacedNames - packages.keys
private fun PackageInfo.toPackage(): Package {
val rawName = checkNotNull(name)
val version = version.orEmpty()
val homepageUrl = homepage.orEmpty()
val vcsFromPackage = parseVcsInfo(this)

return Package(
id = Identifier(
type = "Composer",
namespace = rawName.substringBefore('/'),
name = rawName.substringAfter('/'),
version = version
),
authors = parseAuthors(this),
declaredLicenses = parseDeclaredLicenses(this),
description = description.orEmpty(),
homepageUrl = homepageUrl,
binaryArtifact = RemoteArtifact.EMPTY,
sourceArtifact = dist?.let {
RemoteArtifact(
url = it.url.orEmpty(),
hash = Hash.create(it.shasum.orEmpty())
)
}.orEmpty(),
vcs = vcsFromPackage,
vcsProcessed = processPackageVcs(vcsFromPackage, homepageUrl)
)
}

private fun parseVirtualNames(packageInfo: JsonNode): Set<String> =
listOf("replace", "provide").flatMapTo(mutableSetOf()) {
packageInfo[it]?.fieldNames()?.asSequence().orEmpty()
private fun PackageInfo.getScopeDependencies(scopeName: String): Set<String> =
when (scopeName) {
SCOPE_NAME_REQUIRE -> require.keys
SCOPE_NAME_REQUIRE_DEV -> requireDev.keys
else -> error("Invalid scope name: '$scopeName'.")
}
Loading
Loading