diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index f69e3a2b3..b5e00481f 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -60,6 +60,12 @@ jobs: name: manifest path: vendor/wp-cli/wp-cli/manifest.json + # Prefixes the composer/composer dependency tree so the Phar stops + # imposing its own psr/log, Symfony and React versions on the site it + # runs against. See https://github.com/wp-cli/wp-cli/issues/5920 + - name: Prefix bundled dependencies + run: php utils/scope-dependencies.php + - name: Build the Phar file run: php -dphar.readonly=0 utils/make-phar.php wp-cli.phar --version=$CLI_VERSION diff --git a/features/dependency-isolation.feature b/features/dependency-isolation.feature new file mode 100644 index 000000000..2129ce9dd --- /dev/null +++ b/features/dependency-isolation.feature @@ -0,0 +1,98 @@ +Feature: Bundled dependencies do not conflict with the site's own + + # WP-CLI's autoloader is registered before WordPress boots, so for any class + # shipped both by the Phar and by the site, the Phar's copy wins and is + # imposed on the site. Prefixing the `composer/composer` dependency tree stops + # the Phar from claiming those names at all. + # + # These scenarios run against the built Phar, which is the only artifact the + # prefixing applies to; a Composer-based installation resolves its own + # dependency versions and has no conflict to avoid. + # + # See https://github.com/wp-cli/wp-cli/issues/5920 + + Scenario: A site providing its own psr/log is not broken by the bundled one + Given a WP installation + # Stands in for a site that ships psr/log v3 through its own vendor + # directory, as anything depending on monolog/monolog does. The typed + # signatures are incompatible with the psr/log v1 that composer/composer + # resolves to under the Phar's PHP 7.2 platform requirement, so whichever + # copy of the interface loads first decides whether this fatals. + And a wp-content/mu-plugins/site-logger.php file: + """ + */utils/get-package-require-from-composer\.php$ */utils/make-phar\.php$ + */utils/scope-dependencies\.php$ + */utils/scoper/scoper\.inc\.php$ */utils/get-package-require-from-composer\.php$ */utils/make-phar\.php$ + */utils/scope-dependencies\.php$ + */utils/scoper/scoper\.inc\.php$ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a1f53a4d5..10e149d75 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,6 +3,11 @@ parameters: paths: - php - utils + excludePaths: + analyse: + # Isolated toolchain with its own composer.json; its dependencies are not + # installed in this project's vendor directory. + - utils/scoper/scoper.inc.php scanDirectories: - vendor/wp-cli/wp-cli scanFiles: diff --git a/utils/make-phar.php b/utils/make-phar.php index 8aee6a586..dec33d651 100644 --- a/utils/make-phar.php +++ b/utils/make-phar.php @@ -18,7 +18,6 @@ require WP_CLI_VENDOR_DIR . '/autoload.php'; require WP_CLI_ROOT . '/php/utils.php'; -use Symfony\Component\Finder\Finder; use WP_CLI\Utils; use WP_CLI\Configurator; @@ -190,7 +189,21 @@ function get_composer_versions( $current_version ) { $phar->startBuffering(); // PHP files -$finder = new Finder(); +/* + * `utils/scope-dependencies.php` prefixes symfony/finder along with the rest + * of the Composer tree, so the class this build script itself relies on moves + * depending on whether prefixing has already run. + */ +$finder_class = class_exists( 'Symfony\\Component\\Finder\\Finder' ) + ? 'Symfony\\Component\\Finder\\Finder' + : 'WP_CLI\\Vendor\\Symfony\\Component\\Finder\\Finder'; + +if ( ! class_exists( $finder_class ) ) { + fwrite( STDERR, 'Missing Symfony Finder; run `composer install` first.' . PHP_EOL ); + exit( 1 ); +} + +$finder = new $finder_class(); $finder ->files() ->ignoreVCS( true ) @@ -265,7 +278,7 @@ function get_composer_versions( $current_version ) { } // other files -$finder = new Finder(); +$finder = new $finder_class(); $finder ->files() ->ignoreVCS( true ) @@ -280,7 +293,7 @@ function get_composer_versions( $current_version ) { if ( 'cli' !== BUILD ) { // Include base project files, because the autoloader will load them if ( WP_CLI_BASE_PATH !== WP_CLI_BUNDLE_ROOT && is_dir( WP_CLI_BASE_PATH . '/src' ) ) { - $finder = new Finder(); + $finder = new $finder_class(); $finder ->files() ->ignoreVCS( true ) diff --git a/utils/scope-dependencies.php b/utils/scope-dependencies.php new file mode 100644 index 000000000..dac204e37 --- /dev/null +++ b/utils/scope-dependencies.php @@ -0,0 +1,318 @@ +] [--quiet] + * + * @see https://github.com/wp-cli/wp-cli/issues/5920 + */ + +declare( strict_types=1 ); + +define( 'WP_CLI_BUNDLE_ROOT', rtrim( dirname( __DIR__ ), '/' ) ); + +/** + * Vendor directories handed to php-scoper. Keep in sync with the finders in + * `utils/scoper/scoper.inc.php`. + */ +const SCOPED_VENDOR_DIRS = [ + 'composer', + 'justinrainbow', + 'marc-mabe', // spellchecker:disable-line + 'psr', + 'react', + 'seld', + 'symfony', +]; + +$options = getopt( '', [ 'vendor-dir::', 'quiet' ] ); +$be_quiet = isset( $options['quiet'] ); +$vendor_dir = isset( $options['vendor-dir'] ) && is_string( $options['vendor-dir'] ) + ? rtrim( $options['vendor-dir'], '/' ) + : WP_CLI_BUNDLE_ROOT . '/vendor'; + +$scoper_dir = WP_CLI_BUNDLE_ROOT . '/utils/scoper'; + +/** + * Write a progress line unless running quietly. + */ +function report( string $message ): void { + if ( ! $GLOBALS['be_quiet'] ) { + fwrite( STDOUT, $message . PHP_EOL ); + } +} + +/** + * Run a command, returning its exit code. + * + * @param array $command + */ +function run( array $command, ?string $cwd = null ): int { + $cwd_prefix = null !== $cwd ? sprintf( 'cd %s && ', escapeshellarg( $cwd ) ) : ''; + $escaped = implode( ' ', array_map( 'escapeshellarg', $command ) ); + + passthru( $cwd_prefix . $escaped, $exit_code ); + + return $exit_code; +} + +/** + * Fail with a message. + */ +function fail( string $message ): void { + fwrite( STDERR, 'Error: ' . $message . PHP_EOL ); + exit( 1 ); +} + +if ( ! is_dir( $vendor_dir ) ) { + fail( sprintf( "Vendor directory '%s' does not exist. Run `composer install` first.", $vendor_dir ) ); +} + +// php-scoper needs PHP 8.2+, which is why it lives in its own composer.json +// rather than in the bundle's (that one still has to resolve against PHP 7.2.24). +if ( PHP_VERSION_ID < 80200 ) { + fail( sprintf( 'php-scoper requires PHP 8.2 or newer, but this is PHP %s.', PHP_VERSION ) ); +} + +// --- 1. Make sure the isolated toolchain is installed. ---------------------- + +if ( ! file_exists( $scoper_dir . '/vendor/bin/php-scoper' ) ) { + report( 'Installing the php-scoper toolchain...' ); + if ( 0 !== run( [ 'composer', 'install', '--no-interaction', '--prefer-dist', '--quiet' ], $scoper_dir ) ) { + fail( 'Failed to install the php-scoper toolchain.' ); + } +} + +// --- 2. Prefix the dependency tree. ----------------------------------------- + +$output_dir = $vendor_dir . '/../build/scoped-vendor'; + +if ( is_dir( $output_dir ) ) { + run( [ 'rm', '-rf', $output_dir ] ); +} + +report( 'Prefixing third-party dependencies...' ); + +putenv( 'WP_CLI_SCOPER_VENDOR_DIR=' . $vendor_dir ); + +$scoper_exit = run( + [ + $scoper_dir . '/vendor/bin/php-scoper', + 'add-prefix', + '--config=' . $scoper_dir . '/scoper.inc.php', + '--output-dir=' . $output_dir, + '--force', + '--no-interaction', + $be_quiet ? '--quiet' : '--no-ansi', + ] +); + +if ( 0 !== $scoper_exit ) { + fail( 'php-scoper failed.' ); +} + +// --- 3. Swap the prefixed tree into vendor/. -------------------------------- + +foreach ( SCOPED_VENDOR_DIRS as $dir ) { + $scoped = $output_dir . '/' . $dir; + $target = $vendor_dir . '/' . $dir; + + if ( ! is_dir( $scoped ) ) { + continue; + } + + report( sprintf( ' Replacing vendor/%s', $dir ) ); + + // Composer's autoloader machinery lives alongside the composer/* packages + // in vendor/composer and is regenerated below, so only the package + // subdirectories are replaced wholesale. + if ( 0 !== run( [ 'cp', '-a', $scoped . '/.', $target . '/' ] ) ) { + fail( sprintf( "Failed to copy the prefixed '%s' into place.", $dir ) ); + } +} + +// --- 4. Teach Composer about the new class names. --------------------------- + +/* + * The prefixed files no longer satisfy their packages' PSR-4 rules: the classes + * in vendor/psr/log now declare WP_CLI\Vendor\Psr\Log\*, while psr/log's + * composer.json still maps Psr\Log\ to that directory. Left alone, a dump would + * both re-advertise the unprefixed prefix and skip the prefixed classes as + * "not compliant with PSR-4". + * + * Rewriting the affected packages' autoload rules to a classmap sidesteps both + * problems: Composer scans the directories and records whatever class names the + * files actually declare. + */ +$installed_json = $vendor_dir . '/composer/installed.json'; + +if ( ! file_exists( $installed_json ) ) { + fail( sprintf( "Could not find '%s'.", $installed_json ) ); +} + +$decoded = json_decode( (string) file_get_contents( $installed_json ), true ); + +if ( ! is_array( $decoded ) || ! isset( $decoded['packages'] ) || ! is_array( $decoded['packages'] ) ) { + fail( sprintf( "Could not decode '%s'.", $installed_json ) ); +} + +/** + * @var array $decoded + * @var array $packages + */ +$packages = $decoded['packages']; +$patched = 0; + +foreach ( $packages as $index => $package ) { + if ( ! is_array( $package ) || ! isset( $package['name'] ) || ! is_string( $package['name'] ) ) { + continue; + } + + $vendor_name = explode( '/', $package['name'] )[0]; + + if ( ! in_array( $vendor_name, SCOPED_VENDOR_DIRS, true ) ) { + continue; + } + + if ( ! isset( $package['autoload'] ) || ! is_array( $package['autoload'] ) ) { + continue; + } + + $autoload = $package['autoload']; + $roots = []; + + foreach ( [ 'psr-4', 'psr-0' ] as $standard ) { + $rules = $autoload[ $standard ] ?? []; + + if ( ! is_array( $rules ) ) { + continue; + } + + foreach ( $rules as $paths ) { + foreach ( (array) $paths as $path ) { + if ( is_string( $path ) ) { + $roots[] = '' === $path ? '.' : $path; + } + } + } + } + + $classmap_rules = $autoload['classmap'] ?? []; + + if ( is_array( $classmap_rules ) ) { + foreach ( $classmap_rules as $path ) { + if ( is_string( $path ) ) { + $roots[] = $path; + } + } + } + + if ( ! $roots ) { + continue; + } + + $package['autoload'] = [ 'classmap' => array_values( array_unique( $roots ) ) ]; + $packages[ $index ] = $package; + ++$patched; +} + +$decoded['packages'] = $packages; + +report( sprintf( 'Rewrote autoload rules for %d prefixed package(s).', $patched ) ); + +$encoded = json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + +if ( false === $encoded || false === file_put_contents( $installed_json, $encoded ) ) { + fail( sprintf( "Failed to write '%s'.", $installed_json ) ); +} + +// --- 5. Regenerate the autoloader. ------------------------------------------ + +report( 'Regenerating the Composer autoloader...' ); + +/* + * --classmap-authoritative makes the ClassLoader consult only the classmap, so + * no leftover PSR-4 rule can resurrect an unprefixed name. Everything the Phar + * runs is inside the Phar, so there is nothing to discover at runtime. + */ +// Derived from the vendor directory rather than assumed, so the script can be +// pointed at a scratch tree for testing. +$composer_root = dirname( $vendor_dir ); + +if ( 0 !== run( [ 'composer', 'dump-autoload', '--classmap-authoritative', '--no-interaction' ], $composer_root ) ) { + fail( 'Failed to regenerate the Composer autoloader.' ); +} + +run( [ 'rm', '-rf', dirname( $output_dir ) ] ); + +// --- 6. Verify the autoloader no longer claims the unprefixed names. -------- + +/* + * The failure mode this guards against is silent: php-scoper rewrites source + * files but not Composer's generated maps, so a tree that looks scoped can + * still resolve `Psr\Log\LoggerInterface` to the bundled copy and reintroduce + * the conflict with nothing in the build output to show for it. + */ +$autoload_files = array_filter( + [ + $vendor_dir . '/composer/autoload_classmap.php', + $vendor_dir . '/composer/autoload_psr4.php', + $vendor_dir . '/composer/autoload_static.php', + ], + 'file_exists' +); + +$must_not_appear = [ + 'Psr\\Log\\', + 'Symfony\\Component\\Console\\', + 'React\\Promise\\', + 'Seld\\JsonLint\\', +]; + +$leaked = []; + +foreach ( $autoload_files as $file ) { + $contents = (string) file_get_contents( $file ); + + foreach ( $must_not_appear as $symbol ) { + // Written as it appears in the generated PHP source, where each + // namespace separator is escaped. + $needle = str_replace( '\\', '\\\\', $symbol ); + $prefixed = 'WP_CLI\\\\Vendor\\\\' . $needle; + $occurring = substr_count( $contents, $needle ) - substr_count( $contents, $prefixed ); + + if ( $occurring > 0 ) { + $leaked[] = sprintf( ' %s advertises %s (%d time(s))', basename( $file ), $symbol, $occurring ); + } + } +} + +if ( $leaked ) { + fail( + "The regenerated autoloader still advertises unprefixed dependencies:\n" + . implode( "\n", $leaked ) + . "\nThe Phar would keep imposing these on the site. See https://github.com/wp-cli/wp-cli/issues/5920" + ); +} + +$classmap = $vendor_dir . '/composer/autoload_classmap.php'; + +// strpos() rather than str_contains() so the file still parses under the 7.2 +// baseline phpcs checks this repository against, even though the script itself +// refuses to run on anything below PHP 8.2. +if ( file_exists( $classmap ) && false === strpos( (string) file_get_contents( $classmap ), 'WP_CLI\\\\Vendor\\\\' ) ) { + fail( 'The regenerated classmap contains no prefixed classes at all; the prefixing step did not take effect.' ); +} + +report( 'Verified: the autoloader advertises only prefixed dependencies.' ); +report( 'Done.' ); diff --git a/utils/scoper/.gitignore b/utils/scoper/.gitignore new file mode 100644 index 000000000..57872d0f1 --- /dev/null +++ b/utils/scoper/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/utils/scoper/composer.json b/utils/scoper/composer.json new file mode 100644 index 000000000..4db272d65 --- /dev/null +++ b/utils/scoper/composer.json @@ -0,0 +1,13 @@ +{ + "name": "wp-cli/phar-scoper-toolchain", + "description": "Isolated toolchain used to prefix the Phar's third-party dependencies. Kept out of the bundle's own composer.json because php-scoper requires PHP 8.2+, while WP-CLI still targets PHP 7.2.24.", + "license": "MIT", + "type": "project", + "require": { + "humbug/php-scoper": "^0.18" + }, + "config": { + "sort-packages": true, + "lock": false + } +} diff --git a/utils/scoper/scoper.inc.php b/utils/scoper/scoper.inc.php new file mode 100644 index 000000000..fce325037 --- /dev/null +++ b/utils/scoper/scoper.inc.php @@ -0,0 +1,133 @@ + 'WP_CLI\\Vendor', + 'finders' => [ + /* + * Deliberately without exclusions. The prefixed output is merged back + * over `vendor/` rather than replacing it, because php-scoper only + * emits the PHP files it processed and the directories also hold + * assets the Phar needs (certificate bundles, templates, stubs). + * Any PHP file skipped here would therefore survive the merge with its + * original namespace intact and be picked up by the regenerated + * classmap -- which is exactly the unprefixed name the Phar is not + * supposed to advertise any more. Test fixtures are the usual culprit: + * `Psr\Log\Test\TestLogger` implements the very interface at issue. + */ + $finder_class::create() + ->files() + ->ignoreVCS( true ) + ->name( '*.php' ) + ->in( $scoped_paths ), + ], + + /* + * Left unprefixed so third-party Composer plugins keep implementing the + * real interfaces. References from these files to the prefixed vendors are + * still rewritten by php-scoper. + */ + 'exclude-namespaces' => [ + 'Composer', + ], + + 'exclude-classes' => [], + 'exclude-functions' => [], + 'exclude-constants' => [], + + 'patchers' => [ + /* + * Excluding a namespace stops php-scoper prefixing its declarations, + * but not string literals that name classes inside it. Composer passes + * plenty of class names around as strings -- `ArrayLoader::load()` + * defaults `$class` to 'Composer\Package\CompletePackage' and compares + * against it -- and prefixing those strings points them at classes + * that do not exist, because `Composer\` itself was left alone. + * + * Left unpatched this is quiet rather than fatal: Composer emits a + * spurious "The $class arg is deprecated" notice and carries on, while + * the same mismatch in a `new $class` path would be a hard failure. + */ + static function ( string $file_path, string $prefix, string $contents ): string { + return str_replace( + [ + $prefix . '\\Composer\\', + $prefix . '\\\\Composer\\\\', + ], + [ + 'Composer\\', + 'Composer\\\\', + ], + $contents + ); + }, + ], +];