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..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; @@ -71,6 +72,22 @@ 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 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. * @@ -681,6 +698,215 @@ 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 { + $source = getenv( 'WP_CLI_TEST_CORE_ZIP' ); + $source = false === $source ? '' : $source; + $resolved = self::$core_zip; + + if ( null !== $resolved && self::$core_zip_source === $source ) { + return false === $resolved ? null : $resolved; + } + + self::$core_zip_source = $source; + + $core_zip = $source; + + if ( '' === $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. + * + * 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 + */ + 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(); + + 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 ) { + // 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 ) ) ); + } + + 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(); + } + + 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 ) ) { + // 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 { + self::remove_dir( $temp_dir ); + } + } + + /** + * 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. + * + * @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 +914,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 +937,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 +1818,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..f43ad6d7 --- /dev/null +++ b/tests/tests/TestCoreZip.php @@ -0,0 +1,273 @@ +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 ); + } + + 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' ); + } + + /** + * An archive that escapes its destination must be rejected rather than extracted. + * + * @dataProvider data_unsafe_entries + * + * @param string $entry + */ + #[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 ); + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'would be extracted outside of its destination' ); + + FeatureContext::extract_wp_zip( $zip_file, $this->temp_dir . DIRECTORY_SEPARATOR . 'dest' ); + } + + /** + * @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->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}" ); + + $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 ); + } + + /** + * 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->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->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'Could not read the WP_CLI_TEST_CORE_ZIP archive' ); + + FeatureContext::get_core_cache_dir(); + } +}