1<?php
2
3namespace Drupal\update;
4
5use Drupal\Core\Extension\ExtensionVersion;
6
7/**
8 * Calculates a project's security coverage information.
9 *
10 * @internal
11 *   This class implements logic to determine security coverage for Drupal core
12 *   according to Drupal core security policy. It should not be called directly.
13 */
14final class ProjectSecurityData {
15
16  /**
17   * The number of minor versions of Drupal core that receive security coverage.
18   *
19   * For example, if this value is 2 and the existing version is 9.0.1, the
20   * 9.0.x branch will receive security coverage until the release of version
21   * 9.2.0.
22   *
23   * @todo In https://www.drupal.org/node/2998285 determine if we want this
24   *   policy to be expressed in the updates.drupal.org feed, instead of relying
25   *   on a hard-coded constant.
26   *
27   * @see https://www.drupal.org/core/release-cycle-overview
28   */
29  const CORE_MINORS_WITH_SECURITY_COVERAGE = 2;
30
31  /**
32   * Define constants for versions with security coverage end dates.
33   *
34   * Two types of constants are supported:
35   * - SECURITY_COVERAGE_END_DATE_[VERSION_MAJOR]_[VERSION_MINOR]: A date in
36   *   'Y-m-d' or 'Y-m' format.
37   * - SECURITY_COVERAGE_ENDING_WARN_DATE_[VERSION_MAJOR]_[VERSION_MINOR]: A
38   *   date in 'Y-m-d' format.
39   *
40   * @see \Drupal\update\ProjectSecurityRequirement::getDateEndRequirement()
41   */
42  const SECURITY_COVERAGE_END_DATE_8_8 = '2020-12-02';
43
44  const SECURITY_COVERAGE_ENDING_WARN_DATE_8_8 = '2020-06-02';
45
46  const SECURITY_COVERAGE_END_DATE_8_9 = '2021-11';
47
48  /**
49   * The existing (currently installed) version of the project.
50   *
51   * Because this class only handles the Drupal core project, values will be
52   * semantic version numbers such as 8.8.0, 8.8.0-alpha1, or 9.0.0.
53   *
54   * @var string|null
55   */
56  protected $existingVersion;
57
58  /**
59   * Releases as returned by update_get_available().
60   *
61   * @var array
62   *
63   * Each release item in the array has metadata about that release. This class
64   * uses the keys:
65   * - status (string): The status of the release.
66   * - version (string): The version number of the release.
67   *
68   * @see update_get_available()
69   */
70  protected $releases;
71
72  /**
73   * Constructs a ProjectSecurityData object.
74   *
75   * @param string $existing_version
76   *   The existing (currently installed) version of the project.
77   * @param array $releases
78   *   Project releases as returned by update_get_available().
79   */
80  private function __construct($existing_version = NULL, array $releases = []) {
81    $this->existingVersion = $existing_version;
82    $this->releases = $releases;
83  }
84
85  /**
86   * Creates a ProjectSecurityData object from project data and releases.
87   *
88   * @param array $project_data
89   *   Project data from Drupal\update\UpdateManagerInterface::getProjects() and
90   *   processed by update_process_project_info().
91   * @param array $releases
92   *   Project releases as returned by update_get_available().
93   *
94   * @return static
95   */
96  public static function createFromProjectDataAndReleases(array $project_data, array $releases) {
97    if (!($project_data['project_type'] === 'core' && $project_data['name'] === 'drupal')) {
98      // Only Drupal core has an explicit coverage range.
99      return new static();
100    }
101    return new static($project_data['existing_version'], $releases);
102  }
103
104  /**
105   * Gets the security coverage information for a project.
106   *
107   * Currently only Drupal core is supported.
108   *
109   * @return array
110   *   The security coverage information, or an empty array if no security
111   *   information is available for the project. If security coverage is based
112   *   on release of a specific version, the array will have the following
113   *   keys:
114   *   - security_coverage_end_version (string): The minor version the existing
115   *     version will receive security coverage until.
116   *   - additional_minors_coverage (int): The number of additional minor
117   *     versions the existing version will receive security coverage.
118   *   If the security coverage is based on a specific date, the array will have
119   *   the following keys:
120   *   - security_coverage_end_date (string): The month or date security
121   *     coverage will end for the existing version. It can be in either
122   *     'YYYY-MM' or 'YYYY-MM-DD' format.
123   *   - (optional) security_coverage_ending_warn_date (string): The date, in
124   *     the format 'YYYY-MM-DD', after which a warning should be displayed
125   *     about upgrading to another version.
126   */
127  public function getCoverageInfo() {
128    if (empty($this->releases[$this->existingVersion])) {
129      // If the existing version does not have a release, we cannot get the
130      // security coverage information.
131      return [];
132    }
133    $info = [];
134    $existing_release_version = ExtensionVersion::createFromVersionString($this->existingVersion);
135
136    // Check if the installed version has a specific end date defined.
137    $version_suffix = $existing_release_version->getMajorVersion() . '_' . $this->getSemanticMinorVersion($this->existingVersion);
138    if (defined("self::SECURITY_COVERAGE_END_DATE_$version_suffix")) {
139      $info['security_coverage_end_date'] = constant("self::SECURITY_COVERAGE_END_DATE_$version_suffix");
140      $info['security_coverage_ending_warn_date'] =
141        defined("self::SECURITY_COVERAGE_ENDING_WARN_DATE_$version_suffix")
142          ? constant("self::SECURITY_COVERAGE_ENDING_WARN_DATE_$version_suffix")
143          : NULL;
144    }
145    elseif ($security_coverage_until_version = $this->getSecurityCoverageUntilVersion()) {
146      $info['security_coverage_end_version'] = $security_coverage_until_version;
147      $info['additional_minors_coverage'] = $this->getAdditionalSecurityCoveredMinors($security_coverage_until_version);
148    }
149    return $info;
150  }
151
152  /**
153   * Gets the release the current minor will receive security coverage until.
154   *
155   * For the sake of example, assume that the currently installed version of
156   * Drupal is 8.7.11 and that static::CORE_MINORS_WITH_SECURITY_COVERAGE is 2.
157   * When Drupal 8.9.0 is released, the supported minor versions will be 8.8
158   * and 8.9. At that point, Drupal 8.7 will no longer have security coverage.
159   * Therefore, this function would return "8.9.0".
160   *
161   * @todo In https://www.drupal.org/node/2998285 determine how we will know
162   *    what the final minor release of a particular major version will be. This
163   *    method should not return a version beyond that minor.
164   *
165   * @return string|null
166   *   The version the existing version will receive security coverage until or
167   *   NULL if this cannot be determined.
168   */
169  private function getSecurityCoverageUntilVersion() {
170    $existing_release_version = ExtensionVersion::createFromVersionString($this->existingVersion);
171    if (!empty($existing_release_version->getVersionExtra())) {
172      // Only full releases receive security coverage.
173      return NULL;
174    }
175
176    return $existing_release_version->getMajorVersion() . '.'
177      . ($this->getSemanticMinorVersion($this->existingVersion) + static::CORE_MINORS_WITH_SECURITY_COVERAGE)
178      . '.0';
179  }
180
181  /**
182   * Gets the number of additional minor releases with security coverage.
183   *
184   * This function compares the currently installed (existing) version of
185   * the project with two things:
186   * - The latest available official release of that project.
187   * - The target minor release where security coverage for the current release
188   *   should expire. This target release is determined by
189   *   getSecurityCoverageUntilVersion().
190   *
191   * For the sake of example, assume that the currently installed version of
192   * Drupal is 8.7.11 and that static::CORE_MINORS_WITH_SECURITY_COVERAGE is 2.
193   *
194   * Before the release of Drupal 8.8.0, this function would return 2.
195   *
196   * After the release of Drupal 8.8.0 and before the release of 8.9.0, this
197   * function would return 1 to indicate that the next minor version release
198   * will end security coverage for 8.7.
199   *
200   * When Drupal 8.9.0 is released, this function would return 0 to indicate
201   * that security coverage is over for 8.7.
202   *
203   * If the currently installed version is 9.0.0, and there is no 9.1.0 release
204   * yet, the function would return 2. Once 9.1.0 is out, it would return 1.
205   * When 9.2.0 is released, it would again return 0.
206   *
207   * Note: callers should not test this function's return value with empty()
208   * since 0 is a valid return value that has different meaning than NULL.
209   *
210   * @param string $security_covered_version
211   *   The version until which the existing version receives security coverage.
212   *
213   * @return int|null
214   *   The number of additional minor releases that receive security coverage,
215   *   or NULL if this cannot be determined.
216   *
217   * @see \Drupal\update\ProjectSecurityData\getSecurityCoverageUntilVersion()
218   */
219  private function getAdditionalSecurityCoveredMinors($security_covered_version) {
220    $security_covered_version_major = ExtensionVersion::createFromVersionString($security_covered_version)->getMajorVersion();
221    $security_covered_version_minor = $this->getSemanticMinorVersion($security_covered_version);
222    foreach ($this->releases as $release) {
223      $release_version = ExtensionVersion::createFromVersionString($release['version']);
224      if ($release_version->getMajorVersion() === $security_covered_version_major && $release['status'] === 'published' && !$release_version->getVersionExtra()) {
225        // The releases are ordered with the most recent releases first.
226        // Therefore, if we have found a published, official release with the
227        // same major version as $security_covered_version, then this release
228        // can be used to determine the latest minor.
229        $latest_minor = $this->getSemanticMinorVersion($release['version']);
230        break;
231      }
232    }
233    // If $latest_minor is set, we know that $security_covered_version_minor and
234    // $latest_minor have the same major version. Therefore, we can subtract to
235    // determine the number of additional minor releases with security coverage.
236    return isset($latest_minor) ? $security_covered_version_minor - $latest_minor : NULL;
237  }
238
239  /**
240   * Gets the minor version for a semantic version string.
241   *
242   * @param string $version
243   *   The semantic version string.
244   *
245   * @return int
246   *   The minor version as an integer.
247   */
248  private function getSemanticMinorVersion($version) {
249    return (int) (explode('.', $version)[1]);
250  }
251
252}
253