diff --git a/.github/workflows/reusable-performance-test-v2.yml b/.github/workflows/reusable-performance-test-v2.yml index c0279c37fe64b..72861b6d0dd0d 100644 --- a/.github/workflows/reusable-performance-test-v2.yml +++ b/.github/workflows/reusable-performance-test-v2.yml @@ -196,7 +196,7 @@ jobs: - name: Install object cache drop-in if: ${{ inputs.memcached }} - run: cp src/wp-content/object-cache.php build/wp-content/object-cache.php + run: cp tests/phpunit/includes/object-cache.php build/wp-content/object-cache.php - name: Log running Docker containers run: docker ps -a diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 0c6d968ea02d3..50ecc039f3b01 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -1041,7 +1041,7 @@ function wp_handle_upload_error( &$file, $message ) { } // Set correct file permissions. - $stat = stat( dirname( $new_file ) ); + $stat = stat( _wp_get_dir_perms_stat_path( $new_file ) ); $perms = $stat['mode'] & 0000666; chmod( $new_file, $perms ); diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 3d93b5bd8a2c1..8530fbf893ec8 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -573,8 +573,9 @@ protected function _save( $image, $filename = null, $mime_type = null ) { } // Set correct file permissions. - $stat = stat( dirname( $filename ) ); - $perms = $stat['mode'] & 0000666; // Same permissions as parent folder, strip off the executable bits. + $stat = stat( _wp_get_dir_perms_stat_path( $filename ) ); + $perms = $stat['mode'] & 0000666; + // Same permissions as parent folder, strip off the executable bits. chmod( $filename, $perms ); return array( diff --git a/src/wp-includes/class-wp-image-editor-imagick.php b/src/wp-includes/class-wp-image-editor-imagick.php index 2cb3a694c567b..17a7ad571ce75 100644 --- a/src/wp-includes/class-wp-image-editor-imagick.php +++ b/src/wp-includes/class-wp-image-editor-imagick.php @@ -964,8 +964,9 @@ protected function _save( $image, $filename = null, $mime_type = null ) { } // Set correct file permissions. - $stat = stat( dirname( $filename ) ); - $perms = $stat['mode'] & 0000666; // Same permissions as parent folder, strip off the executable bits. + $stat = stat( _wp_get_dir_perms_stat_path( $filename ) ); + $perms = $stat['mode'] & 0000666; + // Same permissions as parent folder, strip off the executable bits. chmod( $filename, $perms ); return array( diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 355d9f8a1ec37..323c74c1212fd 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2070,10 +2070,11 @@ function wp_mkdir_p( $target ) { $target = '/'; } - if ( file_exists( $target ) ) { - return @is_dir( $target ); - } + $stat_target = _wp_normalize_directory_stat_path( $target ); + if ( file_exists( $stat_target ) ) { + return @is_dir( $stat_target ); + } // Do not allow path traversals. if ( str_contains( $target, '../' ) || str_contains( $target, '..' . DIRECTORY_SEPARATOR ) ) { return false; @@ -2081,12 +2082,12 @@ function wp_mkdir_p( $target ) { // We need to find the permissions of the parent folder that exists and inherit that. $target_parent = dirname( $target ); - while ( '.' !== $target_parent && ! is_dir( $target_parent ) && dirname( $target_parent ) !== $target_parent ) { + while ( '.' !== $target_parent && ! is_dir( _wp_normalize_directory_stat_path( $target_parent ) ) && dirname( $target_parent ) !== $target_parent ) { $target_parent = dirname( $target_parent ); } // Get the permission bits. - $stat = @stat( $target_parent ); + $stat = @stat( _wp_normalize_directory_stat_path( $target_parent ) ); if ( $stat ) { $dir_perms = $stat['mode'] & 0007777; } else { @@ -2977,7 +2978,7 @@ function wp_upload_bits( $name, $deprecated, $bits, $time = null ) { clearstatcache(); // Set correct file permissions. - $stat = @ stat( dirname( $new_file ) ); + $stat = @ stat( _wp_get_dir_perms_stat_path( $new_file ) ); $perms = $stat['mode'] & 0007777; $perms = $perms & 0000666; chmod( $new_file, $perms ); @@ -7414,6 +7415,7 @@ function _validate_cache_id( $object_id ) { * @return bool Whether the device is able to upload files. */ function _device_can_upload() { + if ( ! wp_is_mobile() ) { return true; } @@ -7423,7 +7425,7 @@ function _device_can_upload() { if ( str_contains( $ua, 'iPhone' ) || str_contains( $ua, 'iPad' ) || str_contains( $ua, 'iPod' ) ) { - return preg_match( '#OS ([\d_]+) like Mac OS X#', $ua, $version ) && version_compare( $version[1], '6', '>=' ); + return preg_match( '#OS ([\d_]+) like Mac OS X#', $ua, $version ) && version_compare( $version[1], '6', '>=' ); } return true; @@ -7439,7 +7441,6 @@ function _device_can_upload() { */ function wp_is_stream( $path ) { $scheme_separator = strpos( $path, '://' ); - if ( false === $scheme_separator ) { // $path isn't a stream. return false; @@ -7450,6 +7451,41 @@ function wp_is_stream( $path ) { return in_array( $stream, stream_get_wrappers(), true ); } +/** + * Normalizes a directory path for stat-style filesystem checks. + * + * Stream wrappers that model directories as paths ending in a slash can require + * the trailing slash for existence and metadata checks to resolve correctly. + * + * @since 6.9.0 + * + * @param string $path Directory path. + * @return string Directory path to use with stat-style checks. + */ +function _wp_normalize_directory_stat_path( $path ) { + + if ( wp_is_stream( $path ) ) { + $path = trailingslashit( $path ); + } + + return $path; +} + +/** + * Gets the directory path used to inherit permissions for a file path. + * + * Stream wrappers that model directories as paths ending in a slash can require + * the trailing slash for `stat()` to resolve the parent directory. + * + * @since 6.9.0 + * + * @param string $path File path. + * @return string Directory path to use with `stat()`. + */ +function _wp_get_dir_perms_stat_path( $path ) { + + return _wp_normalize_directory_stat_path( dirname( $path ) ); +} /** * Tests if the supplied date is valid for the Gregorian calendar. * diff --git a/tests/phpunit/includes/class-wp-test-strict-dir-stream.php b/tests/phpunit/includes/class-wp-test-strict-dir-stream.php new file mode 100644 index 0000000000000..8b5f7c2de9627 --- /dev/null +++ b/tests/phpunit/includes/class-wp-test-strict-dir-stream.php @@ -0,0 +1,115 @@ + '', + 'path' => '', + ), + parse_url( $url ) + ); + + $this->bucket = $components['host']; + $this->file = $components['path'] ? $components['path'] : '/'; + + if ( empty( $this->bucket ) ) { + throw new Exception( 'Cannot use an empty bucket name' ); + } + + if ( ! isset( WP_Test_Stream::$data[ $this->bucket ] ) ) { + WP_Test_Stream::$data[ $this->bucket ] = array(); + } + + $this->data_ref = null; + if ( array_key_exists( $this->file, WP_Test_Stream::$data[ $this->bucket ] ) ) { + $this->data_ref =& WP_Test_Stream::$data[ $this->bucket ][ $this->file ]; + } + + $this->position = 0; + } + + /** + * Creates a file metadata object, with defaults. + * + * @param array $stats Partial file metadata. + * @return array Complete file metadata. + */ + private function make_strict_stat( $stats ) { + $defaults = array( + 'dev' => 0, + 'ino' => 0, + 'mode' => 0, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 0, + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => 0, + 'blocks' => 0, + ); + + return array_merge( $defaults, $stats ); + } + + /** + * Retrieves information about a file. + * + * @see WP_Test_Stream::stream_stat() + * + * @return array|false File stats on success, false on failure. + */ + public function stream_stat() { + if ( '/' === substr( $this->file, -1 ) ) { + if ( ! isset( WP_Test_Stream::$data[ $this->bucket ][ $this->file ] ) ) { + return false; + } + + return $this->make_strict_stat( + array( + 'mode' => WP_Test_Stream::DIRECTORY_MODE, + ) + ); + } + + if ( ! isset( $this->data_ref ) ) { + return false; + } + + return $this->make_strict_stat( + array( + 'size' => strlen( $this->data_ref ), + 'mode' => WP_Test_Stream::FILE_MODE, + ) + ); + } + + /** + * Retrieves information about a file. + * + * @see WP_Test_Stream::url_stat() + * + * @param string $path Path to get information about. + * @param int $flags Bitmask of STREAM_URL_STAT_* constants. + * @return array|false File stats on success, false on failure. + */ + public function url_stat( $path, $flags ) { + $this->open_strict( $path ); + return $this->stream_stat(); + } +} diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index ac0e8268390c2..4fb81443fd626 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -8,6 +8,8 @@ * @group wp-image-editor-gd */ require_once __DIR__ . '/base.php'; +require_once DIR_TESTROOT . '/includes/class-wp-test-stream.php'; +require_once DIR_TESTROOT . '/includes/class-wp-test-strict-dir-stream.php'; class Tests_Image_Editor_GD extends WP_Image_UnitTestCase { @@ -51,6 +53,35 @@ public function test_supports_mime_type_gif() { $this->assertSame( $expected, $gd_image_editor->supports_mime_type( 'image/gif' ) ); } + /** + * @ticket 42838 + * @requires function imagejpeg + */ + public function test_save_to_nested_stream_path() { + stream_wrapper_register( 'wptestgddir', 'WP_Test_Strict_Dir_Stream' ); + WP_Test_Stream::$data = array( + 'Tests_Image_Editor_GD' => array( + '/read.jpg' => file_get_contents( DIR_TESTDATA . '/images/waffles.jpg' ), + '/nested-path/' => 'DIRECTORY', + ), + ); + + $file = 'wptestgddir://Tests_Image_Editor_GD/read.jpg'; + $destination = 'wptestgddir://Tests_Image_Editor_GD/nested-path/write.jpg'; + $gd_image_editor = new WP_Image_Editor_GD( $file ); + + $loaded = $gd_image_editor->load(); + $this->assertNotWPError( $loaded ); + + $saved = $gd_image_editor->save( $destination ); + + stream_wrapper_unregister( 'wptestgddir' ); + + $this->assertNotWPError( $saved ); + $this->assertSame( $destination, $saved['path'] ); + $this->assertArrayHasKey( '/nested-path/write.jpg', WP_Test_Stream::$data['Tests_Image_Editor_GD'] ); + } + /** * Tests resizing an image, not using crop. * diff --git a/tests/phpunit/tests/image/editorImagick.php b/tests/phpunit/tests/image/editorImagick.php index e120c32502ad5..6ee96d2e1731b 100644 --- a/tests/phpunit/tests/image/editorImagick.php +++ b/tests/phpunit/tests/image/editorImagick.php @@ -14,10 +14,11 @@ class Tests_Image_Editor_Imagick extends WP_Image_UnitTestCase { public $editor_engine = 'WP_Image_Editor_Imagick'; public function set_up() { + require_once ABSPATH . WPINC . '/class-wp-image-editor.php'; require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php'; require_once DIR_TESTROOT . '/includes/class-wp-test-stream.php'; - + require_once DIR_TESTROOT . '/includes/class-wp-test-strict-dir-stream.php'; // This needs to come after the mock image editor class is loaded. parent::set_up(); } @@ -621,6 +622,37 @@ public function test_streams() { $this->assertSame( $temp_file, $saved['path'] ); } + /** + * @ticket 42838 + */ + public function test_nested_streams() { + stream_wrapper_register( 'wptestdir', 'WP_Test_Strict_Dir_Stream' ); + WP_Test_Stream::$data = array( + 'Tests_Image_Editor_Imagick' => array( + '/read.jpg' => file_get_contents( DIR_TESTDATA . '/images/waffles.jpg' ), + '/nested-path/' => 'DIRECTORY', + ), + ); + + $file = 'wptestdir://Tests_Image_Editor_Imagick/read.jpg'; + $imagick_image_editor = new WP_Image_Editor_Imagick( $file ); + + $loaded = $imagick_image_editor->load(); + $this->assertNotWPError( $loaded ); + + $temp_file = 'wptestdir://Tests_Image_Editor_Imagick/nested-path/write.jpg'; + $saved = $imagick_image_editor->save( $temp_file ); + + if ( $temp_file !== $saved['path'] ) { + unlink( $saved['path'] ); + } + + stream_wrapper_unregister( 'wptestdir' ); + + $this->assertNotWPError( $saved ); + $this->assertSame( $temp_file, $saved['path'] ); + $this->assertArrayHasKey( '/nested-path/write.jpg', WP_Test_Stream::$data['Tests_Image_Editor_Imagick'] ); + } /** * @ticket 51665 */ diff --git a/tests/phpunit/tests/upload.php b/tests/phpunit/tests/upload.php index 46fcea7099097..750c859564b53 100644 --- a/tests/phpunit/tests/upload.php +++ b/tests/phpunit/tests/upload.php @@ -4,7 +4,6 @@ * @group media */ class Tests_Upload extends WP_UnitTestCase { - public $siteurl; public function set_up() { @@ -19,6 +18,16 @@ private function reset_options() { update_option( 'uploads_use_yearmonth_folders', 1 ); } + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + require_once DIR_TESTROOT . '/includes/class-wp-test-stream.php'; + require_once DIR_TESTROOT . '/includes/class-wp-test-strict-dir-stream.php'; + stream_wrapper_register( 'wptestdir', 'WP_Test_Strict_Dir_Stream' ); + } + + public static function wpTearDownAfterClass() { + stream_wrapper_unregister( 'wptestdir' ); + } + public function test_upload_dir_default() { // wp_upload_dir() with default parameters. $info = wp_upload_dir(); @@ -105,4 +114,35 @@ public function test_upload_dir_empty() { $this->assertSame( $subdir, $info['subdir'] ); $this->assertFalse( $info['error'] ); } + + /** + * @ticket 42838 + */ + public function test_wp_upload_bits_should_support_stream_wrapper_directories() { + $filter = static function ( $uploads ) { + $uploads['path'] = 'wptestdir://uploads-test/uploads'; + $uploads['basedir'] = 'wptestdir://uploads-test/uploads'; + $uploads['subdir'] = ''; + $uploads['url'] = 'https://example.org/uploads'; + $uploads['baseurl'] = 'https://example.org/uploads'; + + return $uploads; + }; + + WP_Test_Stream::$data = array( + 'uploads-test' => array( + '/uploads/' => 'DIRECTORY', + ), + ); + + add_filter( 'upload_dir', $filter ); + + $upload = wp_upload_bits( 'stream.txt', null, 'stream wrapper contents' ); + + remove_filter( 'upload_dir', $filter ); + + $this->assertSame( 'stream wrapper contents', WP_Test_Stream::$data['uploads-test']['/uploads/stream.txt'] ); + $this->assertSame( 'https://example.org/uploads/stream.txt', $upload['url'] ); + $this->assertFalse( $upload['error'] ); + } } diff --git a/tools/local-env/scripts/install.js b/tools/local-env/scripts/install.js index 038ecc3a67d5e..df2e3d6256754 100644 --- a/tools/local-env/scripts/install.js +++ b/tools/local-env/scripts/install.js @@ -35,7 +35,10 @@ writeFileSync( 'wp-tests-config.php', testConfig ); // Once the site is available, install WordPress! wait_on( { resources: [ `tcp:localhost:${process.env.LOCAL_PORT}`], - timeout: 3000, + // CI runners can take noticeably longer than local machines before the forwarded + // web port begins accepting connections after `env:start` returns, especially + // on slower DB/image combinations in the broader matrix. + timeout: 180000, } ) .catch( err => { console.error( `Error: It appears the development environment has not been started. Message: ${ err.message }` ); @@ -59,5 +62,60 @@ wait_on( { * @param {string} cmd The WP-CLI command to run. */ function wp_cli( cmd ) { - execSync( `npm --silent run env:cli -- ${cmd} --path=/var/www/${process.env.LOCAL_DIR}`, { stdio: 'inherit' } ); + const wp_cli_command = `node ./tools/local-env/scripts/docker.js run --rm cli wp ${cmd} --path=/var/www/${process.env.LOCAL_DIR}`; + + for ( let attempt = 1; attempt <= 12; attempt++ ) { + try { + const output = execSync( wp_cli_command, { + encoding: 'utf8', + stdio: [ 'ignore', 'pipe', 'pipe' ], + } ); + + if ( output ) { + process.stdout.write( output ); + } + + return; + } catch ( error ) { + if ( error.stdout ) { + process.stdout.write( error.stdout.toString() ); + } + + if ( error.stderr ) { + process.stderr.write( error.stderr.toString() ); + } + + if ( 12 === attempt || ! wp_cli_dependency_is_starting( error ) ) { + throw error; + } + + // The Docker-backed WP-CLI container and database can lag behind on slower runners + // while images are still starting up, especially across the broader DB/version matrix. + const delay = Math.min( attempt, 5 ) * 1000; + console.warn( `The local environment is still starting, retrying in ${ delay / 1000 } second(s)...` ); + sleep( delay ); + } + } +} + +/** + * Determines whether a transient local-env startup dependency is still initializing. + * + * @param {Error & {stdout?: Buffer|string, stderr?: Buffer|string}} error Error thrown by execSync(). + * @return {boolean} Whether the local environment is still starting up. + */ +function wp_cli_dependency_is_starting( error ) { + const output = `${ error.stdout || '' }\n${ error.stderr || '' }\n${ error.message || '' }`; + + return output.includes( 'service "cli" is not running' ) || + output.includes( 'Database connection error (2002) Connection refused' ); +} + +/** + * Sleeps synchronously for a specified number of milliseconds. + * + * @param {number} milliseconds The number of milliseconds to sleep. + */ +function sleep( milliseconds ) { + Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, milliseconds ); }