All Downloads are FREE. Search and download functionalities are using the official Maven repository.

toolkit.plugins.packagemanagers.composer-package-manager.35.0.0.source-code.Composer.kt Maven / Gradle / Ivy

/*
 * Copyright (C) 2017 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.composer

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
import org.ossreviewtoolkit.model.Package
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.VcsInfo
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.orEmpty
import org.ossreviewtoolkit.utils.common.CommandLineTool
import org.ossreviewtoolkit.utils.common.Os
import org.ossreviewtoolkit.utils.common.collectMessages
import org.ossreviewtoolkit.utils.common.splitOnWhitespace
import org.ossreviewtoolkit.utils.common.stashDirectories
import org.ossreviewtoolkit.utils.ort.showStackTrace

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

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.
 */
@Suppress("TooManyFunctions")
class Composer(
    name: String,
    analysisRoot: File,
    analyzerConfig: AnalyzerConfiguration,
    repoConfig: RepositoryConfiguration
) : PackageManager(name, analysisRoot, analyzerConfig, repoConfig), CommandLineTool {
    class Factory : AbstractPackageManagerFactory("Composer") {
        override val globsForDefinitionFiles = listOf("composer.json")

        override fun create(
            analysisRoot: File,
            analyzerConfig: AnalyzerConfiguration,
            repoConfig: RepositoryConfiguration
        ) = Composer(type, analysisRoot, analyzerConfig, repoConfig)
    }

    override fun command(workingDir: File?) =
        if (workingDir?.resolve(COMPOSER_PHAR_BINARY)?.isFile == true) {
            "php $COMPOSER_PHAR_BINARY"
        } else {
            if (Os.isWindows) {
                "composer.bat"
            } else {
                "composer"
            }
        }

    override fun getVersionArguments() = "--no-ansi --version"

    override fun transformVersion(output: String) =
        // The version string can be something like:
        // Composer version 1.5.1 2017-08-09 16:07:22
        // Composer version @package_branch_alias_version@ (1.0.0-beta2) 2016-03-27 16:00:34
        output.splitOnWhitespace().dropLast(2).last().removeSurrounding("(", ")")

    override fun getVersionRequirement(): RangesList = RangesListFactory.create(">=1.5")

    override fun beforeResolution(definitionFiles: List) {
        // If all directories we are analyzing contain a composer.phar, no global installation of Composer is required
        // and hence we skip the version check.
        if (definitionFiles.all { File(it.parentFile, COMPOSER_PHAR_BINARY).isFile }) return

        // We do not actually depend on any features specific to a version of Composer, but we still want to stick to
        // fixed versions to be sure to get consistent results.
        checkVersion()
    }

    override fun mapDefinitionFiles(definitionFiles: List): List {
        val projectFiles = definitionFiles.toMutableList()

        var index = 0
        while (index < projectFiles.size - 1) {
            val projectFile = projectFiles[index++]
            val vendorDir = projectFile.resolveSibling("vendor")
            projectFiles.subList(index, projectFiles.size).removeAll { it.startsWith(vendorDir) }
        }

        return projectFiles
    }

    override fun resolveDependencies(definitionFile: File, labels: Map): List {
        val workingDir = definitionFile.parentFile

        val projectPackageInfo = parsePackageInfo(definitionFile.readText())
        val hasDependencies = projectPackageInfo.require.isNotEmpty()

        if (!hasDependencies) {
            val project = parseProject(definitionFile, scopes = emptySet())
            val result = ProjectAnalyzerResult(project, packages = emptySet())

            return listOf(result)
        }

        val lockfile = stashDirectories(workingDir.resolve("vendor")).use { _ ->
            ensureLockfile(workingDir).let {
                logger.info { "Parsing lockfile at '$it'..." }
                parseLockfile(it.readText())
            }
        }

        val packages = (lockfile.packages + lockfile.packagesDev).associate {
            checkNotNull(it.name) to it.toPackage()
        }

        // 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 ORT to report a package which is not actually installed with the contents of
        // the "replacing" package.
        val virtualPackages = parseVirtualPackageNames(packages, projectPackageInfo, lockfile)

        val scopes = ALL_SCOPE_NAMES.mapTo(mutableSetOf()) { scopeName ->
            val requiredPackages = projectPackageInfo.getScopeDependencies(scopeName)
            val dependencies = buildDependencyTree(requiredPackages, lockfile, packages, virtualPackages)
            Scope(scopeName, dependencies)
        }

        val project = parseProject(definitionFile, scopes)
        val result = ProjectAnalyzerResult(project, packages.values.toSet())

        return listOf(result)
    }

    private fun buildDependencyTree(
        dependencies: Set,
        lockfile: Lockfile,
        packages: Map,
        virtualPackages: Set,
        dependencyBranch: List = emptyList()
    ): Set {
        val packageReferences = mutableSetOf()

        dependencies.filterNot { packageName ->
            packageName.isPlatformDependency() || packageName in virtualPackages // Virtual packages have no metadata.
        }.forEach { packageName ->
            val packageInfo = packages[packageName]
                ?: throw IOException("Could not find package info for $packageName")

            if (packageName in dependencyBranch) {
                logger.debug {
                    "Not adding circular dependency '$packageName' to the tree, it is already on this branch of the " +
                        "dependency tree: ${dependencyBranch.joinToString(" -> ")}."
                }

                return@forEach
            }

            try {
                val runtimeDependencies = getRuntimeDependencies(packageName, lockfile)
                val transitiveDependencies = buildDependencyTree(
                    runtimeDependencies, lockfile, packages, virtualPackages, dependencyBranch + packageName
                )
                packageReferences += packageInfo.toReference(dependencies = transitiveDependencies)
            } catch (e: IOException) {
                e.showStackTrace()

                packageInfo.toReference(
                    issues = listOf(
                        createAndLogIssue(
                            source = managerName,
                            message = "Could not resolve dependencies of '$packageName': ${e.collectMessages()}"
                        )
                    )
                )
            }
        }

        return packageReferences
    }

    private fun parseProject(definitionFile: File, scopes: Set): Project {
        logger.info { "Parsing project metadata from '$definitionFile'..." }

        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)

        return Project(
            id = Identifier(
                type = managerName,
                namespace = namespace,
                name = name,
                version = pkgInfo.version.orEmpty()
            ),
            definitionFilePath = VersionControlSystem.getPathInfo(definitionFile).path,
            authors = parseAuthors(pkgInfo),
            declaredLicenses = parseDeclaredLicenses(pkgInfo),
            vcs = vcs,
            vcsProcessed = processProjectVcs(definitionFile.parentFile, vcs, homepageUrl),
            homepageUrl = homepageUrl,
            scopeDependencies = scopes
        )
    }

    private fun ensureLockfile(workingDir: File): File {
        val lockfile = workingDir.resolve(COMPOSER_LOCK_FILE)

        val hasLockfile = lockfile.isFile
        requireLockfile(workingDir) { hasLockfile }
        if (hasLockfile) return lockfile

        val composerVersion = Semver(getVersion(workingDir))
        val args = listOfNotNull(
            "update",
            "--ignore-platform-reqs",
            "--no-install".takeIf { composerVersion.major >= 2 }
        )

        run(workingDir, *args.toTypedArray())

        return lockfile
    }
}

/**
 * Return whether this String denotes a type of platform dependency, see
 * https://getcomposer.org/doc/articles/composer-platform-dependencies.md#different-types-of-platform-packages.
 */
private fun String.isPlatformDependency(): Boolean =
    this in (COMPOSER_PLATFORM_TYPES + PHP_PLATFORM_TYPES) || startsWith("ext-") || startsWith("lib-")

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: Lockfile): Set {
    (lockfile.packages + lockfile.packagesDev).forEach { packageInfo ->
        if (packageInfo.name == packageName) {
            return packageInfo.require.keys
        }
    }

    return emptySet()
}

private fun parseAuthors(packageInfo: PackageInfo): Set =
    packageInfo.authors.mapNotNullTo(mutableSetOf()) { it.name }

private fun parseDeclaredLicenses(packageInfo: PackageInfo): Set = packageInfo.license.toSet()

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

/**
 * Get all names of "virtual" (replaced or provided) packages in the package or lockfile.
 *
 * While Composer also takes the versions of the virtual packages into account, we simply use priorities here. Since
 * Composer can't handle the same package in multiple version, we can assume that as soon as a package is found in
 * 'composer.lock' we can ignore any virtual package with the same name. Since the code later depends on the virtual
 * packages not accidentally containing a package which is actually installed, we make sure to only return virtual
 * packages for which are not in the installed package map.
 */
private fun parseVirtualPackageNames(
    packages: Map,
    projectPackageInfo: PackageInfo,
    lockfile: Lockfile
): Set =
    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)
    }

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 PackageInfo.getScopeDependencies(scopeName: String): Set =
    when (scopeName) {
        SCOPE_NAME_REQUIRE -> require.keys
        SCOPE_NAME_REQUIRE_DEV -> requireDev.keys
        else -> error("Invalid scope name: '$scopeName'.")
    }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy