Skip to content
Snippets Groups Projects
Forked from project / automatic_updates
372 commits behind the upstream repository.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
ComposerUtility.php 12.67 KiB
<?php

namespace Drupal\package_manager;

use Composer\Composer;
use Composer\Factory;
use Composer\IO\NullIO;
use Composer\Package\Loader\ValidatingArrayLoader;
use Composer\Package\PackageInterface;
use Composer\Package\Version\VersionParser;
use Composer\PartialComposer;
use Composer\Semver\Comparator;
use Drupal\Component\Serialization\Yaml;

/**
 * Defines a utility object to get information from Composer's API.
 */
class ComposerUtility {

  /**
   * The Composer instance.
   *
   * @var \Composer\Composer
   */
  protected $composer;

  /**
   * The statically cached names of the Drupal core packages.
   *
   * @var string[]
   */
  private static $corePackages;

  /**
   * Whether to raise a deprecation error when the constructor is called.
   *
   * @var bool
   */
  private static $triggerConstructorDeprecation = TRUE;

  /**
   * Constructs a new ComposerUtility object.
   *
   * @param \Composer\Composer|\Composer\PartialComposer $composer
   *   The Composer instance.
   */
  public function __construct(object $composer) {
    // @todo Remove this in https://www.drupal.org/project/automatic_updates/issues/3321474.
    if (self::$triggerConstructorDeprecation) {
      @trigger_error(__METHOD__ . '() is deprecated in automatic_updates:8.x-2.5 and will be removed in automatic_updates:3.0.0. Use ' . __CLASS__ . '::createForDirectory() instead. See https://www.drupal.org/node/3314137.', E_USER_DEPRECATED);
    }
    self::$triggerConstructorDeprecation = TRUE;

    // @todo Remove this check when either:
    //   - PHP 8 or later is required, in which case the $composer type hint
    //     should be Composer|PartialComposer.
    //   - Composer 2.3 or later is required, in which case the $composer type
    //     hint should be changed to PartialComposer.
    // @todo Update in https://www.drupal.org/project/automatic_updates/issues/3321474
    // @todo Update in https://www.drupal.org/project/automatic_updates/issues/3321476
    if (!$composer instanceof Composer && !$composer instanceof PartialComposer) {
      throw new \InvalidArgumentException('The $composer argument must be an instance of ' . Composer::class . ' or ' . PartialComposer::class);
    }
    $this->composer = $composer;
  }

  /**
   * Returns the underlying Composer instance.
   *
   * @return \Composer\Composer|\Composer\PartialComposer
   *   The Composer instance.
   */
  public function getComposer(): object {
    return $this->composer;
  }

  /**
   * Creates an instance of this class using the files in a given directory.
   *
   * @param string $dir
   *   The directory that contains composer.json and composer.lock.
   *
   * @return \Drupal\package_manager\ComposerUtility
   *   The utility object.
   *
   * @throws \InvalidArgumentException
   *   When $dir does not contain a composer.json file.
   */
  public static function createForDirectory(string $dir): self {
    $io = new NullIO();

    // Pre-load the contents of composer.json so that Factory::createComposer()
    // won't try to call realpath(), which will fail if composer.json is in a
    // virtual file system.
    $configuration = $dir . DIRECTORY_SEPARATOR . 'composer.json';
    if (file_exists($configuration)) {
      $configuration = file_get_contents($configuration);
      $configuration = json_decode($configuration, TRUE, 512, JSON_THROW_ON_ERROR);
    }

    // The Composer factory requires that either the HOME or COMPOSER_HOME
    // environment variables be set, so momentarily set the COMPOSER_HOME
    // variable to the directory we're trying to create a Composer instance for.
    // We have to do this because the Composer factory doesn't give us a way to
    // pass the home directory in.
    // @see \Composer\Factory::getHomeDir()
    $home = getenv('COMPOSER_HOME');
    // Disable the automatic generation of .htaccess files in the Composer home
    // directory, since we are temporarily overriding that directory.
    // @see \Composer\Factory::createConfig()
    // @see https://getcomposer.org/doc/06-config.md#htaccess-protect
    $htaccess = getenv('COMPOSER_HTACCESS_PROTECT');

    $factory = new Factory();
    putenv("COMPOSER_HOME=$dir");
    putenv("COMPOSER_HTACCESS_PROTECT=false");
    // Initialize the Composer API with plugins disabled and only the root
    // package loaded (i.e., nothing from the global Composer project will be
    // considered or loaded). This allows us to inspect the project directory
    // using Composer's API in a "hands-off" manner.
    $composer = $factory->createComposer($io, $configuration, TRUE, $dir, FALSE);
    putenv("COMPOSER_HOME=$home");
    putenv("COMPOSER_HTACCESS_PROTECT=$htaccess");

    self::$triggerConstructorDeprecation = FALSE;
    return new static($composer);
  }

  /**
   * Returns the canonical names of the supported core packages.
   *
   * @return string[]
   *   The canonical list of supported core package names, as listed in
   *   ../core_packages.json.
   */
  protected static function getCorePackageList(): array {
    if (self::$corePackages === NULL) {
      $file = __DIR__ . '/../core_packages.yml';
      assert(file_exists($file), "$file does not exist.");
      $core_packages = file_get_contents($file);
      $core_packages = Yaml::decode($core_packages);

      assert(is_array($core_packages), "$file did not contain a list of core packages.");
      self::$corePackages = $core_packages;
    }
    return self::$corePackages;
  }

  /**
   * Returns the installed core packages.
   *
   * All packages listed in ../core_packages.json are considered core packages.
   *
   * @return \Composer\Package\PackageInterface[]
   *   The installed core packages.
   */
  public function getCorePackages(): array {
    $core_packages = array_intersect_key(
      $this->getInstalledPackages(),
      array_flip(static::getCorePackageList())
    );

    // If drupal/core-recommended is present, it supersedes drupal/core, since
    // drupal/core will always be one of its direct dependencies.
    if (array_key_exists('drupal/core-recommended', $core_packages)) {
      unset($core_packages['drupal/core']);
    }
    return $core_packages;
  }

  /**
   * Returns information on all installed packages.
   *
   * @return \Composer\Package\PackageInterface[]
   *   All installed packages, keyed by name.
   */
  public function getInstalledPackages(): array {
    $installed_packages = $this->getComposer()
      ->getRepositoryManager()
      ->getLocalRepository()
      ->getPackages();

    $packages = [];
    foreach ($installed_packages as $package) {
      $key = $package->getName();
      $packages[$key] = $package;
    }
    return $packages;
  }

  /**
   * Returns the packages that are in the current project, but not in another.
   *
   * @param self $other
   *   A Composer utility wrapper around a different directory.
   *
   * @return \Composer\Package\PackageInterface[]
   *   The packages which are installed in the current project, but not in the
   *   other one, keyed by name.
   */
  public function getPackagesNotIn(self $other): array {
    return array_diff_key($this->getInstalledPackages(), $other->getInstalledPackages());
  }

  /**
   * Returns the packages which have a different version in another project.
   *
   * This compares the current project with another one, and returns packages
   * which are present in both, but in different versions.
   *
   * @param self $other
   *   A Composer utility wrapper around a different directory.
   *
   * @return \Composer\Package\PackageInterface[]
   *   The packages which are present in both the current project and the other
   *   one, but in different versions, keyed by name.
   */
  public function getPackagesWithDifferentVersionsIn(self $other): array {
    $theirs = $other->getInstalledPackages();

    // Only compare packages that are both here and there.
    $packages = array_intersect_key($this->getInstalledPackages(), $theirs);

    $filter = function (PackageInterface $package, string $name) use ($theirs): bool {
      return Comparator::notEqualTo($package->getVersion(), $theirs[$name]->getVersion());
    };
    return array_filter($packages, $filter, ARRAY_FILTER_USE_BOTH);
  }

  /**
   * Returns installed package data from Composer's `installed.php`.
   *
   * @return array
   *   The installed package data as represented in Composer's `installed.php`,
   *   keyed by package name.
   */
  public function getInstalledPackagesData(): array {
    $installed_php = implode(DIRECTORY_SEPARATOR, [
      // Composer returns the absolute path to the vendor directory by default.
      $this->getComposer()->getConfig()->get('vendor-dir'),
      'composer',
      'installed.php',
    ]);
    $data = include $installed_php;
    return $data['versions'];
  }

  /**
   * Returns the Drupal project name for a given Composer package.
   *
   * @param string $package_name
   *   The name of the package.
   *
   * @return string|null
   *   The name of the Drupal project installed by the package, or NULL if:
   *   - The package is not installed.
   *   - The package is not of a supported type (one of `drupal-module`,
   *     `drupal-theme`, or `drupal-profile`).
   *   - The package name does not begin with `drupal/`.
   *   - The project name could not otherwise be determined.
   */
  public function getProjectForPackage(string $package_name): ?string {
    $data = $this->getInstalledPackagesData();

    if (array_key_exists($package_name, $data)) {
      $package = $data[$package_name];

      $supported_package_types = [
        'drupal-module',
        'drupal-theme',
        'drupal-profile',
      ];
      // Only consider packages which are packaged by drupal.org and will be
      // known to the core Update module.
      if (str_starts_with($package_name, 'drupal/') && in_array($package['type'], $supported_package_types, TRUE)) {
        return $this->scanForProjectName($package['install_path']);
      }
    }
    return NULL;
  }

  /**
   * Returns the package name for a given Drupal project.
   *
   * @param string $project_name
   *   The name of the project.
   *
   * @return string|null
   *   The name of the Composer package which installs the project, or NULL if
   *   it could not be determined.
   */
  public function getPackageForProject(string $project_name): ?string {
    $installed = $this->getInstalledPackagesData();

    $installed = array_keys($installed);
    foreach ($installed as $package_name) {
      if ($this->getProjectForPackage($package_name) === $project_name) {
        return $package_name;
      }
    }
    return NULL;
  }

  /**
   * Determines whether a Composer requirement string is valid.
   *
   * @param string $requirement
   *   A requirement string, optionally with a version constraint, e.g.,
   *   "vendor/package" or "vendor/package:1.2.3", or any combination of package
   *   name and requirement string that Composer understands.
   *
   * @return bool
   *   TRUE if the requirement string is valid, FALSE otherwise.
   *
   * @see https://getcomposer.org/doc/04-schema.md#name
   * @see https://getcomposer.org/doc/articles/versions.md
   *
   * @internal
   *   This method may be changed or removed at any time without warning and
   *   should not be used by external code.
   */
  public static function isValidRequirement(string $requirement): bool {
    $version_parser = new VersionParser();
    $parts = $version_parser->parseNameVersionPairs([$requirement])[0];
    $package_name = $parts['name'];
    $version = $parts['version'] ?? NULL;

    // Validate just the package name.
    if (ValidatingArrayLoader::hasPackageNamingError($package_name)) {
      return FALSE;
    }

    // Return early if there's no version constraint to validate.
    if ($version === NULL) {
      return TRUE;
    }

    // Validate the version constraint.
    try {
      $version_parser->parseConstraints($version);
    }
    catch (\UnexpectedValueException $e) {
      return FALSE;
    }

    // All good.
    return TRUE;
  }
  /**
   * Scans a given path to determine the Drupal project name.
   *
   * The path will be scanned for `.info.yml` files containing a `project` key.
   *
   * @param string $path
   *   The path to scan.
   *
   * @return string|null
   *   The name of the project, as declared in the first found `.info.yml` which
   *   contains a `project` key, or NULL if none was found.
   */
  private function scanForProjectName(string $path): ?string {
    $iterator = new \RecursiveDirectoryIterator($path);
    $iterator = new \RecursiveIteratorIterator($iterator);
    $iterator = new \RegexIterator($iterator, '/.+\.info\.yml$/', \RecursiveRegexIterator::GET_MATCH);

    foreach ($iterator as $match) {
      $info = file_get_contents($match[0]);
      $info = Yaml::decode($info);

      if (is_string($info['project']) && !empty($info['project'])) {
        return $info['project'];
      }
    }
    return NULL;
  }

}