Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 251
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
ForeignResourceManager
0.00% covered (danger)
0.00%
0 / 251
0.00% covered (danger)
0.00%
0 / 19
7832
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 run
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
380
 generateCdx
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 generateCdxData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCdxFileLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setupTempDir
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 cacheKey
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 cacheGet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cacheSet
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 fetch
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
 handleTypeFile
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 handleTypeMultiFile
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 handleTypeTar
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
240
 verbose
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 output
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 error
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 cleanUp
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
72
 validateLicense
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 generateCdxForModules
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\ResourceLoader;
8
9use Composer\Spdx\SpdxLicenses;
10use LogicException;
11use MediaWiki\Json\FormatJson;
12use MediaWiki\MainConfigNames;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Message\Message;
15use PharData;
16use RecursiveDirectoryIterator;
17use RecursiveIteratorIterator;
18use SplFileInfo;
19use Symfony\Component\Yaml\Yaml;
20use Wikimedia\UUID\GlobalIdGenerator;
21
22/**
23 * Manage foreign resources registered with ResourceLoader.
24 *
25 * @since 1.32
26 * @ingroup ResourceLoader
27 * @see https://www.mediawiki.org/wiki/Foreign_resources
28 */
29class ForeignResourceManager {
30    private string $defaultAlgo = 'sha384';
31
32    private bool $hasErrors = false;
33
34    private string $tmpParentDir;
35
36    private string $cacheDir;
37
38    /**
39     * @var callable|Closure
40     * @phan-var callable(string):void
41     */
42    private $infoPrinter;
43
44    /**
45     * @var callable|Closure
46     * @phan-var callable(string):void
47     */
48    private $errorPrinter;
49    /**
50     * @var callable|Closure
51     * @phan-var callable(string):void
52     */
53    private $verbosePrinter;
54
55    /** @var string */
56    private $action;
57
58    /** @var array[] */
59    private $registry;
60
61    private GlobalIdGenerator $globalIdGenerator;
62
63    /**
64     * @param string $registryFile Path to YAML file
65     * @param string $libDir Path to a modules directory
66     * @param callable|null $infoPrinter Callback for printing info about the run.
67     * @param callable|null $errorPrinter Callback for printing errors from the run.
68     * @param callable|null $verbosePrinter Callback for printing extra verbose
69     *  progress information from the run.
70     */
71    public function __construct(
72        private readonly string $registryFile,
73        private readonly string $libDir,
74        ?callable $infoPrinter = null,
75        ?callable $errorPrinter = null,
76        ?callable $verbosePrinter = null
77    ) {
78        $this->globalIdGenerator = MediaWikiServices::getInstance()->getGlobalIdGenerator();
79        $this->infoPrinter = $infoPrinter ?? static function ( $_ ) {
80        };
81        $this->errorPrinter = $errorPrinter ?? $this->infoPrinter;
82        $this->verbosePrinter = $verbosePrinter ?? static function ( $_ ) {
83        };
84
85        // Support XDG_CACHE_HOME to speed up CI by avoiding repeated downloads.
86        $cacheHome = getenv( 'XDG_CACHE_HOME' );
87        if ( $cacheHome !== false ) {
88            $this->cacheDir = realpath( $cacheHome ) . '/mw-foreign';
89        } else {
90            $conf = MediaWikiServices::getInstance()->getMainConfig();
91            $cacheConf = $conf->get( MainConfigNames::CacheDirectory );
92            if ( $cacheConf !== false ) {
93                $this->cacheDir = "$cacheConf/ForeignResourceManager";
94            } else {
95                $this->cacheDir = "{$this->libDir}/.foreign/cache";
96            }
97        }
98    }
99
100    /**
101     * @throws LogicException
102     */
103    public function run( string $action, string $module ): bool {
104        $actions = [ 'update', 'verify', 'make-sri', 'make-cdx' ];
105        if ( !in_array( $action, $actions ) ) {
106            $this->error( "Invalid action.\n\nMust be one of " . implode( ', ', $actions ) . '.' );
107            return false;
108        }
109        $this->action = $action;
110        $this->setupTempDir( $action );
111
112        $this->registry = Yaml::parseFile( $this->registryFile );
113        if ( $module === 'all' ) {
114            $modules = $this->registry;
115        } elseif ( isset( $this->registry[$module] ) ) {
116            $modules = [ $module => $this->registry[$module] ];
117        } else {
118            $this->error( "Unknown module name.\n\nMust be one of:\n" .
119                wordwrap( implode( ', ', array_keys( $this->registry ) ), 80 ) .
120                '.'
121            );
122            return false;
123        }
124
125        if ( $this->action === 'make-cdx' ) {
126            $cdxJson = FormatJson::encode(
127                $this->generateCdxForModules( $modules ),
128                "\t",
129                FormatJson::UTF8_OK
130            );
131
132            if ( $cdxJson === false ) {
133                $this->error( 'json_encode() returned false.' );
134                return false;
135            }
136
137            $cdxFile = $this->getCdxFileLocation();
138            file_put_contents( $cdxFile, $cdxJson );
139            $this->output( "Created CycloneDX file at $cdxFile\n" );
140            return true;
141        }
142
143        foreach ( $modules as $moduleName => $info ) {
144            $this->verbose( "\n### {$moduleName}\n\n" );
145
146            if ( $this->action === 'update' ) {
147                $this->output( "... updating '{$moduleName}'\n" );
148            } elseif ( $this->action === 'verify' ) {
149                $this->output( "... verifying '{$moduleName}'\n" );
150            } else {
151                $this->output( "... checking '{$moduleName}'\n" );
152            }
153
154            // Do checks on YAML content (such as license existence, validity and type keys)
155            // before doing any potentially destructive actions (potentially deleting directories,
156            // depending on the action.
157
158            if ( !isset( $info['type'] ) ) {
159                throw new LogicException( "Module '$moduleName' must have a 'type' key." );
160            }
161
162            $this->validateLicense( $moduleName, $info );
163
164            if ( $info['type'] === 'doc-only' ) {
165                $this->output( "... {$moduleName} is documentation-only, skipping integrity checks.\n" );
166                continue;
167            }
168
169            $destDir = "{$this->libDir}/$moduleName";
170
171            if ( $this->action === 'update' ) {
172                $this->verbose( "... emptying directory for $moduleName\n" );
173                wfRecursiveRemoveDir( $destDir );
174            }
175
176            $this->verbose( "... preparing {$this->tmpParentDir}\n" );
177            wfRecursiveRemoveDir( $this->tmpParentDir );
178            if ( !wfMkdirParents( $this->tmpParentDir ) ) {
179                throw new LogicException( "Unable to create {$this->tmpParentDir}" );
180            }
181
182            switch ( $info['type'] ) {
183                case 'tar':
184                case 'zip':
185                    $this->handleTypeTar( $moduleName, $destDir, $info, $info['type'] );
186                    break;
187                case 'file':
188                    $this->handleTypeFile( $moduleName, $destDir, $info );
189                    break;
190                case 'multi-file':
191                    $this->handleTypeMultiFile( $moduleName, $destDir, $info );
192                    break;
193                default:
194                    throw new LogicException( "Unknown type '{$info['type']}' for '$moduleName'" );
195            }
196        }
197
198        $this->cleanUp();
199        if ( $this->hasErrors ) {
200            // The "verify" action should check all modules and files and fail after, not during.
201            // We don't throw on the first issue so that developers enjoy access to all actionable
202            // information at once (given we can't have cascading errors).
203            // The "verify" action prints errors along the way and simply exits here.
204            return false;
205        }
206
207        return true;
208    }
209
210    /**
211     * Returns a JSON string describing the foreign resources in a CycloneDX format.
212     */
213    public function generateCdx(): string {
214        return json_encode(
215            $this->generateCdxData(),
216            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
217        );
218    }
219
220    /**
221     * Returns an array describing the foreign resources in the CycloneDX format.
222     * @since 1.46
223     */
224    public function generateCdxData(): array {
225        $this->registry = Yaml::parseFile( $this->registryFile );
226        return $this->generateCdxForModules( $this->registry );
227    }
228
229    /**
230     * Get the path to the CycloneDX file that describes the foreign resources.
231     */
232    public function getCdxFileLocation(): string {
233        return "$this->libDir/foreign-resources.cdx.json";
234    }
235
236    /**
237     * Choose the temp parent directory
238     */
239    private function setupTempDir( string $action ): void {
240        if ( $action === 'verify' ) {
241            $this->tmpParentDir = wfTempDir() . '/ForeignResourceManager';
242        } else {
243            // Use a temporary directory under the destination directory instead
244            // of wfTempDir() because PHP's rename() does not work across file
245            // systems, and the user's /tmp and $IP may be on different filesystems.
246            $this->tmpParentDir = "{$this->libDir}/.foreign/tmp";
247        }
248    }
249
250    private function cacheKey( string $src, string $integrity, string $moduleName ): string {
251        $key = $moduleName
252            . '_' . hash( 'fnv132', $integrity )
253            . '_' . hash( 'fnv132', $src )
254            // Append a readable filename to aid cache inspection and debugging
255            . '_' . basename( $src );
256        $key = preg_replace( '/[.\/+?=_-]+/', '_', $key );
257        return rtrim( $key, '_' );
258    }
259
260    private function cacheGet( string $key ): string|false {
261        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
262        return @file_get_contents( "{$this->cacheDir}/$key.data" );
263    }
264
265    private function cacheSet( string $key, mixed $data ): void {
266        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
267        @mkdir( $this->cacheDir, 0o777, true );
268        file_put_contents( "{$this->cacheDir}/$key.data", $data, LOCK_EX );
269    }
270
271    private function fetch( string $src, ?string $integrity, string $moduleName ): string {
272        if ( $integrity !== null ) {
273            $key = $this->cacheKey( $src, $integrity, $moduleName );
274            $data = $this->cacheGet( $key );
275            if ( $data ) {
276                return $data;
277            }
278        }
279
280        $services = MediaWikiServices::getInstance();
281        $req = $services->getHttpRequestFactory()
282            ->create( $src, [ 'method' => 'GET', 'followRedirects' => false ], __METHOD__ );
283        $reqStatusValue = $req->execute();
284        if ( !$reqStatusValue->isOK() ) {
285            $message = "Failed to download resource at {$src}";
286            $reqError = $reqStatusValue->getMessages( 'error' )[0] ?? null;
287            if ( $reqError !== null ) {
288                $message .= ': ' . Message::newFromSpecifier( $reqError )->inLanguage( 'en' )->plain();
289            }
290            throw new ForeignResourceNetworkException( $message );
291        }
292        if ( $req->getStatus() !== 200 ) {
293            throw new ForeignResourceNetworkException( "Unexpected HTTP {$req->getStatus()} response from {$src}" );
294        }
295        $data = $req->getContent();
296        $algo = $integrity === null ? $this->defaultAlgo : explode( '-', $integrity )[0];
297        $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
298        if ( $integrity === $actualIntegrity ) {
299            $this->verbose( "... passed integrity check for {$src}\n" );
300            $key = $this->cacheKey( $src, $actualIntegrity, $moduleName );
301            $this->cacheSet( $key, $data );
302        } elseif ( $this->action === 'make-sri' ) {
303            $this->output( "Integrity for {$src}\n\tintegrity: {$actualIntegrity}\n" );
304        } else {
305            $expectedIntegrity = $integrity ?? 'null';
306            throw new ForeignResourceNetworkException( "Integrity check failed for {$src}\n" .
307                "\tExpected: {$expectedIntegrity}\n" .
308                "\tActual: {$actualIntegrity}"
309            );
310        }
311        return $data;
312    }
313
314    private function handleTypeFile( string $moduleName, string $destDir, array $info ): void {
315        if ( !isset( $info['src'] ) ) {
316            throw new LogicException( "Module '$moduleName' must have a 'src' key." );
317        }
318        $data = $this->fetch( $info['src'], $info['integrity'] ?? null, $moduleName );
319        $dest = $info['dest'] ?? basename( $info['src'] );
320        $path = "$destDir/$dest";
321        if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
322            $this->error( "File for '$moduleName' is different.\n" );
323        }
324        if ( $this->action === 'update' ) {
325            wfMkdirParents( $destDir );
326            file_put_contents( "$destDir/$dest", $data );
327        }
328    }
329
330    private function handleTypeMultiFile( string $moduleName, string $destDir, array $info ): void {
331        if ( !isset( $info['files'] ) ) {
332            throw new LogicException( "Module '$moduleName' must have a 'files' key." );
333        }
334        foreach ( $info['files'] as $dest => $file ) {
335            if ( !isset( $file['src'] ) ) {
336                throw new LogicException( "Module '$moduleName' file '$dest' must have a 'src' key." );
337            }
338            $data = $this->fetch( $file['src'], $file['integrity'] ?? null, $moduleName );
339            $path = "$destDir/$dest";
340            if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
341                $this->error( "File '$dest' for '$moduleName' is different.\n" );
342            } elseif ( $this->action === 'update' ) {
343                wfMkdirParents( $destDir );
344                file_put_contents( "$destDir/$dest", $data );
345            }
346        }
347    }
348
349    private function handleTypeTar( string $moduleName, string $destDir, array $info, string $fileType ): void {
350        $info += [ 'src' => null, 'integrity' => null, 'dest' => null ];
351        if ( $info['src'] === null ) {
352            throw new LogicException( "Module '$moduleName' must have a 'src' key." );
353        }
354        // Download the resource to a temporary file and open it
355        $data = $this->fetch( $info['src'], $info['integrity'], $moduleName );
356        $tmpFile = "{$this->tmpParentDir}/$moduleName." . $fileType;
357        $this->verbose( "... writing '$moduleName' src to $tmpFile\n" );
358        file_put_contents( $tmpFile, $data );
359        $p = new PharData( $tmpFile );
360        $tmpDir = "{$this->tmpParentDir}/$moduleName";
361        $p->extractTo( $tmpDir );
362        unset( $data, $p );
363
364        if ( $info['dest'] === null ) {
365            // Default: Replace the entire directory
366            $toCopy = [ $tmpDir => $destDir ];
367        } else {
368            // Expand and normalise the 'dest' entries
369            $toCopy = [];
370            foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
371                // Use glob() to expand wildcards and check existence
372                $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE );
373                if ( !$fromPaths ) {
374                    throw new LogicException( "Path '$fromSubPath' of '$moduleName' not found." );
375                }
376                foreach ( $fromPaths as $fromPath ) {
377                    $toCopy[$fromPath] = $toSubPath === null
378                        ? "$destDir/" . basename( $fromPath )
379                        : "$destDir/$toSubPath/" . basename( $fromPath );
380                }
381            }
382        }
383        foreach ( $toCopy as $from => $to ) {
384            if ( $this->action === 'verify' ) {
385                $this->verbose( "... verifying $to\n" );
386                if ( is_dir( $from ) ) {
387                    $rii = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(
388                        $from,
389                        RecursiveDirectoryIterator::SKIP_DOTS
390                    ) );
391                    /** @var SplFileInfo $file */
392                    foreach ( $rii as $file ) {
393                        $remote = $file->getPathname();
394                        $local = strtr( $remote, [ $from => $to ] );
395                        if ( sha1_file( $remote ) !== sha1_file( $local ) ) {
396                            $this->error( "File '$local' is different.\n" );
397                        }
398                    }
399                } elseif ( sha1_file( $from ) !== sha1_file( $to ) ) {
400                    $this->error( "File '$to' is different.\n" );
401                }
402            } elseif ( $this->action === 'update' ) {
403                $this->verbose( "... moving $from to $to\n" );
404                wfMkdirParents( dirname( $to ) );
405                if ( !rename( $from, $to ) ) {
406                    throw new LogicException( "Could not move $from to $to." );
407                }
408            }
409        }
410    }
411
412    private function verbose( string $text ): void {
413        ( $this->verbosePrinter )( $text );
414    }
415
416    private function output( string $text ): void {
417        ( $this->infoPrinter )( $text );
418    }
419
420    private function error( string $text ): void {
421        $this->hasErrors = true;
422        ( $this->errorPrinter )( $text );
423    }
424
425    private function cleanUp(): void {
426        wfRecursiveRemoveDir( $this->tmpParentDir );
427
428        // Prune the cache of files we don't recognise.
429        $knownKeys = [];
430        foreach ( $this->registry as $module => $info ) {
431            if ( $info['type'] === 'file' || $info['type'] === 'tar' ) {
432                $knownKeys[] = $this->cacheKey( $info['src'], $info['integrity'], $module );
433            } elseif ( $info['type'] === 'multi-file' ) {
434                foreach ( $info['files'] as $file ) {
435                    $knownKeys[] = $this->cacheKey( $file['src'], $file['integrity'], $module );
436                }
437            }
438        }
439        foreach ( glob( "{$this->cacheDir}/*" ) as $cacheFile ) {
440            if ( !in_array( basename( $cacheFile, '.data' ), $knownKeys ) ) {
441                unlink( $cacheFile );
442            }
443        }
444    }
445
446    private function validateLicense( string $moduleName, array $info ): void {
447        if ( !isset( $info['license'] ) || !is_string( $info['license'] ) ) {
448            throw new LogicException(
449                "Module '$moduleName' needs a valid SPDX license; no license is currently present"
450            );
451        }
452        $licenses = new SpdxLicenses();
453        if ( !$licenses->validate( $info['license'] ) ) {
454            $this->error(
455                "Module '$moduleName' has an invalid SPDX license identifier '{$info['license']}', "
456                . "see <https://spdx.org/licenses/>.\n"
457            );
458        }
459    }
460
461    private function generateCdxForModules( array $modules ): array {
462        $cdx = [
463            '$schema' => 'http://cyclonedx.org/schema/bom-1.6.schema.json',
464            'bomFormat' => 'CycloneDX',
465            'specVersion' => '1.6',
466            'serialNumber' => 'urn:uuid:' . $this->globalIdGenerator->newUUIDv4(),
467            'version' => 1,
468            'components' => [],
469        ];
470        foreach ( $modules as $moduleName => $module ) {
471            $moduleCdx = [
472                'type' => 'library',
473                'name' => $moduleName,
474                'version' => $module['version'],
475            ];
476            if ( preg_match( '/ (AND|OR|WITH) /', $module['license'] ) ) {
477                $moduleCdx['licenses'][] = [ 'expression' => $module['license'] ];
478            } else {
479                $moduleCdx['licenses'][] = [ 'license' => [ 'id' => $module['license'] ] ];
480            }
481            if ( $module['purl'] ?? false ) {
482                $moduleCdx['purl'] = $module['purl'];
483            }
484            if ( $module['version'] ?? false ) {
485                $moduleCdx['version'] = (string)$module['version'];
486            }
487            if ( $module['authors'] ?? false ) {
488                $moduleCdx['authors'] = array_map(
489                    static fn ( $author ) => [ 'name' => $author ],
490                    preg_split( '/,( and)? /', $module['authors'] )
491                );
492            }
493            if ( $module['homepage'] ?? false ) {
494                $moduleCdx['externalReferences'] = [ [ 'url' => $module['homepage'], 'type' => 'website' ] ];
495            }
496            $cdx['components'][] = $moduleCdx;
497        }
498        return $cdx;
499    }
500}