From bb979f451fa675f230c9182c1946042c0b187285 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 21:59:09 +0000 Subject: [PATCH 1/2] Add support for installing WordPress from an arbitrary ZIP archive Behat suites can now install WordPress from a ZIP archive instead of downloading it from WordPress.org, by pointing the new `WP_CLI_TEST_CORE_ZIP` environment variable at either a local file or an HTTP(S) URL. This makes it possible to run the functional tests against a WordPress build that has not been released, such as the `wordpress.zip` and `develop.zip` artifacts produced by the WordPress core build process. The archive may contain WordPress at its root or wrapped in a single folder, so that both the `wordpress/` layout used by WordPress.org releases and the `build/` layout used by some core build artifacts work. Archives are extracted once and cached, keyed by their contents. `WP_VERSION` keeps governing which version-specific tags are filtered out, as the version of a development build cannot be compared meaningfully, and defaults to `trunk` when an archive is configured. Steps requesting an explicit version, such as `Given a WP 6.4.2 installation`, continue to download that version and ignore the archive. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CE81GsxUY597AdaMP1NXzk --- .readme-partials/USING.md | 29 ++++ README.md | 29 ++++ bin/run-behat-tests | 8 ++ src/Context/FeatureContext.php | 187 ++++++++++++++++++++++++- tests/tests/TestCoreZip.php | 241 +++++++++++++++++++++++++++++++++ 5 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 tests/tests/TestCoreZip.php diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index 6904731e..3b2528f7 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -119,6 +119,35 @@ Here's how to run your tests against the latest trunk version of WordPress: WP_VERSION=trunk composer behat ``` +#### WordPress Archive + +Instead of downloading WordPress from WordPress.org, you can run the tests against an arbitrary +WordPress ZIP archive by setting the `WP_CLI_TEST_CORE_ZIP` environment variable. It accepts either +a path to a local archive or an HTTP(S) URL. + +This is useful to test against a WordPress build that has not been released, such as the ZIP file +produced by the WordPress core build process. + +```bash +WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress.zip composer behat +``` + +The archive may contain WordPress at its root, or wrapped in a single folder — both `wordpress/` +(as used by WordPress.org releases) and `build/` (as used by some WordPress core build artifacts) +work. Archives are extracted once and then cached, keyed by their contents. + +`WP_VERSION` still determines which version-specific tags (`@require-wp-6.4`, `@less-than-wp-6.4`) +are filtered out, since the version of a development build cannot be compared meaningfully. It +defaults to `trunk` when an archive is set, which runs every scenario. Set it explicitly when the +archive holds a specific release: + +```bash +WP_VERSION=6.4.2 WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress-6.4.2.zip composer behat +``` + +Note that steps requesting an explicit version, such as `Given a WP 6.4.2 installation`, keep +downloading that version from WordPress.org and ignore the archive. + #### WP-CLI Binary You can run the tests against a specific WP-CLI binary, instead of using the one that has been built in your project's `vendor/bin` folder. diff --git a/README.md b/README.md index ceb0c980..6eeff87a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,35 @@ Here's how to run your tests against the latest trunk version of WordPress: WP_VERSION=trunk composer behat ``` +#### WordPress Archive + +Instead of downloading WordPress from WordPress.org, you can run the tests against an arbitrary +WordPress ZIP archive by setting the `WP_CLI_TEST_CORE_ZIP` environment variable. It accepts either +a path to a local archive or an HTTP(S) URL. + +This is useful to test against a WordPress build that has not been released, such as the ZIP file +produced by the WordPress core build process. + +```bash +WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress.zip composer behat +``` + +The archive may contain WordPress at its root, or wrapped in a single folder — both `wordpress/` +(as used by WordPress.org releases) and `build/` (as used by some WordPress core build artifacts) +work. Archives are extracted once and then cached, keyed by their contents. + +`WP_VERSION` still determines which version-specific tags (`@require-wp-6.4`, `@less-than-wp-6.4`) +are filtered out, since the version of a development build cannot be compared meaningfully. It +defaults to `trunk` when an archive is set, which runs every scenario. Set it explicitly when the +archive holds a specific release: + +```bash +WP_VERSION=6.4.2 WP_CLI_TEST_CORE_ZIP=~/Downloads/wordpress-6.4.2.zip composer behat +``` + +Note that steps requesting an explicit version, such as `Given a WP 6.4.2 installation`, keep +downloading that version from WordPress.org and ignore the archive. + #### WP-CLI Binary You can run the tests against a specific WP-CLI binary, instead of using the one that has been built in your project's `vendor/bin` folder. diff --git a/bin/run-behat-tests b/bin/run-behat-tests index 78160971..b276c7cb 100755 --- a/bin/run-behat-tests +++ b/bin/run-behat-tests @@ -89,6 +89,14 @@ else fi fi +# When installing from an arbitrary archive, there is no version to resolve against +# WordPress.org. Default to "trunk" so that no @require-wp tags are filtered out, as +# such an archive is usually a development build. Set WP_VERSION explicitly alongside +# WP_CLI_TEST_CORE_ZIP when the archive holds a specific release. +if [ -n "${WP_CLI_TEST_CORE_ZIP-}" ] && [ -z "${WP_VERSION-}" ]; then + export WP_VERSION=trunk +fi + # Turn WP_VERSION into an actual number to make sure our tags work correctly. if [ "${WP_VERSION-latest}" = "latest" ]; then export WP_VERSION=$(curl -s https://api.wordpress.org/core/version-check/1.7/ | jq -r ".offers[0].current") diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php index d5c2b888..7963ce62 100644 --- a/src/Context/FeatureContext.php +++ b/src/Context/FeatureContext.php @@ -71,6 +71,14 @@ class FeatureContext implements Context { */ private static $cache_dir; + /** + * Path to the local WordPress ZIP archive configured via WP_CLI_TEST_CORE_ZIP. Resolved once per suite. + * Null while unresolved, false when no archive is configured. + * + * @var string|false|null + */ + private static $core_zip = null; + /** * The directory that holds the install cache, and which is copied to RUN_DIR during a "Given a WP installation" step. Recreated on each suite run. * @@ -681,6 +689,171 @@ private static function configure_sqlite( $dir ): void { file_put_contents( $db_dropin, $file_contents ); } + /** + * Resolve the WordPress archive to install from, as configured through the + * `WP_CLI_TEST_CORE_ZIP` environment variable. + * + * The variable accepts either a path to a local ZIP file or an HTTP(S) URL. + * Remote archives are downloaded once per suite run. + * + * @return ?string Path to a local ZIP file, or null if the variable is not set. + */ + private static function get_core_zip(): ?string { + $resolved = self::$core_zip; + + if ( null !== $resolved ) { + return false === $resolved ? null : $resolved; + } + + $core_zip = getenv( 'WP_CLI_TEST_CORE_ZIP' ); + + if ( false === $core_zip || '' === $core_zip ) { + self::$core_zip = false; + return null; + } + + if ( preg_match( '#^https?://#i', $core_zip ) ) { + $core_zip = self::download_core_zip( $core_zip ); + } + + if ( ! is_file( $core_zip ) || ! is_readable( $core_zip ) ) { + throw new RuntimeException( "Could not read the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" ); + } + + $realpath = realpath( $core_zip ); + $resolved = false !== $realpath ? $realpath : $core_zip; + + self::$core_zip = $resolved; + + return $resolved; + } + + /** + * Download a remote WordPress archive to a local file. + * + * @param string $url + * @return string Path to the downloaded file. + */ + private static function download_core_zip( $url ): string { + $download_location = sys_get_temp_dir() . '/wp-cli-test-core-zip-' . substr( md5( $url ), 0, 12 ) . '.zip'; + + $response = Utils\http_request( + 'GET', + $url, + null, + [], + [ + 'filename' => $download_location, + 'timeout' => 600, + ] + ); + + if ( 200 !== $response->status_code ) { + throw new RuntimeException( "Could not download WordPress archive from {$url} (HTTP code {$response->status_code})" ); + } + + return $download_location; + } + + /** + * Get the directory that a given WordPress version is cached in. + * + * @param string $version + * @return string + */ + private static function get_core_cache_dir( $version = '' ): string { + // An explicit version always takes precedence over a configured archive. + $core_zip = $version ? null : self::get_core_zip(); + + if ( $core_zip ) { + $hash = md5_file( $core_zip ); + + if ( false === $hash ) { + throw new RuntimeException( "Could not hash the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" ); + } + + return sys_get_temp_dir() . '/wp-cli-test-core-download-cache-zip-' . substr( $hash, 0, 12 ); + } + + $wp_version = $version ?: getenv( 'WP_VERSION' ); + + return sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . ( $wp_version ? "-$wp_version" : '' ); + } + + /** + * Extract a WordPress ZIP archive into a destination directory. + * + * Supports archives that wrap WordPress in a single top-level directory -- + * `wordpress/` for wordpress.org releases, `build/` for some WordPress core + * build artifacts -- as well as archives that contain WordPress at the root. + * + * @param string $zip_file + * @param string $dest_dir + */ + public static function extract_wp_zip( $zip_file, $dest_dir ): void { + $temp_dir = sys_get_temp_dir() . '/wp-cli-test-core-zip-extract-' . uniqid( '', true ); + + $zip = new \ZipArchive(); + $opened = $zip->open( $zip_file ); + + if ( true !== $opened ) { + throw new RuntimeException( sprintf( 'Failed to open the zip file %s: %s', $zip_file, $zip->getStatusString() ) ); + } + + if ( ! $zip->extractTo( $temp_dir ) ) { + $error_message = $zip->getStatusString(); + $zip->close(); + self::remove_dir( $temp_dir ); + throw new RuntimeException( sprintf( 'Failed to extract files from the zip %s: %s', $zip_file, $error_message ) ); + } + + $zip->close(); + + try { + $source_dir = self::find_wp_root( $temp_dir ); + + if ( null === $source_dir ) { + throw new RuntimeException( "The archive {$zip_file} does not look like a WordPress archive: no wp-includes/version.php found at its root or one level below." ); + } + + self::remove_dir( $dest_dir ); + + // Both directories live in the system temp folder, so a rename is + // normally possible and avoids copying thousands of files. + if ( ! @rename( $source_dir, $dest_dir ) ) { + self::copy_dir( $source_dir, $dest_dir ); + } + } finally { + self::remove_dir( $temp_dir ); + } + } + + /** + * Find the WordPress root within an extracted archive. + * + * @param string $dir + * @return ?string The directory holding wp-includes/version.php, or null if there is none. + */ + private static function find_wp_root( $dir ): ?string { + if ( is_readable( $dir . '/wp-includes/version.php' ) ) { + return $dir; + } + + foreach ( new DirectoryIterator( $dir ) as $item ) { + if ( ! $item->isDir() || $item->isDot() ) { + continue; + } + + $candidate = $item->getPathname(); + + if ( is_readable( $candidate . '/wp-includes/version.php' ) ) { + return $candidate; + } + } + + return null; + } + /** * We cache the results of `wp core download` to improve test performance. * Ideally, we'd cache at the HTTP layer for more reliable tests. @@ -688,9 +861,9 @@ private static function configure_sqlite( $dir ): void { * @param string $version */ private static function cache_wp_files( $version = '' ): void { + $core_zip = $version ? null : self::get_core_zip(); $wp_version = $version ?: getenv( 'WP_VERSION' ); - $wp_version_suffix = $wp_version ? "-$wp_version" : ''; - $cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix; + $cache_dir = self::get_core_cache_dir( $version ); self::$sqlite_cache_dir = sys_get_temp_dir() . '/wp-cli-test-sqlite-integration-cache'; if ( 'sqlite' === getenv( 'WP_CLI_TEST_DBTYPE' ) ) { @@ -711,6 +884,12 @@ private static function cache_wp_files( $version = '' ): void { return; } + if ( $core_zip ) { + self::extract_wp_zip( $core_zip, $cache_dir ); + self::$cache_dir = $cache_dir; + return; + } + $cmd = Utils\esc_cmd( 'wp core download --force --path=%s', $cache_dir ); if ( $wp_version ) { $cmd .= Utils\esc_cmd( ' --version=%s', $wp_version ); @@ -1586,9 +1765,7 @@ public function add_line_to_wp_config( &$wp_config_code, $line ): void { * @param string $version */ public function download_wp( $subdir = '', $version = '' ): void { - $wp_version = $version ?: getenv( 'WP_VERSION' ); - $wp_version_suffix = $wp_version ? "-$wp_version" : ''; - $expected_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . $wp_version_suffix; + $expected_cache_dir = self::get_core_cache_dir( $version ); if ( ! self::$cache_dir || self::$cache_dir !== $expected_cache_dir ) { self::cache_wp_files( $version ); diff --git a/tests/tests/TestCoreZip.php b/tests/tests/TestCoreZip.php new file mode 100644 index 00000000..5679fd63 --- /dev/null +++ b/tests/tests/TestCoreZip.php @@ -0,0 +1,241 @@ +temp_dir = Utils\get_temp_dir() . uniqid( 'wp-cli-test-core-zip-', true ); + mkdir( $this->temp_dir ); + } + + protected function tear_down(): void { + if ( $this->temp_dir && file_exists( $this->temp_dir ) ) { + FeatureContext::remove_dir( $this->temp_dir ); + } + + parent::tear_down(); + } + + /** + * Build a ZIP file containing the given entries. + * + * @param string $name File name for the archive. + * @param array $entries Map of entry path to file contents. + * @return string Path to the created archive. + */ + private function create_zip( $name, array $entries ): string { + $zip_file = $this->temp_dir . DIRECTORY_SEPARATOR . $name; + + $zip = new ZipArchive(); + $this->assertTrue( $zip->open( $zip_file, ZipArchive::CREATE ) === true ); + + foreach ( $entries as $path => $contents ) { + $zip->addFromString( $path, $contents ); + } + + $zip->close(); + + return $zip_file; + } + + /** + * Entries making up a minimal WordPress installation, below the given prefix. + * + * @param string $prefix + * @return array + */ + private function wp_entries( $prefix = '' ): array { + return [ + "{$prefix}wp-includes/version.php" => " " "assertFileExists( $dir . '/wp-includes/version.php' ); + $this->assertFileExists( $dir . '/wp-load.php' ); + $this->assertFileExists( $dir . '/wp-admin/index.php' ); + $this->assertStringContainsString( '7.2-alpha-12345', (string) file_get_contents( $dir . '/wp-includes/version.php' ) ); + } + + /** + * WordPress.org release archives wrap everything in a `wordpress/` folder. + */ + public function testExtractsArchiveWithWordpressFolder(): void { + $zip_file = $this->create_zip( 'release.zip', $this->wp_entries( 'wordpress/' ) ); + $dest_dir = $this->temp_dir . DIRECTORY_SEPARATOR . 'dest'; + + FeatureContext::extract_wp_zip( $zip_file, $dest_dir ); + + $this->assertIsExtractedWordPress( $dest_dir ); + $this->assertDirectoryDoesNotExist( $dest_dir . '/wordpress' ); + } + + /** + * Some WordPress core build artifacts wrap everything in a `build/` folder instead. + */ + public function testExtractsArchiveWithBuildFolder(): void { + // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Matches the artifact name used by WordPress core. + $zip_file = $this->create_zip( 'wordpress.zip', $this->wp_entries( 'build/' ) ); + $dest_dir = $this->temp_dir . DIRECTORY_SEPARATOR . 'dest'; + + FeatureContext::extract_wp_zip( $zip_file, $dest_dir ); + + $this->assertIsExtractedWordPress( $dest_dir ); + $this->assertDirectoryDoesNotExist( $dest_dir . '/build' ); + } + + public function testExtractsArchiveWithoutWrappingFolder(): void { + $zip_file = $this->create_zip( 'flat.zip', $this->wp_entries() ); + $dest_dir = $this->temp_dir . DIRECTORY_SEPARATOR . 'dest'; + + FeatureContext::extract_wp_zip( $zip_file, $dest_dir ); + + $this->assertIsExtractedWordPress( $dest_dir ); + } + + /** + * Archives created on macOS carry an additional `__MACOSX/` folder. + */ + public function testExtractsArchiveWithSiblingFolder(): void { + $entries = $this->wp_entries( 'wordpress/' ); + $entries['__MACOSX/._wp-load.php'] = 'junk'; + $entries['__MACOSX/nested/._foo.php'] = 'junk'; + + $zip_file = $this->create_zip( 'macos.zip', $entries ); + $dest_dir = $this->temp_dir . DIRECTORY_SEPARATOR . 'dest'; + + FeatureContext::extract_wp_zip( $zip_file, $dest_dir ); + + $this->assertIsExtractedWordPress( $dest_dir ); + } + + /** + * The destination is replaced wholesale, so files from a previous extraction do not linger. + */ + public function testReplacesExistingDestination(): void { + $dest_dir = $this->temp_dir . DIRECTORY_SEPARATOR . 'dest'; + mkdir( $dest_dir . '/wp-content', 0777, true ); + file_put_contents( $dest_dir . '/wp-content/stale.php', 'create_zip( 'release.zip', $this->wp_entries( 'wordpress/' ) ); + + FeatureContext::extract_wp_zip( $zip_file, $dest_dir ); + + $this->assertIsExtractedWordPress( $dest_dir ); + $this->assertFileDoesNotExist( $dest_dir . '/wp-content/stale.php' ); + } + + public function testThrowsOnArchiveWithoutWordPress(): void { + $zip_file = $this->create_zip( + 'not-wordpress.zip', + [ + 'some-plugin/some-plugin.php' => "expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'does not look like a WordPress archive' ); + + FeatureContext::extract_wp_zip( $zip_file, $this->temp_dir . DIRECTORY_SEPARATOR . 'dest' ); + } + + public function testThrowsOnUnreadableArchive(): void { + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'Failed to open the zip file' ); + + FeatureContext::extract_wp_zip( $this->temp_dir . DIRECTORY_SEPARATOR . 'missing.zip', $this->temp_dir . DIRECTORY_SEPARATOR . 'dest' ); + } + + /** + * Both `cache_wp_files()` and `download_wp()` need to agree on the cache directory, + * so it is worth pinning down how it is derived. These are internals, hence reflection. + * + * @param string $version + * @return string + */ + private function get_core_cache_dir( $version = '' ): string { + $method = new \ReflectionMethod( FeatureContext::class, 'get_core_cache_dir' ); + $method->setAccessible( true ); + + /** @var string $cache_dir */ + $cache_dir = $method->invoke( null, $version ); + + return $cache_dir; + } + + private function reset_core_zip(): void { + $property = new \ReflectionProperty( FeatureContext::class, 'core_zip' ); + $property->setAccessible( true ); + $property->setValue( null, null ); + } + + public function testCacheDirIsDerivedFromWpVersionWithoutArchive(): void { + putenv( 'WP_CLI_TEST_CORE_ZIP' ); + $this->reset_core_zip(); + + $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-6.4.2', $this->get_core_cache_dir( '6.4.2' ) ); + } + + public function testCacheDirIsDerivedFromArchiveContents(): void { + $zip_file = $this->create_zip( 'release.zip', $this->wp_entries( 'wordpress/' ) ); + + putenv( "WP_CLI_TEST_CORE_ZIP={$zip_file}" ); + $this->reset_core_zip(); + + try { + $expected = substr( (string) md5_file( $zip_file ), 0, 12 ); + $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-zip-' . $expected, $this->get_core_cache_dir() ); + } finally { + putenv( 'WP_CLI_TEST_CORE_ZIP' ); + $this->reset_core_zip(); + } + } + + /** + * A "Given a WP 6.4.2 installation" step must keep working while an archive is configured. + */ + public function testExplicitVersionTakesPrecedenceOverArchive(): void { + $zip_file = $this->create_zip( 'release.zip', $this->wp_entries( 'wordpress/' ) ); + + putenv( "WP_CLI_TEST_CORE_ZIP={$zip_file}" ); + $this->reset_core_zip(); + + try { + $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-6.4.2', $this->get_core_cache_dir( '6.4.2' ) ); + } finally { + putenv( 'WP_CLI_TEST_CORE_ZIP' ); + $this->reset_core_zip(); + } + } + + public function testThrowsOnMissingConfiguredArchive(): void { + putenv( 'WP_CLI_TEST_CORE_ZIP=' . $this->temp_dir . DIRECTORY_SEPARATOR . 'missing.zip' ); + $this->reset_core_zip(); + + try { + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'Could not read the WP_CLI_TEST_CORE_ZIP archive' ); + + $this->get_core_cache_dir(); + } finally { + putenv( 'WP_CLI_TEST_CORE_ZIP' ); + $this->reset_core_zip(); + } + } +} From e7f616eb61aa2bbfa4fd3bda275716bf897df6d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:52:04 +0000 Subject: [PATCH 2/2] Address review feedback on the WordPress archive support Fixes the unit test failure on PHP < 8.0, where calling `ZipArchive::getStatusString()` on an archive that failed to open errors out with "Invalid or uninitialized Zip object". The error code returned by `ZipArchive::open()` is now used instead. Creates the destination directory before falling back to `copy_dir()`, which copies into an existing directory and would otherwise fail to copy the files at the root of the archive. Rejects archives holding entries that point outside of the directory they are extracted into. `ZipArchive::extractTo()` normalizes such entries rather than following them, but an archive containing them is malformed for our purposes on any PHP version. Drops the reflection from the tests, which called `Reflection*::setAccessible()`, deprecated as of PHP 8.5. The cache directory is now derived through a public method, and the resolved archive is memoized against the environment variable it came from, so a change of that variable is picked up rather than served from the memoized value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CE81GsxUY597AdaMP1NXzk --- src/Context/FeatureContext.php | 75 ++++++++++++++++++---- tests/tests/TestCoreZip.php | 112 +++++++++++++++++++++------------ 2 files changed, 136 insertions(+), 51 deletions(-) diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php index 7963ce62..4e6434b0 100644 --- a/src/Context/FeatureContext.php +++ b/src/Context/FeatureContext.php @@ -21,6 +21,7 @@ use SebastianBergmann\Environment\Runtime; use RuntimeException; use DirectoryIterator; +use WP_CLI\Extractor; use WP_CLI\Process; use WP_CLI\ProcessRun; use WP_CLI\Utils; @@ -79,6 +80,14 @@ class FeatureContext implements Context { */ private static $core_zip = null; + /** + * The raw WP_CLI_TEST_CORE_ZIP value that self::$core_zip was resolved from, so that a + * change of the environment variable is picked up instead of served from the memoized value. + * + * @var ?string + */ + private static $core_zip_source = null; + /** * The directory that holds the install cache, and which is copied to RUN_DIR during a "Given a WP installation" step. Recreated on each suite run. * @@ -699,15 +708,19 @@ private static function configure_sqlite( $dir ): void { * @return ?string Path to a local ZIP file, or null if the variable is not set. */ private static function get_core_zip(): ?string { + $source = getenv( 'WP_CLI_TEST_CORE_ZIP' ); + $source = false === $source ? '' : $source; $resolved = self::$core_zip; - if ( null !== $resolved ) { + if ( null !== $resolved && self::$core_zip_source === $source ) { return false === $resolved ? null : $resolved; } - $core_zip = getenv( 'WP_CLI_TEST_CORE_ZIP' ); + self::$core_zip_source = $source; + + $core_zip = $source; - if ( false === $core_zip || '' === $core_zip ) { + if ( '' === $core_zip ) { self::$core_zip = false; return null; } @@ -758,10 +771,13 @@ private static function download_core_zip( $url ): string { /** * Get the directory that a given WordPress version is cached in. * + * Without an explicit version, this is derived from the contents of the archive configured + * through `WP_CLI_TEST_CORE_ZIP`, if any, and from `WP_VERSION` otherwise. + * * @param string $version * @return string */ - private static function get_core_cache_dir( $version = '' ): string { + public static function get_core_cache_dir( $version = '' ): string { // An explicit version always takes precedence over a configured archive. $core_zip = $version ? null : self::get_core_zip(); @@ -797,18 +813,21 @@ public static function extract_wp_zip( $zip_file, $dest_dir ): void { $opened = $zip->open( $zip_file ); if ( true !== $opened ) { - throw new RuntimeException( sprintf( 'Failed to open the zip file %s: %s', $zip_file, $zip->getStatusString() ) ); + // Note that ZipArchive::getStatusString() cannot be used to describe this failure, + // as it errors out on an archive that failed to open on PHP < 8.0. + throw new RuntimeException( sprintf( 'Failed to open the zip file %s: %s', $zip_file, Extractor::zip_error_msg( (int) $opened ) ) ); } - if ( ! $zip->extractTo( $temp_dir ) ) { - $error_message = $zip->getStatusString(); + try { + self::validate_zip_entries( $zip, $zip_file ); + + if ( ! $zip->extractTo( $temp_dir ) ) { + throw new RuntimeException( sprintf( 'Failed to extract files from the zip %s: %s', $zip_file, $zip->getStatusString() ) ); + } + } finally { $zip->close(); - self::remove_dir( $temp_dir ); - throw new RuntimeException( sprintf( 'Failed to extract files from the zip %s: %s', $zip_file, $error_message ) ); } - $zip->close(); - try { $source_dir = self::find_wp_root( $temp_dir ); @@ -821,6 +840,11 @@ public static function extract_wp_zip( $zip_file, $dest_dir ): void { // Both directories live in the system temp folder, so a rename is // normally possible and avoids copying thousands of files. if ( ! @rename( $source_dir, $dest_dir ) ) { + // copy_dir() copies into an existing directory, so create it first. + if ( ! is_dir( $dest_dir ) && ! mkdir( $dest_dir, 0777, true ) && ! is_dir( $dest_dir ) ) { + throw new RuntimeException( "Could not create the WordPress destination directory: {$dest_dir}" ); + } + self::copy_dir( $source_dir, $dest_dir ); } } finally { @@ -828,6 +852,35 @@ public static function extract_wp_zip( $zip_file, $dest_dir ): void { } } + /** + * Reject archives holding entries that point outside of the directory they are extracted into. + * + * ZipArchive::extractTo() normalizes such entries rather than following them, but an archive + * containing them is malformed for our purposes on any PHP version. + * + * @param \ZipArchive $zip + * @param string $zip_file + */ + private static function validate_zip_entries( \ZipArchive $zip, $zip_file ): void { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the PHP ZipArchive class. + $num_files = $zip->numFiles; + + for ( $i = 0; $i < $num_files; $i++ ) { + $name = $zip->getNameIndex( $i ); + + if ( false === $name ) { + continue; + } + + $segments = explode( '/', str_replace( '\\', '/', $name ) ); + + // An empty first segment means the entry is an absolute path. + if ( in_array( '..', $segments, true ) || '' === $segments[0] || preg_match( '#^[a-zA-Z]:$#', $segments[0] ) ) { + throw new RuntimeException( "The archive {$zip_file} contains an entry that would be extracted outside of its destination: {$name}" ); + } + } + } + /** * Find the WordPress root within an extracted archive. * diff --git a/tests/tests/TestCoreZip.php b/tests/tests/TestCoreZip.php index 5679fd63..f43ad6d7 100644 --- a/tests/tests/TestCoreZip.php +++ b/tests/tests/TestCoreZip.php @@ -2,6 +2,7 @@ namespace WP_CLI\Tests\Tests; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use WP_CLI\Tests\Context\FeatureContext; use WP_CLI\Tests\TestCase; @@ -15,14 +16,32 @@ class TestCoreZip extends TestCase { */ public $temp_dir; + /** + * The WP_CLI_TEST_CORE_ZIP value the test process started with, if any. + * + * @var ?string + */ + private $original_core_zip; + protected function set_up(): void { parent::set_up(); + $original = getenv( 'WP_CLI_TEST_CORE_ZIP' ); + $this->original_core_zip = false === $original ? null : $original; + $this->temp_dir = Utils\get_temp_dir() . uniqid( 'wp-cli-test-core-zip-', true ); mkdir( $this->temp_dir ); } protected function tear_down(): void { + // FeatureContext re-resolves the archive when the environment variable changes, + // so restoring it is enough to leave the configuration as it was found. + if ( null === $this->original_core_zip ) { + putenv( 'WP_CLI_TEST_CORE_ZIP' ); + } else { + putenv( 'WP_CLI_TEST_CORE_ZIP=' . $this->original_core_zip ); + } + if ( $this->temp_dir && file_exists( $this->temp_dir ) ) { FeatureContext::remove_dir( $this->temp_dir ); } @@ -163,48 +182,73 @@ public function testThrowsOnUnreadableArchive(): void { } /** - * Both `cache_wp_files()` and `download_wp()` need to agree on the cache directory, - * so it is worth pinning down how it is derived. These are internals, hence reflection. + * An archive that escapes its destination must be rejected rather than extracted. * - * @param string $version - * @return string + * @dataProvider data_unsafe_entries + * + * @param string $entry */ - private function get_core_cache_dir( $version = '' ): string { - $method = new \ReflectionMethod( FeatureContext::class, 'get_core_cache_dir' ); - $method->setAccessible( true ); + #[DataProvider( 'data_unsafe_entries' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function testThrowsOnArchiveEscapingItsDestination( $entry ): void { + $entries = $this->wp_entries( 'wordpress/' ); + $entries[ $entry ] = 'escaped'; + + $zip_file = $this->create_zip( 'unsafe.zip', $entries ); - /** @var string $cache_dir */ - $cache_dir = $method->invoke( null, $version ); + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'would be extracted outside of its destination' ); - return $cache_dir; + FeatureContext::extract_wp_zip( $zip_file, $this->temp_dir . DIRECTORY_SEPARATOR . 'dest' ); } - private function reset_core_zip(): void { - $property = new \ReflectionProperty( FeatureContext::class, 'core_zip' ); - $property->setAccessible( true ); - $property->setValue( null, null ); + /** + * @return array> + */ + public static function data_unsafe_entries(): array { + return [ + 'parent directory' => [ '../escaped.txt' ], + 'nested parent directory' => [ 'wordpress/../../escaped.txt' ], + 'absolute path' => [ '/etc/escaped.txt' ], + 'windows separator' => [ '..\\escaped.txt' ], + 'windows drive letter' => [ 'C:/escaped.txt' ], + ]; } + /** + * Both `cache_wp_files()` and `download_wp()` need to agree on the cache directory, + * so it is worth pinning down how it is derived. + */ public function testCacheDirIsDerivedFromWpVersionWithoutArchive(): void { putenv( 'WP_CLI_TEST_CORE_ZIP' ); - $this->reset_core_zip(); - $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-6.4.2', $this->get_core_cache_dir( '6.4.2' ) ); + $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-6.4.2', FeatureContext::get_core_cache_dir( '6.4.2' ) ); } public function testCacheDirIsDerivedFromArchiveContents(): void { $zip_file = $this->create_zip( 'release.zip', $this->wp_entries( 'wordpress/' ) ); putenv( "WP_CLI_TEST_CORE_ZIP={$zip_file}" ); - $this->reset_core_zip(); - try { - $expected = substr( (string) md5_file( $zip_file ), 0, 12 ); - $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-zip-' . $expected, $this->get_core_cache_dir() ); - } finally { - putenv( 'WP_CLI_TEST_CORE_ZIP' ); - $this->reset_core_zip(); - } + $expected = substr( (string) md5_file( $zip_file ), 0, 12 ); + + $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-zip-' . $expected, FeatureContext::get_core_cache_dir() ); + } + + /** + * A change of the environment variable must not be served from the memoized value. + */ + public function testCacheDirFollowsAChangedArchive(): void { + $first = $this->create_zip( 'first.zip', $this->wp_entries( 'wordpress/' ) ); + $second = $this->create_zip( 'second.zip', $this->wp_entries( 'build/' ) ); + + putenv( "WP_CLI_TEST_CORE_ZIP={$first}" ); + $first_cache_dir = FeatureContext::get_core_cache_dir(); + + putenv( "WP_CLI_TEST_CORE_ZIP={$second}" ); + $second_cache_dir = FeatureContext::get_core_cache_dir(); + + $this->assertNotSame( $first_cache_dir, $second_cache_dir ); + $this->assertStringEndsWith( substr( (string) md5_file( $second ), 0, 12 ), $second_cache_dir ); } /** @@ -214,28 +258,16 @@ public function testExplicitVersionTakesPrecedenceOverArchive(): void { $zip_file = $this->create_zip( 'release.zip', $this->wp_entries( 'wordpress/' ) ); putenv( "WP_CLI_TEST_CORE_ZIP={$zip_file}" ); - $this->reset_core_zip(); - try { - $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-6.4.2', $this->get_core_cache_dir( '6.4.2' ) ); - } finally { - putenv( 'WP_CLI_TEST_CORE_ZIP' ); - $this->reset_core_zip(); - } + $this->assertStringEndsWith( 'wp-cli-test-core-download-cache-6.4.2', FeatureContext::get_core_cache_dir( '6.4.2' ) ); } public function testThrowsOnMissingConfiguredArchive(): void { putenv( 'WP_CLI_TEST_CORE_ZIP=' . $this->temp_dir . DIRECTORY_SEPARATOR . 'missing.zip' ); - $this->reset_core_zip(); - try { - $this->expectException( RuntimeException::class ); - $this->expectExceptionMessage( 'Could not read the WP_CLI_TEST_CORE_ZIP archive' ); + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'Could not read the WP_CLI_TEST_CORE_ZIP archive' ); - $this->get_core_cache_dir(); - } finally { - putenv( 'WP_CLI_TEST_CORE_ZIP' ); - $this->reset_core_zip(); - } + FeatureContext::get_core_cache_dir(); } }