Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
141 / 141
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
VersionChecker
100.00% covered (success)
100.00%
141 / 141
100.00% covered (success)
100.00%
7 / 7
37
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setLoadedExtensionsAndSkins
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCoreVersion
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setPhpVersion
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 checkArray
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
23
 handleDependency
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 handleExtensionDependency
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use Composer\Semver\Constraint\Constraint;
22use Composer\Semver\VersionParser;
23
24/**
25 * Check whether extensions and their dependencies meet certain version requirements.
26 *
27 * @since 1.29
28 * @ingroup ExtensionRegistry
29 * @author Legoktm
30 * @author Florian Schmidt
31 */
32class VersionChecker {
33    /**
34     * @var Constraint|bool representing MediaWiki core
35     */
36    private $coreVersion = false;
37
38    /**
39     * @var Constraint|bool representing the PHP engine
40     */
41    private $phpVersion = false;
42
43    /**
44     * @var string[] List of installed PHP extensions
45     */
46    private $phpExtensions;
47
48    /**
49     * @var bool[] List of provided abilities
50     */
51    private $abilities;
52
53    /**
54     * @var string[] List of provided ability errors
55     */
56    private $abilityErrors;
57
58    /**
59     * @var array Loaded extensions
60     */
61    private $loaded = [];
62
63    /**
64     * @var VersionParser
65     */
66    private $versionParser;
67
68    /**
69     * @param string $coreVersion Current version of core
70     * @param string $phpVersion Current PHP version
71     * @param string[] $phpExtensions List of installed PHP extensions
72     * @param bool[] $abilities List of provided abilities
73     * @param string[] $abilityErrors Error messages for the abilities
74     */
75    public function __construct(
76        $coreVersion, $phpVersion, array $phpExtensions,
77        array $abilities = [], array $abilityErrors = []
78    ) {
79        $this->versionParser = new VersionParser();
80        $this->setCoreVersion( $coreVersion );
81        $this->setPhpVersion( $phpVersion );
82        $this->phpExtensions = $phpExtensions;
83        $this->abilities = $abilities;
84        $this->abilityErrors = $abilityErrors;
85    }
86
87    /**
88     * Set an array with credits of all loaded extensions and skins.
89     *
90     * @param array $credits An array of installed extensions with credits of them
91     * @return VersionChecker $this
92     */
93    public function setLoadedExtensionsAndSkins( array $credits ) {
94        $this->loaded = $credits;
95
96        return $this;
97    }
98
99    /**
100     * Set MediaWiki core version.
101     *
102     * @param string $coreVersion Current version of core
103     */
104    private function setCoreVersion( $coreVersion ) {
105        try {
106            $this->coreVersion = new Constraint(
107                '==',
108                $this->versionParser->normalize( $coreVersion )
109            );
110            $this->coreVersion->setPrettyString( $coreVersion );
111        } catch ( UnexpectedValueException $e ) {
112            // Non-parsable version, don't fatal.
113        }
114    }
115
116    /**
117     * @param string $phpVersion Current PHP version. Must be well-formed.
118     * @throws UnexpectedValueException
119     */
120    private function setPhpVersion( $phpVersion ) {
121        // normalize to make this throw an exception if the version is invalid
122        $this->phpVersion = new Constraint(
123            '==',
124            $this->versionParser->normalize( $phpVersion )
125        );
126        $this->phpVersion->setPrettyString( $phpVersion );
127    }
128
129    /**
130     * Check all given dependencies if they are compatible with the named
131     * installed extensions in the $credits array.
132     *
133     * Example $extDependencies:
134     *     {
135     *       'FooBar' => {
136     *         'MediaWiki' => '>= 1.25.0',
137     *         'platform': {
138     *           'php': '>= 7.0.0',
139     *           'ext-foo': '*',
140     *           'ability-bar': true
141     *         },
142     *         'extensions' => {
143     *           'FooBaz' => '>= 1.25.0'
144     *         },
145     *         'skins' => {
146     *           'BazBar' => '>= 1.0.0'
147     *         }
148     *       }
149     *     }
150     *
151     * @param array $extDependencies All extensions that depend on other ones
152     * @return array[] List of errors
153     */
154    public function checkArray( array $extDependencies ) {
155        $errors = [];
156        foreach ( $extDependencies as $extension => $dependencies ) {
157            foreach ( $dependencies as $dependencyType => $values ) {
158                switch ( $dependencyType ) {
159                    case ExtensionRegistry::MEDIAWIKI_CORE:
160                        $mwError = $this->handleDependency(
161                            $this->coreVersion,
162                            $values
163                        );
164                        if ( $mwError !== false ) {
165                            $errors[] = [
166                                'msg' =>
167                                    "{$extension} is not compatible with the current MediaWiki "
168                                    . "core (version {$this->coreVersion->getPrettyString()}), "
169                                    . "it requires: $values.",
170                                'type' => 'incompatible-core',
171                            ];
172                        }
173                        break;
174                    case 'platform':
175                        foreach ( $values as $dependency => $constraint ) {
176                            if ( $dependency === 'php' ) {
177                                // PHP version
178                                $phpError = $this->handleDependency(
179                                    $this->phpVersion,
180                                    $constraint
181                                );
182                                if ( $phpError !== false ) {
183                                    $errors[] = [
184                                        'msg' =>
185                                            "{$extension} is not compatible with the current PHP "
186                                            . "version {$this->phpVersion->getPrettyString()}), "
187                                            . "it requires: $constraint.",
188                                        'type' => 'incompatible-php',
189                                    ];
190                                }
191                            } elseif ( substr( $dependency, 0, 4 ) === 'ext-' ) {
192                                // PHP extensions
193                                $phpExtension = substr( $dependency, 4 );
194                                if ( $constraint !== '*' ) {
195                                    throw new UnexpectedValueException( 'Version constraints for '
196                                        . 'PHP extensions are not supported in ' . $extension );
197                                }
198                                if ( !in_array( $phpExtension, $this->phpExtensions, true ) ) {
199                                    $errors[] = [
200                                        'msg' =>
201                                            "{$extension} requires {$phpExtension} PHP extension "
202                                            . "to be installed.",
203                                        'type' => 'missing-phpExtension',
204                                        'missing' => $phpExtension,
205                                    ];
206                                }
207                            } elseif ( substr( $dependency, 0, 8 ) === 'ability-' ) {
208                                // Other abilities the environment might provide.
209                                $ability = substr( $dependency, 8 );
210                                if ( !isset( $this->abilities[$ability] ) ) {
211                                    throw new UnexpectedValueException( 'Dependency type '
212                                    . $dependency . ' unknown in ' . $extension );
213                                }
214                                if ( !is_bool( $constraint ) ) {
215                                    throw new UnexpectedValueException( 'Only booleans are '
216                                        . 'allowed to to indicate the presence of abilities '
217                                        . 'in ' . $extension );
218                                }
219
220                                if ( $constraint &&
221                                    $this->abilities[$ability] !== true
222                                ) {
223                                    // add custom error message for missing ability if specified
224                                    $customMessage = '';
225                                    if ( isset( $this->abilityErrors[$ability] ) ) {
226                                        $customMessage = ': ' . $this->abilityErrors[$ability];
227                                    }
228
229                                    $errors[] = [
230                                        'msg' =>
231                                            "{$extension} requires \"{$ability}\" ability"
232                                            . $customMessage,
233                                        'type' => 'missing-ability',
234                                        'missing' => $ability,
235                                    ];
236                                }
237                            } else {
238                                // add other platform dependencies here
239                                throw new UnexpectedValueException( 'Dependency type ' . $dependency .
240                                    ' unknown in ' . $extension );
241                            }
242                        }
243                        break;
244                    case 'extensions':
245                    case 'skins':
246                        foreach ( $values as $dependency => $constraint ) {
247                            $extError = $this->handleExtensionDependency(
248                                $dependency, $constraint, $extension, $dependencyType
249                            );
250                            if ( $extError !== false ) {
251                                $errors[] = $extError;
252                            }
253                        }
254                        break;
255                    default:
256                        throw new UnexpectedValueException( 'Dependency type ' . $dependencyType .
257                            ' unknown in ' . $extension );
258                }
259            }
260        }
261
262        return $errors;
263    }
264
265    /**
266     * Handle a simple dependency to MediaWiki core or PHP. See handleMediaWikiDependency and
267     * handlePhpDependency for details.
268     *
269     * @param Constraint|false $version The version installed
270     * @param string $constraint The required version constraint for this dependency
271     * @return bool false if no error, true else
272     */
273    private function handleDependency( $version, $constraint ) {
274        if ( $version === false ) {
275            // Couldn't parse the version, so we can't check anything
276            return false;
277        }
278
279        // if the installed and required version are compatible, return an empty array
280        if ( $this->versionParser->parseConstraints( $constraint )
281            ->matches( $version ) ) {
282            return false;
283        }
284
285        return true;
286    }
287
288    /**
289     * Handle a dependency to another extension.
290     *
291     * @param string $dependencyName The name of the dependency
292     * @param string $constraint The required version constraint for this dependency
293     * @param string $checkedExt The Extension, which depends on this dependency
294     * @param string $type Either 'extensions' or 'skins'
295     * @return bool|array false for no errors, or an array of info
296     */
297    private function handleExtensionDependency( $dependencyName, $constraint, $checkedExt,
298        $type
299    ) {
300        // Check if the dependency is even installed
301        if ( !isset( $this->loaded[$dependencyName] ) ) {
302            return [
303                'msg' => "{$checkedExt} requires {$dependencyName} to be installed.",
304                'type' => "missing-$type",
305                'missing' => $dependencyName,
306            ];
307        }
308        if ( $constraint === '*' ) {
309            // short-circuit since any version is OK.
310            return false;
311        }
312        // Check if the dependency has specified a version
313        if ( !isset( $this->loaded[$dependencyName]['version'] ) ) {
314            $msg = "{$dependencyName} does not expose its version, but {$checkedExt}"
315                . " requires: {$constraint}.";
316            return [
317                'msg' => $msg,
318                'type' => "incompatible-$type",
319                'incompatible' => $checkedExt,
320            ];
321        } else {
322            // Try to get a constraint for the dependency version
323            try {
324                $installedVersion = new Constraint(
325                    '==',
326                    $this->versionParser->normalize( $this->loaded[$dependencyName]['version'] )
327                );
328            } catch ( UnexpectedValueException $e ) {
329                // Non-parsable version, output an error message that the version
330                // string is invalid
331                return [
332                    'msg' => "$dependencyName does not have a valid version string.",
333                    'type' => 'invalid-version',
334                ];
335            }
336            // Check if the constraint actually matches...
337            if (
338                !$this->versionParser->parseConstraints( $constraint )->matches( $installedVersion )
339            ) {
340                $msg = "{$checkedExt} is not compatible with the current "
341                    . "installed version of {$dependencyName} "
342                    . "({$this->loaded[$dependencyName]['version']}), "
343                    . "it requires: " . $constraint . '.';
344                return [
345                    'msg' => $msg,
346                    'type' => "incompatible-$type",
347                    'incompatible' => $checkedExt,
348                ];
349            }
350        }
351
352        return false;
353    }
354}