2121use SebastianBergmann \Environment \Runtime ;
2222use RuntimeException ;
2323use DirectoryIterator ;
24+ use WP_CLI \Extractor ;
2425use WP_CLI \Process ;
2526use WP_CLI \ProcessRun ;
2627use WP_CLI \Utils ;
@@ -71,6 +72,22 @@ class FeatureContext implements Context {
7172 */
7273 private static $ cache_dir ;
7374
75+ /**
76+ * Path to the local WordPress ZIP archive configured via WP_CLI_TEST_CORE_ZIP. Resolved once per suite.
77+ * Null while unresolved, false when no archive is configured.
78+ *
79+ * @var string|false|null
80+ */
81+ private static $ core_zip = null ;
82+
83+ /**
84+ * The raw WP_CLI_TEST_CORE_ZIP value that self::$core_zip was resolved from, so that a
85+ * change of the environment variable is picked up instead of served from the memoized value.
86+ *
87+ * @var ?string
88+ */
89+ private static $ core_zip_source = null ;
90+
7491 /**
7592 * 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.
7693 *
@@ -681,16 +698,225 @@ private static function configure_sqlite( $dir ): void {
681698 file_put_contents ( $ db_dropin , $ file_contents );
682699 }
683700
701+ /**
702+ * Resolve the WordPress archive to install from, as configured through the
703+ * `WP_CLI_TEST_CORE_ZIP` environment variable.
704+ *
705+ * The variable accepts either a path to a local ZIP file or an HTTP(S) URL.
706+ * Remote archives are downloaded once per suite run.
707+ *
708+ * @return ?string Path to a local ZIP file, or null if the variable is not set.
709+ */
710+ private static function get_core_zip (): ?string {
711+ $ source = getenv ( 'WP_CLI_TEST_CORE_ZIP ' );
712+ $ source = false === $ source ? '' : $ source ;
713+ $ resolved = self ::$ core_zip ;
714+
715+ if ( null !== $ resolved && self ::$ core_zip_source === $ source ) {
716+ return false === $ resolved ? null : $ resolved ;
717+ }
718+
719+ self ::$ core_zip_source = $ source ;
720+
721+ $ core_zip = $ source ;
722+
723+ if ( '' === $ core_zip ) {
724+ self ::$ core_zip = false ;
725+ return null ;
726+ }
727+
728+ if ( preg_match ( '#^https?://#i ' , $ core_zip ) ) {
729+ $ core_zip = self ::download_core_zip ( $ core_zip );
730+ }
731+
732+ if ( ! is_file ( $ core_zip ) || ! is_readable ( $ core_zip ) ) {
733+ throw new RuntimeException ( "Could not read the WP_CLI_TEST_CORE_ZIP archive: {$ core_zip }" );
734+ }
735+
736+ $ realpath = realpath ( $ core_zip );
737+ $ resolved = false !== $ realpath ? $ realpath : $ core_zip ;
738+
739+ self ::$ core_zip = $ resolved ;
740+
741+ return $ resolved ;
742+ }
743+
744+ /**
745+ * Download a remote WordPress archive to a local file.
746+ *
747+ * @param string $url
748+ * @return string Path to the downloaded file.
749+ */
750+ private static function download_core_zip ( $ url ): string {
751+ $ download_location = sys_get_temp_dir () . '/wp-cli-test-core-zip- ' . substr ( md5 ( $ url ), 0 , 12 ) . '.zip ' ;
752+
753+ $ response = Utils \http_request (
754+ 'GET ' ,
755+ $ url ,
756+ null ,
757+ [],
758+ [
759+ 'filename ' => $ download_location ,
760+ 'timeout ' => 600 ,
761+ ]
762+ );
763+
764+ if ( 200 !== $ response ->status_code ) {
765+ throw new RuntimeException ( "Could not download WordPress archive from {$ url } (HTTP code {$ response ->status_code }) " );
766+ }
767+
768+ return $ download_location ;
769+ }
770+
771+ /**
772+ * Get the directory that a given WordPress version is cached in.
773+ *
774+ * Without an explicit version, this is derived from the contents of the archive configured
775+ * through `WP_CLI_TEST_CORE_ZIP`, if any, and from `WP_VERSION` otherwise.
776+ *
777+ * @param string $version
778+ * @return string
779+ */
780+ public static function get_core_cache_dir ( $ version = '' ): string {
781+ // An explicit version always takes precedence over a configured archive.
782+ $ core_zip = $ version ? null : self ::get_core_zip ();
783+
784+ if ( $ core_zip ) {
785+ $ hash = md5_file ( $ core_zip );
786+
787+ if ( false === $ hash ) {
788+ throw new RuntimeException ( "Could not hash the WP_CLI_TEST_CORE_ZIP archive: {$ core_zip }" );
789+ }
790+
791+ return sys_get_temp_dir () . '/wp-cli-test-core-download-cache-zip- ' . substr ( $ hash , 0 , 12 );
792+ }
793+
794+ $ wp_version = $ version ?: getenv ( 'WP_VERSION ' );
795+
796+ return sys_get_temp_dir () . '/wp-cli-test-core-download-cache ' . ( $ wp_version ? "- $ wp_version " : '' );
797+ }
798+
799+ /**
800+ * Extract a WordPress ZIP archive into a destination directory.
801+ *
802+ * Supports archives that wrap WordPress in a single top-level directory --
803+ * `wordpress/` for wordpress.org releases, `build/` for some WordPress core
804+ * build artifacts -- as well as archives that contain WordPress at the root.
805+ *
806+ * @param string $zip_file
807+ * @param string $dest_dir
808+ */
809+ public static function extract_wp_zip ( $ zip_file , $ dest_dir ): void {
810+ $ temp_dir = sys_get_temp_dir () . '/wp-cli-test-core-zip-extract- ' . uniqid ( '' , true );
811+
812+ $ zip = new \ZipArchive ();
813+ $ opened = $ zip ->open ( $ zip_file );
814+
815+ if ( true !== $ opened ) {
816+ // Note that ZipArchive::getStatusString() cannot be used to describe this failure,
817+ // as it errors out on an archive that failed to open on PHP < 8.0.
818+ throw new RuntimeException ( sprintf ( 'Failed to open the zip file %s: %s ' , $ zip_file , Extractor::zip_error_msg ( (int ) $ opened ) ) );
819+ }
820+
821+ try {
822+ self ::validate_zip_entries ( $ zip , $ zip_file );
823+
824+ if ( ! $ zip ->extractTo ( $ temp_dir ) ) {
825+ throw new RuntimeException ( sprintf ( 'Failed to extract files from the zip %s: %s ' , $ zip_file , $ zip ->getStatusString () ) );
826+ }
827+ } finally {
828+ $ zip ->close ();
829+ }
830+
831+ try {
832+ $ source_dir = self ::find_wp_root ( $ temp_dir );
833+
834+ if ( null === $ source_dir ) {
835+ 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. " );
836+ }
837+
838+ self ::remove_dir ( $ dest_dir );
839+
840+ // Both directories live in the system temp folder, so a rename is
841+ // normally possible and avoids copying thousands of files.
842+ if ( ! @rename ( $ source_dir , $ dest_dir ) ) {
843+ // copy_dir() copies into an existing directory, so create it first.
844+ if ( ! is_dir ( $ dest_dir ) && ! mkdir ( $ dest_dir , 0777 , true ) && ! is_dir ( $ dest_dir ) ) {
845+ throw new RuntimeException ( "Could not create the WordPress destination directory: {$ dest_dir }" );
846+ }
847+
848+ self ::copy_dir ( $ source_dir , $ dest_dir );
849+ }
850+ } finally {
851+ self ::remove_dir ( $ temp_dir );
852+ }
853+ }
854+
855+ /**
856+ * Reject archives holding entries that point outside of the directory they are extracted into.
857+ *
858+ * ZipArchive::extractTo() normalizes such entries rather than following them, but an archive
859+ * containing them is malformed for our purposes on any PHP version.
860+ *
861+ * @param \ZipArchive $zip
862+ * @param string $zip_file
863+ */
864+ private static function validate_zip_entries ( \ZipArchive $ zip , $ zip_file ): void {
865+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the PHP ZipArchive class.
866+ $ num_files = $ zip ->numFiles ;
867+
868+ for ( $ i = 0 ; $ i < $ num_files ; $ i ++ ) {
869+ $ name = $ zip ->getNameIndex ( $ i );
870+
871+ if ( false === $ name ) {
872+ continue ;
873+ }
874+
875+ $ segments = explode ( '/ ' , str_replace ( '\\' , '/ ' , $ name ) );
876+
877+ // An empty first segment means the entry is an absolute path.
878+ if ( in_array ( '.. ' , $ segments , true ) || '' === $ segments [0 ] || preg_match ( '#^[a-zA-Z]:$# ' , $ segments [0 ] ) ) {
879+ throw new RuntimeException ( "The archive {$ zip_file } contains an entry that would be extracted outside of its destination: {$ name }" );
880+ }
881+ }
882+ }
883+
884+ /**
885+ * Find the WordPress root within an extracted archive.
886+ *
887+ * @param string $dir
888+ * @return ?string The directory holding wp-includes/version.php, or null if there is none.
889+ */
890+ private static function find_wp_root ( $ dir ): ?string {
891+ if ( is_readable ( $ dir . '/wp-includes/version.php ' ) ) {
892+ return $ dir ;
893+ }
894+
895+ foreach ( new DirectoryIterator ( $ dir ) as $ item ) {
896+ if ( ! $ item ->isDir () || $ item ->isDot () ) {
897+ continue ;
898+ }
899+
900+ $ candidate = $ item ->getPathname ();
901+
902+ if ( is_readable ( $ candidate . '/wp-includes/version.php ' ) ) {
903+ return $ candidate ;
904+ }
905+ }
906+
907+ return null ;
908+ }
909+
684910 /**
685911 * We cache the results of `wp core download` to improve test performance.
686912 * Ideally, we'd cache at the HTTP layer for more reliable tests.
687913 *
688914 * @param string $version
689915 */
690916 private static function cache_wp_files ( $ version = '' ): void {
917+ $ core_zip = $ version ? null : self ::get_core_zip ();
691918 $ wp_version = $ version ?: getenv ( 'WP_VERSION ' );
692- $ wp_version_suffix = $ wp_version ? "- $ wp_version " : '' ;
693- $ cache_dir = sys_get_temp_dir () . '/wp-cli-test-core-download-cache ' . $ wp_version_suffix ;
919+ $ cache_dir = self ::get_core_cache_dir ( $ version );
694920 self ::$ sqlite_cache_dir = sys_get_temp_dir () . '/wp-cli-test-sqlite-integration-cache ' ;
695921
696922 if ( 'sqlite ' === getenv ( 'WP_CLI_TEST_DBTYPE ' ) ) {
@@ -711,6 +937,12 @@ private static function cache_wp_files( $version = '' ): void {
711937 return ;
712938 }
713939
940+ if ( $ core_zip ) {
941+ self ::extract_wp_zip ( $ core_zip , $ cache_dir );
942+ self ::$ cache_dir = $ cache_dir ;
943+ return ;
944+ }
945+
714946 $ cmd = Utils \esc_cmd ( 'wp core download --force --path=%s ' , $ cache_dir );
715947 if ( $ wp_version ) {
716948 $ cmd .= Utils \esc_cmd ( ' --version=%s ' , $ wp_version );
@@ -1586,9 +1818,7 @@ public function add_line_to_wp_config( &$wp_config_code, $line ): void {
15861818 * @param string $version
15871819 */
15881820 public function download_wp ( $ subdir = '' , $ version = '' ): void {
1589- $ wp_version = $ version ?: getenv ( 'WP_VERSION ' );
1590- $ wp_version_suffix = $ wp_version ? "- $ wp_version " : '' ;
1591- $ expected_cache_dir = sys_get_temp_dir () . '/wp-cli-test-core-download-cache ' . $ wp_version_suffix ;
1821+ $ expected_cache_dir = self ::get_core_cache_dir ( $ version );
15921822
15931823 if ( ! self ::$ cache_dir || self ::$ cache_dir !== $ expected_cache_dir ) {
15941824 self ::cache_wp_files ( $ version );
0 commit comments