Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 250
0.00% covered (danger)
0.00%
0 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ForeignResourceManager
0.00% covered (danger)
0.00%
0 / 250
0.00% covered (danger)
0.00%
0 / 18
7656
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 / 5
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        $this->registry = Yaml::parseFile( $this->registryFile );
215        return json_encode(
216            $this->generateCdxForModules( $this->registry ),
217            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
218        );
219    }
220
221    /**
222     * Get the path to the CycloneDX file that describes the foreign resources.
223     */
224    public function getCdxFileLocation(): string {
225        return "$this->libDir/foreign-resources.cdx.json";
226    }
227
228    /**
229     * Choose the temp parent directory
230     */
231    private function setupTempDir( string $action ): void {
232        if ( $action === 'verify' ) {
233            $this->tmpParentDir = wfTempDir() . '/ForeignResourceManager';
234        } else {
235            // Use a temporary directory under the destination directory instead
236            // of wfTempDir() because PHP's rename() does not work across file
237            // systems, and the user's /tmp and $IP may be on different filesystems.
238            $this->tmpParentDir = "{$this->libDir}/.foreign/tmp";
239        }
240    }
241
242    private function cacheKey( string $src, string $integrity, string $moduleName ): string {
243        $key = $moduleName
244            . '_' . hash( 'fnv132', $integrity )
245            . '_' . hash( 'fnv132', $src )
246            // Append a readable filename to aid cache inspection and debugging
247            . '_' . basename( $src );
248        $key = preg_replace( '/[.\/+?=_-]+/', '_', $key );
249        return rtrim( $key, '_' );
250    }
251
252    private function cacheGet( string $key ): string|false {
253        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
254        return @file_get_contents( "{$this->cacheDir}/$key.data" );
255    }
256
257    private function cacheSet( string $key, mixed $data ): void {
258        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
259        @mkdir( $this->cacheDir, 0o777, true );
260        file_put_contents( "{$this->cacheDir}/$key.data", $data, LOCK_EX );
261    }
262
263    private function fetch( string $src, ?string $integrity, string $moduleName ): string {
264        if ( $integrity !== null ) {
265            $key = $this->cacheKey( $src, $integrity, $moduleName );
266            $data = $this->cacheGet( $key );
267            if ( $data ) {
268                return $data;
269            }
270        }
271
272        $services = MediaWikiServices::getInstance();
273        $req = $services->getHttpRequestFactory()
274            ->create( $src, [ 'method' => 'GET', 'followRedirects' => false ], __METHOD__ );
275        $reqStatusValue = $req->execute();
276        if ( !$reqStatusValue->isOK() ) {
277            $message = "Failed to download resource at {$src}";
278            $reqError = $reqStatusValue->getMessages( 'error' )[0] ?? null;
279            if ( $reqError !== null ) {
280                $message .= ': ' . Message::newFromSpecifier( $reqError )->inLanguage( 'en' )->plain();
281            }
282            throw new ForeignResourceNetworkException( $message );
283        }
284        if ( $req->getStatus() !== 200 ) {
285            throw new ForeignResourceNetworkException( "Unexpected HTTP {$req->getStatus()} response from {$src}" );
286        }
287        $data = $req->getContent();
288        $algo = $integrity === null ? $this->defaultAlgo : explode( '-', $integrity )[0];
289        $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
290        if ( $integrity === $actualIntegrity ) {
291            $this->verbose( "... passed integrity check for {$src}\n" );
292            $key = $this->cacheKey( $src, $actualIntegrity, $moduleName );
293            $this->cacheSet( $key, $data );
294        } elseif ( $this->action === 'make-sri' ) {
295            $this->output( "Integrity for {$src}\n\tintegrity: {$actualIntegrity}\n" );
296        } else {
297            $expectedIntegrity = $integrity ?? 'null';
298            throw new ForeignResourceNetworkException( "Integrity check failed for {$src}\n" .
299                "\tExpected: {$expectedIntegrity}\n" .
300                "\tActual: {$actualIntegrity}"
301            );
302        }
303        return $data;
304    }
305
306    private function handleTypeFile( string $moduleName, string $destDir, array $info ): void {
307        if ( !isset( $info['src'] ) ) {
308            throw new LogicException( "Module '$moduleName' must have a 'src' key." );
309        }
310        $data = $this->fetch( $info['src'], $info['integrity'] ?? null, $moduleName );
311        $dest = $info['dest'] ?? basename( $info['src'] );
312        $path = "$destDir/$dest";
313        if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
314            $this->error( "File for '$moduleName' is different.\n" );
315        }
316        if ( $this->action === 'update' ) {
317            wfMkdirParents( $destDir );
318            file_put_contents( "$destDir/$dest", $data );
319        }
320    }
321
322    private function handleTypeMultiFile( string $moduleName, string $destDir, array $info ): void {
323        if ( !isset( $info['files'] ) ) {
324            throw new LogicException( "Module '$moduleName' must have a 'files' key." );
325        }
326        foreach ( $info['files'] as $dest => $file ) {
327            if ( !isset( $file['src'] ) ) {
328                throw new LogicException( "Module '$moduleName' file '$dest' must have a 'src' key." );
329            }
330            $data = $this->fetch( $file['src'], $file['integrity'] ?? null, $moduleName );
331            $path = "$destDir/$dest";
332            if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
333                $this->error( "File '$dest' for '$moduleName' is different.\n" );
334            } elseif ( $this->action === 'update' ) {
335                wfMkdirParents( $destDir );
336                file_put_contents( "$destDir/$dest", $data );
337            }
338        }
339    }
340
341    private function handleTypeTar( string $moduleName, string $destDir, array $info, string $fileType ): void {
342        $info += [ 'src' => null, 'integrity' => null, 'dest' => null ];
343        if ( $info['src'] === null ) {
344            throw new LogicException( "Module '$moduleName' must have a 'src' key." );
345        }
346        // Download the resource to a temporary file and open it
347        $data = $this->fetch( $info['src'], $info['integrity'], $moduleName );
348        $tmpFile = "{$this->tmpParentDir}/$moduleName." . $fileType;
349        $this->verbose( "... writing '$moduleName' src to $tmpFile\n" );
350        file_put_contents( $tmpFile, $data );
351        $p = new PharData( $tmpFile );
352        $tmpDir = "{$this->tmpParentDir}/$moduleName";
353        $p->extractTo( $tmpDir );
354        unset( $data, $p );
355
356        if ( $info['dest'] === null ) {
357            // Default: Replace the entire directory
358            $toCopy = [ $tmpDir => $destDir ];
359        } else {
360            // Expand and normalise the 'dest' entries
361            $toCopy = [];
362            foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
363                // Use glob() to expand wildcards and check existence
364                $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE );
365                if ( !$fromPaths ) {
366                    throw new LogicException( "Path '$fromSubPath' of '$moduleName' not found." );
367                }
368                foreach ( $fromPaths as $fromPath ) {
369                    $toCopy[$fromPath] = $toSubPath === null
370                        ? "$destDir/" . basename( $fromPath )
371                        : "$destDir/$toSubPath/" . basename( $fromPath );
372                }
373            }
374        }
375        foreach ( $toCopy as $from => $to ) {
376            if ( $this->action === 'verify' ) {
377                $this->verbose( "... verifying $to\n" );
378                if ( is_dir( $from ) ) {
379                    $rii = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(
380                        $from,
381                        RecursiveDirectoryIterator::SKIP_DOTS
382                    ) );
383                    /** @var SplFileInfo $file */
384                    foreach ( $rii as $file ) {
385                        $remote = $file->getPathname();
386                        $local = strtr( $remote, [ $from => $to ] );
387                        if ( sha1_file( $remote ) !== sha1_file( $local ) ) {
388                            $this->error( "File '$local' is different.\n" );
389                        }
390                    }
391                } elseif ( sha1_file( $from ) !== sha1_file( $to ) ) {
392                    $this->error( "File '$to' is different.\n" );
393                }
394            } elseif ( $this->action === 'update' ) {
395                $this->verbose( "... moving $from to $to\n" );
396                wfMkdirParents( dirname( $to ) );
397                if ( !rename( $from, $to ) ) {
398                    throw new LogicException( "Could not move $from to $to." );
399                }
400            }
401        }
402    }
403
404    private function verbose( string $text ): void {
405        ( $this->verbosePrinter )( $text );
406    }
407
408    private function output( string $text ): void {
409        ( $this->infoPrinter )( $text );
410    }
411
412    private function error( string $text ): void {
413        $this->hasErrors = true;
414        ( $this->errorPrinter )( $text );
415    }
416
417    private function cleanUp(): void {
418        wfRecursiveRemoveDir( $this->tmpParentDir );
419
420        // Prune the cache of files we don't recognise.
421        $knownKeys = [];
422        foreach ( $this->registry as $module => $info ) {
423            if ( $info['type'] === 'file' || $info['type'] === 'tar' ) {
424                $knownKeys[] = $this->cacheKey( $info['src'], $info['integrity'], $module );
425            } elseif ( $info['type'] === 'multi-file' ) {
426                foreach ( $info['files'] as $file ) {
427                    $knownKeys[] = $this->cacheKey( $file['src'], $file['integrity'], $module );
428                }
429            }
430        }
431        foreach ( glob( "{$this->cacheDir}/*" ) as $cacheFile ) {
432            if ( !in_array( basename( $cacheFile, '.data' ), $knownKeys ) ) {
433                unlink( $cacheFile );
434            }
435        }
436    }
437
438    private function validateLicense( string $moduleName, array $info ): void {
439        if ( !isset( $info['license'] ) || !is_string( $info['license'] ) ) {
440            throw new LogicException(
441                "Module '$moduleName' needs a valid SPDX license; no license is currently present"
442            );
443        }
444        $licenses = new SpdxLicenses();
445        if ( !$licenses->validate( $info['license'] ) ) {
446            $this->error(
447                "Module '$moduleName' has an invalid SPDX license identifier '{$info['license']}', "
448                . "see <https://spdx.org/licenses/>.\n"
449            );
450        }
451    }
452
453    private function generateCdxForModules( array $modules ): array {
454        $cdx = [
455            '$schema' => 'http://cyclonedx.org/schema/bom-1.6.schema.json',
456            'bomFormat' => 'CycloneDX',
457            'specVersion' => '1.6',
458            'serialNumber' => 'urn:uuid:' . $this->globalIdGenerator->newUUIDv4(),
459            'version' => 1,
460            'components' => [],
461        ];
462        foreach ( $modules as $moduleName => $module ) {
463            $moduleCdx = [
464                'type' => 'library',
465                'name' => $moduleName,
466                'version' => $module['version'],
467            ];
468            if ( preg_match( '/ (AND|OR|WITH) /', $module['license'] ) ) {
469                $moduleCdx['licenses'][] = [ 'expression' => $module['license'] ];
470            } else {
471                $moduleCdx['licenses'][] = [ 'license' => [ 'id' => $module['license'] ] ];
472            }
473            if ( $module['purl'] ?? false ) {
474                $moduleCdx['purl'] = $module['purl'];
475            }
476            if ( $module['version'] ?? false ) {
477                $moduleCdx['version'] = $module['version'];
478            }
479            if ( $module['authors'] ?? false ) {
480                $moduleCdx['authors'] = array_map(
481                    static fn ( $author ) => [ 'name' => $author ],
482                    preg_split( '/,( and)? /', $module['authors'] )
483                );
484            }
485            if ( $module['homepage'] ?? false ) {
486                $moduleCdx['externalReferences'] = [ [ 'url' => $module['homepage'], 'type' => 'website' ] ];
487            }
488            $cdx['components'][] = $moduleCdx;
489        }
490        return $cdx;
491    }
492}