Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 250 |
|
0.00% |
0 / 18 |
CRAP | |
0.00% |
0 / 1 |
| ForeignResourceManager | |
0.00% |
0 / 250 |
|
0.00% |
0 / 18 |
7656 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
| run | |
0.00% |
0 / 66 |
|
0.00% |
0 / 1 |
380 | |||
| generateCdx | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| getCdxFileLocation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setupTempDir | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| cacheKey | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
| cacheGet | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| cacheSet | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| fetch | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
90 | |||
| handleTypeFile | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
| handleTypeMultiFile | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
56 | |||
| handleTypeTar | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
240 | |||
| verbose | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| output | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| error | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| cleanUp | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
72 | |||
| validateLicense | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| generateCdxForModules | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
56 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\ResourceLoader; |
| 8 | |
| 9 | use Composer\Spdx\SpdxLicenses; |
| 10 | use LogicException; |
| 11 | use MediaWiki\Json\FormatJson; |
| 12 | use MediaWiki\MainConfigNames; |
| 13 | use MediaWiki\MediaWikiServices; |
| 14 | use MediaWiki\Message\Message; |
| 15 | use PharData; |
| 16 | use RecursiveDirectoryIterator; |
| 17 | use RecursiveIteratorIterator; |
| 18 | use SplFileInfo; |
| 19 | use Symfony\Component\Yaml\Yaml; |
| 20 | use 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 | */ |
| 29 | class 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 | } |