Passed
Pull Request — 4 (#10232)
by Steve
07:04
created

Module::__unserialize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Core\Manifest;
4
5
use Composer\Semver\Semver;
6
use Exception;
7
use InvalidArgumentException;
8
use RuntimeException;
9
use Serializable;
10
use SilverStripe\Core\Path;
11
use SilverStripe\Dev\Deprecation;
12
13
/**
14
 * Abstraction of a PHP Package. Can be used to retrieve information about Silverstripe CMS modules, and other packages
15
 * managed via composer, by reading their `composer.json` file.
16
 */
17
class Module implements Serializable
18
{
19
    /**
20
     * @deprecated 4.1.0:5.0.0 Use Path::normalise() instead
21
     */
22
    const TRIM_CHARS = ' /\\';
23
24
    /**
25
     * Return value of getCIConfig() when module uses PHPUNit 9
26
     */
27
    const CI_PHPUNIT_NINE = 'CI_PHPUNIT_NINE';
28
29
    /**
30
     * Return value of getCIConfig() when module uses PHPUNit 5
31
     */
32
    const CI_PHPUNIT_FIVE = 'CI_PHPUNIT_FIVE';
33
34
    /**
35
     * Return value of getCIConfig() when module does not use any CI
36
     */
37
    const CI_UNKNOWN = 'CI_UNKNOWN';
38
39
40
41
    /**
42
     * Full directory path to this module with no trailing slash
43
     *
44
     * @var string
45
     */
46
    protected $path = null;
47
48
    /**
49
     * Base folder of application with no trailing slash
50
     *
51
     * @var string
52
     */
53
    protected $basePath = null;
54
55
    /**
56
     * Cache of composer data
57
     *
58
     * @var array
59
     */
60
    protected $composerData = null;
61
62
    /**
63
     * Loaded resources for this module
64
     *
65
     * @var ModuleResource[]
66
     */
67
    protected $resources = [];
68
69
    /**
70
     * Construct a module
71
     *
72
     * @param string $path Absolute filesystem path to this module
73
     * @param string $basePath base path for the application this module is installed in
74
     */
75
    public function __construct($path, $basePath)
76
    {
77
        $this->path = Path::normalise($path);
78
        $this->basePath = Path::normalise($basePath);
79
        $this->loadComposer();
80
    }
81
82
    /**
83
     * Gets name of this module. Used as unique key and identifier for this module.
84
     *
85
     * If installed by composer, this will be the full composer name (vendor/name).
86
     * If not installed by composer this will default to the `basedir()`
87
     *
88
     * @return string
89
     */
90
    public function getName()
91
    {
92
        return $this->getComposerName() ?: $this->getShortName();
93
    }
94
95
    /**
96
     * Get full composer name. Will be `null` if no composer.json is available
97
     *
98
     * @return string|null
99
     */
100
    public function getComposerName()
101
    {
102
        if (isset($this->composerData['name'])) {
103
            return $this->composerData['name'];
104
        }
105
        return null;
106
    }
107
108
    /**
109
     * Get list of folders that need to be made available
110
     *
111
     * @return array
112
     */
113
    public function getExposedFolders()
114
    {
115
        if (isset($this->composerData['extra']['expose'])) {
116
            return $this->composerData['extra']['expose'];
117
        }
118
        return [];
119
    }
120
121
    /**
122
     * Gets "short" name of this module. This is the base directory this module
123
     * is installed in.
124
     *
125
     * If installed in root, this will be generated from the composer name instead
126
     *
127
     * @return string
128
     */
129
    public function getShortName()
130
    {
131
        // If installed in the root directory we need to infer from composer
132
        if ($this->path === $this->basePath && $this->composerData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->composerData of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
133
            // Sometimes we customise installer name
134
            if (isset($this->composerData['extra']['installer-name'])) {
135
                return $this->composerData['extra']['installer-name'];
136
            }
137
138
            // Strip from full composer name
139
            $composerName = $this->getComposerName();
140
            if ($composerName) {
141
                list(, $name) = explode('/', $composerName);
142
                return $name;
143
            }
144
        }
145
146
        // Base name of directory
147
        return basename($this->path);
148
    }
149
150
    /**
151
     * Name of the resource directory where vendor resources should be exposed as defined by the `extra.resources-dir`
152
     * key in the composer file. A blank string will be returned if the key is undefined.
153
     *
154
     * Only applicable when reading the composer file for the main project.
155
     * @return string
156
     */
157
    public function getResourcesDir()
158
    {
159
        return isset($this->composerData['extra']['resources-dir'])
160
            ? $this->composerData['extra']['resources-dir']
161
            : '';
162
    }
163
164
    /**
165
     * Get base path for this module
166
     *
167
     * @return string Path with no trailing slash E.g. /var/www/module
168
     */
169
    public function getPath()
170
    {
171
        return $this->path;
172
    }
173
174
    /**
175
     * Get path relative to base dir.
176
     * If module path is base this will be empty string
177
     *
178
     * @return string Path with trimmed slashes. E.g. vendor/silverstripe/module.
179
     */
180
    public function getRelativePath()
181
    {
182
        if ($this->path === $this->basePath) {
183
            return '';
184
        }
185
        return substr($this->path, strlen($this->basePath) + 1);
186
    }
187
188
    public function __serialize(): array
189
    {
190
        return [
191
            'path' => $this->path,
192
            'basePath' => $this->basePath,
193
            'composerData' => $this->composerData
194
        ];
195
    }
196
197
    public function __unserialize(array $data): void
198
    {
199
            $this->path = $data['path'];
200
            $this->basePath = $data['basePath'];
201
            $this->composerData = $data['composerData'];
202
            $this->resources = [];
203
    }
204
205
    /**
206
     * The __serialize() magic method will be automatically used instead of this
207
     *
208
     * @return string
209
     * @deprecated will be removed in 5.0
210
     */
211
    public function serialize()
212
    {
213
        return json_encode([$this->path, $this->basePath, $this->composerData]);
214
    }
215
216
    /**
217
     * The __unserialize() magic method will be automatically used instead of this almost all the time
218
     * This method will be automatically used if existing serialized data was not saved as an associative array
219
     * and the PHP version used in less than PHP 9.0
220
     *
221
     * @param string $serialized
222
     * @deprecated will be removed in 5.0
223
     */
224
    public function unserialize($serialized)
225
    {
226
        list($this->path, $this->basePath, $this->composerData) = json_decode($serialized, true);
227
        $this->resources = [];
228
    }
229
230
    /**
231
     * Activate _config.php for this module, if one exists
232
     */
233
    public function activate()
234
    {
235
        $config = "{$this->path}/_config.php";
236
        if (file_exists($config)) {
237
            requireFile($config);
238
        }
239
    }
240
241
    /**
242
     * @throws Exception
243
     */
244
    protected function loadComposer()
245
    {
246
        // Load composer data
247
        $path = "{$this->path}/composer.json";
248
        if (file_exists($path)) {
249
            $content = file_get_contents($path);
250
            $result = json_decode($content, true);
251
            if (json_last_error()) {
252
                $errorMessage = json_last_error_msg();
253
                throw new Exception("$path: $errorMessage");
254
            }
255
            $this->composerData = $result;
256
        }
257
    }
258
259
    /**
260
     * Get resource for this module
261
     *
262
     * @param string $path
263
     * @return ModuleResource
264
     */
265
    public function getResource($path)
266
    {
267
        $path = Path::normalise($path, true);
268
        if (empty($path)) {
269
            throw new InvalidArgumentException('$path is required');
270
        }
271
        if (isset($this->resources[$path])) {
272
            return $this->resources[$path];
273
        }
274
        return $this->resources[$path] = new ModuleResource($this, $path);
275
    }
276
277
    /**
278
     * @deprecated 4.0.0:5.0.0 Use getResource($path)->getRelativePath() instead
279
     * @param string $path
280
     * @return string
281
     */
282
    public function getRelativeResourcePath($path)
283
    {
284
        Deprecation::notice('5.0', 'Use getResource($path)->getRelativePath() instead');
285
        return $this
286
            ->getResource($path)
287
            ->getRelativePath();
288
    }
289
290
    /**
291
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getPath() instead
292
     * @param string $path
293
     * @return string
294
     */
295
    public function getResourcePath($path)
296
    {
297
        Deprecation::notice('5.0', 'Use getResource($path)->getPath() instead');
298
        return $this
299
            ->getResource($path)
300
            ->getPath();
301
    }
302
303
    /**
304
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->getURL() instead
305
     * @param string $path
306
     * @return string
307
     */
308
    public function getResourceURL($path)
309
    {
310
        Deprecation::notice('5.0', 'Use getResource($path)->getURL() instead');
311
        return $this
312
            ->getResource($path)
313
            ->getURL();
314
    }
315
316
    /**
317
     * @deprecated 4.0.0:5.0.0 Use ->getResource($path)->exists() instead
318
     * @param string $path
319
     * @return string
320
     */
321
    public function hasResource($path)
322
    {
323
        Deprecation::notice('5.0', 'Use getResource($path)->exists() instead');
324
        return $this
325
            ->getResource($path)
326
            ->exists();
327
    }
328
329
    /**
330
     * Determine what configurations the module is using to run various aspects of its CI. THe only aspect
331
     * that is observed is `PHP`
332
     * @return array List of configuration aspects e.g.: `['PHP' => 'CI_PHPUNIT_NINE']`
333
     * @internal
334
     */
335
    public function getCIConfig(): array
336
    {
337
        return [
338
            'PHP' => $this->getPhpCiConfig()
339
        ];
340
    }
341
342
    /**
343
     * Determine what CI Configuration the module uses to test its PHP code.
344
     */
345
    private function getPhpCiConfig(): string
346
    {
347
        // We don't have any composer data at all
348
        if (empty($this->composerData)) {
349
            return self::CI_UNKNOWN;
350
        }
351
352
        // We don't have any dev dependencies
353
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
354
            return self::CI_UNKNOWN;
355
        }
356
357
        // We are assuming a typical setup where the CI lib is defined in require-dev rather than require
358
        $requireDev = $this->composerData['require-dev'];
0 ignored issues
show
Unused Code introduced by
The assignment to $requireDev is dead and can be removed.
Loading history...
359
360
        // Try to pick which CI we are using based on phpunit constraint
361
        $phpUnitConstraint = $this->requireDevConstraint(['sminnee/phpunit', 'phpunit/phpunit']);
362
        if ($phpUnitConstraint) {
363
            if ($this->constraintSatisfies(
364
                $phpUnitConstraint,
365
                ['5.7.0', '5.0.0', '5.x-dev', '5.7.x-dev'],
366
                5
367
            )) {
368
                return self::CI_PHPUNIT_FIVE;
369
            }
370
            if ($this->constraintSatisfies(
371
                $phpUnitConstraint,
372
                ['9.0.0', '9.5.0', '9.x-dev', '9.5.x-dev'],
373
                9
374
            )) {
375
                return self::CI_PHPUNIT_NINE;
376
            }
377
        }
378
379
        // Try to pick which CI we are using based on recipe-testing constraint
380
        $recipeTestingConstraint = $this->requireDevConstraint(['silverstripe/recipe-testing']);
381
        if ($recipeTestingConstraint) {
382
            if ($this->constraintSatisfies(
383
                $recipeTestingConstraint,
384
                ['1.0.0', '1.1.0', '1.2.0', '1.1.x-dev', '1.2.x-dev', '1.x-dev'],
385
                1
386
            )) {
387
                return self::CI_PHPUNIT_FIVE;
388
            }
389
            if ($this->constraintSatisfies(
390
                $recipeTestingConstraint,
391
                ['2.0.0', '2.0.x-dev', '2.x-dev'],
392
                2
393
            )) {
394
                return self::CI_PHPUNIT_NINE;
395
            }
396
        }
397
398
        return self::CI_UNKNOWN;
399
    }
400
401
    /**
402
     * Retrieve the constraint for the first module that is found in the require-dev section
403
     * @param string[] $modules
404
     * @return false|string
405
     */
406
    private function requireDevConstraint(array $modules)
407
    {
408
        if (empty($this->composerData['require-dev']) || !is_array($this->composerData['require-dev'])) {
409
            return false;
410
        }
411
412
        $requireDev = $this->composerData['require-dev'];
413
        foreach ($modules as $module) {
414
            if (isset($requireDev[$module])) {
415
                return $requireDev[$module];
416
            }
417
        }
418
419
        return false;
420
    }
421
422
    /**
423
     * Determines if the provided constraint allows at least one of the version provided
424
     */
425
    private function constraintSatisfies(
426
        string $constraint,
427
        array $possibleVersions,
428
        int $majorVersionFallback
429
    ): bool {
430
        // Let's see of any of our possible versions is allowed by the constraint
431
        if (!empty(Semver::satisfiedBy($possibleVersions, $constraint))) {
432
            return true;
433
        }
434
435
        // Let's see if we are using an exact version constraint. e.g. ~1.2.3 or 1.2.3 or ~1.2 or 1.2.*
436
        if (preg_match("/^~?$majorVersionFallback(\.(\d+)|\*){0,2}/", $constraint)) {
437
            return true;
438
        }
439
440
        return false;
441
    }
442
}
443
444
/**
445
 * Scope isolated require - prevents access to $this, and prevents module _config.php
446
 * files potentially leaking variables. Required argument $file is commented out
447
 * to avoid leaking that into _config.php
448
 *
449
 * @param string $file
450
 */
451
function requireFile()
452
{
453
    require_once func_get_arg(0);
454
}
455